Implement Phase 9: client libraries (Go, Rust, Lisp, Python)
- 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
This commit is contained in:
85
clients/go/README.md
Normal file
85
clients/go/README.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# mcias-client (Go)
|
||||
|
||||
Go client library for the [MCIAS](../../README.md) identity and access management API.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go 1.21+
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
go get git.wntrmute.dev/kyle/mcias/clients/go
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```go
|
||||
import mciasgoclient "git.wntrmute.dev/kyle/mcias/clients/go"
|
||||
|
||||
// Connect to the MCIAS server.
|
||||
client, err := mciasgoclient.New("https://auth.example.com", mciasgoclient.Options{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Authenticate.
|
||||
token, expiresAt, err := client.Login("alice", "s3cret", "")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("token expires at %s\n", expiresAt)
|
||||
|
||||
// The token is stored in the client automatically.
|
||||
// Call authenticated endpoints...
|
||||
accounts, err := client.ListAccounts()
|
||||
|
||||
// Revoke the token when done.
|
||||
if err := client.Logout(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Custom CA Certificate
|
||||
|
||||
```go
|
||||
client, err := mciasgoclient.New("https://auth.example.com", mciasgoclient.Options{
|
||||
CACertPath: "/etc/mcias/ca.pem",
|
||||
})
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
All methods return typed errors:
|
||||
|
||||
```go
|
||||
_, _, err := client.Login("alice", "wrongpass", "")
|
||||
switch {
|
||||
case errors.Is(err, new(mciasgoclient.MciasAuthError)):
|
||||
// 401 — wrong credentials or token invalid
|
||||
case errors.Is(err, new(mciasgoclient.MciasForbiddenError)):
|
||||
// 403 — insufficient role
|
||||
case errors.Is(err, new(mciasgoclient.MciasNotFoundError)):
|
||||
// 404 — resource not found
|
||||
case errors.Is(err, new(mciasgoclient.MciasInputError)):
|
||||
// 400 — malformed request
|
||||
case errors.Is(err, new(mciasgoclient.MciasConflictError)):
|
||||
// 409 — conflict (e.g. duplicate username)
|
||||
case errors.Is(err, new(mciasgoclient.MciasServerError)):
|
||||
// 5xx — unexpected server error
|
||||
}
|
||||
```
|
||||
|
||||
All error types embed `MciasError` which carries `StatusCode int` and
|
||||
`Message string`.
|
||||
|
||||
## Thread Safety
|
||||
|
||||
`Client` is safe for concurrent use from multiple goroutines. The internal
|
||||
token is protected by `sync.RWMutex`.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```sh
|
||||
go test -race ./...
|
||||
```
|
||||
378
clients/go/client.go
Normal file
378
clients/go/client.go
Normal file
@@ -0,0 +1,378 @@
|
||||
// Package mciasgoclient provides a thread-safe Go client for the MCIAS REST API.
|
||||
//
|
||||
// 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"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"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"`
|
||||
Username string `json:"username"`
|
||||
AccountType string `json:"account_type"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
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"`
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
Use string `json:"use,omitempty"`
|
||||
Alg string `json:"alg,omitempty"`
|
||||
}
|
||||
// TokenClaims is returned by ValidateToken.
|
||||
type TokenClaims struct {
|
||||
Valid bool `json:"valid"`
|
||||
Sub string `json:"sub,omitempty"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
// PGCreds holds Postgres connection credentials.
|
||||
type PGCreds struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Database string `json:"database"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
// verification of self-signed or private-CA certificates.
|
||||
CACertPath string
|
||||
// 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.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
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.
|
||||
func New(serverURL string, opts Options) (*Client, error) {
|
||||
serverURL = strings.TrimRight(serverURL, "/")
|
||||
// Security: never negotiate TLS < 1.2; this prevents POODLE/BEAST-class
|
||||
// downgrade attacks against the token-bearing transport.
|
||||
tlsCfg := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
if opts.CACertPath != "" {
|
||||
pem, err := os.ReadFile(opts.CACertPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mciasgoclient: read CA cert: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("mciasgoclient: no valid certs in CA file")
|
||||
}
|
||||
tlsCfg.RootCAs = pool
|
||||
}
|
||||
transport := &http.Transport{TLSClientConfig: tlsCfg}
|
||||
c := &Client{
|
||||
baseURL: serverURL,
|
||||
http: &http.Client{Transport: transport},
|
||||
token: opts.Token,
|
||||
}
|
||||
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 {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mciasgoclient: marshal request: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, c.baseURL+path, reqBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mciasgoclient: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
// Security: token is read under the lock and added to the Authorization
|
||||
// header only; it is never written to any log or error message in this
|
||||
// library.
|
||||
c.mu.RLock()
|
||||
tok := c.token
|
||||
c.mu.RUnlock()
|
||||
if tok != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mciasgoclient: request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mciasgoclient: read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
var errResp struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(respBytes, &errResp)
|
||||
msg := errResp.Error
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return makeError(resp.StatusCode, msg)
|
||||
}
|
||||
if out != nil && len(respBytes) > 0 {
|
||||
dec := json.NewDecoder(bytes.NewReader(respBytes))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(out); err != nil {
|
||||
return fmt.Errorf("mciasgoclient: decode response: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func makeError(status int, msg string) error {
|
||||
base := MciasError{StatusCode: status, Message: msg}
|
||||
switch {
|
||||
case status == 401:
|
||||
return &MciasAuthError{base}
|
||||
case status == 403:
|
||||
return &MciasForbiddenError{base}
|
||||
case status == 404:
|
||||
return &MciasNotFoundError{base}
|
||||
case status == 400:
|
||||
return &MciasInputError{base}
|
||||
case status == 409:
|
||||
return &MciasConflictError{base}
|
||||
default:
|
||||
return &MciasServerError{base}
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// API methods
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
if err := c.do(http.MethodGet, "/v1/keys/public", nil, &pk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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.
|
||||
func (c *Client) Login(username, password, totpCode string) (token, expiresAt string, err error) {
|
||||
req := map[string]string{"username": username, "password": password}
|
||||
if totpCode != "" {
|
||||
req["totp_code"] = totpCode
|
||||
}
|
||||
var resp struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
if err := c.do(http.MethodPost, "/v1/auth/login", req, &resp); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
c.setToken(resp.Token)
|
||||
return resp.Token, resp.ExpiresAt, nil
|
||||
}
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
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) {
|
||||
var resp struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
if err := c.do(http.MethodPost, "/v1/auth/renew", map[string]string{}, &resp); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
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
|
||||
}
|
||||
// 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) {
|
||||
req := map[string]string{
|
||||
"username": username,
|
||||
"account_type": accountType,
|
||||
}
|
||||
if password != "" {
|
||||
req["password"] = password
|
||||
}
|
||||
var acct Account
|
||||
if err := c.do(http.MethodPost, "/v1/accounts", req, &acct); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
if err := c.do(http.MethodGet, "/v1/accounts/"+id, nil, &acct); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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) {
|
||||
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
|
||||
}
|
||||
// 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 {
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
if err := c.do(http.MethodGet, "/v1/accounts/"+accountID+"/roles", nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 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 {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
if err := c.do(http.MethodPost, "/v1/token/issue",
|
||||
map[string]string{"account_id": accountID}, &resp); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
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)
|
||||
}
|
||||
// GetPGCreds returns Postgres credentials for accountID. Requires admin.
|
||||
func (c *Client) GetPGCreds(accountID string) (*PGCreds, error) {
|
||||
var creds PGCreds
|
||||
if err := c.do(http.MethodGet, "/v1/accounts/"+accountID+"/pgcreds", nil, &creds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
return c.do(http.MethodPut, "/v1/accounts/"+accountID+"/pgcreds", map[string]interface{}{
|
||||
"host": host,
|
||||
"port": port,
|
||||
"database": database,
|
||||
"username": username,
|
||||
"password": password,
|
||||
}, nil)
|
||||
}
|
||||
731
clients/go/client_test.go
Normal file
731
clients/go/client_test.go
Normal file
@@ -0,0 +1,731 @@
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
3
clients/go/go.mod
Normal file
3
clients/go/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module git.wntrmute.dev/kyle/mcias/clients/go
|
||||
|
||||
go 1.21
|
||||
0
clients/go/go.sum
Normal file
0
clients/go/go.sum
Normal file
Reference in New Issue
Block a user