480 lines
14 KiB
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/clavitor/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"])
|
|
}
|