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:
2026-03-11 16:38:32 -07:00
parent f34e9a69a0
commit 0c441f5c4f
1974 changed files with 10151 additions and 33 deletions

516
test/mock/mockserver.go Normal file
View File

@@ -0,0 +1,516 @@
// Package mock provides an in-memory MCIAS server for integration tests.
//
// Security note: this package is test-only. It never enforces TLS and uses
// trivial token generation. Do not use in production.
package mock
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
)
// Account holds mock account state.
type Account struct {
ID string
Username string
Password string
AccountType string
Status string
Roles []string
}
// PGCreds holds mock Postgres credential state.
type PGCreds struct {
Host string
Port int
Database string
Username string
Password string
}
// Server is an in-memory MCIAS mock server.
type Server struct {
mu sync.RWMutex
accounts map[string]*Account // id → account
byName map[string]*Account // username → account
tokens map[string]string // token → account id
revoked map[string]bool // revoked tokens
pgcreds map[string]*PGCreds // account id → pg creds
nextSeq int
httpServer *httptest.Server
}
// NewServer creates and starts a new mock server. Call Close() when done.
func NewServer() *Server {
s := &Server{
accounts: make(map[string]*Account),
byName: make(map[string]*Account),
tokens: make(map[string]string),
revoked: make(map[string]bool),
pgcreds: make(map[string]*PGCreds),
}
mux := http.NewServeMux()
mux.HandleFunc("/v1/health", s.handleHealth)
mux.HandleFunc("/v1/keys/public", s.handlePublicKey)
mux.HandleFunc("/v1/auth/login", s.handleLogin)
mux.HandleFunc("/v1/auth/logout", s.handleLogout)
mux.HandleFunc("/v1/auth/renew", s.handleRenew)
mux.HandleFunc("/v1/token/validate", s.handleValidate)
mux.HandleFunc("/v1/token/issue", s.handleIssueToken)
mux.HandleFunc("/v1/accounts", s.handleAccounts)
mux.HandleFunc("/v1/accounts/", s.handleAccountByID)
s.httpServer = httptest.NewServer(mux)
return s
}
// URL returns the base URL of the mock server.
func (s *Server) URL() string {
return s.httpServer.URL
}
// Close shuts down the mock server.
func (s *Server) Close() {
s.httpServer.Close()
}
// AddAccount adds a test account and returns its ID.
func (s *Server) AddAccount(username, password, accountType string, roles ...string) string {
s.mu.Lock()
defer s.mu.Unlock()
s.nextSeq++
id := fmt.Sprintf("mock-uuid-%d", s.nextSeq)
acct := &Account{
ID: id,
Username: username,
Password: password,
AccountType: accountType,
Status: "active",
Roles: append([]string{}, roles...),
}
s.accounts[id] = acct
s.byName[username] = acct
return id
}
// IssueToken directly adds a token for an account (for pre-auth test setup).
func (s *Server) IssueToken(accountID, token string) {
s.mu.Lock()
defer s.mu.Unlock()
s.tokens[token] = accountID
}
// issueToken creates a new token for the given account ID.
// Caller must hold s.mu (write lock).
func (s *Server) issueToken(accountID string) string {
s.nextSeq++
tok := fmt.Sprintf("mock-token-%d", s.nextSeq)
s.tokens[tok] = accountID
return tok
}
func (s *Server) bearerToken(r *http.Request) string {
auth := r.Header.Get("Authorization")
if len(auth) > 7 && strings.ToLower(auth[:7]) == "bearer " {
return auth[7:]
}
return ""
}
func (s *Server) authenticatedAccount(r *http.Request) *Account {
tok := s.bearerToken(r)
if tok == "" {
return nil
}
s.mu.RLock()
defer s.mu.RUnlock()
if s.revoked[tok] {
return nil
}
id, ok := s.tokens[tok]
if !ok {
return nil
}
return s.accounts[id]
}
func sendJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func sendError(w http.ResponseWriter, status int, msg string) {
sendJSON(w, status, map[string]string{"error": msg})
}
func (s *Server) accountToMap(a *Account) map[string]interface{} {
return map[string]interface{}{
"id": a.ID,
"username": a.Username,
"account_type": a.AccountType,
"status": a.Status,
"created_at": "2023-11-15T12:00:00Z",
"updated_at": "2023-11-15T12:00:00Z",
"totp_enabled": false,
}
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
sendError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
sendJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handlePublicKey(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
sendError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
sendJSON(w, http.StatusOK, map[string]string{
"kty": "OKP",
"crv": "Ed25519",
"x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"use": "sig",
"alg": "EdDSA",
})
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
sendError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendError(w, http.StatusBadRequest, "bad request")
return
}
s.mu.Lock()
defer s.mu.Unlock()
acct, ok := s.byName[req.Username]
if !ok || acct.Password != req.Password || acct.Status != "active" {
sendError(w, http.StatusUnauthorized, "invalid credentials")
return
}
tok := s.issueToken(acct.ID)
sendJSON(w, http.StatusOK, map[string]string{
"token": tok,
"expires_at": "2099-01-01T00:00:00Z",
})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
sendError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
tok := s.bearerToken(r)
if tok == "" {
sendError(w, http.StatusUnauthorized, "unauthorized")
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.revoked[tok] = true
delete(s.tokens, tok)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
sendError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
tok := s.bearerToken(r)
if tok == "" {
sendError(w, http.StatusUnauthorized, "unauthorized")
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.revoked[tok] {
sendError(w, http.StatusUnauthorized, "unauthorized")
return
}
aid, ok := s.tokens[tok]
if !ok {
sendError(w, http.StatusUnauthorized, "unauthorized")
return
}
s.revoked[tok] = true
delete(s.tokens, tok)
newTok := s.issueToken(aid)
sendJSON(w, http.StatusOK, map[string]string{
"token": newTok,
"expires_at": "2099-01-01T00:00:00Z",
})
}
func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
sendError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req struct {
Token string `json:"token"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
tok := req.Token
if tok == "" {
tok = s.bearerToken(r)
}
s.mu.RLock()
defer s.mu.RUnlock()
if tok == "" || s.revoked[tok] {
sendJSON(w, http.StatusOK, map[string]interface{}{"valid": false})
return
}
aid, ok := s.tokens[tok]
if !ok {
sendJSON(w, http.StatusOK, map[string]interface{}{"valid": false})
return
}
acct := s.accounts[aid]
sendJSON(w, http.StatusOK, map[string]interface{}{
"valid": true,
"sub": acct.ID,
"roles": acct.Roles,
"expires_at": "2099-01-01T00:00:00Z",
})
}
func (s *Server) handleIssueToken(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
sendError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
acct := s.authenticatedAccount(r)
if acct == nil {
sendError(w, http.StatusUnauthorized, "unauthorized")
return
}
isAdmin := false
for _, role := range acct.Roles {
if role == "admin" {
isAdmin = true
break
}
}
if !isAdmin {
sendError(w, http.StatusForbidden, "forbidden")
return
}
var req struct {
AccountID string `json:"account_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AccountID == "" {
sendError(w, http.StatusBadRequest, "bad request")
return
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.accounts[req.AccountID]; !ok {
sendError(w, http.StatusNotFound, "account not found")
return
}
tok := s.issueToken(req.AccountID)
sendJSON(w, http.StatusOK, map[string]string{
"token": tok,
"expires_at": "2099-01-01T00:00:00Z",
})
}
func (s *Server) handleAccounts(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if s.requireAdmin(w, r) == nil {
return
}
s.mu.RLock()
list := make([]map[string]interface{}, 0, len(s.accounts))
for _, a := range s.accounts {
list = append(list, s.accountToMap(a))
}
s.mu.RUnlock()
sendJSON(w, http.StatusOK, list)
case http.MethodPost:
if s.requireAdmin(w, r) == nil {
return
}
var req struct {
Username string `json:"username"`
AccountType string `json:"account_type"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Username == "" {
sendError(w, http.StatusBadRequest, "bad request")
return
}
s.mu.Lock()
if _, exists := s.byName[req.Username]; exists {
s.mu.Unlock()
sendError(w, http.StatusConflict, "username already exists")
return
}
s.nextSeq++
id := fmt.Sprintf("mock-uuid-%d", s.nextSeq)
newAcct := &Account{
ID: id,
Username: req.Username,
Password: req.Password,
AccountType: req.AccountType,
Status: "active",
Roles: []string{},
}
s.accounts[id] = newAcct
s.byName[req.Username] = newAcct
s.mu.Unlock()
sendJSON(w, http.StatusCreated, s.accountToMap(newAcct))
default:
sendError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (s *Server) handleAccountByID(w http.ResponseWriter, r *http.Request) {
// Parse path: /v1/accounts/{id}[/roles|/pgcreds]
path := strings.TrimPrefix(r.URL.Path, "/v1/accounts/")
parts := strings.SplitN(path, "/", 2)
id := parts[0]
sub := ""
if len(parts) == 2 {
sub = parts[1]
}
switch {
case sub == "roles":
s.handleRoles(w, r, id)
case sub == "pgcreds":
s.handlePGCreds(w, r, id)
case sub == "":
s.handleSingleAccount(w, r, id)
default:
sendError(w, http.StatusNotFound, "not found")
}
}
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) *Account {
acct := s.authenticatedAccount(r)
if acct == nil {
sendError(w, http.StatusUnauthorized, "unauthorized")
return nil
}
for _, role := range acct.Roles {
if role == "admin" {
return acct
}
}
sendError(w, http.StatusForbidden, "forbidden")
return nil
}
func (s *Server) handleSingleAccount(w http.ResponseWriter, r *http.Request, id string) {
if s.requireAdmin(w, r) == nil {
return
}
s.mu.RLock()
acct, ok := s.accounts[id]
s.mu.RUnlock()
if !ok {
sendError(w, http.StatusNotFound, "account not found")
return
}
switch r.Method {
case http.MethodGet:
sendJSON(w, http.StatusOK, s.accountToMap(acct))
case http.MethodPatch:
var req struct {
Status string `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendError(w, http.StatusBadRequest, "bad request")
return
}
s.mu.Lock()
if req.Status != "" {
acct.Status = req.Status
}
s.mu.Unlock()
sendJSON(w, http.StatusOK, s.accountToMap(acct))
case http.MethodDelete:
s.mu.Lock()
delete(s.accounts, id)
delete(s.byName, acct.Username)
s.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
default:
sendError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (s *Server) handleRoles(w http.ResponseWriter, r *http.Request, id string) {
if s.requireAdmin(w, r) == nil {
return
}
s.mu.RLock()
acct, ok := s.accounts[id]
s.mu.RUnlock()
if !ok {
sendError(w, http.StatusNotFound, "account not found")
return
}
switch r.Method {
case http.MethodGet:
s.mu.RLock()
roles := append([]string{}, acct.Roles...)
s.mu.RUnlock()
sendJSON(w, http.StatusOK, map[string]interface{}{"roles": roles})
case http.MethodPut:
var req struct {
Roles []string `json:"roles"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendError(w, http.StatusBadRequest, "bad request")
return
}
s.mu.Lock()
acct.Roles = req.Roles
s.mu.Unlock()
sendJSON(w, http.StatusOK, map[string]interface{}{"roles": req.Roles})
default:
sendError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (s *Server) handlePGCreds(w http.ResponseWriter, r *http.Request, id string) {
if s.requireAdmin(w, r) == nil {
return
}
s.mu.RLock()
_, ok := s.accounts[id]
s.mu.RUnlock()
if !ok {
sendError(w, http.StatusNotFound, "account not found")
return
}
switch r.Method {
case http.MethodGet:
s.mu.RLock()
creds, hasCreds := s.pgcreds[id]
s.mu.RUnlock()
if !hasCreds {
sendError(w, http.StatusNotFound, "no pg credentials")
return
}
sendJSON(w, http.StatusOK, map[string]interface{}{
"host": creds.Host,
"port": creds.Port,
"database": creds.Database,
"username": creds.Username,
"password": creds.Password,
})
case http.MethodPut:
var req struct {
Host string `json:"host"`
Port int `json:"port"`
Database string `json:"database"`
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendError(w, http.StatusBadRequest, "bad request")
return
}
s.mu.Lock()
s.pgcreds[id] = &PGCreds{
Host: req.Host,
Port: req.Port,
Database: req.Database,
Username: req.Username,
Password: req.Password,
}
s.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
default:
sendError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}