diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-16 20:05:39 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-16 20:13:47 +0900 |
| commit | 071e7cc78d3f13fa782dbc6ca5fcec3a37263a4d (patch) | |
| tree | d0174928c0d320bb76e0cdb899beee0476643d55 | |
| parent | 5ed369a6c70707543fd5ec9a13c79851fdfc5d6c (diff) | |
| download | phperkaigi-2026-albatross-071e7cc78d3f13fa782dbc6ca5fcec3a37263a4d.tar.gz phperkaigi-2026-albatross-071e7cc78d3f13fa782dbc6ca5fcec3a37263a4d.tar.zst phperkaigi-2026-albatross-071e7cc78d3f13fa782dbc6ca5fcec3a37263a4d.zip | |
test(backend): add unit tests for auth, config, ratelimit, game, and api
Cover previously untested logic: session ID generation/hashing,
password authentication, IP rate limiting, game state helpers,
handler endpoints, task enqueue/result processing, and config loading.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| -rw-r--r-- | backend/api/handler_test.go | 245 | ||||
| -rw-r--r-- | backend/auth/auth_test.go | 125 | ||||
| -rw-r--r-- | backend/auth/session_test.go | 47 | ||||
| -rw-r--r-- | backend/config/config_test.go | 139 | ||||
| -rw-r--r-- | backend/game/hub_test.go | 249 | ||||
| -rw-r--r-- | backend/justfile | 2 | ||||
| -rw-r--r-- | backend/ratelimit/ratelimit_test.go | 123 |
7 files changed, 928 insertions, 2 deletions
diff --git a/backend/api/handler_test.go b/backend/api/handler_test.go index 47ad92c..a54f995 100644 --- a/backend/api/handler_test.go +++ b/backend/api/handler_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -15,7 +16,8 @@ import ( // mockQuerier implements db.Querier for testing. type mockQuerier struct { db.Querier - getGameByIDFunc func(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) + getGameByIDFunc func(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) + listMainPlayersFunc func(ctx context.Context, gameIDs []int32) ([]db.ListMainPlayersRow, error) } func (m *mockQuerier) GetGameByID(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) { @@ -25,6 +27,13 @@ func (m *mockQuerier) GetGameByID(ctx context.Context, gameID int32) (db.GetGame return db.GetGameByIDRow{}, pgx.ErrNoRows } +func (m *mockQuerier) ListMainPlayers(ctx context.Context, gameIDs []int32) ([]db.ListMainPlayersRow, error) { + if m.listMainPlayersFunc != nil { + return m.listMainPlayersFunc(ctx, gameIDs) + } + return nil, nil +} + // mockTxManager implements db.TxManager for testing. type mockTxManager struct{} @@ -110,6 +119,240 @@ func TestPostGamePlaySubmit_GameNotRunning(t *testing.T) { } } +func TestIsGameRunning(t *testing.T) { + now := time.Now() + tests := []struct { + name string + game db.GetGameByIDRow + want bool + }{ + { + name: "not started", + game: db.GetGameByIDRow{ + StartedAt: pgtype.Timestamp{Valid: false}, + DurationSeconds: 300, + }, + want: false, + }, + { + name: "running", + game: db.GetGameByIDRow{ + StartedAt: pgtype.Timestamp{Time: now.Add(-1 * time.Minute), Valid: true}, + DurationSeconds: 300, + }, + want: true, + }, + { + name: "finished", + game: db.GetGameByIDRow{ + StartedAt: pgtype.Timestamp{Time: now.Add(-10 * time.Minute), Valid: true}, + DurationSeconds: 300, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isGameRunning(tt.game) + if got != tt.want { + t.Errorf("isGameRunning() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsGameFinished(t *testing.T) { + now := time.Now() + tests := []struct { + name string + game db.GetGameByIDRow + want bool + }{ + { + name: "not started", + game: db.GetGameByIDRow{ + StartedAt: pgtype.Timestamp{Valid: false}, + DurationSeconds: 300, + }, + want: false, + }, + { + name: "still running", + game: db.GetGameByIDRow{ + StartedAt: pgtype.Timestamp{Time: now.Add(-1 * time.Minute), Valid: true}, + DurationSeconds: 300, + }, + want: false, + }, + { + name: "finished", + game: db.GetGameByIDRow{ + StartedAt: pgtype.Timestamp{Time: now.Add(-10 * time.Minute), Valid: true}, + DurationSeconds: 300, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isGameFinished(tt.game) + if got != tt.want { + t.Errorf("isGameFinished() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestToNullable(t *testing.T) { + t.Run("nil value", func(t *testing.T) { + result := toNullable[string](nil) + if !result.IsNull() { + t.Error("expected null for nil input") + } + }) + t.Run("non-nil value", func(t *testing.T) { + s := "hello" + result := toNullable(&s) + if result.IsNull() { + t.Error("expected non-null for non-nil input") + } + v, err := result.Get() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v != "hello" { + t.Errorf("expected 'hello', got %q", v) + } + }) +} + +func TestGetMe(t *testing.T) { + h := Handler{ + q: &mockQuerier{}, + txm: &mockTxManager{}, + hub: &mockGameHub{}, + auth: &mockAuthenticator{}, + conf: &config.Config{}, + } + user := &db.User{ + UserID: 1, + Username: "testuser", + DisplayName: "Test User", + IsAdmin: false, + } + resp, err := h.GetMe(context.Background(), GetMeRequestObject{}, user) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + okResp, ok := resp.(GetMe200JSONResponse) + if !ok { + t.Fatalf("expected 200 response, got %T", resp) + } + if okResp.User.UserID != 1 { + t.Errorf("expected user ID 1, got %d", okResp.User.UserID) + } + if okResp.User.Username != "testuser" { + t.Errorf("expected username 'testuser', got %q", okResp.User.Username) + } + if okResp.User.IsAdmin { + t.Error("expected non-admin user") + } +} + +func TestGetGame_NotFound(t *testing.T) { + h := Handler{ + q: &mockQuerier{}, + txm: &mockTxManager{}, + hub: &mockGameHub{}, + auth: &mockAuthenticator{}, + conf: &config.Config{}, + } + user := &db.User{UserID: 1} + resp, err := h.GetGame(context.Background(), GetGameRequestObject{GameID: 999}, user) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := resp.(GetGame404JSONResponse); !ok { + t.Errorf("expected 404 response, got %T", resp) + } +} + +func TestGetGame_NonPublicAsNonAdmin(t *testing.T) { + h := Handler{ + q: &mockQuerier{ + getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) { + return db.GetGameByIDRow{ + GameID: 1, + IsPublic: false, + Language: "php", + }, nil + }, + }, + txm: &mockTxManager{}, + hub: &mockGameHub{}, + auth: &mockAuthenticator{}, + conf: &config.Config{}, + } + user := &db.User{UserID: 1, IsAdmin: false} + resp, err := h.GetGame(context.Background(), GetGameRequestObject{GameID: 1}, user) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := resp.(GetGame404JSONResponse); !ok { + t.Errorf("expected 404 for non-public game as non-admin, got %T", resp) + } +} + +func TestGetGame_PublicGameSuccess(t *testing.T) { + now := time.Now() + h := Handler{ + q: &mockQuerier{ + getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) { + return db.GetGameByIDRow{ + GameID: 1, + IsPublic: true, + Language: "php", + DisplayName: "Test Game", + DurationSeconds: 300, + StartedAt: pgtype.Timestamp{Time: now, Valid: true}, + GameType: "golf", + ProblemID: 10, + Title: "Test Problem", + Description: "desc", + SampleCode: "<?php", + }, nil + }, + listMainPlayersFunc: func(_ context.Context, _ []int32) ([]db.ListMainPlayersRow, error) { + return []db.ListMainPlayersRow{ + {UserID: 1, Username: "player1", DisplayName: "Player 1", IsAdmin: false}, + }, nil + }, + }, + txm: &mockTxManager{}, + hub: &mockGameHub{}, + auth: &mockAuthenticator{}, + conf: &config.Config{}, + } + user := &db.User{UserID: 1, IsAdmin: false} + resp, err := h.GetGame(context.Background(), GetGameRequestObject{GameID: 1}, user) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + okResp, ok := resp.(GetGame200JSONResponse) + if !ok { + t.Fatalf("expected 200 response, got %T", resp) + } + if okResp.Game.GameID != 1 { + t.Errorf("expected game ID 1, got %d", okResp.Game.GameID) + } + if len(okResp.Game.MainPlayers) != 1 { + t.Fatalf("expected 1 main player, got %d", len(okResp.Game.MainPlayers)) + } + if okResp.Game.MainPlayers[0].Username != "player1" { + t.Errorf("expected username 'player1', got %q", okResp.Game.MainPlayers[0].Username) + } +} + func TestPostLogin_AuthFailure(t *testing.T) { h := Handler{ q: &mockQuerier{}, diff --git a/backend/auth/auth_test.go b/backend/auth/auth_test.go new file mode 100644 index 0000000..00d4527 --- /dev/null +++ b/backend/auth/auth_test.go @@ -0,0 +1,125 @@ +package auth + +import ( + "context" + "errors" + "testing" + + "github.com/jackc/pgx/v5" + "golang.org/x/crypto/bcrypt" + + "albatross-2026-backend/db" +) + +type mockQuerier struct { + db.Querier + getUserAuthByUsernameFunc func(ctx context.Context, username string) (db.GetUserAuthByUsernameRow, error) +} + +func (m *mockQuerier) GetUserAuthByUsername(ctx context.Context, username string) (db.GetUserAuthByUsernameRow, error) { + if m.getUserAuthByUsernameFunc != nil { + return m.getUserAuthByUsernameFunc(ctx, username) + } + return db.GetUserAuthByUsernameRow{}, pgx.ErrNoRows +} + +type mockTxManager struct{} + +func (m *mockTxManager) RunInTx(_ context.Context, fn func(q db.Querier) error) error { + return fn(&mockQuerier{}) +} + +func TestLogin_PasswordAuth_Success(t *testing.T) { + hash, err := bcrypt.GenerateFromPassword([]byte("correct-password"), bcrypt.MinCost) + if err != nil { + t.Fatalf("failed to generate hash: %v", err) + } + hashStr := string(hash) + + a := NewAuthenticator( + &mockQuerier{ + getUserAuthByUsernameFunc: func(_ context.Context, _ string) (db.GetUserAuthByUsernameRow, error) { + return db.GetUserAuthByUsernameRow{ + UserID: 42, + AuthType: "password", + PasswordHash: &hashStr, + }, nil + }, + }, + &mockTxManager{}, + ) + + userID, err := a.Login(context.Background(), "testuser", "correct-password") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if userID != 42 { + t.Errorf("expected userID 42, got %d", userID) + } +} + +func TestLogin_PasswordAuth_WrongPassword(t *testing.T) { + hash, err := bcrypt.GenerateFromPassword([]byte("correct-password"), bcrypt.MinCost) + if err != nil { + t.Fatalf("failed to generate hash: %v", err) + } + hashStr := string(hash) + + a := NewAuthenticator( + &mockQuerier{ + getUserAuthByUsernameFunc: func(_ context.Context, _ string) (db.GetUserAuthByUsernameRow, error) { + return db.GetUserAuthByUsernameRow{ + UserID: 42, + AuthType: "password", + PasswordHash: &hashStr, + }, nil + }, + }, + &mockTxManager{}, + ) + + _, err = a.Login(context.Background(), "testuser", "wrong-password") + if err == nil { + t.Fatal("expected error for wrong password, got nil") + } +} + +func TestLogin_PasswordAuth_NilHash(t *testing.T) { + a := NewAuthenticator( + &mockQuerier{ + getUserAuthByUsernameFunc: func(_ context.Context, _ string) (db.GetUserAuthByUsernameRow, error) { + return db.GetUserAuthByUsernameRow{ + UserID: 42, + AuthType: "password", + PasswordHash: nil, + }, nil + }, + }, + &mockTxManager{}, + ) + + _, err := a.Login(context.Background(), "testuser", "any") + if err == nil { + t.Fatal("expected error for nil password hash, got nil") + } + if err.Error() != "inconsistent data: password auth type but no password hash" { + t.Errorf("unexpected error message: %s", err.Error()) + } +} + +func TestLogin_DBError(t *testing.T) { + dbErr := errors.New("database connection failed") + a := NewAuthenticator( + &mockQuerier{ + getUserAuthByUsernameFunc: func(_ context.Context, _ string) (db.GetUserAuthByUsernameRow, error) { + return db.GetUserAuthByUsernameRow{}, dbErr + }, + }, + &mockTxManager{}, + ) + + _, err := a.Login(context.Background(), "testuser", "any") + if !errors.Is(err, dbErr) { + t.Errorf("expected db error, got: %v", err) + } +} diff --git a/backend/auth/session_test.go b/backend/auth/session_test.go new file mode 100644 index 0000000..2a47739 --- /dev/null +++ b/backend/auth/session_test.go @@ -0,0 +1,47 @@ +package auth + +import ( + "testing" +) + +func TestGenerateSessionID(t *testing.T) { + id, err := GenerateSessionID() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // 32 bytes → 64 hex characters + if len(id) != 64 { + t.Errorf("expected session ID length 64, got %d", len(id)) + } +} + +func TestGenerateSessionID_Unique(t *testing.T) { + id1, err := GenerateSessionID() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + id2, err := GenerateSessionID() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id1 == id2 { + t.Error("expected unique session IDs, got identical values") + } +} + +func TestHashSessionID(t *testing.T) { + raw := "abc123" + hashed := HashSessionID(raw) + // SHA-256 produces 32 bytes → 64 hex characters + if len(hashed) != 64 { + t.Errorf("expected hash length 64, got %d", len(hashed)) + } + // Same input should produce same hash + if hashed != HashSessionID(raw) { + t.Error("expected deterministic hash") + } + // Different input should produce different hash + if hashed == HashSessionID("different") { + t.Error("expected different hashes for different inputs") + } +} diff --git a/backend/config/config_test.go b/backend/config/config_test.go new file mode 100644 index 0000000..5110e0c --- /dev/null +++ b/backend/config/config_test.go @@ -0,0 +1,139 @@ +package config + +import ( + "testing" +) + +func TestNewConfigFromEnv_AllSet(t *testing.T) { + t.Setenv("ALBATROSS_DB_HOST", "localhost") + t.Setenv("ALBATROSS_DB_PORT", "5432") + t.Setenv("ALBATROSS_DB_USER", "user") + t.Setenv("ALBATROSS_DB_PASSWORD", "pass") + t.Setenv("ALBATROSS_DB_NAME", "testdb") + t.Setenv("ALBATROSS_BASE_PATH", "/app") + t.Setenv("ALBATROSS_IS_LOCAL", "1") + + conf, err := NewConfigFromEnv() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conf.DBHost != "localhost" { + t.Errorf("expected DBHost 'localhost', got %q", conf.DBHost) + } + if conf.DBPort != "5432" { + t.Errorf("expected DBPort '5432', got %q", conf.DBPort) + } + if conf.DBUser != "user" { + t.Errorf("expected DBUser 'user', got %q", conf.DBUser) + } + if conf.DBPassword != "pass" { + t.Errorf("expected DBPassword 'pass', got %q", conf.DBPassword) + } + if conf.DBName != "testdb" { + t.Errorf("expected DBName 'testdb', got %q", conf.DBName) + } + if conf.BasePath != "/app" { + t.Errorf("expected BasePath '/app', got %q", conf.BasePath) + } + if !conf.IsLocal { + t.Error("expected IsLocal true") + } +} + +func TestNewConfigFromEnv_IsLocalFalse(t *testing.T) { + t.Setenv("ALBATROSS_DB_HOST", "localhost") + t.Setenv("ALBATROSS_DB_PORT", "5432") + t.Setenv("ALBATROSS_DB_USER", "user") + t.Setenv("ALBATROSS_DB_PASSWORD", "pass") + t.Setenv("ALBATROSS_DB_NAME", "testdb") + t.Setenv("ALBATROSS_BASE_PATH", "/app") + // ALBATROSS_IS_LOCAL not set + + conf, err := NewConfigFromEnv() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conf.IsLocal { + t.Error("expected IsLocal false when env not set") + } +} + +func TestNewConfigFromEnv_MissingRequired(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + }{ + { + name: "missing DB_HOST", + envVars: map[string]string{ + "ALBATROSS_DB_PORT": "5432", + "ALBATROSS_DB_USER": "user", + "ALBATROSS_DB_PASSWORD": "pass", + "ALBATROSS_DB_NAME": "testdb", + "ALBATROSS_BASE_PATH": "/app", + }, + }, + { + name: "missing DB_PORT", + envVars: map[string]string{ + "ALBATROSS_DB_HOST": "localhost", + "ALBATROSS_DB_USER": "user", + "ALBATROSS_DB_PASSWORD": "pass", + "ALBATROSS_DB_NAME": "testdb", + "ALBATROSS_BASE_PATH": "/app", + }, + }, + { + name: "missing DB_USER", + envVars: map[string]string{ + "ALBATROSS_DB_HOST": "localhost", + "ALBATROSS_DB_PORT": "5432", + "ALBATROSS_DB_PASSWORD": "pass", + "ALBATROSS_DB_NAME": "testdb", + "ALBATROSS_BASE_PATH": "/app", + }, + }, + { + name: "missing DB_PASSWORD", + envVars: map[string]string{ + "ALBATROSS_DB_HOST": "localhost", + "ALBATROSS_DB_PORT": "5432", + "ALBATROSS_DB_USER": "user", + "ALBATROSS_DB_NAME": "testdb", + "ALBATROSS_BASE_PATH": "/app", + }, + }, + { + name: "missing DB_NAME", + envVars: map[string]string{ + "ALBATROSS_DB_HOST": "localhost", + "ALBATROSS_DB_PORT": "5432", + "ALBATROSS_DB_USER": "user", + "ALBATROSS_DB_PASSWORD": "pass", + "ALBATROSS_BASE_PATH": "/app", + }, + }, + { + name: "missing BASE_PATH", + envVars: map[string]string{ + "ALBATROSS_DB_HOST": "localhost", + "ALBATROSS_DB_PORT": "5432", + "ALBATROSS_DB_USER": "user", + "ALBATROSS_DB_PASSWORD": "pass", + "ALBATROSS_DB_NAME": "testdb", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.envVars { + t.Setenv(k, v) + } + _, err := NewConfigFromEnv() + if err == nil { + t.Error("expected error for missing env var, got nil") + } + }) + } +} diff --git a/backend/game/hub_test.go b/backend/game/hub_test.go index a8fad58..59ce996 100644 --- a/backend/game/hub_test.go +++ b/backend/game/hub_test.go @@ -1,6 +1,253 @@ package game -import "testing" +import ( + "context" + "errors" + "testing" + + "albatross-2026-backend/db" + "albatross-2026-backend/taskqueue" +) + +// mockTaskQueue implements TaskQueueInterface for testing. +type mockTaskQueue struct { + enqueued []taskqueue.TaskPayloadRunTestcase + err error +} + +func (m *mockTaskQueue) EnqueueTaskRunTestcase(gameID, userID, submissionID, testcaseID int, language, code, stdin, stdout string) error { + if m.err != nil { + return m.err + } + m.enqueued = append(m.enqueued, taskqueue.TaskPayloadRunTestcase{ + GameID: gameID, + UserID: userID, + SubmissionID: submissionID, + TestcaseID: testcaseID, + Language: language, + Code: code, + Stdin: stdin, + Stdout: stdout, + }) + return nil +} + +// mockQuerier implements db.Querier for testing. +type mockQuerier struct { + db.Querier + listTestcasesByGameIDFunc func(ctx context.Context, gameID int32) ([]db.Testcase, error) + createTestcaseResultFunc func(ctx context.Context, arg db.CreateTestcaseResultParams) error + createTestcaseResultCalls []db.CreateTestcaseResultParams +} + +func (m *mockQuerier) ListTestcasesByGameID(ctx context.Context, gameID int32) ([]db.Testcase, error) { + if m.listTestcasesByGameIDFunc != nil { + return m.listTestcasesByGameIDFunc(ctx, gameID) + } + return nil, nil +} + +func (m *mockQuerier) CreateTestcaseResult(_ context.Context, arg db.CreateTestcaseResultParams) error { + m.createTestcaseResultCalls = append(m.createTestcaseResultCalls, arg) + if m.createTestcaseResultFunc != nil { + return m.createTestcaseResultFunc(context.Background(), arg) + } + return nil +} + +func TestEnqueueTestTasks(t *testing.T) { + testcases := []db.Testcase{ + {TestcaseID: 1, ProblemID: 10, Stdin: "input1", Stdout: "output1"}, + {TestcaseID: 2, ProblemID: 10, Stdin: "input2", Stdout: "output2"}, + } + + tq := &mockTaskQueue{} + mq := &mockQuerier{ + listTestcasesByGameIDFunc: func(_ context.Context, _ int32) ([]db.Testcase, error) { + return testcases, nil + }, + } + + hub := &Hub{q: mq, taskQueue: tq, ctx: context.Background()} + + err := hub.EnqueueTestTasks(context.Background(), 100, 1, 42, "php", "<?php echo 1;") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(tq.enqueued) != 2 { + t.Fatalf("expected 2 enqueued tasks, got %d", len(tq.enqueued)) + } + if tq.enqueued[0].TestcaseID != 1 || tq.enqueued[1].TestcaseID != 2 { + t.Errorf("unexpected testcase IDs: %d, %d", tq.enqueued[0].TestcaseID, tq.enqueued[1].TestcaseID) + } + if tq.enqueued[0].SubmissionID != 100 { + t.Errorf("expected submission ID 100, got %d", tq.enqueued[0].SubmissionID) + } +} + +func TestEnqueueTestTasks_QueueError(t *testing.T) { + queueErr := errors.New("queue full") + tq := &mockTaskQueue{err: queueErr} + mq := &mockQuerier{ + listTestcasesByGameIDFunc: func(_ context.Context, _ int32) ([]db.Testcase, error) { + return []db.Testcase{{TestcaseID: 1, Stdin: "in", Stdout: "out"}}, nil + }, + } + + hub := &Hub{q: mq, taskQueue: tq, ctx: context.Background()} + + err := hub.EnqueueTestTasks(context.Background(), 100, 1, 42, "php", "code") + if !errors.Is(err, queueErr) { + t.Errorf("expected queue error, got: %v", err) + } +} + +func TestEnqueueTestTasks_DBError(t *testing.T) { + dbErr := errors.New("db error") + tq := &mockTaskQueue{} + mq := &mockQuerier{ + listTestcasesByGameIDFunc: func(_ context.Context, _ int32) ([]db.Testcase, error) { + return nil, dbErr + }, + } + + hub := &Hub{q: mq, taskQueue: tq, ctx: context.Background()} + + err := hub.EnqueueTestTasks(context.Background(), 100, 1, 42, "php", "code") + if !errors.Is(err, dbErr) { + t.Errorf("expected db error, got: %v", err) + } +} + +func TestProcessTaskResultRunTestcase_Success_Correct(t *testing.T) { + mq := &mockQuerier{} + hub := &Hub{q: mq, ctx: context.Background()} + + result := &taskqueue.TaskResultRunTestcase{ + TaskPayload: &taskqueue.TaskPayloadRunTestcase{ + SubmissionID: 1, + TestcaseID: 2, + Stdout: "expected output", + }, + Status: "success", + Stdout: "expected output", + Stderr: "", + } + + err := hub.processTaskResultRunTestcase(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(mq.createTestcaseResultCalls) != 1 { + t.Fatalf("expected 1 call, got %d", len(mq.createTestcaseResultCalls)) + } + if mq.createTestcaseResultCalls[0].Status != "success" { + t.Errorf("expected status 'success', got %q", mq.createTestcaseResultCalls[0].Status) + } +} + +func TestProcessTaskResultRunTestcase_Success_WrongAnswer(t *testing.T) { + mq := &mockQuerier{} + hub := &Hub{q: mq, ctx: context.Background()} + + result := &taskqueue.TaskResultRunTestcase{ + TaskPayload: &taskqueue.TaskPayloadRunTestcase{ + SubmissionID: 1, + TestcaseID: 2, + Stdout: "expected", + }, + Status: "success", + Stdout: "wrong", + Stderr: "", + } + + err := hub.processTaskResultRunTestcase(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(mq.createTestcaseResultCalls) != 1 { + t.Fatalf("expected 1 call, got %d", len(mq.createTestcaseResultCalls)) + } + if mq.createTestcaseResultCalls[0].Status != "wrong_answer" { + t.Errorf("expected status 'wrong_answer', got %q", mq.createTestcaseResultCalls[0].Status) + } +} + +func TestProcessTaskResultRunTestcase_NonSuccess(t *testing.T) { + mq := &mockQuerier{} + hub := &Hub{q: mq, ctx: context.Background()} + + result := &taskqueue.TaskResultRunTestcase{ + TaskPayload: &taskqueue.TaskPayloadRunTestcase{ + SubmissionID: 1, + TestcaseID: 2, + Stdout: "expected", + }, + Status: "timeout", + Stdout: "", + Stderr: "execution timed out", + } + + err := hub.processTaskResultRunTestcase(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(mq.createTestcaseResultCalls) != 1 { + t.Fatalf("expected 1 call, got %d", len(mq.createTestcaseResultCalls)) + } + if mq.createTestcaseResultCalls[0].Status != "timeout" { + t.Errorf("expected status 'timeout', got %q", mq.createTestcaseResultCalls[0].Status) + } +} + +func TestProcessTaskResultRunTestcase_TaskError(t *testing.T) { + mq := &mockQuerier{} + hub := &Hub{q: mq, ctx: context.Background()} + + taskErr := errors.New("worker crashed") + result := &taskqueue.TaskResultRunTestcase{ + TaskPayload: &taskqueue.TaskPayloadRunTestcase{ + SubmissionID: 1, + TestcaseID: 2, + }, + Err: taskErr, + } + + err := hub.processTaskResultRunTestcase(result) + if !errors.Is(err, taskErr) { + t.Errorf("expected task error, got: %v", err) + } + if len(mq.createTestcaseResultCalls) != 0 { + t.Error("expected no DB calls when task has error") + } +} + +func TestNormalizeTestcaseResultOutput(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"empty", "", ""}, + {"no changes needed", "hello", "hello"}, + {"trim spaces", " hello ", "hello"}, + {"CRLF to LF", "line1\r\nline2", "line1\nline2"}, + {"CR to LF", "line1\rline2", "line1\nline2"}, + {"mixed", " line1\r\nline2\r ", "line1\nline2"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeTestcaseResultOutput(tt.input) + if got != tt.want { + t.Errorf("normalizeTestcaseResultOutput(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} func TestCalcCodeSize_PHP(t *testing.T) { hub := &Hub{} diff --git a/backend/justfile b/backend/justfile index 1897cab..4a3c14e 100644 --- a/backend/justfile +++ b/backend/justfile @@ -7,3 +7,5 @@ test: gen: go generate ./... + +ci: check test diff --git a/backend/ratelimit/ratelimit_test.go b/backend/ratelimit/ratelimit_test.go new file mode 100644 index 0000000..65878c4 --- /dev/null +++ b/backend/ratelimit/ratelimit_test.go @@ -0,0 +1,123 @@ +package ratelimit + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" + "golang.org/x/time/rate" +) + +func TestGetLimiter_SameIP(t *testing.T) { + rl := &IPRateLimiter{ + rate: rate.Limit(10), + burst: 1, + } + + l1 := rl.getLimiter("192.168.1.1") + l2 := rl.getLimiter("192.168.1.1") + if l1 != l2 { + t.Error("expected same limiter for same IP") + } +} + +func TestGetLimiter_DifferentIP(t *testing.T) { + rl := &IPRateLimiter{ + rate: rate.Limit(10), + burst: 1, + } + + l1 := rl.getLimiter("192.168.1.1") + l2 := rl.getLimiter("192.168.1.2") + if l1 == l2 { + t.Error("expected different limiters for different IPs") + } +} + +func TestLoginRateLimitMiddleware_AllowsNonLogin(t *testing.T) { + rl := &IPRateLimiter{ + rate: rate.Limit(0), // zero rate = deny all + burst: 0, + } + + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/api/games", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/api/games") + + handler := LoginRateLimitMiddleware(rl)(func(c echo.Context) error { + return c.NoContent(http.StatusOK) + }) + + if err := handler(c); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } +} + +func TestLoginRateLimitMiddleware_BlocksExcessiveLogin(t *testing.T) { + rl := &IPRateLimiter{ + rate: rate.Limit(0.001), // very low rate + burst: 1, + } + + e := echo.New() + + // First request should succeed (burst = 1) + req1 := httptest.NewRequest(http.MethodPost, "/api/login", nil) + rec1 := httptest.NewRecorder() + c1 := e.NewContext(req1, rec1) + c1.SetPath("/api/login") + + handler := LoginRateLimitMiddleware(rl)(func(c echo.Context) error { + return c.NoContent(http.StatusOK) + }) + + if err := handler(c1); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec1.Code != http.StatusOK { + t.Errorf("first request: expected 200, got %d", rec1.Code) + } + + // Second request should be rate limited + req2 := httptest.NewRequest(http.MethodPost, "/api/login", nil) + rec2 := httptest.NewRecorder() + c2 := e.NewContext(req2, rec2) + c2.SetPath("/api/login") + + if err := handler(c2); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec2.Code != http.StatusTooManyRequests { + t.Errorf("second request: expected 429, got %d", rec2.Code) + } +} + +func TestLoginRateLimitMiddleware_AllowsNonPostLogin(t *testing.T) { + rl := &IPRateLimiter{ + rate: rate.Limit(0), + burst: 0, + } + + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/api/login", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/api/login") + + handler := LoginRateLimitMiddleware(rl)(func(c echo.Context) error { + return c.NoContent(http.StatusOK) + }) + + if err := handler(c); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusOK { + t.Errorf("expected 200 for GET /login, got %d", rec.Code) + } +} |
