package server import ( "context" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "log/slog" "github.com/go-chi/chi/v5" "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, chi.Router) { 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, slog.Default()) _ = 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, slog.Default()) policyEngine := policy.NewEngine(b) engineRegistry := engine.NewRegistry(b, slog.Default()) 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, "test") r := chi.NewRouter() srv.registerRoutes(r) return srv, sealMgr, r } 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 TestRootNotFound(t *testing.T) { _, _, mux := setupTestServer(t) // The vault server no longer serves a web UI at /; that is handled by metacrypt-web. req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Errorf("root: got %d, want %d", w.Code, http.StatusNotFound) } } func unsealServer(t *testing.T, sealMgr *seal.Manager, _ interface{}) { t.Helper() params := crypto.Argon2Params{Time: 1, Memory: 64 * 1024, Threads: 1} if err := sealMgr.Initialize(context.Background(), []byte("password"), params); err != nil { t.Fatalf("initialize: %v", err) } } func makeEngineRequest(mount, operation string) string { return `{"mount":"` + mount + `","operation":"` + operation + `","data":{}}` } // stubEngine is a minimal engine implementation for testing the generic endpoint. type stubEngine struct { engineType engine.EngineType } func (e *stubEngine) Type() engine.EngineType { return e.engineType } func (e *stubEngine) Initialize(_ context.Context, _ barrier.Barrier, _ string, _ map[string]interface{}) error { return nil } func (e *stubEngine) Unseal(_ context.Context, _ barrier.Barrier, _ string) error { return nil } func (e *stubEngine) Seal() error { return nil } func (e *stubEngine) HandleRequest(_ context.Context, req *engine.Request) (*engine.Response, error) { return &engine.Response{Data: map[string]interface{}{"ok": true}}, nil } // mountStubEngine registers a factory and mounts a stub engine of the given type. func mountStubEngine(t *testing.T, srv *Server, name string, engineType engine.EngineType) { t.Helper() srv.engines.RegisterFactory(engineType, func() engine.Engine { return &stubEngine{engineType: engineType} }) if err := srv.engines.Mount(context.Background(), name, engineType, nil); err != nil { // Ignore "already exists" from re-mounting the same name. if !strings.Contains(err.Error(), "already exists") { t.Fatalf("mount stub %q as %s: %v", name, engineType, err) } } } func withTokenInfo(r *http.Request, info *auth.TokenInfo) *http.Request { return r.WithContext(context.WithValue(r.Context(), tokenInfoKey, info)) } // TestEngineRequestPolicyDeniesNonAdmin verifies that a non-admin user without // an explicit allow rule is denied by the policy engine. func TestEngineRequestPolicyDeniesNonAdmin(t *testing.T) { srv, sealMgr, _ := setupTestServer(t) unsealServer(t, sealMgr, nil) mountStubEngine(t, srv, "pki", engine.EngineTypeCA) body := makeEngineRequest("pki", "list-issuers") req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body)) req = withTokenInfo(req, &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false}) w := httptest.NewRecorder() srv.handleEngineRequest(w, req) if w.Code != http.StatusForbidden { t.Errorf("expected 403 Forbidden for non-admin without policy rule, got %d: %s", w.Code, w.Body.String()) } } // TestEngineRequestPolicyAllowsAdmin verifies that admin users bypass policy. func TestEngineRequestPolicyAllowsAdmin(t *testing.T) { srv, sealMgr, _ := setupTestServer(t) unsealServer(t, sealMgr, nil) mountStubEngine(t, srv, "pki", engine.EngineTypeCA) body := makeEngineRequest("pki", "list-issuers") req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body)) req = withTokenInfo(req, &auth.TokenInfo{Username: "admin", Roles: []string{"admin"}, IsAdmin: true}) w := httptest.NewRecorder() srv.handleEngineRequest(w, req) // Admin bypasses policy; stub engine returns 200. if w.Code == http.StatusForbidden { t.Errorf("admin should not be forbidden by policy, got 403: %s", w.Body.String()) } } // TestEngineRequestPolicyAllowsWithRule verifies that a non-admin user with an // explicit allow rule is permitted to proceed. func TestEngineRequestPolicyAllowsWithRule(t *testing.T) { srv, sealMgr, _ := setupTestServer(t) unsealServer(t, sealMgr, nil) mountStubEngine(t, srv, "pki", engine.EngineTypeCA) ctx := context.Background() _ = srv.policy.CreateRule(ctx, &policy.Rule{ ID: "allow-user-read", Priority: 100, Effect: policy.EffectAllow, Roles: []string{"user"}, Resources: []string{"engine/*/*"}, Actions: []string{"read"}, }) body := makeEngineRequest("pki", "list-issuers") req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body)) req = withTokenInfo(req, &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false}) w := httptest.NewRecorder() srv.handleEngineRequest(w, req) // Policy allows; stub engine returns 200. if w.Code == http.StatusForbidden { t.Errorf("user with allow rule should not be forbidden, got 403: %s", w.Body.String()) } } // TestEngineRequestAdminOnlyBlocksNonAdmin verifies that admin-only operations // via the generic endpoint are rejected for non-admin users. func TestEngineRequestAdminOnlyBlocksNonAdmin(t *testing.T) { srv, sealMgr, _ := setupTestServer(t) unsealServer(t, sealMgr, nil) // Mount stub engines so the admin-only lookup can resolve engine types. mountStubEngine(t, srv, "ca-mount", engine.EngineTypeCA) mountStubEngine(t, srv, "transit-mount", engine.EngineTypeTransit) mountStubEngine(t, srv, "sshca-mount", engine.EngineTypeSSHCA) mountStubEngine(t, srv, "user-mount", engine.EngineTypeUser) cases := []struct { mount string op string }{ {"ca-mount", "create-issuer"}, {"ca-mount", "delete-cert"}, {"transit-mount", "create-key"}, {"transit-mount", "rotate-key"}, {"sshca-mount", "create-profile"}, {"user-mount", "provision"}, } for _, tc := range cases { body := makeEngineRequest(tc.mount, tc.op) req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body)) req = withTokenInfo(req, &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false}) w := httptest.NewRecorder() srv.handleEngineRequest(w, req) if w.Code != http.StatusForbidden { t.Errorf("%s/%s: expected 403 for non-admin, got %d", tc.mount, tc.op, w.Code) } } } // TestEngineRequestAdminOnlyAllowsAdmin verifies that admin-only operations // via the generic endpoint are allowed for admin users. func TestEngineRequestAdminOnlyAllowsAdmin(t *testing.T) { srv, sealMgr, _ := setupTestServer(t) unsealServer(t, sealMgr, nil) mountStubEngine(t, srv, "ca-mount", engine.EngineTypeCA) mountStubEngine(t, srv, "transit-mount", engine.EngineTypeTransit) mountStubEngine(t, srv, "sshca-mount", engine.EngineTypeSSHCA) mountStubEngine(t, srv, "user-mount", engine.EngineTypeUser) cases := []struct { mount string op string }{ {"ca-mount", "create-issuer"}, {"ca-mount", "delete-cert"}, {"transit-mount", "create-key"}, {"transit-mount", "rotate-key"}, {"sshca-mount", "create-profile"}, {"user-mount", "provision"}, } for _, tc := range cases { body := makeEngineRequest(tc.mount, tc.op) req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body)) req = withTokenInfo(req, &auth.TokenInfo{Username: "admin", Roles: []string{"admin"}, IsAdmin: true}) w := httptest.NewRecorder() srv.handleEngineRequest(w, req) // Admin passes the admin check; stub engine returns 200. if w.Code == http.StatusForbidden { t.Errorf("%s/%s: admin should not be forbidden, got 403", tc.mount, tc.op) } } } // TestEngineRequestUserRotateKeyOnUserMount verifies that a non-admin user // can call rotate-key on a user engine mount (not blocked by transit's admin gate). func TestEngineRequestUserRotateKeyOnUserMount(t *testing.T) { srv, sealMgr, _ := setupTestServer(t) unsealServer(t, sealMgr, nil) mountStubEngine(t, srv, "user-mount", engine.EngineTypeUser) // Create a policy rule allowing user operations. ctx := context.Background() _ = srv.policy.CreateRule(ctx, &policy.Rule{ ID: "allow-user-ops", Priority: 100, Effect: policy.EffectAllow, Roles: []string{"user"}, Resources: []string{"engine/*/*"}, Actions: []string{"any"}, }) body := makeEngineRequest("user-mount", "rotate-key") req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body)) req = withTokenInfo(req, &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false}) w := httptest.NewRecorder() srv.handleEngineRequest(w, req) // rotate-key on a user mount should NOT be blocked as admin-only. if w.Code == http.StatusForbidden { t.Errorf("user rotate-key on user mount should not be forbidden, got 403: %s", w.Body.String()) } } // TestEngineRequestUserRotateKeyOnTransitMount verifies that a non-admin user // is blocked from calling rotate-key on a transit engine mount. func TestEngineRequestUserRotateKeyOnTransitMount(t *testing.T) { srv, sealMgr, _ := setupTestServer(t) unsealServer(t, sealMgr, nil) mountStubEngine(t, srv, "transit-mount", engine.EngineTypeTransit) body := makeEngineRequest("transit-mount", "rotate-key") req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body)) req = withTokenInfo(req, &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false}) w := httptest.NewRecorder() srv.handleEngineRequest(w, req) if w.Code != http.StatusForbidden { t.Errorf("user rotate-key on transit mount should be 403, got %d", w.Code) } } // TestOperationAction verifies the action classification of operations. func TestOperationAction(t *testing.T) { tests := map[string]string{ // Read operations. "list-issuers": policy.ActionRead, "list-certs": policy.ActionRead, "get-cert": policy.ActionRead, "get-root": policy.ActionRead, "get-chain": policy.ActionRead, "get-issuer": policy.ActionRead, "list-keys": policy.ActionRead, "get-key": policy.ActionRead, "get-public-key": policy.ActionRead, "list-users": policy.ActionRead, "get-profile": policy.ActionRead, "list-profiles": policy.ActionRead, // Granular crypto operations (including batch variants). "encrypt": policy.ActionEncrypt, "batch-encrypt": policy.ActionEncrypt, "decrypt": policy.ActionDecrypt, "batch-decrypt": policy.ActionDecrypt, "sign": policy.ActionSign, "sign-host": policy.ActionSign, "sign-user": policy.ActionSign, "verify": policy.ActionVerify, "hmac": policy.ActionHMAC, // Write operations. "issue": policy.ActionWrite, "renew": policy.ActionWrite, "create-issuer": policy.ActionWrite, "delete-issuer": policy.ActionWrite, "sign-csr": policy.ActionWrite, "revoke": policy.ActionWrite, } for op, want := range tests { if got := operationAction(op); got != want { t.Errorf("operationAction(%q) = %q, want %q", op, got, want) } } } 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") } }