- clients/README.md: canonical API surface and error type reference - clients/testdata/: shared JSON response fixtures - clients/go/: mciasgoclient package; net/http + TLS 1.2+; sync.RWMutex token state; DisallowUnknownFields on all decoders; 25 tests pass - clients/rust/: async mcias-client crate; reqwest+rustls (no OpenSSL); thiserror MciasError enum; Arc<RwLock> token state; 22+1 tests pass; cargo clippy -D warnings clean - clients/lisp/: ASDF mcias-client; dexador HTTP, yason JSON; mcias-error condition hierarchy; Hunchentoot mock-dispatcher; 37 fiveam checks pass on SBCL 2.6.1; yason boolean normalisation in validate-token - clients/python/: mcias_client package (Python 3.11+); httpx sync; py.typed; dataclasses; 32 pytest tests; mypy --strict + ruff clean - test/mock/mockserver.go: in-memory mock server for Go client tests - ARCHITECTURE.md §19: updated per-language notes to match implementation - PROGRESS.md: Phase 9 marked complete - .gitignore: exclude clients/rust/target/, python .venv, .pytest_cache, .fasl files Security: token never logged or exposed in error messages in any library; TLS enforced in all four languages; token stored under lock/mutex/RwLock
732 lines
24 KiB
Go
732 lines
24 KiB
Go
// 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
|
|
// ---------------------------------------------------------------------------
|
|
// newTestClient creates a client pointed at the given test server URL.
|
|
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
|
|
}
|
|
// writeJSON is a shorthand for writing a JSON response.
|
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
// writeError writes a JSON error body with the given status code.
|
|
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)
|
|
}
|
|
}
|
|
// ---------------------------------------------------------------------------
|
|
// TestHealthError
|
|
// ---------------------------------------------------------------------------
|
|
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")
|
|
}
|
|
// Token must be stored in the client.
|
|
if c.Token() != "tok-abc123" {
|
|
t.Errorf("Token() = %q, want tok-abc123", c.Token())
|
|
}
|
|
}
|
|
// ---------------------------------------------------------------------------
|
|
// TestLoginUnauthorized
|
|
// ---------------------------------------------------------------------------
|
|
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())
|
|
}
|
|
}
|
|
// ---------------------------------------------------------------------------
|
|
// 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)
|
|
}
|
|
}
|
|
// ---------------------------------------------------------------------------
|
|
// TestValidateTokenInvalid
|
|
// ---------------------------------------------------------------------------
|
|
func TestValidateTokenInvalid(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Server returns 200 with valid=false for an expired/revoked token.
|
|
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)
|
|
}
|
|
}
|
|
// ---------------------------------------------------------------------------
|
|
// TestCreateAccountConflict
|
|
// ---------------------------------------------------------------------------
|
|
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 {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !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
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"id": "acct-uuid-42",
|
|
"username": "carol",
|
|
"account_type": "human",
|
|
"status": "disabled",
|
|
"created_at": "2024-01-01T00:00:00Z",
|
|
"updated_at": "2024-02-01T00:00:00Z",
|
|
"totp_enabled": false,
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
c := newTestClient(t, srv.URL)
|
|
acct, err := c.UpdateAccount("acct-uuid-42", "disabled")
|
|
if err != nil {
|
|
t.Fatalf("UpdateAccount: %v", err)
|
|
}
|
|
if acct.Status != "disabled" {
|
|
t.Errorf("expected status=disabled, got %q", acct.Status)
|
|
}
|
|
}
|
|
// ---------------------------------------------------------------------------
|
|
// 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)
|
|
}
|
|
}
|
|
// ---------------------------------------------------------------------------
|
|
// TestGetRoles
|
|
// ---------------------------------------------------------------------------
|
|
func TestGetRoles(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !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])
|
|
}
|
|
}
|
|
// ---------------------------------------------------------------------------
|
|
// TestSetRoles
|
|
// ---------------------------------------------------------------------------
|
|
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)
|
|
}
|
|
}
|
|
// ---------------------------------------------------------------------------
|
|
// TestIssueServiceToken
|
|
// ---------------------------------------------------------------------------
|
|
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")
|
|
}
|
|
}
|
|
// ---------------------------------------------------------------------------
|
|
// TestRevokeToken
|
|
// ---------------------------------------------------------------------------
|
|
func TestRevokeToken(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
|
|
}
|
|
if !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
|
|
// ---------------------------------------------------------------------------
|
|
func TestGetPGCreds(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !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)
|
|
}
|
|
}
|
|
// ---------------------------------------------------------------------------
|
|
// TestSetPGCreds
|
|
// ---------------------------------------------------------------------------
|
|
func TestSetPGCreds(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
|
|
}
|
|
if !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)
|
|
err := c.SetPGCreds("acct-uuid-42", "db.example.com", 5432, "myapp", "appuser", "secretpw")
|
|
if err != nil {
|
|
t.Fatalf("SetPGCreds: 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
|
|
}
|
|
// Verify Authorization header is present.
|
|
auth := r.Header.Get("Authorization")
|
|
if auth == "" {
|
|
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: login with wrong credentials should fail.
|
|
_, _, 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: login with correct credentials.
|
|
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 the returned token.
|
|
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: validate an unknown token returns 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 the 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())
|
|
}
|
|
}
|