package main import ( "bytes" "database/sql" "encoding/json" "net/http" "net/http/httptest" "os" "strings" "testing" _ "github.com/lib/pq" ) // openTestDB opens a test database for rotate-key integration tests. func openTestDBAPI(t *testing.T) *sql.DB { t.Helper() dsn := os.Getenv("ACB_TEST_DATABASE_URL") if dsn == "" { t.Skip("ACB_TEST_DATABASE_URL not set, skipping integration test") } db, err := sql.Open("postgres", dsn) if err != nil { t.Fatalf("open test db: %v", err) } db.SetMaxOpenConns(2) return db } // setupRotateKeySchema creates the bots table needed for rotate-key tests. func setupRotateKeySchema(t *testing.T, db *sql.DB) { t.Helper() _, err := db.Exec(` CREATE TABLE IF NOT EXISTS bots ( bot_id VARCHAR(16) PRIMARY KEY, name VARCHAR(32) UNIQUE NOT NULL, owner VARCHAR(128) NOT NULL DEFAULT 'test', endpoint_url TEXT NOT NULL DEFAULT 'http://localhost:8080', shared_secret TEXT NOT NULL DEFAULT 'secret', status VARCHAR(16) NOT NULL DEFAULT 'active', rating_mu DOUBLE PRECISION NOT NULL DEFAULT 1500.0, rating_phi DOUBLE PRECISION NOT NULL DEFAULT 350.0, rating_sigma DOUBLE PRECISION NOT NULL DEFAULT 0.06, evolved BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_active TIMESTAMPTZ, debug_public BOOLEAN NOT NULL DEFAULT FALSE ) `) if err != nil { t.Fatalf("create bots table: %v", err) } } // insertTestBot inserts a bot with a known encrypted secret and returns the plaintext secret. func insertTestBot(t *testing.T, db *sql.DB, encKey, botID, name, status string) string { t.Helper() secret, err := generateSecret() if err != nil { t.Fatal(err) } var encrypted string if encKey != "" { encrypted, err = encryptSecret(secret, encKey) if err != nil { t.Fatal(err) } } else { encrypted = secret } _, err = db.Exec(`INSERT INTO bots (bot_id, name, shared_secret, status) VALUES ($1, $2, $3, $4) ON CONFLICT (bot_id) DO UPDATE SET shared_secret = $3, status = $4`, botID, name, encrypted, status) if err != nil { t.Fatalf("insert test bot: %v", err) } return secret } // TestRotateKeyRoute tests that POST /api/rotate-key is registered (no DB needed). func TestRotateKeyRoute(t *testing.T) { srv := newTestServer() mux := http.NewServeMux() srv.RegisterRoutes(mux) req := httptest.NewRequest("POST", "/api/rotate-key", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) // Should not be 404 — proves the route is registered if w.Code == http.StatusNotFound { t.Fatal("POST /api/rotate-key returned 404 — route not registered") } } // TestRotateKey_Success rotates a bot's secret and verifies the new one works. func TestRotateKey_Success(t *testing.T) { db := openTestDBAPI(t) defer db.Close() setupRotateKeySchema(t, db) encKey := strings.Repeat("ab", 32) botID := "b_test001" secret := insertTestBot(t, db, encKey, botID, "TestBot1", "active") srv := &Server{cfg: Config{EncryptionKey: encKey}, db: db} mux := http.NewServeMux() srv.RegisterRoutes(mux) // Rotate the key body, _ := json.Marshal(map[string]string{ "bot_id": botID, "shared_secret": secret, }) req := httptest.NewRequest("POST", "/api/rotate-key", bytes.NewReader(body)) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("rotate-key status = %d, want 200; body = %s", w.Code, w.Body.String()) } var resp map[string]interface{} json.NewDecoder(w.Body).Decode(&resp) newSecret, ok := resp["shared_secret"].(string) if !ok || newSecret == "" { t.Fatal("response missing shared_secret") } if newSecret == secret { t.Error("new secret should differ from old secret") } if newSecret == "" || len(newSecret) != 64 { t.Errorf("new secret length = %d, want 64", len(newSecret)) } } // TestRotateKey_OldSecretRejected verifies the old secret is rejected after rotation. func TestRotateKey_OldSecretRejected(t *testing.T) { db := openTestDBAPI(t) defer db.Close() setupRotateKeySchema(t, db) encKey := strings.Repeat("ab", 32) botID := "b_test002" secret := insertTestBot(t, db, encKey, botID, "TestBot2", "active") srv := &Server{cfg: Config{EncryptionKey: encKey}, db: db} mux := http.NewServeMux() srv.RegisterRoutes(mux) // Rotate the key body, _ := json.Marshal(map[string]string{ "bot_id": botID, "shared_secret": secret, }) req := httptest.NewRequest("POST", "/api/rotate-key", bytes.NewReader(body)) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("first rotation: status = %d, want 200", w.Code) } // Try to rotate again with the OLD secret — should fail body2, _ := json.Marshal(map[string]string{ "bot_id": botID, "shared_secret": secret, }) req2 := httptest.NewRequest("POST", "/api/rotate-key", bytes.NewReader(body2)) w2 := httptest.NewRecorder() mux.ServeHTTP(w2, req2) if w2.Code != http.StatusUnauthorized { t.Errorf("old secret should be rejected: status = %d, want 401; body = %s", w2.Code, w2.Body.String()) } } // TestRotateKey_Retire sets the bot to retired and verifies status. func TestRotateKey_Retire(t *testing.T) { db := openTestDBAPI(t) defer db.Close() setupRotateKeySchema(t, db) encKey := strings.Repeat("ab", 32) botID := "b_test003" secret := insertTestBot(t, db, encKey, botID, "TestBot3", "active") srv := &Server{cfg: Config{EncryptionKey: encKey}, db: db} mux := http.NewServeMux() srv.RegisterRoutes(mux) // Rotate and retire body, _ := json.Marshal(map[string]interface{}{ "bot_id": botID, "shared_secret": secret, "retire": true, }) req := httptest.NewRequest("POST", "/api/rotate-key", bytes.NewReader(body)) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("retire status = %d, want 200; body = %s", w.Code, w.Body.String()) } var resp map[string]interface{} json.NewDecoder(w.Body).Decode(&resp) if resp["status"] != "retired" { t.Errorf("response status = %v, want 'retired'", resp["status"]) } // Verify DB status var dbStatus string err := db.QueryRow(`SELECT status FROM bots WHERE bot_id = $1`, botID).Scan(&dbStatus) if err != nil { t.Fatal(err) } if dbStatus != "retired" { t.Errorf("db status = %q, want 'retired'", dbStatus) } } // TestRotateKey_RetiredBotCannotRotate verifies a retired bot is rejected. func TestRotateKey_RetiredBotCannotRotate(t *testing.T) { db := openTestDBAPI(t) defer db.Close() setupRotateKeySchema(t, db) encKey := strings.Repeat("ab", 32) botID := "b_test004" secret := insertTestBot(t, db, encKey, botID, "TestBot4", "retired") srv := &Server{cfg: Config{EncryptionKey: encKey}, db: db} mux := http.NewServeMux() srv.RegisterRoutes(mux) body, _ := json.Marshal(map[string]string{ "bot_id": botID, "shared_secret": secret, }) req := httptest.NewRequest("POST", "/api/rotate-key", bytes.NewReader(body)) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusConflict { t.Errorf("retired bot rotate should return 409: status = %d, body = %s", w.Code, w.Body.String()) } } // TestRotateKey_InvalidSecret verifies wrong secret is rejected. func TestRotateKey_InvalidSecret(t *testing.T) { db := openTestDBAPI(t) defer db.Close() setupRotateKeySchema(t, db) encKey := strings.Repeat("ab", 32) botID := "b_test005" insertTestBot(t, db, encKey, botID, "TestBot5", "active") srv := &Server{cfg: Config{EncryptionKey: encKey}, db: db} mux := http.NewServeMux() srv.RegisterRoutes(mux) body, _ := json.Marshal(map[string]string{ "bot_id": botID, "shared_secret": "completely-wrong-secret", }) req := httptest.NewRequest("POST", "/api/rotate-key", bytes.NewReader(body)) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Errorf("wrong secret should return 401: status = %d, body = %s", w.Code, w.Body.String()) } } // TestRotateKey_MissingFields verifies required fields are enforced. func TestRotateKey_MissingFields(t *testing.T) { srv := newTestServer() mux := http.NewServeMux() srv.RegisterRoutes(mux) cases := []struct { name string body map[string]string }{ {"no bot_id", map[string]string{"shared_secret": "abc"}}, {"no secret", map[string]string{"bot_id": "b_test"}}, {"empty body", map[string]string{}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { body, _ := json.Marshal(tc.body) req := httptest.NewRequest("POST", "/api/rotate-key", bytes.NewReader(body)) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", w.Code) } }) } } // TestRotateKey_NewSecretWorksForSecondRotation verifies that the new secret // returned by a rotation can be used for a subsequent rotation. func TestRotateKey_NewSecretWorksForSecondRotation(t *testing.T) { db := openTestDBAPI(t) defer db.Close() setupRotateKeySchema(t, db) encKey := strings.Repeat("ab", 32) botID := "b_test006" secret := insertTestBot(t, db, encKey, botID, "TestBot6", "active") srv := &Server{cfg: Config{EncryptionKey: encKey}, db: db} mux := http.NewServeMux() srv.RegisterRoutes(mux) // First rotation body, _ := json.Marshal(map[string]string{ "bot_id": botID, "shared_secret": secret, }) req := httptest.NewRequest("POST", "/api/rotate-key", bytes.NewReader(body)) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("first rotation: status = %d, want 200", w.Code) } var resp map[string]interface{} json.NewDecoder(w.Body).Decode(&resp) newSecret := resp["shared_secret"].(string) // Second rotation using the new secret body2, _ := json.Marshal(map[string]string{ "bot_id": botID, "shared_secret": newSecret, }) req2 := httptest.NewRequest("POST", "/api/rotate-key", bytes.NewReader(body2)) w2 := httptest.NewRecorder() mux.ServeHTTP(w2, req2) if w2.Code != http.StatusOK { t.Errorf("second rotation with new secret: status = %d, want 200; body = %s", w2.Code, w2.Body.String()) } } // TestRotateKey_RetiredBotExcludedFromMatchmaking verifies that a retired bot // does not appear in the matchmaker's active bot query. func TestRotateKey_RetiredBotExcludedFromMatchmaking(t *testing.T) { db := openTestDBAPI(t) defer db.Close() setupRotateKeySchema(t, db) encKey := strings.Repeat("ab", 32) // Insert one active and one retired bot insertTestBot(t, db, encKey, "b_active1", "ActiveBot", "active") insertTestBot(t, db, encKey, "b_retired1", "RetiredBot", "retired") // Run the matchmaker eligibility query (from cmd/acb-matchmaker/tickers.go) rows, err := db.Query(`SELECT bot_id FROM bots WHERE status = 'active'`) if err != nil { t.Fatal(err) } defer rows.Close() var activeIDs []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { t.Fatal(err) } activeIDs = append(activeIDs, id) } for _, id := range activeIDs { if id == "b_retired1" { t.Error("retired bot should not appear in matchmaker eligibility query") } } found := false for _, id := range activeIDs { if id == "b_active1" { found = true } } if !found { t.Error("active bot should appear in matchmaker eligibility query") } } // TestRotateKey_PlaintextFallback verifies rotation works without encryption key. func TestRotateKey_PlaintextFallback(t *testing.T) { db := openTestDBAPI(t) defer db.Close() setupRotateKeySchema(t, db) // No encryption key — secrets stored as plaintext botID := "b_test007" secret := insertTestBot(t, db, "", botID, "TestBot7", "active") srv := &Server{cfg: Config{}, db: db} mux := http.NewServeMux() srv.RegisterRoutes(mux) body, _ := json.Marshal(map[string]string{ "bot_id": botID, "shared_secret": secret, }) req := httptest.NewRequest("POST", "/api/rotate-key", bytes.NewReader(body)) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("plaintext rotation: status = %d, want 200; body = %s", w.Code, w.Body.String()) } } // TestRotateKey_ BotNotFound verifies 404 for nonexistent bot. func TestRotateKey_BotNotFound(t *testing.T) { db := openTestDBAPI(t) defer db.Close() setupRotateKeySchema(t, db) srv := &Server{cfg: Config{}, db: db} mux := http.NewServeMux() srv.RegisterRoutes(mux) body, _ := json.Marshal(map[string]string{ "bot_id": "b_nonexistent", "shared_secret": "whatever", }) req := httptest.NewRequest("POST", "/api/rotate-key", bytes.NewReader(body)) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Errorf("nonexistent bot should return 404: status = %d", w.Code) } } // TestRotateKey_MethodNotAllowed verifies only POST is accepted. func TestRotateKey_MethodNotAllowed(t *testing.T) { srv := newTestServer() mux := http.NewServeMux() srv.RegisterRoutes(mux) for _, method := range []string{"GET", "PUT", "PATCH", "DELETE"} { req := httptest.NewRequest(method, "/api/rotate-key", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusMethodNotAllowed { t.Errorf("%s /api/rotate-key: status = %d, want 405", method, w.Code) } } }