clavitor/clovis/clovis-vault/api/integration_test.go

480 lines
14 KiB
Go

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"])
}