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:
516
test/mock/mockserver.go
Normal file
516
test/mock/mockserver.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user