clients: expand Go, Python, Rust client APIs

- Add TOTP enrollment/confirmation/removal to all clients
- Add password change and admin set-password endpoints
- Add account listing, status update, and tag management
- Add audit log listing with filter support
- Add policy rule CRUD operations
- Expand test coverage for all new endpoints across clients
- Fix .gitignore to exclude built binaries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 20:29:11 -07:00
parent ec7c966ad2
commit cbcb1a0533
11 changed files with 1938 additions and 255 deletions

View File

@@ -3,6 +3,7 @@
// Security: bearer tokens are stored under a sync.RWMutex and are never written
// to logs or included in error messages anywhere in this package.
package mciasgoclient
import (
"bytes"
"crypto/tls"
@@ -15,32 +16,43 @@ import (
"strings"
"sync"
)
// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------
// MciasError is the base error type for all MCIAS client errors.
type MciasError struct {
StatusCode int
Message string
}
func (e *MciasError) Error() string {
return fmt.Sprintf("mciasgoclient: HTTP %d: %s", e.StatusCode, e.Message)
}
// MciasAuthError is returned for 401 Unauthorized responses.
type MciasAuthError struct{ MciasError }
// MciasForbiddenError is returned for 403 Forbidden responses.
type MciasForbiddenError struct{ MciasError }
// MciasNotFoundError is returned for 404 Not Found responses.
type MciasNotFoundError struct{ MciasError }
// MciasInputError is returned for 400 Bad Request responses.
type MciasInputError struct{ MciasError }
// MciasConflictError is returned for 409 Conflict responses.
type MciasConflictError struct{ MciasError }
// MciasServerError is returned for 5xx responses.
type MciasServerError struct{ MciasError }
// ---------------------------------------------------------------------------
// Data types
// ---------------------------------------------------------------------------
// Account represents a user or service account.
type Account struct {
ID string `json:"id"`
@@ -51,6 +63,7 @@ type Account struct {
UpdatedAt string `json:"updated_at"`
TOTPEnabled bool `json:"totp_enabled"`
}
// PublicKey represents the server's Ed25519 public key in JWK format.
type PublicKey struct {
Kty string `json:"kty"`
@@ -59,6 +72,7 @@ type PublicKey struct {
Use string `json:"use,omitempty"`
Alg string `json:"alg,omitempty"`
}
// TokenClaims is returned by ValidateToken.
type TokenClaims struct {
Valid bool `json:"valid"`
@@ -66,6 +80,7 @@ type TokenClaims struct {
Roles []string `json:"roles,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// PGCreds holds Postgres connection credentials.
type PGCreds struct {
Host string `json:"host"`
@@ -74,9 +89,94 @@ type PGCreds struct {
Username string `json:"username"`
Password string `json:"password"`
}
// TOTPEnrollResponse is returned by EnrollTOTP.
type TOTPEnrollResponse struct {
Secret string `json:"secret"`
OTPAuthURI string `json:"otpauth_uri"`
}
// AuditEvent is a single entry in the audit log.
type AuditEvent struct {
ID int `json:"id"`
EventType string `json:"event_type"`
EventTime string `json:"event_time"`
ActorID string `json:"actor_id,omitempty"`
TargetID string `json:"target_id,omitempty"`
IPAddress string `json:"ip_address"`
Details string `json:"details,omitempty"`
}
// AuditListResponse is returned by ListAudit.
type AuditListResponse struct {
Events []AuditEvent `json:"events"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// AuditFilter holds optional filter parameters for ListAudit.
type AuditFilter struct {
Limit int
Offset int
EventType string
ActorID string
}
// PolicyRuleBody holds the match conditions and effect of a policy rule.
// All fields except Effect are optional; an omitted field acts as a wildcard.
type PolicyRuleBody struct {
Effect string `json:"effect"`
Roles []string `json:"roles,omitempty"`
AccountTypes []string `json:"account_types,omitempty"`
SubjectUUID string `json:"subject_uuid,omitempty"`
Actions []string `json:"actions,omitempty"`
ResourceType string `json:"resource_type,omitempty"`
OwnerMatchesSubject bool `json:"owner_matches_subject,omitempty"`
ServiceNames []string `json:"service_names,omitempty"`
RequiredTags []string `json:"required_tags,omitempty"`
}
// PolicyRule is a complete operator-defined policy rule as returned by the API.
type PolicyRule struct {
ID int `json:"id"`
Priority int `json:"priority"`
Description string `json:"description"`
Rule PolicyRuleBody `json:"rule"`
Enabled bool `json:"enabled"`
NotBefore string `json:"not_before,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// CreatePolicyRuleRequest holds the parameters for creating a policy rule.
type CreatePolicyRuleRequest struct {
Description string `json:"description"`
Priority int `json:"priority,omitempty"`
Rule PolicyRuleBody `json:"rule"`
NotBefore string `json:"not_before,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// UpdatePolicyRuleRequest holds the parameters for updating a policy rule.
// All fields are optional; omitted fields are left unchanged.
// Set ClearNotBefore or ClearExpiresAt to true to remove those constraints.
type UpdatePolicyRuleRequest struct {
Description string `json:"description,omitempty"`
Priority *int `json:"priority,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Rule *PolicyRuleBody `json:"rule,omitempty"`
NotBefore string `json:"not_before,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
ClearNotBefore bool `json:"clear_not_before,omitempty"`
ClearExpiresAt bool `json:"clear_expires_at,omitempty"`
}
// ---------------------------------------------------------------------------
// Options and Client struct
// ---------------------------------------------------------------------------
// Options configures the MCIAS client.
type Options struct {
// CACertPath is an optional path to a PEM-encoded CA certificate for TLS
@@ -85,6 +185,7 @@ type Options struct {
// Token is an optional pre-existing bearer token.
Token string
}
// Client is a thread-safe MCIAS REST API client.
// Security: the bearer token is guarded by a sync.RWMutex; it is never
// written to logs or included in error messages in this library.
@@ -94,9 +195,11 @@ type Client struct {
mu sync.RWMutex
token string
}
// ---------------------------------------------------------------------------
// Constructor
// ---------------------------------------------------------------------------
// New creates a new Client for the given serverURL.
// TLS 1.2 is the minimum version enforced on all connections.
// If opts.CACertPath is set, that CA certificate is added to the trust pool.
@@ -126,20 +229,24 @@ func New(serverURL string, opts Options) (*Client, error) {
}
return c, nil
}
// Token returns the current bearer token (empty string if not logged in).
func (c *Client) Token() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.token
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
func (c *Client) setToken(tok string) {
c.mu.Lock()
defer c.mu.Unlock()
c.token = tok
}
func (c *Client) do(method, path string, body interface{}, out interface{}) error {
var reqBody io.Reader
if body != nil {
@@ -195,6 +302,7 @@ func (c *Client) do(method, path string, body interface{}, out interface{}) erro
}
return nil
}
func makeError(status int, msg string) error {
base := MciasError{StatusCode: status, Message: msg}
switch {
@@ -212,13 +320,16 @@ func makeError(status int, msg string) error {
return &MciasServerError{base}
}
}
// ---------------------------------------------------------------------------
// API methods
// API methods — Public
// ---------------------------------------------------------------------------
// Health calls GET /v1/health. Returns nil if the server is healthy.
func (c *Client) Health() error {
return c.do(http.MethodGet, "/v1/health", nil, nil)
}
// GetPublicKey returns the server's Ed25519 public key in JWK format.
func (c *Client) GetPublicKey() (*PublicKey, error) {
var pk PublicKey
@@ -227,6 +338,7 @@ func (c *Client) GetPublicKey() (*PublicKey, error) {
}
return &pk, nil
}
// Login authenticates with username and password. On success the token is
// stored in the Client and returned along with the expiry timestamp.
// totpCode may be empty for accounts without TOTP.
@@ -245,6 +357,23 @@ func (c *Client) Login(username, password, totpCode string) (token, expiresAt st
c.setToken(resp.Token)
return resp.Token, resp.ExpiresAt, nil
}
// ValidateToken validates a token string against the server.
// Returns claims; Valid is false (not an error) if the token is expired or
// revoked.
func (c *Client) ValidateToken(token string) (*TokenClaims, error) {
var claims TokenClaims
if err := c.do(http.MethodPost, "/v1/token/validate",
map[string]string{"token": token}, &claims); err != nil {
return nil, err
}
return &claims, nil
}
// ---------------------------------------------------------------------------
// API methods — Authenticated
// ---------------------------------------------------------------------------
// Logout revokes the current token on the server and clears it from the client.
func (c *Client) Logout() error {
if err := c.do(http.MethodPost, "/v1/auth/logout", nil, nil); err != nil {
@@ -253,6 +382,7 @@ func (c *Client) Logout() error {
c.setToken("")
return nil
}
// RenewToken exchanges the current token for a fresh one.
// The new token is stored in the client and returned.
func (c *Client) RenewToken() (token, expiresAt string, err error) {
@@ -266,17 +396,63 @@ func (c *Client) RenewToken() (token, expiresAt string, err error) {
c.setToken(resp.Token)
return resp.Token, resp.ExpiresAt, nil
}
// ValidateToken validates a token string against the server.
// Returns claims; Valid is false (not an error) if the token is expired or
// revoked.
func (c *Client) ValidateToken(token string) (*TokenClaims, error) {
var claims TokenClaims
if err := c.do(http.MethodPost, "/v1/token/validate",
map[string]string{"token": token}, &claims); err != nil {
// EnrollTOTP begins TOTP enrollment for the authenticated account.
// Returns a base32 secret and an otpauth:// URI for QR-code generation.
// The secret is shown once; it is not retrievable after this call.
// TOTP is not enforced until confirmed via ConfirmTOTP.
func (c *Client) EnrollTOTP() (*TOTPEnrollResponse, error) {
var resp TOTPEnrollResponse
if err := c.do(http.MethodPost, "/v1/auth/totp/enroll", nil, &resp); err != nil {
return nil, err
}
return &claims, nil
return &resp, nil
}
// ConfirmTOTP completes TOTP enrollment by verifying the current code against
// the pending secret. On success, TOTP becomes required for all future logins.
func (c *Client) ConfirmTOTP(code string) error {
return c.do(http.MethodPost, "/v1/auth/totp/confirm",
map[string]string{"code": code}, nil)
}
// ChangePassword changes the password of the currently authenticated human
// account. currentPassword is required to prevent token-theft attacks.
// On success, all active sessions except the caller's are revoked.
//
// Security: both passwords are transmitted over TLS only; the server verifies
// currentPassword with constant-time comparison before accepting the change.
func (c *Client) ChangePassword(currentPassword, newPassword string) error {
return c.do(http.MethodPut, "/v1/auth/password", map[string]string{
"current_password": currentPassword,
"new_password": newPassword,
}, nil)
}
// ---------------------------------------------------------------------------
// API methods — Admin: Auth
// ---------------------------------------------------------------------------
// RemoveTOTP clears TOTP enrollment for the given account (admin).
// Use for account recovery when a user has lost their TOTP device.
func (c *Client) RemoveTOTP(accountID string) error {
return c.do(http.MethodDelete, "/v1/auth/totp",
map[string]string{"account_id": accountID}, nil)
}
// ---------------------------------------------------------------------------
// API methods — Admin: Accounts
// ---------------------------------------------------------------------------
// ListAccounts returns all accounts. Requires admin role.
func (c *Client) ListAccounts() ([]Account, error) {
var accounts []Account
if err := c.do(http.MethodGet, "/v1/accounts", nil, &accounts); err != nil {
return nil, err
}
return accounts, nil
}
// CreateAccount creates a new account. accountType is "human" or "system".
// password is required for human accounts.
func (c *Client) CreateAccount(username, accountType, password string) (*Account, error) {
@@ -293,14 +469,7 @@ func (c *Client) CreateAccount(username, accountType, password string) (*Account
}
return &acct, nil
}
// ListAccounts returns all accounts. Requires admin role.
func (c *Client) ListAccounts() ([]Account, error) {
var accounts []Account
if err := c.do(http.MethodGet, "/v1/accounts", nil, &accounts); err != nil {
return nil, err
}
return accounts, nil
}
// GetAccount returns the account with the given ID. Requires admin role.
func (c *Client) GetAccount(id string) (*Account, error) {
var acct Account
@@ -309,23 +478,22 @@ func (c *Client) GetAccount(id string) (*Account, error) {
}
return &acct, nil
}
// UpdateAccount updates mutable account fields. Requires admin role.
// Pass an empty string for fields that should not be changed.
func (c *Client) UpdateAccount(id, status string) (*Account, error) {
// UpdateAccount updates mutable account fields (currently only status).
// Requires admin role. Returns nil on success (HTTP 204).
func (c *Client) UpdateAccount(id, status string) error {
req := map[string]string{}
if status != "" {
req["status"] = status
}
var acct Account
if err := c.do(http.MethodPatch, "/v1/accounts/"+id, req, &acct); err != nil {
return nil, err
}
return &acct, nil
return c.do(http.MethodPatch, "/v1/accounts/"+id, req, nil)
}
// DeleteAccount soft-deletes the account with the given ID. Requires admin.
func (c *Client) DeleteAccount(id string) error {
return c.do(http.MethodDelete, "/v1/accounts/"+id, nil, nil)
}
// GetRoles returns the roles for accountID. Requires admin.
func (c *Client) GetRoles(accountID string) ([]string, error) {
var resp struct {
@@ -336,11 +504,49 @@ func (c *Client) GetRoles(accountID string) ([]string, error) {
}
return resp.Roles, nil
}
// SetRoles replaces the role set for accountID. Requires admin.
func (c *Client) SetRoles(accountID string, roles []string) error {
return c.do(http.MethodPut, "/v1/accounts/"+accountID+"/roles",
map[string][]string{"roles": roles}, nil)
}
// AdminSetPassword resets a human account's password without requiring the
// current password. Requires admin. All active sessions for the target account
// are revoked on success.
func (c *Client) AdminSetPassword(accountID, newPassword string) error {
return c.do(http.MethodPut, "/v1/accounts/"+accountID+"/password",
map[string]string{"new_password": newPassword}, nil)
}
// GetAccountTags returns the current tag set for an account. Requires admin.
func (c *Client) GetAccountTags(accountID string) ([]string, error) {
var resp struct {
Tags []string `json:"tags"`
}
if err := c.do(http.MethodGet, "/v1/accounts/"+accountID+"/tags", nil, &resp); err != nil {
return nil, err
}
return resp.Tags, nil
}
// SetAccountTags replaces the full tag set for an account atomically.
// Pass an empty slice to clear all tags. Requires admin.
func (c *Client) SetAccountTags(accountID string, tags []string) ([]string, error) {
var resp struct {
Tags []string `json:"tags"`
}
if err := c.do(http.MethodPut, "/v1/accounts/"+accountID+"/tags",
map[string][]string{"tags": tags}, &resp); err != nil {
return nil, err
}
return resp.Tags, nil
}
// ---------------------------------------------------------------------------
// API methods — Admin: Tokens
// ---------------------------------------------------------------------------
// IssueServiceToken issues a long-lived token for a system account. Requires admin.
func (c *Client) IssueServiceToken(accountID string) (token, expiresAt string, err error) {
var resp struct {
@@ -353,10 +559,16 @@ func (c *Client) IssueServiceToken(accountID string) (token, expiresAt string, e
}
return resp.Token, resp.ExpiresAt, nil
}
// RevokeToken revokes a token by JTI. Requires admin.
func (c *Client) RevokeToken(jti string) error {
return c.do(http.MethodDelete, "/v1/token/"+jti, nil, nil)
}
// ---------------------------------------------------------------------------
// API methods — Admin: Credentials
// ---------------------------------------------------------------------------
// GetPGCreds returns Postgres credentials for accountID. Requires admin.
func (c *Client) GetPGCreds(accountID string) (*PGCreds, error) {
var creds PGCreds
@@ -365,6 +577,7 @@ func (c *Client) GetPGCreds(accountID string) (*PGCreds, error) {
}
return &creds, nil
}
// SetPGCreds stores Postgres credentials for accountID. Requires admin.
// The password is sent over TLS and encrypted at rest server-side.
func (c *Client) SetPGCreds(accountID, host string, port int, database, username, password string) error {
@@ -376,3 +589,78 @@ func (c *Client) SetPGCreds(accountID, host string, port int, database, username
"password": password,
}, nil)
}
// ---------------------------------------------------------------------------
// API methods — Admin: Audit
// ---------------------------------------------------------------------------
// ListAudit retrieves audit log entries, newest first. Requires admin.
// f may be zero-valued to use defaults (limit=50, offset=0, no filter).
func (c *Client) ListAudit(f AuditFilter) (*AuditListResponse, error) {
path := "/v1/audit?"
if f.Limit > 0 {
path += fmt.Sprintf("limit=%d&", f.Limit)
}
if f.Offset > 0 {
path += fmt.Sprintf("offset=%d&", f.Offset)
}
if f.EventType != "" {
path += fmt.Sprintf("event_type=%s&", f.EventType)
}
if f.ActorID != "" {
path += fmt.Sprintf("actor_id=%s&", f.ActorID)
}
path = strings.TrimRight(path, "&?")
var resp AuditListResponse
if err := c.do(http.MethodGet, path, nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// ---------------------------------------------------------------------------
// API methods — Admin: Policy
// ---------------------------------------------------------------------------
// ListPolicyRules returns all operator-defined policy rules ordered by
// priority (ascending). Requires admin.
func (c *Client) ListPolicyRules() ([]PolicyRule, error) {
var rules []PolicyRule
if err := c.do(http.MethodGet, "/v1/policy/rules", nil, &rules); err != nil {
return nil, err
}
return rules, nil
}
// CreatePolicyRule creates a new policy rule. Requires admin.
func (c *Client) CreatePolicyRule(req CreatePolicyRuleRequest) (*PolicyRule, error) {
var rule PolicyRule
if err := c.do(http.MethodPost, "/v1/policy/rules", req, &rule); err != nil {
return nil, err
}
return &rule, nil
}
// GetPolicyRule returns a single policy rule by integer ID. Requires admin.
func (c *Client) GetPolicyRule(id int) (*PolicyRule, error) {
var rule PolicyRule
if err := c.do(http.MethodGet, fmt.Sprintf("/v1/policy/rules/%d", id), nil, &rule); err != nil {
return nil, err
}
return &rule, nil
}
// UpdatePolicyRule updates one or more fields of an existing policy rule.
// Requires admin.
func (c *Client) UpdatePolicyRule(id int, req UpdatePolicyRuleRequest) (*PolicyRule, error) {
var rule PolicyRule
if err := c.do(http.MethodPatch, fmt.Sprintf("/v1/policy/rules/%d", id), req, &rule); err != nil {
return nil, err
}
return &rule, nil
}
// DeletePolicyRule permanently deletes a policy rule. Requires admin.
func (c *Client) DeletePolicyRule(id int) error {
return c.do(http.MethodDelete, fmt.Sprintf("/v1/policy/rules/%d", id), nil, nil)
}

View File

@@ -2,6 +2,7 @@
// All tests use inline httptest.NewServer mocks to keep this module
// self-contained (no cross-module imports).
package mciasgoclient_test
import (
"encoding/json"
"errors"
@@ -9,12 +10,14 @@ import (
"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{})
@@ -23,19 +26,21 @@ func newTestClient(t *testing.T, serverURL string) *mciasgoclient.Client {
}
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 {
@@ -45,6 +50,7 @@ func TestNew(t *testing.T) {
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 {
@@ -54,15 +60,18 @@ func TestNewWithPresetToken(t *testing.T) {
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 {
@@ -77,9 +86,7 @@ func TestHealth(t *testing.T) {
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")
@@ -98,9 +105,11 @@ func TestHealthError(t *testing.T) {
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" {
@@ -131,9 +140,11 @@ func TestGetPublicKey(t *testing.T) {
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 {
@@ -157,14 +168,11 @@ func TestLogin(t *testing.T) {
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")
@@ -180,16 +188,17 @@ func TestLoginUnauthorized(t *testing.T) {
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",
"token": "tok-logout", "expires_at": "2099-01-01T00:00:00Z",
})
case "/v1/auth/logout":
w.WriteHeader(http.StatusOK)
@@ -212,21 +221,21 @@ func TestLogout(t *testing.T) {
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",
"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",
"token": "tok-new", "expires_at": "2099-06-01T00:00:00Z",
})
default:
http.Error(w, "not found", http.StatusNotFound)
@@ -248,9 +257,125 @@ func TestRenewToken(t *testing.T) {
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" {
@@ -258,10 +383,8 @@ func TestValidateToken(t *testing.T) {
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"valid": true,
"sub": "user-uuid-1",
"roles": []string{"admin"},
"expires_at": "2099-01-01T00:00:00Z",
"valid": true, "sub": "user-uuid-1",
"roles": []string{"admin"}, "expires_at": "2099-01-01T00:00:00Z",
})
}))
defer srv.Close()
@@ -277,15 +400,10 @@ func TestValidateToken(t *testing.T) {
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,
})
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false})
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
@@ -297,9 +415,11 @@ func TestValidateTokenInvalid(t *testing.T) {
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 {
@@ -307,13 +427,9 @@ func TestCreateAccount(t *testing.T) {
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,
"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()
@@ -329,9 +445,7 @@ func TestCreateAccount(t *testing.T) {
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")
@@ -347,21 +461,19 @@ func TestCreateAccountConflict(t *testing.T) {
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",
{"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",
"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,
},
"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 {
@@ -383,27 +495,21 @@ func TestListAccounts(t *testing.T) {
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/") {
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,
"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()
@@ -416,38 +522,30 @@ func TestGetAccount(t *testing.T) {
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,
})
w.WriteHeader(http.StatusNoContent)
}))
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)
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 {
@@ -462,16 +560,33 @@ func TestDeleteAccount(t *testing.T) {
t.Fatalf("DeleteAccount: unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// TestGetRoles
// TestAdminSetPassword
// ---------------------------------------------------------------------------
func TestGetRoles(t *testing.T) {
func TestAdminSetPassword(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)
if r.Method != http.MethodPut || !strings.HasSuffix(r.URL.Path, "/password") {
http.Error(w, "not found", http.StatusNotFound)
return
}
if !strings.HasSuffix(r.URL.Path, "/roles") {
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
}
@@ -492,9 +607,7 @@ func TestGetRoles(t *testing.T) {
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 {
@@ -509,9 +622,79 @@ func TestSetRoles(t *testing.T) {
t.Fatalf("SetRoles: unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// TestIssueServiceToken
// 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 {
@@ -519,8 +702,7 @@ func TestIssueServiceToken(t *testing.T) {
return
}
writeJSON(w, http.StatusOK, map[string]string{
"token": "svc-tok-xyz",
"expires_at": "2099-01-01T00:00:00Z",
"token": "svc-tok-xyz", "expires_at": "2099-01-01T00:00:00Z",
})
}))
defer srv.Close()
@@ -536,16 +718,10 @@ func TestIssueServiceToken(t *testing.T) {
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/") {
if r.Method != http.MethodDelete || !strings.HasPrefix(r.URL.Path, "/v1/token/") {
http.Error(w, "not found", http.StatusNotFound)
return
}
@@ -557,25 +733,20 @@ func TestRevokeToken(t *testing.T) {
t.Fatalf("RevokeToken: unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// TestGetPGCreds
// TestGetPGCreds / TestSetPGCreds
// ---------------------------------------------------------------------------
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") {
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",
"host": "db.example.com", "port": 5432,
"database": "myapp", "username": "appuser", "password": "secretpw",
})
}))
defer srv.Close()
@@ -594,16 +765,10 @@ func TestGetPGCreds(t *testing.T) {
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") {
if r.Method != http.MethodPut || !strings.HasSuffix(r.URL.Path, "/pgcreds") {
http.Error(w, "not found", http.StatusNotFound)
return
}
@@ -611,14 +776,238 @@ func TestSetPGCreds(t *testing.T) {
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
err := c.SetPGCreds("acct-uuid-42", "db.example.com", 5432, "myapp", "appuser", "secretpw")
if err != nil {
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, &notFoundErr) {
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()
@@ -640,8 +1029,7 @@ func TestIntegration(t *testing.T) {
return
}
writeJSON(w, http.StatusOK, map[string]string{
"token": sessionToken,
"expires_at": "2099-01-01T00:00:00Z",
"token": sessionToken, "expires_at": "2099-01-01T00:00:00Z",
})
})
mux.HandleFunc("/v1/token/validate", func(w http.ResponseWriter, r *http.Request) {
@@ -658,15 +1046,11 @@ func TestIntegration(t *testing.T) {
}
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",
"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,
})
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false})
}
})
mux.HandleFunc("/v1/auth/logout", func(w http.ResponseWriter, r *http.Request) {
@@ -674,9 +1058,7 @@ func TestIntegration(t *testing.T) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Verify Authorization header is present.
auth := r.Header.Get("Authorization")
if auth == "" {
if r.Header.Get("Authorization") == "" {
writeError(w, http.StatusUnauthorized, "missing token")
return
}
@@ -685,7 +1067,8 @@ func TestIntegration(t *testing.T) {
srv := httptest.NewServer(mux)
defer srv.Close()
c := newTestClient(t, srv.URL)
// Step 1: login with wrong credentials should fail.
// Step 1: wrong credentials → MciasAuthError.
_, _, err := c.Login("alice", "wrong-password", "")
if err == nil {
t.Fatal("expected error for wrong credentials")
@@ -694,7 +1077,8 @@ func TestIntegration(t *testing.T) {
if !errors.As(err, &authErr) {
t.Errorf("expected MciasAuthError, got %T", err)
}
// Step 2: login with correct credentials.
// Step 2: correct login.
tok, _, err := c.Login("alice", "correct-horse", "")
if err != nil {
t.Fatalf("Login: %v", err)
@@ -702,7 +1086,8 @@ func TestIntegration(t *testing.T) {
if tok != sessionToken {
t.Errorf("expected %q, got %q", sessionToken, tok)
}
// Step 3: validate the returned token.
// Step 3: validate → valid=true.
claims, err := c.ValidateToken(tok)
if err != nil {
t.Fatalf("ValidateToken: %v", err)
@@ -713,7 +1098,8 @@ func TestIntegration(t *testing.T) {
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.
// 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)
@@ -721,7 +1107,8 @@ func TestIntegration(t *testing.T) {
if claims2.Valid {
t.Error("expected Valid=false for garbage token")
}
// Step 5: logout clears the stored token.
// Step 5: logout clears stored token.
if err := c.Logout(); err != nil {
t.Fatalf("Logout: %v", err)
}