Skip to content
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,22 @@ Nouns:
game add <name>
update <id> <name>
delete <id>
access grant <gameId> <playerId>
revoke <gameId> <playerId>
access grant <game id> <player id>
revoke <game id> <player id>
player add <name> <username> <password>
rename <id> <name>
passwd <id> <currentPassword> <newPassword>
passwd <id> <current password> <new password>
passwd-force <id> <password>
delete <id>
auth validate-passwd <password>
hash-passwd <password>
check-passwd <username> <password>
data games <playerId>
shared <gameId>
interactions <gameId> <count>
session save <gameId> <playerId> <RFC3339 timestamp>
resume <gameId> <playerId>
pause <gameId> <playerId>
data games <player id>
shared <game id>
interactions <game id> <limit>
session save <game id> <player id> <duration in seconds>
resume <game id> <player id>
pause <game id> <player id>
```

## HTTP API
Expand Down
2 changes: 1 addition & 1 deletion cmd/manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func run() int {
Access: usecase.NewAccessManagement(q),
Players: usecase.NewPlayerManagement(q, auth),
Auth: auth,
Data: usecase.NewDataFetching(),
Data: usecase.NewDataFetching(q),
Commands: usecase.NewGameCommands(),
Stdout: os.Stdout,
Stderr: os.Stderr,
Expand Down
56 changes: 53 additions & 3 deletions database/migrations/20260815203118_initial.sql
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,67 @@ CREATE TABLE interactions (
action TEXT NOT NULL CHECK (
action IN ('saved', 'paused', 'resumed')
),
occurred_at TEXT NOT NULL,

occurred_at TEXT NOT NULL, -- Uses ISO 8601 format
saved_by INTEGER, -- duration in seconds, not a timestamp; NULL unless action = 'saved'

CONSTRAINT valid_occurred_at_iso
CHECK (occurred_at = strftime('%Y-%m-%d %H:%M:%S', occurred_at)),

CONSTRAINT saved_by_matches_action
CHECK ((action = 'saved') = (saved_by IS NOT NULL)),

CONSTRAINT saved_by_positive
CHECK (saved_by IS NULL OR saved_by > 0),

FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE,
FOREIGN KEY (player_id) REFERENCES players(id)
);
) STRICT;

CREATE INDEX idx_interactions_game_occurred
ON interactions (game_id, occurred_at DESC);
ON interactions (game_id, occurred_at DESC, id DESC);

-- +goose StatementBegin
CREATE TRIGGER trg_interactions_state_machine
BEFORE INSERT ON interactions
FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, 'occurred_at precedes an existing interaction for this game')
WHERE EXISTS (
SELECT 1 FROM interactions
WHERE game_id = NEW.game_id AND occurred_at > NEW.occurred_at
);

SELECT RAISE(ABORT, 'cannot save while paused')
WHERE NEW.action = 'saved'
AND (SELECT action FROM interactions
WHERE game_id = NEW.game_id
ORDER BY occurred_at DESC, id DESC LIMIT 1) = 'paused';

SELECT RAISE(ABORT, 'cannot pause: game is not currently playing')
WHERE NEW.action = 'paused'
AND COALESCE(
(SELECT action FROM interactions
WHERE game_id = NEW.game_id
ORDER BY occurred_at DESC, id DESC LIMIT 1),
'none'
) NOT IN ('saved', 'resumed');

SELECT RAISE(ABORT, 'cannot resume: game is not currently paused')
WHERE NEW.action = 'resumed'
AND COALESCE(
(SELECT action FROM interactions
WHERE game_id = NEW.game_id
ORDER BY occurred_at DESC, id DESC LIMIT 1),
'none'
) != 'paused';
END;
-- +goose StatementEnd

-- +goose Down

DROP TRIGGER IF EXISTS trg_interactions_state_machine;

DROP INDEX idx_players_username;
DROP INDEX idx_interactions_game_occurred;

Expand Down
20 changes: 20 additions & 0 deletions database/queries/fetching.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- name: ListPlayerGames :many
SELECT
games.id,
games.name
FROM games
JOIN access ON access.game_id = games.id
WHERE access.player_id = ?;

-- name: ListRecentInteractions :many
SELECT id, game_id, player_id, action, occurred_at, saved_by
FROM interactions
WHERE game_id = ?
ORDER BY occurred_at DESC, id DESC
LIMIT ?;

-- name: ListInteractionsForReplay :many
SELECT id, game_id, player_id, action, occurred_at, saved_by
FROM interactions
WHERE game_id = ?
ORDER BY occurred_at ASC, id ASC;
29 changes: 2 additions & 27 deletions internal/application/usecase/authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import (
"errors"
"fmt"
"keep-it-up/internal/core/model"
"keep-it-up/internal/core/service"
"keep-it-up/internal/infrastructure/database"
"strings"

"golang.org/x/crypto/bcrypt"
)
Expand All @@ -19,31 +19,6 @@ func NewAuthentication(q *database.Queries) *Authentication {
return &Authentication{q: q}
}

func (uc *Authentication) IsPasswordValid(password string) error {
if strings.ContainsRune(password, ' ') {
return fmt.Errorf("Password cannot have whitespaces")
}

if len(password) < 6 {
return fmt.Errorf("Password cannot have less than 6 characters")
}

return nil
}

func (uc *Authentication) GeneratePasswordHash(password string) (string, error) {
if err := uc.IsPasswordValid(password); err != nil {
return "", err
}

hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}

return string(hash), nil
}

func (uc *Authentication) CheckPlayerPassword(ctx context.Context, username string, password string) (bool, error) {
if uc.q == nil {
return false, fmt.Errorf("database queries are not initialized")
Expand All @@ -53,7 +28,7 @@ func (uc *Authentication) CheckPlayerPassword(ctx context.Context, username stri
return false, fmt.Errorf("Username cannot have less than 3 characters: '%s'", username)
}

if err := uc.IsPasswordValid(password); err != nil {
if err := service.IsPasswordValid(password); err != nil {
return false, err
}

Expand Down
166 changes: 0 additions & 166 deletions internal/application/usecase/authentication_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,75 +9,6 @@ import (
"golang.org/x/crypto/bcrypt"
)

func TestAuthentication_IsPasswordValid(t *testing.T) {
auth := NewAuthentication(nil)

for _, password := range []string{"secret123", "abc123"} {
if err := auth.IsPasswordValid(password); err != nil {
t.Fatalf("IsPasswordValid() rejected a valid password %q: %v", password, err)
}
}

for _, password := range []string{
"", "short", "abc12", "pa ss", "p ass ", "secret 123", "pass ",
"alice", " alice ", " secret123 ", "secret123 ", " secret123",
"\tsecret 123\n",
} {
if err := auth.IsPasswordValid(password); err == nil {
t.Fatalf("IsPasswordValid() accepted an invalid password %q", password)
}
}
}

func TestAuthentication_IsPasswordValidBoundary(t *testing.T) {
// Test boundary condition: exactly 6 characters (minimum valid length)
auth := NewAuthentication(nil)

if err := auth.IsPasswordValid("abc123"); err != nil {
t.Fatalf("IsPasswordValid() rejected valid 6-character password: %v", err)
}

// Test one below boundary: 5 characters (should fail)
if err := auth.IsPasswordValid("abc12"); err == nil {
t.Fatal("IsPasswordValid() accepted 5-character password (below minimum)")
}

// Test with whitespace at boundary
if err := auth.IsPasswordValid(" abc123"); err == nil {
t.Fatalf("IsPasswordValid() accepted invalid 6-char password with leading space: %v", err)
}
if err := auth.IsPasswordValid("abc123 "); err == nil {
t.Fatalf("IsPasswordValid() accepted invalid 6-char password with trailing space: %v", err)
}
}

func TestAuthentication_GeneratePasswordHash(t *testing.T) {
auth := NewAuthentication(nil)

hash, err := auth.GeneratePasswordHash("secret123")
if err != nil {
t.Fatalf("GeneratePasswordHash() returned error: %v", err)
}
if hash == "" {
t.Fatal("GeneratePasswordHash() returned empty hash")
}
if hash == "secret123" {
t.Fatal("GeneratePasswordHash() returned the raw password instead of a hash")
}

if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte("secret123")); err != nil {
t.Fatalf("bcrypt.CompareHashAndPassword() failed for generated hash: %v", err)
}
}

func TestAuthentication_GeneratePasswordHashRejectsShortPassword(t *testing.T) {
auth := NewAuthentication(nil)

if _, err := auth.GeneratePasswordHash("short"); err == nil {
t.Fatal("GeneratePasswordHash() accepted a password shorter than 6 characters")
}
}

func TestAuthentication_CheckPlayerPasswordUsesPasswordRuleValidation(t *testing.T) {
ctx := context.Background()
queries := newTestDB(t)
Expand Down Expand Up @@ -237,58 +168,6 @@ func TestAuthentication_CheckPlayerPasswordWithCancelledContext(t *testing.T) {
}
}

func TestAuthentication_IsPasswordValidBoundaryPrecision(t *testing.T) {
auth := NewAuthentication(nil)

// Test exactly at boundary: 6 characters (minimum valid)
// Note: This test documents desired behavior for password validation boundaries
testCases := []struct {
password string
shouldValidate bool
}{
{"abc123", true}, // exactly 6 chars - at boundary
{"abc12", false}, // 5 chars - one below minimum
{"abc1234", true}, // 7 chars - one above minimum
{"123456", true}, // 6 digits
{"aaaaaa", true}, // 6 of same letter
}

for _, tc := range testCases {
err := auth.IsPasswordValid(tc.password)
if tc.shouldValidate && err != nil {
t.Fatalf("IsPasswordValid() should validate %q (length=%d): %v", tc.password, len(tc.password), err)
}
if !tc.shouldValidate && err == nil {
t.Fatalf("IsPasswordValid() should reject %q (length=%d)", tc.password, len(tc.password))
}
}
}

func TestAuthentication_GeneratePasswordHashWithValidPasswords(t *testing.T) {
auth := NewAuthentication(nil)

validPasswords := []string{
"abc123", // exactly 6 chars
"secret123", // 9 chars
"password", // 8 chars
"aaaaaaaaa", // 9 of same character
"123456789", // 9 digits
}

for _, password := range validPasswords {
hash, err := auth.GeneratePasswordHash(password)
if err != nil {
t.Fatalf("GeneratePasswordHash() failed for valid password %q: %v", password, err)
}
if hash == "" {
t.Fatalf("GeneratePasswordHash() returned empty hash for %q", password)
}
if hash == password {
t.Fatalf("GeneratePasswordHash() returned unhashed password for %q", password)
}
}
}

func TestAuthentication_CheckPlayerPasswordWithTrimmablePassword(t *testing.T) {
ctx := context.Background()
queries := newTestDB(t)
Expand Down Expand Up @@ -323,48 +202,3 @@ func TestAuthentication_CheckPlayerPasswordWithTrimmablePassword(t *testing.T) {
_ = err
}
}

func TestAuthentication_IsPasswordValidWithUnicodeCharacters(t *testing.T) {
auth := NewAuthentication(nil)

// Test that unicode characters are allowed (if validation allows non-ASCII)
// The exact behavior depends on the validation implementation
testCases := []struct {
password string
desc string
}{
{"пароль123", "Cyrillic password"},
{"password123", "ASCII password"},
{"пароль", "Cyrillic word only"},
}

for _, tc := range testCases {
// Just verify the function doesn't panic or error unexpectedly
_ = auth.IsPasswordValid(tc.password)
}
}

func TestAuthentication_GeneratePasswordHashIsConsistent(t *testing.T) {
auth := NewAuthentication(nil)

password := "secret123"
hash1, err1 := auth.GeneratePasswordHash(password)
hash2, err2 := auth.GeneratePasswordHash(password)

if err1 != nil || err2 != nil {
t.Fatalf("GeneratePasswordHash() returned error: err1=%v, err2=%v", err1, err2)
}

// Bcrypt hashes should be different even for same password (due to salt)
if hash1 == hash2 {
t.Fatal("GeneratePasswordHash() produced identical hashes for same password (should be different due to salt)")
}

// But both should validate against the same password
if err := bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password)); err != nil {
t.Fatalf("First hash does not match password: %v", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password)); err != nil {
t.Fatalf("Second hash does not match password: %v", err)
}
}
Loading
Loading