package server import ( "bytes" "crypto/ed25519" "crypto/rand" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "time" "git.wntrmute.dev/kyle/mcias/internal/auth" "git.wntrmute.dev/kyle/mcias/internal/config" "git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/kyle/mcias/internal/token" ) const testIssuer = "https://auth.example.com" func newTestServer(t *testing.T) (*Server, ed25519.PublicKey, ed25519.PrivateKey, *db.DB) { t.Helper() pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("generate key: %v", err) } database, err := db.Open(":memory:") if err != nil { t.Fatalf("open db: %v", err) } if err := db.Migrate(database); err != nil { t.Fatalf("migrate db: %v", err) } t.Cleanup(func() { _ = database.Close() }) masterKey := make([]byte, 32) if _, err := rand.Read(masterKey); err != nil { t.Fatalf("generate master key: %v", err) } cfg := config.NewTestConfig(testIssuer) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) srv := New(database, cfg, priv, pub, masterKey, logger) return srv, pub, priv, database } // createTestHumanAccount creates a human account with password "testpass123". func createTestHumanAccount(t *testing.T, srv *Server, username string) *model.Account { t.Helper() hash, err := auth.HashPassword("testpass123", auth.ArgonParams{Time: 3, Memory: 65536, Threads: 4}) if err != nil { t.Fatalf("hash password: %v", err) } acct, err := srv.db.CreateAccount(username, model.AccountTypeHuman, hash) if err != nil { t.Fatalf("create account: %v", err) } return acct } // issueAdminToken creates an account with admin role, issues a JWT, and tracks it. func issueAdminToken(t *testing.T, srv *Server, priv ed25519.PrivateKey, username string) (string, *model.Account) { t.Helper() acct := createTestHumanAccount(t, srv, username) if err := srv.db.GrantRole(acct.ID, "admin", nil); err != nil { t.Fatalf("grant admin role: %v", err) } tokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, []string{"admin"}, time.Hour) if err != nil { t.Fatalf("issue token: %v", err) } if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil { t.Fatalf("track token: %v", err) } return tokenStr, acct } func doRequest(t *testing.T, handler http.Handler, method, path string, body interface{}, authToken string) *httptest.ResponseRecorder { t.Helper() var bodyReader io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { t.Fatalf("marshal body: %v", err) } bodyReader = bytes.NewReader(b) } else { bodyReader = bytes.NewReader(nil) } req := httptest.NewRequest(method, path, bodyReader) req.Header.Set("Content-Type", "application/json") if authToken != "" { req.Header.Set("Authorization", "Bearer "+authToken) } rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) return rr } func TestHealth(t *testing.T) { srv, _, _, _ := newTestServer(t) handler := srv.Handler() rr := doRequest(t, handler, "GET", "/v1/health", nil, "") if rr.Code != http.StatusOK { t.Errorf("health status = %d, want 200", rr.Code) } } func TestPublicKey(t *testing.T) { srv, _, _, _ := newTestServer(t) handler := srv.Handler() rr := doRequest(t, handler, "GET", "/v1/keys/public", nil, "") if rr.Code != http.StatusOK { t.Errorf("public key status = %d, want 200", rr.Code) } var jwk map[string]string if err := json.Unmarshal(rr.Body.Bytes(), &jwk); err != nil { t.Fatalf("unmarshal JWK: %v", err) } if jwk["kty"] != "OKP" { t.Errorf("kty = %q, want OKP", jwk["kty"]) } if jwk["alg"] != "EdDSA" { t.Errorf("alg = %q, want EdDSA", jwk["alg"]) } } func TestLoginSuccess(t *testing.T) { srv, _, _, _ := newTestServer(t) createTestHumanAccount(t, srv, "alice") handler := srv.Handler() rr := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{ "username": "alice", "password": "testpass123", }, "") if rr.Code != http.StatusOK { t.Errorf("login status = %d, want 200; body: %s", rr.Code, rr.Body.String()) } var resp loginResponse if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal login response: %v", err) } if resp.Token == "" { t.Error("expected non-empty token in login response") } if resp.ExpiresAt == "" { t.Error("expected non-empty expires_at in login response") } } func TestLoginWrongPassword(t *testing.T) { srv, _, _, _ := newTestServer(t) createTestHumanAccount(t, srv, "bob") handler := srv.Handler() rr := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{ "username": "bob", "password": "wrongpassword", }, "") if rr.Code != http.StatusUnauthorized { t.Errorf("status = %d, want 401", rr.Code) } } func TestLoginUnknownUser(t *testing.T) { srv, _, _, _ := newTestServer(t) handler := srv.Handler() rr := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{ "username": "nobody", "password": "password", }, "") if rr.Code != http.StatusUnauthorized { t.Errorf("status = %d, want 401", rr.Code) } } func TestLoginResponseDoesNotContainCredentials(t *testing.T) { srv, _, _, _ := newTestServer(t) createTestHumanAccount(t, srv, "charlie") handler := srv.Handler() rr := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{ "username": "charlie", "password": "testpass123", }, "") body := rr.Body.String() // Security: password hash must never appear in any API response. if strings.Contains(body, "argon2id") || strings.Contains(body, "password_hash") { t.Error("login response contains password hash material") } } func TestTokenValidate(t *testing.T) { srv, _, priv, _ := newTestServer(t) acct := createTestHumanAccount(t, srv, "dave") handler := srv.Handler() // Issue and track a token. tokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour) if err != nil { t.Fatalf("IssueToken: %v", err) } if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil { t.Fatalf("TrackToken: %v", err) } req := httptest.NewRequest("POST", "/v1/token/validate", nil) req.Header.Set("Authorization", "Bearer "+tokenStr) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("validate status = %d, want 200", rr.Code) } var resp validateResponse if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } if !resp.Valid { t.Error("expected valid=true for valid token") } } func TestLogout(t *testing.T) { srv, _, priv, _ := newTestServer(t) acct := createTestHumanAccount(t, srv, "eve") handler := srv.Handler() tokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour) if err != nil { t.Fatalf("IssueToken: %v", err) } if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil { t.Fatalf("TrackToken: %v", err) } // Logout. rr := doRequest(t, handler, "POST", "/v1/auth/logout", nil, tokenStr) if rr.Code != http.StatusNoContent { t.Errorf("logout status = %d, want 204; body: %s", rr.Code, rr.Body.String()) } // Token should now be invalid on validate. req := httptest.NewRequest("POST", "/v1/token/validate", nil) req.Header.Set("Authorization", "Bearer "+tokenStr) rr2 := httptest.NewRecorder() handler.ServeHTTP(rr2, req) var resp validateResponse _ = json.Unmarshal(rr2.Body.Bytes(), &resp) if resp.Valid { t.Error("expected valid=false after logout") } } func TestCreateAccountAdmin(t *testing.T) { srv, _, priv, _ := newTestServer(t) adminToken, _ := issueAdminToken(t, srv, priv, "admin-user") handler := srv.Handler() rr := doRequest(t, handler, "POST", "/v1/accounts", map[string]string{ "username": "new-user", "password": "newpassword123", "account_type": "human", }, adminToken) if rr.Code != http.StatusCreated { t.Errorf("create account status = %d, want 201; body: %s", rr.Code, rr.Body.String()) } var resp accountResponse if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } if resp.Username != "new-user" { t.Errorf("Username = %q, want %q", resp.Username, "new-user") } // Security: password hash must not appear in account response. body := rr.Body.String() if strings.Contains(body, "password_hash") || strings.Contains(body, "argon2id") { t.Error("account creation response contains password hash") } } func TestCreateAccountRequiresAdmin(t *testing.T) { srv, _, priv, _ := newTestServer(t) acct := createTestHumanAccount(t, srv, "regular-user") tokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, []string{"reader"}, time.Hour) if err != nil { t.Fatalf("IssueToken: %v", err) } if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil { t.Fatalf("TrackToken: %v", err) } handler := srv.Handler() rr := doRequest(t, handler, "POST", "/v1/accounts", map[string]string{ "username": "other-user", "password": "password", "account_type": "human", }, tokenStr) if rr.Code != http.StatusForbidden { t.Errorf("status = %d, want 403", rr.Code) } } func TestListAccounts(t *testing.T) { srv, _, priv, _ := newTestServer(t) adminToken, _ := issueAdminToken(t, srv, priv, "admin2") createTestHumanAccount(t, srv, "user1") createTestHumanAccount(t, srv, "user2") handler := srv.Handler() rr := doRequest(t, handler, "GET", "/v1/accounts", nil, adminToken) if rr.Code != http.StatusOK { t.Errorf("list accounts status = %d, want 200", rr.Code) } var accounts []accountResponse if err := json.Unmarshal(rr.Body.Bytes(), &accounts); err != nil { t.Fatalf("unmarshal: %v", err) } if len(accounts) < 3 { // admin + user1 + user2 t.Errorf("expected at least 3 accounts, got %d", len(accounts)) } // Security: no credential fields in any response. body := rr.Body.String() for _, bad := range []string{"password_hash", "argon2id", "totp_secret", "PasswordHash"} { if strings.Contains(body, bad) { t.Errorf("account list response contains credential field %q", bad) } } } func TestDeleteAccount(t *testing.T) { srv, _, priv, _ := newTestServer(t) adminToken, _ := issueAdminToken(t, srv, priv, "admin3") target := createTestHumanAccount(t, srv, "delete-me") handler := srv.Handler() rr := doRequest(t, handler, "DELETE", "/v1/accounts/"+target.UUID, nil, adminToken) if rr.Code != http.StatusNoContent { t.Errorf("delete status = %d, want 204; body: %s", rr.Code, rr.Body.String()) } } func TestSetAndGetRoles(t *testing.T) { srv, _, priv, _ := newTestServer(t) adminToken, _ := issueAdminToken(t, srv, priv, "admin4") target := createTestHumanAccount(t, srv, "role-target") handler := srv.Handler() // Set roles. rr := doRequest(t, handler, "PUT", "/v1/accounts/"+target.UUID+"/roles", map[string][]string{ "roles": {"reader", "writer"}, }, adminToken) if rr.Code != http.StatusNoContent { t.Errorf("set roles status = %d, want 204; body: %s", rr.Code, rr.Body.String()) } // Get roles. rr2 := doRequest(t, handler, "GET", "/v1/accounts/"+target.UUID+"/roles", nil, adminToken) if rr2.Code != http.StatusOK { t.Errorf("get roles status = %d, want 200", rr2.Code) } var resp rolesResponse if err := json.Unmarshal(rr2.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } if len(resp.Roles) != 2 { t.Errorf("expected 2 roles, got %d", len(resp.Roles)) } } func TestLoginRateLimited(t *testing.T) { srv, _, _, _ := newTestServer(t) createTestHumanAccount(t, srv, "ratelimit-user") handler := srv.Handler() // The login endpoint uses RateLimit(10, 10): burst of 10 requests. // Exhaust the burst with 10 requests (valid or invalid — doesn't matter). body := map[string]string{ "username": "ratelimit-user", "password": "wrongpassword", } for i := range 10 { rr := doRequest(t, handler, "POST", "/v1/auth/login", body, "") if rr.Code == http.StatusTooManyRequests { t.Fatalf("request %d was rate-limited prematurely", i+1) } } // The 11th request from the same IP should be rate-limited. rr := doRequest(t, handler, "POST", "/v1/auth/login", body, "") if rr.Code != http.StatusTooManyRequests { t.Errorf("expected 429 after exhausting burst, got %d", rr.Code) } // Verify the Retry-After header is set. if rr.Header().Get("Retry-After") == "" { t.Error("expected Retry-After header on 429 response") } } func TestTokenValidateRateLimited(t *testing.T) { srv, _, _, _ := newTestServer(t) handler := srv.Handler() // The token/validate endpoint shares the same per-IP rate limiter as login. // Use a distinct RemoteAddr so we get a fresh bucket. body := map[string]string{"token": "not.a.valid.token"} for i := range 10 { b, _ := json.Marshal(body) req := httptest.NewRequest("POST", "/v1/token/validate", bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") req.RemoteAddr = "10.99.99.1:12345" rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code == http.StatusTooManyRequests { t.Fatalf("request %d was rate-limited prematurely", i+1) } } // 11th request should be rate-limited. b, _ := json.Marshal(body) req := httptest.NewRequest("POST", "/v1/token/validate", bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") req.RemoteAddr = "10.99.99.1:12345" rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code != http.StatusTooManyRequests { t.Errorf("expected 429 after exhausting burst, got %d", rr.Code) } } func TestHealthNotRateLimited(t *testing.T) { srv, _, _, _ := newTestServer(t) handler := srv.Handler() // Health endpoint should not be rate-limited — send 20 rapid requests. for i := range 20 { req := httptest.NewRequest("GET", "/v1/health", nil) req.RemoteAddr = "10.88.88.1:12345" rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("health request %d: status = %d, want 200", i+1, rr.Code) } } } func TestRenewToken(t *testing.T) { srv, _, priv, _ := newTestServer(t) acct := createTestHumanAccount(t, srv, "renew-user") handler := srv.Handler() oldTokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour) if err != nil { t.Fatalf("IssueToken: %v", err) } oldJTI := claims.JTI if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil { t.Fatalf("TrackToken: %v", err) } rr := doRequest(t, handler, "POST", "/v1/auth/renew", nil, oldTokenStr) if rr.Code != http.StatusOK { t.Fatalf("renew status = %d, want 200; body: %s", rr.Code, rr.Body.String()) } var resp loginResponse if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal renew response: %v", err) } if resp.Token == "" || resp.Token == oldTokenStr { t.Error("expected new, distinct token after renewal") } // Old token should be revoked in the database. rec, err := srv.db.GetTokenRecord(oldJTI) if err != nil { t.Fatalf("GetTokenRecord: %v", err) } if !rec.IsRevoked() { t.Error("old token should be revoked after renewal") } }