package api // Integration tests for the Clavitor vault API (rewrite). // // Uses a fake PRF to derive L0/L1/L2/L3 from a deterministic seed. // Tests: // - Full CRUD with legacy L1 bearer (vault owner) // - CVT token agent auth with scoped access // - Agent creation, scope assignment, deletion // - Key leak detection: L1, L2, L3 never appear in API responses // - Agents cannot manage other agents or scopes // - all_access agents see everything // - Owner-only (0000 scope) entries invisible to scoped agents import ( "bytes" "embed" "encoding/base64" "encoding/hex" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/johanj/clavitor/lib" ) // --------------------------------------------------------------------------- // Fake PRF — deterministic key hierarchy for testing // --------------------------------------------------------------------------- // fakePRF simulates the PRF output (32 bytes) from which all tiers derive. // In production this comes from WebAuthn PRF extension. var fakePRF = []byte{ 0xAA, 0xBB, 0xCC, 0xDD, // L0 (4 bytes) 0x11, 0x22, 0x33, 0x44, // L1 = L0 + these 4 bytes (8 total) 0x55, 0x66, 0x77, 0x88, 0x99, 0xA0, 0xB1, 0xC2, // L2 = L1 + these 8 bytes (16 total) 0xD3, 0xE4, 0xF5, 0x06, 0x17, 0x28, 0x39, 0x4A, // L3 = L2 + these 8 bytes (24 total) 0x5B, 0x6C, 0x7D, 0x8E, 0x9F, 0xA0, 0xB1, 0xC2, // L3 continued (32 total) } func fakeL0() []byte { return fakePRF[:4] } func fakeL1() []byte { return fakePRF[:8] } func fakeL2() []byte { return fakePRF[:16] } func fakeL3() []byte { return fakePRF[:32] } // --------------------------------------------------------------------------- // Test client // --------------------------------------------------------------------------- type tc struct { srv *httptest.Server bearer string // base64url-encoded L1 key (8 bytes) for legacy auth t *testing.T } func newTestClient(t *testing.T) *tc { t.Helper() tmpDir := t.TempDir() cfg := &lib.Config{Port: "0", DataDir: tmpDir, SessionTTL: 86400} l1Raw := fakeL1() bearer := base64.RawURLEncoding.EncodeToString(l1Raw) // Create vault DB matching L1Middleware's naming convention prefix := base64.RawURLEncoding.EncodeToString(l1Raw[:4]) dbPath := tmpDir + "/clavitor-" + prefix db, err := lib.OpenDB(dbPath) if err != nil { t.Fatalf("opendb: %v", err) } if err := lib.InitSchema(db); err != nil { t.Fatalf("init schema: %v", err) } db.Close() var emptyFS embed.FS srv := httptest.NewServer(NewRouter(cfg, emptyFS)) t.Cleanup(srv.Close) return &tc{srv: srv, bearer: bearer, t: t} } // req sends a legacy L1 bearer request (vault owner). func (c *tc) req(method, path string, body any) *http.Response { c.t.Helper() var r io.Reader if body != nil { b, _ := json.Marshal(body) r = bytes.NewReader(b) } req, _ := http.NewRequest(method, c.srv.URL+path, r) if body != nil { req.Header.Set("Content-Type", "application/json") } req.Header.Set("Authorization", "Bearer "+c.bearer) resp, err := c.srv.Client().Do(req) if err != nil { c.t.Fatalf("req %s %s: %v", method, path, err) } return resp } // reqNoAuth sends an unauthenticated request. func (c *tc) reqNoAuth(method, path string, body any) *http.Response { c.t.Helper() var r io.Reader if body != nil { b, _ := json.Marshal(body) r = bytes.NewReader(b) } req, _ := http.NewRequest(method, c.srv.URL+path, r) if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.srv.Client().Do(req) if err != nil { c.t.Fatalf("req %s %s: %v", method, path, err) } return resp } // reqAgent sends a request with CVT wire token authentication (agent). // The wireToken is a type 0x00 CVT token containing L1 + agent_id. func (c *tc) reqAgent(method, path string, body any, wireToken string) *http.Response { c.t.Helper() var r io.Reader if body != nil { b, _ := json.Marshal(body) r = bytes.NewReader(b) } req, _ := http.NewRequest(method, c.srv.URL+path, r) if body != nil { req.Header.Set("Content-Type", "application/json") } req.Header.Set("Authorization", "Bearer "+wireToken) resp, err := c.srv.Client().Do(req) if err != nil { c.t.Fatalf("reqAgent %s %s: %v", method, path, err) } return resp } // mintWireToken creates a CVT wire token (type 0x00) for agent authentication. func (c *tc) mintWireToken(agentID []byte) string { c.t.Helper() token, err := lib.MintWireToken(fakeL0(), fakeL1(), agentID) if err != nil { c.t.Fatalf("mintWireToken: %v", err) } return token } // must asserts status code and returns parsed JSON object. func (c *tc) must(resp *http.Response, wantStatus int) map[string]any { c.t.Helper() defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != wantStatus { c.t.Fatalf("expected %d, got %d: %s", wantStatus, resp.StatusCode, body) } var out map[string]any json.Unmarshal(body, &out) return out } // mustList asserts status and returns JSON array. func (c *tc) mustList(resp *http.Response, wantStatus int) []map[string]any { c.t.Helper() defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != wantStatus { c.t.Fatalf("expected %d, got %d: %s", wantStatus, resp.StatusCode, body) } var out []map[string]any json.Unmarshal(body, &out) return out } // mustRaw asserts status and returns raw body bytes. func (c *tc) mustRaw(resp *http.Response, wantStatus int) []byte { c.t.Helper() defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != wantStatus { c.t.Fatalf("expected %d, got %d: %s", wantStatus, resp.StatusCode, body) } return body } // createAgent creates an agent via owner API, returns the agent_id. // NOTE: Credential is now generated client-side by web UI using crypto.js func (c *tc) createAgent(name string, allAccess bool) string { c.t.Helper() resp := c.req("POST", "/api/agents", map[string]any{ "name": name, "all_access": allAccess, }) result := c.must(resp, 201) agentID, ok := result["agent_id"].(string) if !ok || agentID == "" { c.t.Fatalf("createAgent: no agent_id in response: %v", result) } return agentID } // --------------------------------------------------------------------------- // Test data // --------------------------------------------------------------------------- func credentialEntry(title, username, password string, urls []string) map[string]any { return map[string]any{ "title": title, "type": "credential", "data": map[string]any{ "title": title, "type": "credential", "fields": []map[string]any{ {"label": "username", "value": username, "kind": "text"}, {"label": "password", "value": password, "kind": "password"}, }, "urls": urls, }, } } // --------------------------------------------------------------------------- // Health & Ping // --------------------------------------------------------------------------- func TestHealth(t *testing.T) { c := newTestClient(t) result := c.must(c.req("GET", "/health", nil), 200) if result["status"] != "ok" { t.Fatal("health not ok") } } func TestPing(t *testing.T) { c := newTestClient(t) result := c.must(c.reqNoAuth("GET", "/ping", nil), 200) if result["ok"] != true { t.Fatal("ping not ok") } } // --------------------------------------------------------------------------- // Entry CRUD (legacy L1 bearer = vault owner) // --------------------------------------------------------------------------- func TestCreateEntry(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) entry := c.must(c.req("POST", "/api/entries", credentialEntry("GitHub", "octocat", "pass123", []string{"https://github.com"})), 201) if entry["title"] != "GitHub" { t.Fatalf("title = %v", entry["title"]) } if entry["scopes"] != "0000" { t.Fatalf("default scope should be 0000, got %v", entry["scopes"]) } } func TestGetEntry(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) created := c.must(c.req("POST", "/api/entries", credentialEntry("Test", "user", "pass", nil)), 201) id := created["entry_id"].(string) got := c.must(c.req("GET", "/api/entries/"+id, nil), 200) if got["title"] != "Test" { t.Fatalf("title = %v", got["title"]) } } func TestListEntries(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) c.must(c.req("POST", "/api/entries", credentialEntry("A", "u", "p", nil)), 201) c.must(c.req("POST", "/api/entries", credentialEntry("B", "u", "p", nil)), 201) list := c.mustList(c.req("GET", "/api/entries", nil), 200) if len(list) != 2 { t.Fatalf("expected 2 entries, got %d", len(list)) } } func TestDeleteEntry(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) created := c.must(c.req("POST", "/api/entries", credentialEntry("Del", "u", "p", nil)), 201) id := created["entry_id"].(string) c.must(c.req("DELETE", "/api/entries/"+id, nil), 200) // Should be gone list := c.mustList(c.req("GET", "/api/entries", nil), 200) if len(list) != 0 { t.Fatalf("expected 0, got %d", len(list)) } } func TestUpsert_creates_when_new(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) c.must(c.req("PUT", "/api/entries", credentialEntry("Fresh", "u", "p", nil)), 201) } func TestUpsert_updates_when_exists(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) c.must(c.req("PUT", "/api/entries", credentialEntry("Same", "u1", "p1", nil)), 201) c.must(c.req("PUT", "/api/entries", credentialEntry("Same", "u2", "p2", nil)), 200) // update, not create list := c.mustList(c.req("GET", "/api/entries", nil), 200) if len(list) != 1 { t.Fatalf("expected 1, got %d", len(list)) } } func TestSearch(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) c.must(c.req("POST", "/api/entries", credentialEntry("GitHub Token", "u", "p", nil)), 201) c.must(c.req("POST", "/api/entries", credentialEntry("AWS Key", "u", "p", nil)), 201) results := c.mustList(c.req("GET", "/api/search?q=GitHub", nil), 200) if len(results) != 1 { t.Fatalf("expected 1 search result, got %d", len(results)) } } func TestPasswordGenerator(t *testing.T) { c := newTestClient(t) result := c.must(c.req("GET", "/api/generate", nil), 200) pw := result["password"].(string) if len(pw) < 10 { t.Fatalf("password too short: %s", pw) } } func TestAuditLog(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) c.must(c.req("POST", "/api/entries", credentialEntry("Audit Test", "u", "p", nil)), 201) events := c.mustList(c.req("GET", "/api/audit", nil), 200) if len(events) < 1 { t.Fatal("expected at least 1 audit event") } } // --------------------------------------------------------------------------- // Scoped Access // --------------------------------------------------------------------------- func TestScopedAccess_agent_sees_only_scoped_entries(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) // Create entries with different scopes c.must(c.req("POST", "/api/entries", credentialEntry("Work Entry", "user", "pass", nil)), 201) c.must(c.req("POST", "/api/entries", credentialEntry("Personal Entry", "user", "pass", nil)), 201) // Create agent with specific scope (work only) agentResp := c.must(c.req("POST", "/api/agents", map[string]any{ "name": "Work Agent", "all_access": false, "scope_whitelist": []string{"work"}, }), 201) agentIDHex := agentResp["agent_id"].(string) agentID, _ := hex.DecodeString(agentIDHex) // Mint wire token for agent authentication wireToken := c.mintWireToken(agentID) // Agent with work scope should see entries (scope filtering happens at data level) // With empty scope_whitelist, agent sees nothing // With ["work"], agent sees work entries list := c.mustList(c.reqAgent("GET", "/api/entries", nil, wireToken), 200) // Agent with work scope should see entries (default scope is "0000" which is owner-only) // Since entries have no explicit scope, they default to "0000" (owner-only) // So agent should see 0 entries if len(list) != 0 { t.Fatalf("agent with work scope should see 0 owner-scoped entries, got %d", len(list)) } } func TestScopedAccess_agent_forbidden_on_unscoped(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) // Create an entry entry := c.must(c.req("POST", "/api/entries", credentialEntry("Test Entry", "user", "pass", nil)), 201) entryID := entry["entry_id"].(string) // Create agent with no scope whitelist (cannot access owner-only entries) agentResp := c.must(c.req("POST", "/api/agents", map[string]any{ "name": "Limited Agent", "all_access": false, "scope_whitelist": []string{}, // Empty whitelist }), 201) agentIDHex := agentResp["agent_id"].(string) agentID, _ := hex.DecodeString(agentIDHex) // Mint wire token for agent authentication wireToken := c.mintWireToken(agentID) // Agent should get 403 when trying to access owner-only entry resp := c.reqAgent("GET", "/api/entries/"+entryID, nil, wireToken) if resp.StatusCode != 403 { t.Fatalf("agent without scope access should get 403, got %d", resp.StatusCode) } resp.Body.Close() } func TestScopedAccess_all_access_sees_everything(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) // Create entries c.must(c.req("POST", "/api/entries", credentialEntry("Entry 1", "user", "pass", nil)), 201) c.must(c.req("POST", "/api/entries", credentialEntry("Entry 2", "user", "pass", nil)), 201) // Create agent with all_access flag agentResp := c.must(c.req("POST", "/api/agents", map[string]any{ "name": "All Access Agent", "all_access": true, }), 201) agentIDHex := agentResp["agent_id"].(string) agentID, _ := hex.DecodeString(agentIDHex) // Mint wire token for agent authentication wireToken := c.mintWireToken(agentID) // Agent with all_access should see all entries list := c.mustList(c.reqAgent("GET", "/api/entries", nil, wireToken), 200) if len(list) != 2 { t.Fatalf("all_access agent should see 2 entries, got %d", len(list)) } } func TestScopedAccess_agent_cannot_manage_agents(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) // Create agent agentResp := c.must(c.req("POST", "/api/agents", map[string]any{ "name": "Regular Agent", "all_access": false, }), 201) agentIDHex := agentResp["agent_id"].(string) agentID, _ := hex.DecodeString(agentIDHex) // Mint wire token for agent authentication wireToken := c.mintWireToken(agentID) // Agent should get 403 when trying to create another agent resp := c.reqAgent("POST", "/api/agents", map[string]any{ "name": "New Agent", "all_access": false, }, wireToken) if resp.StatusCode != 403 { t.Fatalf("agent should not be able to create agents, expected 403 got %d", resp.StatusCode) } resp.Body.Close() // Agent should get 403 when trying to list agents resp = c.reqAgent("GET", "/api/agents", nil, wireToken) if resp.StatusCode != 403 { t.Fatalf("agent should not be able to list agents, expected 403 got %d", resp.StatusCode) } resp.Body.Close() } func TestScopedAccess_agent_cannot_create_system_types(t *testing.T) { // Agents cannot create entries with system types (agent, scope) c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) // Create agent agentResp := c.must(c.req("POST", "/api/agents", map[string]any{ "name": "Regular Agent", "all_access": false, }), 201) agentIDHex := agentResp["agent_id"].(string) agentID, _ := hex.DecodeString(agentIDHex) // Mint wire token for agent authentication wireToken := c.mintWireToken(agentID) // Agent should get 403 when trying to create entry with type=agent resp := c.reqAgent("POST", "/api/entries", map[string]any{ "title": "Fake Agent", "type": "agent", "data": map[string]any{ "title": "Fake Agent", "type": "agent", }, }, wireToken) if resp.StatusCode != 403 { t.Fatalf("agent should not be able to create agent-type entries, expected 403 got %d", resp.StatusCode) } resp.Body.Close() // Agent should get 403 when trying to create entry with type=scope resp = c.reqAgent("POST", "/api/entries", map[string]any{ "title": "Fake Scope", "type": "scope", "data": map[string]any{ "title": "Fake Scope", "type": "scope", }, }, wireToken) if resp.StatusCode != 403 { t.Fatalf("agent should not be able to create scope-type entries, expected 403 got %d", resp.StatusCode) } resp.Body.Close() } func TestScopedAccess_agent_entries_invisible(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) // Create entries c.must(c.req("POST", "/api/entries", credentialEntry("Entry 1", "user1", "pass1", nil)), 201) c.must(c.req("POST", "/api/entries", credentialEntry("Entry 2", "user2", "pass2", nil)), 201) // Create agent with limited scope agentResp := c.must(c.req("POST", "/api/agents", map[string]any{ "name": "Limited Agent", "all_access": false, "scope_whitelist": []string{"work"}, }), 201) agentIDHex := agentResp["agent_id"].(string) agentID, _ := hex.DecodeString(agentIDHex) // Mint wire token for agent authentication wireToken := c.mintWireToken(agentID) // Agent with work scope should not see owner-only entries (default scope "0000") list := c.mustList(c.reqAgent("GET", "/api/entries", nil, wireToken), 200) // Entries default to "0000" scope (owner-only), so agent sees nothing if len(list) != 0 { t.Fatalf("agent with work scope should see 0 owner-only entries, got %d", len(list)) } } // --------------------------------------------------------------------------- // Key Leak Detection // --------------------------------------------------------------------------- func TestKeyLeak_L1_not_in_responses(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) c.must(c.req("POST", "/api/entries", credentialEntry("Leak Test", "u", "p", nil)), 201) l1Hex := hex.EncodeToString(fakeL1()) l1B64 := base64.StdEncoding.EncodeToString(fakeL1()) // Check list response body := c.mustRaw(c.req("GET", "/api/entries", nil), 200) bodyStr := string(body) if strings.Contains(bodyStr, l1Hex) { t.Fatal("L1 key (hex) found in entries response") } if strings.Contains(bodyStr, l1B64) { t.Fatal("L1 key (b64) found in entries response") } } func TestKeyLeak_L2_not_in_responses(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) // Create agent — response includes credential token (which contains L2 encrypted) // but should NOT contain raw L2 l2Hex := hex.EncodeToString(fakeL2()) l2B64 := base64.StdEncoding.EncodeToString(fakeL2()) body := c.mustRaw(c.req("POST", "/api/agents", map[string]any{ "name": "Leak Check Agent", }), 201) bodyStr := string(body) if strings.Contains(bodyStr, l2Hex) { t.Fatal("L2 key (hex) found in agent creation response") } if strings.Contains(bodyStr, l2B64) { t.Fatal("L2 key (b64) found in agent creation response") } } func TestKeyLeak_L3_never_appears(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) c.must(c.req("POST", "/api/entries", credentialEntry("L3 Test", "u", "p", nil)), 201) c.createAgent("L3 Agent", false) l3Hex := hex.EncodeToString(fakeL3()) l3B64 := base64.StdEncoding.EncodeToString(fakeL3()) // Check multiple endpoints endpoints := []struct{ method, path string }{ {"GET", "/api/entries"}, {"GET", "/api/agents"}, {"GET", "/api/audit"}, {"GET", "/health"}, } for _, ep := range endpoints { body := c.mustRaw(c.req(ep.method, ep.path, nil), 200) bodyStr := string(body) if strings.Contains(bodyStr, l3Hex) { t.Fatalf("L3 key (hex) found in %s %s", ep.method, ep.path) } if strings.Contains(bodyStr, l3B64) { t.Fatalf("L3 key (b64) found in %s %s", ep.method, ep.path) } } } func TestKeyLeak_agent_credential_is_opaque(t *testing.T) { // CREDENTIAL GENERATION IS NOW CLIENT-SIDE // Server returns agent_id only, web UI generates credential using crypto.js // This test verifies that the server response contains no key material c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) // Create agent - server returns only agent_id, no credential body := c.mustRaw(c.req("POST", "/api/agents", map[string]any{ "name": "Leak Check Agent", }), 201) bodyStr := string(body) // Verify response contains agent_id but no credential token if !strings.Contains(bodyStr, "agent_id") { t.Fatal("agent creation response should contain agent_id") } // Response should NOT contain cvt_ prefix (credential tokens start with cvt_) if strings.Contains(bodyStr, "cvt_") { t.Fatal("agent creation response should not contain cvt_ credential (now client-side)") } // Verify no raw L2 in response l2Hex := hex.EncodeToString(fakeL2()) if strings.Contains(bodyStr, l2Hex) { t.Fatal("L2 key (hex) found in agent creation response") } } // --------------------------------------------------------------------------- // TOTP // --------------------------------------------------------------------------- func TestTOTP_valid_code(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) entry := c.must(c.req("POST", "/api/entries", map[string]any{ "title": "TOTP Test", "type": "credential", "data": map[string]any{ "title": "TOTP Test", "type": "credential", "fields": []map[string]any{ {"label": "TOTP", "value": "JBSWY3DPEHPK3PXP", "kind": "totp"}, }, }, }), 201) id := entry["entry_id"].(string) result := c.must(c.req("GET", "/api/ext/totp/"+id, nil), 200) code := result["code"].(string) if len(code) != 6 { t.Fatalf("expected 6-digit code, got %s", code) } } func TestURLMatch(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) c.must(c.req("POST", "/api/entries", credentialEntry("GitHub", "octocat", "p", []string{"https://github.com"})), 201) results := c.mustList(c.req("GET", "/api/ext/match?url=https://github.com/login", nil), 200) if len(results) != 1 { t.Fatalf("expected 1 match, got %d", len(results)) } } // --------------------------------------------------------------------------- // Auth endpoints // --------------------------------------------------------------------------- func TestAuthStatus_fresh(t *testing.T) { c := newTestClient(t) result := c.must(c.reqNoAuth("GET", "/api/auth/status", nil), 200) // Without authentication, we cannot determine vault state (multi-vault hosted mode) if result["state"] != "auth_required" { t.Fatalf("expected auth_required, got %v", result["state"]) } } func TestAuthRegisterBegin(t *testing.T) { c := newTestClient(t) result := c.must(c.reqNoAuth("POST", "/api/auth/register/begin", nil), 200) pk := result["publicKey"].(map[string]any) if pk["challenge"] == nil { t.Fatal("no challenge in response") } } // --------------------------------------------------------------------------- // Metadata list // --------------------------------------------------------------------------- func TestListEntries_meta(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/auth/setup", nil), 200) c.must(c.req("POST", "/api/entries", credentialEntry("Meta A", "u", "p", nil)), 201) c.must(c.req("POST", "/api/entries", credentialEntry("Meta B", "u", "p", nil)), 201) list := c.mustList(c.req("GET", "/api/entries?meta=1", nil), 200) if len(list) != 2 { t.Fatalf("expected 2, got %d", len(list)) } // Meta mode should not include VaultData if list[0]["data"] != nil { t.Fatal("meta mode should not include data") } }