fix(api): remove unused time import from rotatekey_test.go
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
7df2fad568
commit
35c705fa66
1 changed files with 468 additions and 0 deletions
468
cmd/acb-api/rotatekey_test.go
Normal file
468
cmd/acb-api/rotatekey_test.go
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue