Files
metacrypt/internal/server/server_test.go
Kyle Isom 4ddd32b117 Implement Phase 1: core framework, operational tooling, and runbook
Core packages: crypto (Argon2id/AES-256-GCM), config (TOML/viper),
db (SQLite/migrations), barrier (encrypted storage), seal (state machine
with rate-limited unseal), auth (MCIAS integration with token cache),
policy (priority-based ACL engine), engine (interface + registry).

Server: HTTPS with TLS 1.2+, REST API, auth/admin middleware, htmx web UI
(init, unseal, login, dashboard pages).

CLI: cobra/viper subcommands (server, init, status, snapshot) with env
var override support (METACRYPT_ prefix).

Operational tooling: Dockerfile (multi-stage, non-root), docker-compose,
hardened systemd units (service + daily backup timer), install script,
backup script with retention pruning, production config examples.

Runbook covering installation, configuration, daily operations,
backup/restore, monitoring, troubleshooting, and security procedures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 20:43:11 -07:00

180 lines
5.1 KiB
Go

package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"log/slog"
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
"git.wntrmute.dev/kyle/metacrypt/internal/config"
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
"git.wntrmute.dev/kyle/metacrypt/internal/db"
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
"git.wntrmute.dev/kyle/metacrypt/internal/policy"
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
// auth is used indirectly via the server
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
)
func setupTestServer(t *testing.T) (*Server, *seal.Manager, *http.ServeMux) {
t.Helper()
dir := t.TempDir()
database, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { database.Close() })
db.Migrate(database)
b := barrier.NewAESGCMBarrier(database)
sealMgr := seal.NewManager(database, b)
sealMgr.CheckInitialized()
// Auth requires MCIAS client which we can't create in tests easily,
// so we pass nil and avoid auth-dependent routes in these tests.
authenticator := auth.NewAuthenticator(nil)
policyEngine := policy.NewEngine(b)
engineRegistry := engine.NewRegistry(b)
cfg := &config.Config{
Server: config.ServerConfig{
ListenAddr: ":0",
TLSCert: "cert.pem",
TLSKey: "key.pem",
},
Database: config.DatabaseConfig{Path: filepath.Join(dir, "test.db")},
MCIAS: config.MCIASConfig{ServerURL: "https://mcias.test"},
Seal: config.SealConfig{
Argon2Time: 1,
Argon2Memory: 64 * 1024,
Argon2Threads: 1,
},
}
logger := slog.Default()
srv := New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger)
mux := http.NewServeMux()
srv.registerRoutes(mux)
return srv, sealMgr, mux
}
func TestStatusEndpoint(t *testing.T) {
_, _, mux := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status code: got %d, want %d", w.Code, http.StatusOK)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["state"] != "uninitialized" {
t.Errorf("state: got %q, want %q", resp["state"], "uninitialized")
}
}
func TestInitEndpoint(t *testing.T) {
_, _, mux := setupTestServer(t)
body := `{"password":"test-password"}`
req := httptest.NewRequest(http.MethodPost, "/v1/init", strings.NewReader(body))
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status code: got %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["state"] != "unsealed" {
t.Errorf("state: got %q, want %q", resp["state"], "unsealed")
}
// Second init should fail.
req2 := httptest.NewRequest(http.MethodPost, "/v1/init", strings.NewReader(body))
w2 := httptest.NewRecorder()
mux.ServeHTTP(w2, req2)
if w2.Code != http.StatusConflict {
t.Errorf("double init: got %d, want %d", w2.Code, http.StatusConflict)
}
}
func TestUnsealEndpoint(t *testing.T) {
_, sealMgr, mux := setupTestServer(t)
// Initialize first.
params := crypto.Argon2Params{Time: 1, Memory: 64 * 1024, Threads: 1}
sealMgr.Initialize(context.Background(), []byte("password"), params)
sealMgr.Seal()
// Unseal with wrong password.
body := `{"password":"wrong"}`
req := httptest.NewRequest(http.MethodPost, "/v1/unseal", strings.NewReader(body))
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("wrong password: got %d, want %d", w.Code, http.StatusUnauthorized)
}
// Unseal with correct password.
body = `{"password":"password"}`
req = httptest.NewRequest(http.MethodPost, "/v1/unseal", strings.NewReader(body))
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("correct password: got %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String())
}
}
func TestStatusMethodNotAllowed(t *testing.T) {
_, _, mux := setupTestServer(t)
req := httptest.NewRequest(http.MethodPost, "/v1/status", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("POST /v1/status: got %d, want %d", w.Code, http.StatusMethodNotAllowed)
}
}
func TestRootRedirect(t *testing.T) {
_, _, mux := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Errorf("root redirect: got %d, want %d", w.Code, http.StatusFound)
}
loc := w.Header().Get("Location")
if loc != "/init" {
t.Errorf("redirect location: got %q, want /init", loc)
}
}
func TestTokenInfoFromContext(t *testing.T) {
ctx := context.Background()
if info := TokenInfoFromContext(ctx); info != nil {
t.Error("expected nil from empty context")
}
info := &auth.TokenInfo{Username: "test", IsAdmin: true}
ctx = context.WithValue(ctx, tokenInfoKey, info)
got := TokenInfoFromContext(ctx)
if got == nil || got.Username != "test" {
t.Error("expected token info from context")
}
}