package api // Integration tests — white-box (same package) so we can call NewRouter directly. // Tests the three core user journeys: // 1. Create login → agents and humans store credentials // 2. Read login → round-trip encrypt/decrypt verification // 3. Use login → URL match, TOTP generation, MCP tool calls // // Uses inou.com as the test credential — a real login flow. // Run: go test ./api/... -v import ( "bytes" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "embed" "github.com/johanj/vault1984/lib" ) // --- test helpers --- type tc struct { srv *httptest.Server token string t *testing.T } func newTestClient(t *testing.T) *tc { t.Helper() tmpDir := t.TempDir() cfg := &lib.Config{ Port: "0", DataDir: tmpDir, Mode: "self-hosted", SessionTTL: 86400, } // Create a DB with a fake credential so VaultMiddleware can derive vault key fakePubKey := make([]byte, 65) for i := range fakePubKey { fakePubKey[i] = byte(i + 1) } dbPath := tmpDir + "/01020304.db" db, err := lib.OpenDB(dbPath) if err != nil { t.Fatalf("opendb: %v", err) } if err := lib.MigrateDB(db); err != nil { t.Fatalf("migrate: %v", err) } // Store fake credential for vault key derivation lib.StoreWebAuthnCredential(db, &lib.WebAuthnCredential{ CredID: lib.HexID(1), Name: "test", PublicKey: fakePubKey, CredentialID: []byte("test-raw-id"), }) db.Close() var emptyFS embed.FS srv := httptest.NewServer(NewRouter(cfg, emptyFS, emptyFS)) t.Cleanup(srv.Close) // Auth resp, _ := srv.Client().Post(srv.URL+"/api/auth/setup", "application/json", nil) var setup struct{ Token string `json:"token"` } json.NewDecoder(resp.Body).Decode(&setup) resp.Body.Close() return &tc{srv: srv, token: setup.Token, t: t} } 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") } if c.token != "" { req.Header.Set("Authorization", "Bearer "+c.token) } resp, err := c.srv.Client().Do(req) if err != nil { c.t.Fatalf("req %s %s: %v", method, path, err) } return resp } 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 } func inouEntry() map[string]any { return map[string]any{ "title": "inou.com", "type": "credential", "data": map[string]any{ "title": "inou.com", "type": "credential", "fields": []map[string]any{ {"label": "username", "value": "test@inou.com", "kind": "text"}, {"label": "password", "value": "TestPass!InouDev42", "kind": "password"}, {"label": "api_key", "value": "inou_sk_test_abc123", "kind": "text"}, }, "urls": []string{"https://inou.com", "https://app.inou.com"}, }, } } // --- Tests --- func TestHealth(t *testing.T) { c := newTestClient(t) result := c.must(c.req("GET", "/health", nil), 200) if result["status"] != "ok" { t.Errorf("health status = %v", result["status"]) } } func TestCreateLogin(t *testing.T) { c := newTestClient(t) result := c.must(c.req("POST", "/api/entries", inouEntry()), 201) id, _ := result["entry_id"].(string) if id == "" { t.Fatal("create returned no entry_id") } t.Logf("Created entry: %s", id) } func TestReadLogin_RoundTrip(t *testing.T) { c := newTestClient(t) // Create created := c.must(c.req("POST", "/api/entries", inouEntry()), 201) id := created["entry_id"].(string) // Read back result := c.must(c.req("GET", "/api/entries/"+id, nil), 200) data, _ := result["data"].(map[string]any) if data == nil { t.Fatal("read returned no data") } if data["title"] != "inou.com" { t.Errorf("title = %v", data["title"]) } // Verify password survived encrypt/decrypt fields, _ := data["fields"].([]any) var pw string for _, f := range fields { fm := f.(map[string]any) if fm["label"] == "password" { pw, _ = fm["value"].(string) } } if pw != "TestPass!InouDev42" { t.Errorf("password round-trip failed: got %q", pw) } t.Logf("Round-trip OK — password decrypted correctly") } func TestURLMatch(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/entries", inouEntry()), 201) // Extension calls this when you land on app.inou.com/login c.must(c.req("GET", "/api/ext/match?url=https://app.inou.com/login", nil), 200) // result is an array resp := c.req("GET", "/api/ext/match?url=https://app.inou.com/login", nil) defer resp.Body.Close() var matches []map[string]any json.NewDecoder(resp.Body).Decode(&matches) if len(matches) == 0 { t.Error("no URL match for app.inou.com — expected inou.com entry to match") } t.Logf("URL match: %d entries for app.inou.com", len(matches)) } func TestTOTP_AgentGeneratesCode(t *testing.T) { c := newTestClient(t) entry := map[string]any{ "title": "inou.com", "type": "credential", "data": map[string]any{ "title": "inou.com", "type": "credential", "fields": []map[string]any{ {"label": "username", "value": "test@inou.com", "kind": "text"}, {"label": "password", "value": "pass", "kind": "password"}, {"label": "totp", "value": "JBSWY3DPEHPK3PXP", "kind": "totp", "l2": false}, }, "urls": []string{"https://inou.com"}, }, } created := c.must(c.req("POST", "/api/entries", entry), 201) id := created["entry_id"].(string) result := c.must(c.req("GET", "/api/ext/totp/"+id, nil), 200) code, _ := result["code"].(string) if len(code) != 6 { t.Errorf("TOTP code = %q, want 6 digits", code) } expiresIn, _ := result["expires_in"].(float64) if expiresIn <= 0 || expiresIn > 30 { t.Errorf("expires_in = %v", expiresIn) } t.Logf("TOTP: %s (expires in %.0fs) — agent can complete 2FA without human", code, expiresIn) } func TestMCP_ListCredentials(t *testing.T) { c := newTestClient(t) for _, title := range []string{"inou.com", "GitHub", "AWS"} { c.req("POST", "/api/entries", map[string]any{ "title": title, "type": "credential", "data": map[string]any{ "title": title, "type": "credential", "fields": []map[string]any{{"label": "password", "value": "x", "kind": "password"}}, }, }) } mcp := map[string]any{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]any{"name": "list_credentials", "arguments": map[string]any{}}, } result := c.must(c.req("POST", "/mcp", mcp), 200) res, _ := result["result"].(map[string]any) if res == nil { t.Fatalf("MCP error: %v", result["error"]) } content, _ := res["content"].([]any) if len(content) == 0 { t.Fatal("MCP list_credentials returned empty content") } text := content[0].(map[string]any)["text"].(string) t.Logf("MCP list_credentials: %s", text[:min(len(text), 120)]) } func TestMCP_GetCredential_Inou(t *testing.T) { c := newTestClient(t) c.must(c.req("POST", "/api/entries", inouEntry()), 201) mcp := map[string]any{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": map[string]any{"name": "get_credential", "arguments": map[string]any{"query": "inou.com"}}, } result := c.must(c.req("POST", "/mcp", mcp), 200) res, _ := result["result"].(map[string]any) if res == nil { t.Fatalf("MCP error: %v", result["error"]) } content, _ := res["content"].([]any) if len(content) == 0 { t.Fatal("empty content") } text := content[0].(map[string]any)["text"].(string) if !strings.Contains(text, "inou_sk_test_abc123") { t.Errorf("API key missing from MCP response: %s", text) } t.Logf("MCP get_credential: agent retrieved inou.com API key") } func TestMCP_GetTOTP(t *testing.T) { c := newTestClient(t) entry := map[string]any{ "title": "inou.com", "type": "credential", "data": map[string]any{ "title": "inou.com", "type": "credential", "fields": []map[string]any{ {"label": "totp", "value": "JBSWY3DPEHPK3PXP", "kind": "totp"}, }, }, } c.must(c.req("POST", "/api/entries", entry), 201) mcp := map[string]any{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": map[string]any{"name": "get_totp", "arguments": map[string]any{"query": "inou.com"}}, } result := c.must(c.req("POST", "/mcp", mcp), 200) res, _ := result["result"].(map[string]any) if res == nil { t.Fatalf("MCP error: %v", result["error"]) } content, _ := res["content"].([]any) text := content[0].(map[string]any)["text"].(string) // MCP get_totp returns JSON: {"code":"XXXXXX","expires_in":N} var totpJSON struct { Code string `json:"code"` ExpiresIn int `json:"expires_in"` } if err := json.Unmarshal([]byte(text), &totpJSON); err != nil { t.Fatalf("get_totp response not valid JSON: %s", text) } if len(totpJSON.Code) != 6 { t.Errorf("TOTP code = %q, want 6 digits", totpJSON.Code) } if totpJSON.ExpiresIn <= 0 || totpJSON.ExpiresIn > 30 { t.Errorf("expires_in = %d", totpJSON.ExpiresIn) } t.Logf("MCP get_totp: %s (expires in %ds)", totpJSON.Code, totpJSON.ExpiresIn) } func TestScopedToken_HidesOtherEntries(t *testing.T) { c := newTestClient(t) // Two entries — we'll scope the token to only one of them inouResult := c.must(c.req("POST", "/api/entries", map[string]any{ "title": "inou.com", "type": "credential", "data": map[string]any{ "title": "inou.com", "type": "credential", "fields": []map[string]any{{"label": "key", "value": "inou_key", "kind": "text"}}, }, }), 201) inouID, _ := inouResult["entry_id"].(string) c.must(c.req("POST", "/api/entries", map[string]any{ "title": "GitHub", "type": "credential", "data": map[string]any{ "title": "GitHub", "type": "credential", "fields": []map[string]any{{"label": "token", "value": "ghp_secret", "kind": "password"}}, }, }), 201) // Scoped token: inou entry only tokenResult := c.must(c.req("POST", "/api/mcp-tokens", map[string]any{ "label": "inou-agent", "entry_ids": []string{inouID}, "read_only": true, }), 201) scopedToken, _ := tokenResult["token"].(string) // Scoped tokens are MCP-only — test via MCP list_credentials scoped := &tc{srv: c.srv, token: scopedToken, t: t} mcp := map[string]any{ "jsonrpc": "2.0", "id": 99, "method": "tools/call", "params": map[string]any{"name": "list_credentials", "arguments": map[string]any{"search": ""}}, } result := scoped.must(scoped.req("POST", "/mcp", mcp), 200) res, _ := result["result"].(map[string]any) if res == nil { t.Fatalf("MCP error: %v", result["error"]) } contentList, _ := res["content"].([]any) text := "" if len(contentList) > 0 { text, _ = contentList[0].(map[string]any)["text"].(string) } if strings.Contains(text, "GitHub") { t.Errorf("scoped token leaked GitHub entry: %s", text) } t.Logf("Scoped token OK: only scoped entries visible") } func TestPasswordGenerator(t *testing.T) { c := newTestClient(t) result := c.must(c.req("GET", "/api/generate?length=24&type=random", nil), 200) pw, _ := result["password"].(string) if len(pw) < 16 { t.Errorf("password too short: %q", pw) } t.Logf("Generated: %s", pw) } func TestAuditLog(t *testing.T) { c := newTestClient(t) created := c.must(c.req("POST", "/api/entries", inouEntry()), 201) id := created["entry_id"].(string) c.req("GET", "/api/entries/"+id, nil) // Audit log returns a plain JSON array auditResp := c.req("GET", "/api/audit", nil) if auditResp.StatusCode != 200 { t.Fatalf("audit returned %d", auditResp.StatusCode) } defer auditResp.Body.Close() var events []map[string]any json.NewDecoder(auditResp.Body).Decode(&events) if len(events) == 0 { t.Error("audit log empty after create + read") } t.Logf("Audit log: %d events", len(events)) } func min(a, b int) int { if a < b { return a }; return b } // --- WebAuthn Auth Flow Tests --- func TestAuthStatus_FreshVault(t *testing.T) { c := newTestClient(t) resp, _ := c.srv.Client().Get(c.srv.URL + "/api/auth/status") defer resp.Body.Close() var result map[string]any json.NewDecoder(resp.Body).Decode(&result) if result["state"] != "fresh" { t.Errorf("expected fresh state, got %v", result["state"]) } if result["credentials"].(float64) != 0 { t.Errorf("expected 0 credentials, got %v", result["credentials"]) } t.Logf("Auth status: %v", result) } func TestAuthStatus_LockedVault(t *testing.T) { c := newTestClient(t) // Register a credential via authenticated endpoint c.must(c.req("POST", "/api/webauthn/register/complete", map[string]any{ "public_key": []byte{1, 2, 3}, "prf_salt": []byte{4, 5, 6}, "name": "Test Key", }), 201) // Check status (unauthenticated) resp, _ := c.srv.Client().Get(c.srv.URL + "/api/auth/status") defer resp.Body.Close() var result map[string]any json.NewDecoder(resp.Body).Decode(&result) if result["state"] != "locked" { t.Errorf("expected locked state, got %v", result["state"]) } t.Logf("Auth status after registration: %v", result) } func TestAuthRegister_BlocksWhenCredentialsExist(t *testing.T) { c := newTestClient(t) // Register a credential c.must(c.req("POST", "/api/webauthn/register/complete", map[string]any{ "public_key": []byte{1, 2, 3}, "prf_salt": []byte{4, 5, 6}, "name": "Test Key", }), 201) // Try unauthenticated registration (should be blocked) resp, _ := c.srv.Client().Post(c.srv.URL+"/api/auth/register/begin", "application/json", nil) defer resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Errorf("expected 403, got %d", resp.StatusCode) } t.Log("Unauthenticated register correctly blocked when credentials exist") } func TestSetup_CreatesSession(t *testing.T) { c := newTestClient(t) // Setup should create a session resp, _ := c.srv.Client().Post(c.srv.URL+"/api/auth/setup", "application/json", nil) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("expected 200, got %d", resp.StatusCode) } t.Log("Setup creates session correctly") } func TestAuthRegisterBegin_FreshVault(t *testing.T) { c := newTestClient(t) resp, _ := c.srv.Client().Post(c.srv.URL+"/api/auth/register/begin", "application/json", strings.NewReader("{}")) defer resp.Body.Close() if resp.StatusCode != 200 { t.Fatalf("expected 200, got %d", resp.StatusCode) } var options map[string]any json.NewDecoder(resp.Body).Decode(&options) pk := options["publicKey"].(map[string]any) if pk["challenge"] == nil { t.Fatal("no challenge in response") } t.Logf("Register begin: challenge generated, rp=%v", pk["rp"]) }