651 lines
20 KiB
Go
651 lines
20 KiB
Go
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.MigrateDB(db); err != nil {
|
|
t.Fatalf("migrate: %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
|
|
}
|
|
|
|
// reqAgent sends a request using a cvt_ wire token.
|
|
func (c *tc) reqAgent(credential, method, path string, body any) *http.Response {
|
|
c.t.Helper()
|
|
|
|
// Convert client credential (0x01) → wire token (0x00)
|
|
wire, _, err := lib.CredentialToWire(credential)
|
|
if err != nil {
|
|
c.t.Fatalf("CredentialToWire: %v", err)
|
|
}
|
|
|
|
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 "+wire)
|
|
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
|
|
}
|
|
|
|
// 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 credential token.
|
|
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)
|
|
cred, ok := result["credential"].(string)
|
|
if !ok || cred == "" {
|
|
c.t.Fatalf("createAgent: no credential in response: %v", result)
|
|
}
|
|
return cred
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 agent
|
|
credential := c.createAgent("Claude Code", false)
|
|
|
|
// Get agent info to find its scope
|
|
agents := c.mustList(c.req("GET", "/api/agents", nil), 200)
|
|
if len(agents) != 1 {
|
|
t.Fatalf("expected 1 agent, got %d", len(agents))
|
|
}
|
|
agentScope := agents[0]["scopes"].(string)
|
|
|
|
// Create two entries
|
|
c.must(c.req("POST", "/api/entries", credentialEntry("Scoped", "u", "p", nil)), 201)
|
|
c.must(c.req("POST", "/api/entries", credentialEntry("Owner Only", "u", "p", nil)), 201)
|
|
|
|
// Assign agent scope to one entry
|
|
ownerEntries := c.mustList(c.req("GET", "/api/entries", nil), 200)
|
|
for _, e := range ownerEntries {
|
|
if e["title"] == "Scoped" {
|
|
c.must(c.req("PUT", "/api/entries/"+e["entry_id"].(string)+"/scopes", map[string]any{
|
|
"scopes": agentScope,
|
|
}), 200)
|
|
}
|
|
}
|
|
|
|
// Agent sees only scoped entry
|
|
agentEntries := c.mustList(c.reqAgent(credential, "GET", "/api/entries", nil), 200)
|
|
if len(agentEntries) != 1 {
|
|
t.Fatalf("agent should see 1 entry, got %d", len(agentEntries))
|
|
}
|
|
if agentEntries[0]["title"] != "Scoped" {
|
|
t.Errorf("wrong entry: %v", agentEntries[0]["title"])
|
|
}
|
|
}
|
|
|
|
func TestScopedAccess_agent_forbidden_on_unscoped(t *testing.T) {
|
|
c := newTestClient(t)
|
|
c.must(c.req("POST", "/api/auth/setup", nil), 200)
|
|
|
|
credential := c.createAgent("Test Agent", false)
|
|
|
|
entry := c.must(c.req("POST", "/api/entries", credentialEntry("Secret", "u", "p", nil)), 201)
|
|
entryID := entry["entry_id"].(string)
|
|
|
|
resp := c.reqAgent(credential, "GET", "/api/entries/"+entryID, nil)
|
|
if resp.StatusCode != 403 {
|
|
resp.Body.Close()
|
|
t.Fatalf("expected 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)
|
|
|
|
credential := c.createAgent("Full Access", true)
|
|
|
|
c.must(c.req("POST", "/api/entries", credentialEntry("Owner Secret", "u", "p", nil)), 201)
|
|
|
|
agentEntries := c.mustList(c.reqAgent(credential, "GET", "/api/entries", nil), 200)
|
|
if len(agentEntries) != 1 {
|
|
t.Fatalf("all_access agent should see 1 entry, got %d", len(agentEntries))
|
|
}
|
|
}
|
|
|
|
func TestScopedAccess_agent_cannot_manage_agents(t *testing.T) {
|
|
c := newTestClient(t)
|
|
c.must(c.req("POST", "/api/auth/setup", nil), 200)
|
|
|
|
credential := c.createAgent("Evil Agent", false)
|
|
|
|
// Agent tries to create another agent
|
|
resp := c.reqAgent(credential, "POST", "/api/agents", map[string]any{"name": "Backdoor"})
|
|
if resp.StatusCode != 403 {
|
|
resp.Body.Close()
|
|
t.Fatalf("agent should not create agents: got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// Agent tries to list agents
|
|
resp = c.reqAgent(credential, "GET", "/api/agents", nil)
|
|
if resp.StatusCode != 403 {
|
|
resp.Body.Close()
|
|
t.Fatalf("agent should not list agents: got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestScopedAccess_agent_cannot_modify_scopes(t *testing.T) {
|
|
c := newTestClient(t)
|
|
c.must(c.req("POST", "/api/auth/setup", nil), 200)
|
|
|
|
credential := c.createAgent("Scope Hijacker", false)
|
|
|
|
entry := c.must(c.req("POST", "/api/entries", credentialEntry("Target", "u", "p", nil)), 201)
|
|
entryID := entry["entry_id"].(string)
|
|
|
|
resp := c.reqAgent(credential, "PUT", "/api/entries/"+entryID+"/scopes", map[string]any{"scopes": "0001"})
|
|
if resp.StatusCode != 403 {
|
|
resp.Body.Close()
|
|
t.Fatalf("agent should not modify scopes: 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)
|
|
|
|
credential := c.createAgent("Agent A", true) // all_access
|
|
|
|
c.must(c.req("POST", "/api/entries", credentialEntry("Normal Entry", "u", "p", nil)), 201)
|
|
|
|
// Agent should see entries but NOT agent entries (type filtering)
|
|
agentEntries := c.mustList(c.reqAgent(credential, "GET", "/api/entries", nil), 200)
|
|
for _, e := range agentEntries {
|
|
if e["type"] == "agent" || e["type"] == "scope" {
|
|
t.Fatalf("agent/scope entries should be hidden from list, got type=%v", e["type"])
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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) {
|
|
c := newTestClient(t)
|
|
c.must(c.req("POST", "/api/auth/setup", nil), 200)
|
|
|
|
result := c.must(c.req("POST", "/api/agents", map[string]any{"name": "Opaque Test"}), 201)
|
|
credential := result["credential"].(string)
|
|
|
|
// Credential must start with cvt_
|
|
if !strings.HasPrefix(credential, "cvt_") {
|
|
t.Fatalf("credential should start with cvt_, got: %s", credential[:10])
|
|
}
|
|
|
|
// Raw L1 and L2 bytes should NOT appear as substrings
|
|
l1Hex := hex.EncodeToString(fakeL1())
|
|
l2Hex := hex.EncodeToString(fakeL2())
|
|
if strings.Contains(credential, l1Hex) {
|
|
t.Fatal("L1 hex found in credential string")
|
|
}
|
|
if strings.Contains(credential, l2Hex) {
|
|
t.Fatal("L2 hex found in credential string")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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)
|
|
if result["state"] != "fresh" {
|
|
t.Fatalf("expected fresh, 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")
|
|
}
|
|
}
|