// Package mciasgoclient_test provides tests for the MCIAS Go client. // All tests use inline httptest.NewServer mocks to keep this module // self-contained (no cross-module imports). package mciasgoclient_test import ( "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" mciasgoclient "git.wntrmute.dev/kyle/mcias/clients/go" ) // --------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------- func newTestClient(t *testing.T, serverURL string) *mciasgoclient.Client { t.Helper() c, err := mciasgoclient.New(serverURL, mciasgoclient.Options{}) if err != nil { t.Fatalf("New: %v", err) } return c } func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } // --------------------------------------------------------------------------- // TestNew // --------------------------------------------------------------------------- func TestNew(t *testing.T) { c, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{}) if err != nil { t.Fatalf("expected no error, got %v", err) } if c == nil { t.Fatal("expected non-nil client") } } func TestNewWithPresetToken(t *testing.T) { c, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{Token: "preset-tok"}) if err != nil { t.Fatalf("expected no error, got %v", err) } if c.Token() != "preset-tok" { t.Errorf("expected preset-tok, got %q", c.Token()) } } func TestNewBadCACert(t *testing.T) { _, err := mciasgoclient.New("https://example.com", mciasgoclient.Options{CACertPath: "/nonexistent/ca.pem"}) if err == nil { t.Fatal("expected error for missing CA cert file") } } // --------------------------------------------------------------------------- // TestHealth // --------------------------------------------------------------------------- func TestHealth(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/health" || r.Method != http.MethodGet { http.Error(w, "not found", http.StatusNotFound) return } w.WriteHeader(http.StatusOK) })) defer srv.Close() c := newTestClient(t, srv.URL) if err := c.Health(); err != nil { t.Fatalf("Health: unexpected error: %v", err) } } func TestHealthError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusServiceUnavailable, "service unavailable") })) defer srv.Close() c := newTestClient(t, srv.URL) err := c.Health() if err == nil { t.Fatal("expected error for 503") } var srvErr *mciasgoclient.MciasServerError if !errors.As(err, &srvErr) { t.Errorf("expected MciasServerError, got %T: %v", err, err) } if srvErr.StatusCode != 503 { t.Errorf("expected StatusCode 503, got %d", srvErr.StatusCode) } } // --------------------------------------------------------------------------- // TestGetPublicKey // --------------------------------------------------------------------------- func TestGetPublicKey(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/keys/public" { http.Error(w, "not found", http.StatusNotFound) return } writeJSON(w, http.StatusOK, map[string]string{ "kty": "OKP", "crv": "Ed25519", "x": "base64urlpublickeyvalue", "use": "sig", "alg": "EdDSA", }) })) defer srv.Close() c := newTestClient(t, srv.URL) pk, err := c.GetPublicKey() if err != nil { t.Fatalf("GetPublicKey: %v", err) } if pk.Kty != "OKP" { t.Errorf("expected kty=OKP, got %q", pk.Kty) } if pk.Crv != "Ed25519" { t.Errorf("expected crv=Ed25519, got %q", pk.Crv) } if pk.X == "" { t.Error("expected non-empty x") } } // --------------------------------------------------------------------------- // TestLogin // --------------------------------------------------------------------------- func TestLogin(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/auth/login" || r.Method != http.MethodPost { http.Error(w, "not found", http.StatusNotFound) return } writeJSON(w, http.StatusOK, map[string]string{ "token": "tok-abc123", "expires_at": "2099-01-01T00:00:00Z", }) })) defer srv.Close() c := newTestClient(t, srv.URL) tok, exp, err := c.Login("alice", "secret", "") if err != nil { t.Fatalf("Login: %v", err) } if tok != "tok-abc123" { t.Errorf("expected tok-abc123, got %q", tok) } if exp == "" { t.Error("expected non-empty expires_at") } if c.Token() != "tok-abc123" { t.Errorf("Token() = %q, want tok-abc123", c.Token()) } } func TestLoginUnauthorized(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusUnauthorized, "invalid credentials") })) defer srv.Close() c := newTestClient(t, srv.URL) _, _, err := c.Login("alice", "wrong", "") if err == nil { t.Fatal("expected error for 401") } var authErr *mciasgoclient.MciasAuthError if !errors.As(err, &authErr) { t.Errorf("expected MciasAuthError, got %T: %v", err, err) } } // --------------------------------------------------------------------------- // TestLogout // --------------------------------------------------------------------------- func TestLogout(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/auth/login": writeJSON(w, http.StatusOK, map[string]string{ "token": "tok-logout", "expires_at": "2099-01-01T00:00:00Z", }) case "/v1/auth/logout": w.WriteHeader(http.StatusOK) default: http.Error(w, "not found", http.StatusNotFound) } })) defer srv.Close() c := newTestClient(t, srv.URL) if _, _, err := c.Login("alice", "pass", ""); err != nil { t.Fatalf("Login: %v", err) } if c.Token() == "" { t.Fatal("expected token after login") } if err := c.Logout(); err != nil { t.Fatalf("Logout: %v", err) } if c.Token() != "" { t.Errorf("expected empty token after logout, got %q", c.Token()) } } // --------------------------------------------------------------------------- // TestRenewToken // --------------------------------------------------------------------------- func TestRenewToken(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/auth/login": writeJSON(w, http.StatusOK, map[string]string{ "token": "tok-old", "expires_at": "2099-01-01T00:00:00Z", }) case "/v1/auth/renew": writeJSON(w, http.StatusOK, map[string]string{ "token": "tok-new", "expires_at": "2099-06-01T00:00:00Z", }) default: http.Error(w, "not found", http.StatusNotFound) } })) defer srv.Close() c := newTestClient(t, srv.URL) if _, _, err := c.Login("alice", "pass", ""); err != nil { t.Fatalf("Login: %v", err) } tok, _, err := c.RenewToken() if err != nil { t.Fatalf("RenewToken: %v", err) } if tok != "tok-new" { t.Errorf("expected tok-new, got %q", tok) } if c.Token() != "tok-new" { t.Errorf("Token() = %q, want tok-new", c.Token()) } } // --------------------------------------------------------------------------- // TestEnrollTOTP / TestConfirmTOTP // --------------------------------------------------------------------------- func TestEnrollTOTP(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/auth/totp/enroll" || r.Method != http.MethodPost { http.Error(w, "not found", http.StatusNotFound) return } writeJSON(w, http.StatusOK, map[string]string{ "secret": "JBSWY3DPEHPK3PXP", "otpauth_uri": "otpauth://totp/MCIAS:alice?secret=JBSWY3DPEHPK3PXP&issuer=MCIAS", }) })) defer srv.Close() c := newTestClient(t, srv.URL) resp, err := c.EnrollTOTP() if err != nil { t.Fatalf("EnrollTOTP: %v", err) } if resp.Secret != "JBSWY3DPEHPK3PXP" { t.Errorf("expected secret=JBSWY3DPEHPK3PXP, got %q", resp.Secret) } if resp.OTPAuthURI == "" { t.Error("expected non-empty otpauth_uri") } } func TestConfirmTOTP(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/auth/totp/confirm" || r.Method != http.MethodPost { http.Error(w, "not found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) })) defer srv.Close() c := newTestClient(t, srv.URL) if err := c.ConfirmTOTP("123456"); err != nil { t.Fatalf("ConfirmTOTP: unexpected error: %v", err) } } func TestConfirmTOTPBadCode(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid TOTP code") })) defer srv.Close() c := newTestClient(t, srv.URL) err := c.ConfirmTOTP("000000") if err == nil { t.Fatal("expected error for bad TOTP code") } var inputErr *mciasgoclient.MciasInputError if !errors.As(err, &inputErr) { t.Errorf("expected MciasInputError, got %T: %v", err, err) } } // --------------------------------------------------------------------------- // TestChangePassword // --------------------------------------------------------------------------- func TestChangePassword(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/auth/password" || r.Method != http.MethodPut { http.Error(w, "not found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) })) defer srv.Close() c := newTestClient(t, srv.URL) if err := c.ChangePassword("old-s3cr3t", "new-s3cr3t-long"); err != nil { t.Fatalf("ChangePassword: unexpected error: %v", err) } } func TestChangePasswordWrongCurrent(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusUnauthorized, "current password is incorrect") })) defer srv.Close() c := newTestClient(t, srv.URL) err := c.ChangePassword("wrong", "new-s3cr3t-long") if err == nil { t.Fatal("expected error for wrong current password") } var authErr *mciasgoclient.MciasAuthError if !errors.As(err, &authErr) { t.Errorf("expected MciasAuthError, got %T: %v", err, err) } } // --------------------------------------------------------------------------- // TestRemoveTOTP // --------------------------------------------------------------------------- func TestRemoveTOTP(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/auth/totp" || r.Method != http.MethodDelete { http.Error(w, "not found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) })) defer srv.Close() c := newTestClient(t, srv.URL) if err := c.RemoveTOTP("acct-uuid-42"); err != nil { t.Fatalf("RemoveTOTP: unexpected error: %v", err) } } // --------------------------------------------------------------------------- // TestValidateToken // --------------------------------------------------------------------------- func TestValidateToken(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/token/validate" { http.Error(w, "not found", http.StatusNotFound) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "valid": true, "sub": "user-uuid-1", "roles": []string{"admin"}, "expires_at": "2099-01-01T00:00:00Z", }) })) defer srv.Close() c := newTestClient(t, srv.URL) claims, err := c.ValidateToken("some.jwt.token") if err != nil { t.Fatalf("ValidateToken: %v", err) } if !claims.Valid { t.Error("expected claims.Valid = true") } if claims.Sub != "user-uuid-1" { t.Errorf("expected sub=user-uuid-1, got %q", claims.Sub) } } func TestValidateTokenInvalid(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false}) })) defer srv.Close() c := newTestClient(t, srv.URL) claims, err := c.ValidateToken("expired.jwt.token") if err != nil { t.Fatalf("ValidateToken: unexpected error: %v", err) } if claims.Valid { t.Error("expected claims.Valid = false") } } // --------------------------------------------------------------------------- // TestCreateAccount // --------------------------------------------------------------------------- func TestCreateAccount(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/accounts" || r.Method != http.MethodPost { http.Error(w, "not found", http.StatusNotFound) return } writeJSON(w, http.StatusCreated, map[string]interface{}{ "id": "acct-uuid-1", "username": "bob", "account_type": "human", "status": "active", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", "totp_enabled": false, }) })) defer srv.Close() c := newTestClient(t, srv.URL) acct, err := c.CreateAccount("bob", "human", "pass123") if err != nil { t.Fatalf("CreateAccount: %v", err) } if acct.ID != "acct-uuid-1" { t.Errorf("expected id=acct-uuid-1, got %q", acct.ID) } if acct.Username != "bob" { t.Errorf("expected username=bob, got %q", acct.Username) } } func TestCreateAccountConflict(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusConflict, "username already exists") })) defer srv.Close() c := newTestClient(t, srv.URL) _, err := c.CreateAccount("bob", "human", "pass123") if err == nil { t.Fatal("expected error for 409") } var conflictErr *mciasgoclient.MciasConflictError if !errors.As(err, &conflictErr) { t.Errorf("expected MciasConflictError, got %T: %v", err, err) } } // --------------------------------------------------------------------------- // TestListAccounts // --------------------------------------------------------------------------- func TestListAccounts(t *testing.T) { accounts := []map[string]interface{}{ {"id": "acct-1", "username": "alice", "account_type": "human", "status": "active", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", "totp_enabled": false}, {"id": "acct-2", "username": "bob", "account_type": "human", "status": "active", "created_at": "2024-01-02T00:00:00Z", "updated_at": "2024-01-02T00:00:00Z", "totp_enabled": false}, } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/accounts" || r.Method != http.MethodGet { http.Error(w, "not found", http.StatusNotFound) return } writeJSON(w, http.StatusOK, accounts) })) defer srv.Close() c := newTestClient(t, srv.URL) list, err := c.ListAccounts() if err != nil { t.Fatalf("ListAccounts: %v", err) } if len(list) != 2 { t.Errorf("expected 2 accounts, got %d", len(list)) } if list[0].Username != "alice" { t.Errorf("expected alice, got %q", list[0].Username) } } // --------------------------------------------------------------------------- // TestGetAccount // --------------------------------------------------------------------------- func TestGetAccount(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/v1/accounts/") { http.Error(w, "not found", http.StatusNotFound) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "id": "acct-uuid-42", "username": "carol", "account_type": "human", "status": "active", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", "totp_enabled": false, }) })) defer srv.Close() c := newTestClient(t, srv.URL) acct, err := c.GetAccount("acct-uuid-42") if err != nil { t.Fatalf("GetAccount: %v", err) } if acct.ID != "acct-uuid-42" { t.Errorf("expected acct-uuid-42, got %q", acct.ID) } } // --------------------------------------------------------------------------- // TestUpdateAccount // --------------------------------------------------------------------------- func TestUpdateAccount(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPatch { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } w.WriteHeader(http.StatusNoContent) })) defer srv.Close() c := newTestClient(t, srv.URL) if err := c.UpdateAccount("acct-uuid-42", "inactive"); err != nil { t.Fatalf("UpdateAccount: unexpected error: %v", err) } } // --------------------------------------------------------------------------- // TestDeleteAccount // --------------------------------------------------------------------------- func TestDeleteAccount(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } w.WriteHeader(http.StatusNoContent) })) defer srv.Close() c := newTestClient(t, srv.URL) if err := c.DeleteAccount("acct-uuid-42"); err != nil { t.Fatalf("DeleteAccount: unexpected error: %v", err) } } // --------------------------------------------------------------------------- // TestAdminSetPassword // --------------------------------------------------------------------------- func TestAdminSetPassword(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut || !strings.HasSuffix(r.URL.Path, "/password") { http.Error(w, "not found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) })) defer srv.Close() c := newTestClient(t, srv.URL) if err := c.AdminSetPassword("acct-uuid-42", "new-s3cr3t-long"); err != nil { t.Fatalf("AdminSetPassword: unexpected error: %v", err) } } // --------------------------------------------------------------------------- // TestGetRoles / TestSetRoles // --------------------------------------------------------------------------- func TestGetRoles(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/roles") { http.Error(w, "not found", http.StatusNotFound) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "roles": []string{"admin", "viewer"}, }) })) defer srv.Close() c := newTestClient(t, srv.URL) roles, err := c.GetRoles("acct-uuid-42") if err != nil { t.Fatalf("GetRoles: %v", err) } if len(roles) != 2 { t.Errorf("expected 2 roles, got %d", len(roles)) } if roles[0] != "admin" { t.Errorf("expected roles[0]=admin, got %q", roles[0]) } } func TestSetRoles(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } w.WriteHeader(http.StatusOK) })) defer srv.Close() c := newTestClient(t, srv.URL) if err := c.SetRoles("acct-uuid-42", []string{"admin"}); err != nil { t.Fatalf("SetRoles: unexpected error: %v", err) } } // --------------------------------------------------------------------------- // TestGetAccountTags / TestSetAccountTags // --------------------------------------------------------------------------- func TestGetAccountTags(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/tags") { http.Error(w, "not found", http.StatusNotFound) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "tags": []string{"env:production", "svc:payments-api"}, }) })) defer srv.Close() c := newTestClient(t, srv.URL) tags, err := c.GetAccountTags("acct-uuid-42") if err != nil { t.Fatalf("GetAccountTags: %v", err) } if len(tags) != 2 { t.Errorf("expected 2 tags, got %d", len(tags)) } if tags[0] != "env:production" { t.Errorf("expected tags[0]=env:production, got %q", tags[0]) } } func TestSetAccountTags(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut || !strings.HasSuffix(r.URL.Path, "/tags") { http.Error(w, "not found", http.StatusNotFound) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "tags": []string{"env:staging"}, }) })) defer srv.Close() c := newTestClient(t, srv.URL) tags, err := c.SetAccountTags("acct-uuid-42", []string{"env:staging"}) if err != nil { t.Fatalf("SetAccountTags: unexpected error: %v", err) } if len(tags) != 1 || tags[0] != "env:staging" { t.Errorf("expected [env:staging], got %v", tags) } } func TestSetAccountTagsClear(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } writeJSON(w, http.StatusOK, map[string]interface{}{"tags": []string{}}) })) defer srv.Close() c := newTestClient(t, srv.URL) tags, err := c.SetAccountTags("acct-uuid-42", []string{}) if err != nil { t.Fatalf("SetAccountTags (clear): unexpected error: %v", err) } if len(tags) != 0 { t.Errorf("expected empty tags, got %v", tags) } } // --------------------------------------------------------------------------- // TestIssueServiceToken / TestRevokeToken // --------------------------------------------------------------------------- func TestIssueServiceToken(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/token/issue" || r.Method != http.MethodPost { http.Error(w, "not found", http.StatusNotFound) return } writeJSON(w, http.StatusOK, map[string]string{ "token": "svc-tok-xyz", "expires_at": "2099-01-01T00:00:00Z", }) })) defer srv.Close() c := newTestClient(t, srv.URL) tok, exp, err := c.IssueServiceToken("svc-uuid-1") if err != nil { t.Fatalf("IssueServiceToken: %v", err) } if tok != "svc-tok-xyz" { t.Errorf("expected svc-tok-xyz, got %q", tok) } if exp == "" { t.Error("expected non-empty expires_at") } } func TestRevokeToken(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete || !strings.HasPrefix(r.URL.Path, "/v1/token/") { http.Error(w, "not found", http.StatusNotFound) return } w.WriteHeader(http.StatusOK) })) defer srv.Close() c := newTestClient(t, srv.URL) if err := c.RevokeToken("jti-abc123"); err != nil { t.Fatalf("RevokeToken: unexpected error: %v", err) } } // --------------------------------------------------------------------------- // TestGetPGCreds / TestSetPGCreds // --------------------------------------------------------------------------- func TestGetPGCreds(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/pgcreds") { http.Error(w, "not found", http.StatusNotFound) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "host": "db.example.com", "port": 5432, "database": "myapp", "username": "appuser", "password": "secretpw", }) })) defer srv.Close() c := newTestClient(t, srv.URL) creds, err := c.GetPGCreds("acct-uuid-42") if err != nil { t.Fatalf("GetPGCreds: %v", err) } if creds.Host != "db.example.com" { t.Errorf("expected host=db.example.com, got %q", creds.Host) } if creds.Port != 5432 { t.Errorf("expected port=5432, got %d", creds.Port) } if creds.Password != "secretpw" { t.Errorf("expected password=secretpw, got %q", creds.Password) } } func TestSetPGCreds(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut || !strings.HasSuffix(r.URL.Path, "/pgcreds") { http.Error(w, "not found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) })) defer srv.Close() c := newTestClient(t, srv.URL) if err := c.SetPGCreds("acct-uuid-42", "db.example.com", 5432, "myapp", "appuser", "secretpw"); err != nil { t.Fatalf("SetPGCreds: unexpected error: %v", err) } } // --------------------------------------------------------------------------- // TestListAudit // --------------------------------------------------------------------------- func TestListAudit(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/v1/audit") || r.Method != http.MethodGet { http.Error(w, "not found", http.StatusNotFound) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "events": []map[string]interface{}{ {"id": 42, "event_type": "login_ok", "event_time": "2026-03-11T09:01:23Z", "actor_id": "acct-uuid-1", "ip_address": "192.0.2.1"}, }, "total": 1, "limit": 50, "offset": 0, }) })) defer srv.Close() c := newTestClient(t, srv.URL) resp, err := c.ListAudit(mciasgoclient.AuditFilter{}) if err != nil { t.Fatalf("ListAudit: %v", err) } if resp.Total != 1 { t.Errorf("expected total=1, got %d", resp.Total) } if len(resp.Events) != 1 { t.Fatalf("expected 1 event, got %d", len(resp.Events)) } if resp.Events[0].EventType != "login_ok" { t.Errorf("expected event_type=login_ok, got %q", resp.Events[0].EventType) } } func TestListAuditWithFilter(t *testing.T) { var capturedQuery string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capturedQuery = r.URL.RawQuery writeJSON(w, http.StatusOK, map[string]interface{}{ "events": []map[string]interface{}{}, "total": 0, "limit": 10, "offset": 5, }) })) defer srv.Close() c := newTestClient(t, srv.URL) _, err := c.ListAudit(mciasgoclient.AuditFilter{ Limit: 10, Offset: 5, EventType: "login_fail", ActorID: "acct-uuid-1", }) if err != nil { t.Fatalf("ListAudit: %v", err) } for _, want := range []string{"limit=10", "offset=5", "event_type=login_fail", "actor_id=acct-uuid-1"} { if !strings.Contains(capturedQuery, want) { t.Errorf("expected %q in query string, got %q", want, capturedQuery) } } } // --------------------------------------------------------------------------- // TestListPolicyRules // --------------------------------------------------------------------------- func TestListPolicyRules(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/policy/rules" || r.Method != http.MethodGet { http.Error(w, "not found", http.StatusNotFound) return } writeJSON(w, http.StatusOK, []map[string]interface{}{ { "id": 1, "priority": 100, "description": "Allow payments-api to read its own pgcreds", "rule": map[string]interface{}{"effect": "allow", "actions": []string{"pgcreds:read"}}, "enabled": true, "created_at": "2026-03-11T09:00:00Z", "updated_at": "2026-03-11T09:00:00Z", }, }) })) defer srv.Close() c := newTestClient(t, srv.URL) rules, err := c.ListPolicyRules() if err != nil { t.Fatalf("ListPolicyRules: %v", err) } if len(rules) != 1 { t.Fatalf("expected 1 rule, got %d", len(rules)) } if rules[0].ID != 1 { t.Errorf("expected id=1, got %d", rules[0].ID) } if rules[0].Description != "Allow payments-api to read its own pgcreds" { t.Errorf("unexpected description: %q", rules[0].Description) } } // --------------------------------------------------------------------------- // TestCreatePolicyRule // --------------------------------------------------------------------------- func TestCreatePolicyRule(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/policy/rules" || r.Method != http.MethodPost { http.Error(w, "not found", http.StatusNotFound) return } writeJSON(w, http.StatusCreated, map[string]interface{}{ "id": 7, "priority": 50, "description": "Test rule", "rule": map[string]interface{}{"effect": "deny"}, "enabled": true, "created_at": "2026-03-11T09:00:00Z", "updated_at": "2026-03-11T09:00:00Z", }) })) defer srv.Close() c := newTestClient(t, srv.URL) rule, err := c.CreatePolicyRule(mciasgoclient.CreatePolicyRuleRequest{ Description: "Test rule", Priority: 50, Rule: mciasgoclient.PolicyRuleBody{Effect: "deny"}, }) if err != nil { t.Fatalf("CreatePolicyRule: %v", err) } if rule.ID != 7 { t.Errorf("expected id=7, got %d", rule.ID) } if rule.Priority != 50 { t.Errorf("expected priority=50, got %d", rule.Priority) } } // --------------------------------------------------------------------------- // TestGetPolicyRule // --------------------------------------------------------------------------- func TestGetPolicyRule(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/v1/policy/rules/7" { http.Error(w, "not found", http.StatusNotFound) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "id": 7, "priority": 50, "description": "Test rule", "rule": map[string]interface{}{"effect": "allow"}, "enabled": true, "created_at": "2026-03-11T09:00:00Z", "updated_at": "2026-03-11T09:00:00Z", }) })) defer srv.Close() c := newTestClient(t, srv.URL) rule, err := c.GetPolicyRule(7) if err != nil { t.Fatalf("GetPolicyRule: %v", err) } if rule.ID != 7 { t.Errorf("expected id=7, got %d", rule.ID) } } func TestGetPolicyRuleNotFound(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "rule not found") })) defer srv.Close() c := newTestClient(t, srv.URL) _, err := c.GetPolicyRule(999) if err == nil { t.Fatal("expected error for 404") } var notFoundErr *mciasgoclient.MciasNotFoundError if !errors.As(err, ¬FoundErr) { t.Errorf("expected MciasNotFoundError, got %T: %v", err, err) } } // --------------------------------------------------------------------------- // TestUpdatePolicyRule // --------------------------------------------------------------------------- func TestUpdatePolicyRule(t *testing.T) { enabled := false srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPatch || r.URL.Path != "/v1/policy/rules/7" { http.Error(w, "not found", http.StatusNotFound) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "id": 7, "priority": 50, "description": "Test rule", "rule": map[string]interface{}{"effect": "allow"}, "enabled": false, "created_at": "2026-03-11T09:00:00Z", "updated_at": "2026-03-12T10:00:00Z", }) })) defer srv.Close() c := newTestClient(t, srv.URL) rule, err := c.UpdatePolicyRule(7, mciasgoclient.UpdatePolicyRuleRequest{Enabled: &enabled}) if err != nil { t.Fatalf("UpdatePolicyRule: %v", err) } if rule.Enabled { t.Error("expected enabled=false after update") } } // --------------------------------------------------------------------------- // TestDeletePolicyRule // --------------------------------------------------------------------------- func TestDeletePolicyRule(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete || r.URL.Path != "/v1/policy/rules/7" { http.Error(w, "not found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) })) defer srv.Close() c := newTestClient(t, srv.URL) if err := c.DeletePolicyRule(7); err != nil { t.Fatalf("DeletePolicyRule: unexpected error: %v", err) } } // --------------------------------------------------------------------------- // TestIntegration: full login → validate → logout flow // --------------------------------------------------------------------------- func TestIntegration(t *testing.T) { const sessionToken = "integration-tok-999" mux := http.NewServeMux() mux.HandleFunc("/v1/auth/login", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var body struct { Username string `json:"username"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "bad request") return } if body.Username != "alice" || body.Password != "correct-horse" { writeError(w, http.StatusUnauthorized, "invalid credentials") return } writeJSON(w, http.StatusOK, map[string]string{ "token": sessionToken, "expires_at": "2099-01-01T00:00:00Z", }) }) mux.HandleFunc("/v1/token/validate", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var body struct { Token string `json:"token"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "bad request") return } if body.Token == sessionToken { writeJSON(w, http.StatusOK, map[string]interface{}{ "valid": true, "sub": "alice-uuid", "roles": []string{"user"}, "expires_at": "2099-01-01T00:00:00Z", }) } else { writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false}) } }) mux.HandleFunc("/v1/auth/logout", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if r.Header.Get("Authorization") == "" { writeError(w, http.StatusUnauthorized, "missing token") return } w.WriteHeader(http.StatusOK) }) srv := httptest.NewServer(mux) defer srv.Close() c := newTestClient(t, srv.URL) // Step 1: wrong credentials → MciasAuthError. _, _, err := c.Login("alice", "wrong-password", "") if err == nil { t.Fatal("expected error for wrong credentials") } var authErr *mciasgoclient.MciasAuthError if !errors.As(err, &authErr) { t.Errorf("expected MciasAuthError, got %T", err) } // Step 2: correct login. tok, _, err := c.Login("alice", "correct-horse", "") if err != nil { t.Fatalf("Login: %v", err) } if tok != sessionToken { t.Errorf("expected %q, got %q", sessionToken, tok) } // Step 3: validate → valid=true. claims, err := c.ValidateToken(tok) if err != nil { t.Fatalf("ValidateToken: %v", err) } if !claims.Valid { t.Error("expected Valid=true after login") } if claims.Sub != "alice-uuid" { t.Errorf("expected sub=alice-uuid, got %q", claims.Sub) } // Step 4: garbage token → valid=false (not an error). claims2, err := c.ValidateToken("garbage-token") if err != nil { t.Fatalf("ValidateToken(garbage): unexpected error: %v", err) } if claims2.Valid { t.Error("expected Valid=false for garbage token") } // Step 5: logout clears stored token. if err := c.Logout(); err != nil { t.Fatalf("Logout: %v", err) } if c.Token() != "" { t.Errorf("expected empty token after logout, got %q", c.Token()) } }