package slidestest import ( "crypto/sha256" "encoding/hex" "net/http" "net/url" "regexp" "strings" "testing" ) // agentToken finds the token on the page the create form answers with. // It is shown once, in that one response, and never again. // // That response is the POST's own body rather than a redirect it // carried the token into: a URL with a live credential in it lands in // the browser's history, in a Referer and in any log that records // paths, and none of those is somewhere a token should be resting. var agentToken = regexp.MustCompile(`(sl_[A-Za-z0-9]{40})`) // agentRevokeAction finds an agent row's revoke form on /agents. var agentRevokeAction = regexp.MustCompile(`/agents/(\d+)/revoke`) // mintAgent posts the create form on /agents and returns the token it // was shown once. func mintAgent(t *testing.T, a *testApp, cookies []*http.Cookie, name string) string { t.Helper() rec := post(t, a, "/agents", url.Values{"name": {name}}, cookies...) if rec.Code != http.StatusOK { t.Fatalf("POST /agents %q: status %d, want 200\n%s", name, rec.Code, rec.Body) } m := agentToken.FindStringSubmatch(rec.Body.String()) if m == nil { t.Fatalf("POST /agents %q: the answer carries no new token\n%s", name, rec.Body) } return m[1] } // TestAgentTokenShownOnceAndHashed: minting shows the token once, in a // read-only field; coming back never shows it again; and what the // database holds is the fingerprint, never the token. func TestAgentTokenShownOnceAndHashed(t *testing.T) { a := newApp(t) cookies := signedIn(t, a, allowedAddress) empty := mustGet(t, a, "/agents", http.StatusOK, cookies).Body.String() if !strings.Contains(empty, "No agents yet") { t.Errorf("a person with no agents is not told so:\n%s", empty) } // The POST answers with the page itself, token and all: the one // moment it is readable. minted := post(t, a, "/agents", url.Values{"name": {"claude"}}, cookies...) if minted.Code != http.StatusOK { t.Fatalf("POST /agents: status %d, want 200\n%s", minted.Code, minted.Body) } if loc := minted.Header().Get("Location"); loc != "" { t.Errorf("POST /agents redirects to %q; a token must not ride a URL", loc) } shown := minted.Body.String() m := agentToken.FindStringSubmatch(shown) if m == nil { t.Fatalf("POST /agents shows no token:\n%s", shown) } token := m[1] if !strings.Contains(shown, "readonly") { t.Errorf("the new token is not in a read-only field:\n%s", shown) } // A page carrying a credential is not one a cache — shared, or the // browser's own back/forward store — may keep or hand to anybody // else. if cc := minted.Header().Get("Cache-Control"); cc != "no-store" { t.Errorf("the page showing a token has Cache-Control %q, want %q", cc, "no-store") } // The principal is what a person recognises the agent by in an // activity list, so the page that mints it has to name it. if want := "claude~" + allowedAddress; !strings.Contains(shown, want) { t.Errorf("the agents page does not show the principal %q:\n%s", want, shown) } again := mustGet(t, a, "/agents", http.StatusOK, cookies).Body.String() if strings.Contains(again, token) { t.Errorf("coming back to /agents shows the token again:\n%s", again) } // And there is no URL that will show it either: the query // parameter that used to carry it is not read any more, so a token // out of somebody's history cannot be replayed into the page. replayed := mustGet(t, a, "/agents?new="+token, http.StatusOK, cookies).Body.String() if strings.Contains(replayed, token) { t.Errorf("/agents?new= shows the token:\n%s", replayed) } // Every column concatenated, so this is an assertion about the // whole row rather than about the one column that was remembered. var whole []string err := a.db.G.Raw(`SELECT COALESCE(id,'') || COALESCE(name,'') || COALESCE(operator_id,'') || COALESCE(token_hash,'') || COALESCE(created_at,'') || COALESCE(revoked_at,'') FROM agents`).Scan(&whole).Error if err != nil { t.Fatalf("read agents: %v", err) } if len(whole) != 1 { t.Fatalf("agents holds %d rows, want 1", len(whole)) } if strings.Contains(whole[0], token) { t.Errorf("agents holds the plaintext token: %s", whole[0]) } var stored struct { Name string TokenHash string } if err := a.db.G.Raw("SELECT name, token_hash FROM agents").Scan(&stored).Error; err != nil { t.Fatalf("read agents: %v", err) } if stored.Name != "claude" { t.Errorf("agents.name = %q, want %q", stored.Name, "claude") } sum := sha256.Sum256([]byte(token)) if want := hex.EncodeToString(sum[:]); stored.TokenHash != want { t.Errorf("agents.token_hash = %q, want the SHA-256 hex %s", stored.TokenHash, want) } } // TestRevokedTokenIs401: a token that worked stops working the moment // its agent is revoked, and the page says the agent is off rather than // dropping the row. func TestRevokedTokenIs401(t *testing.T) { a := newApp(t) cookies := signedIn(t, a, allowedAddress) token := mintAgent(t, a, cookies, "claude") apiJSON(t, a, http.MethodGet, "/api/v1/decks", token, "", http.StatusOK) page := mustGet(t, a, "/agents", http.StatusOK, cookies).Body.String() m := agentRevokeAction.FindStringSubmatch(page) if m == nil { t.Fatalf("no revoke form on /agents:\n%s", page) } rec := post(t, a, "/agents/"+m[1]+"/revoke", nil, cookies...) if rec.Code != http.StatusSeeOther { t.Fatalf("POST /agents/%s/revoke: status %d, want 303\n%s", m[1], rec.Code, rec.Body) } body := apiJSON(t, a, http.MethodGet, "/api/v1/decks", token, "", http.StatusUnauthorized) if body["error"] != "unauthorized" { t.Errorf("a revoked token: error = %v, want %q", body["error"], "unauthorized") } after := mustGet(t, a, "/agents", http.StatusOK, cookies).Body.String() if !strings.Contains(after, "Revoked") { t.Errorf("/agents does not say the agent is revoked:\n%s", after) } } // TestAgentNameIsChecked: the name is a principal component, so it is // held to the shape a principal can carry, and one person cannot have // two live agents answering to the same name. func TestAgentNameIsChecked(t *testing.T) { a := newApp(t) cookies := signedIn(t, a, allowedAddress) for _, name := range []string{"", "x", "Claude", "my agent", strings.Repeat("a", 33)} { rec := post(t, a, "/agents", url.Values{"name": {name}}, cookies...) if rec.Code != http.StatusUnprocessableEntity { t.Errorf("POST /agents name=%q: status %d, want 422", name, rec.Code) } } mintAgent(t, a, cookies, "claude") rec := post(t, a, "/agents", url.Values{"name": {"claude"}}, cookies...) if rec.Code != http.StatusUnprocessableEntity { t.Errorf("POST /agents with a name already live: status %d, want 422\n%s", rec.Code, rec.Body) } } // TestAgentsAreTheirOperatorsAlone: /agents lists the person's own // agents and nobody else's, and a revoke aimed at another person's // agent is a 404 rather than a revocation. func TestAgentsAreTheirOperatorsAlone(t *testing.T) { a := newSharedApp(t) mine := signedIn(t, a, allowedAddress) theirs := signedIn(t, a, otherAddress) mintAgent(t, a, mine, "claude") page := mustGet(t, a, "/agents", http.StatusOK, mine).Body.String() m := agentRevokeAction.FindStringSubmatch(page) if m == nil { t.Fatalf("no revoke form on /agents:\n%s", page) } other := mustGet(t, a, "/agents", http.StatusOK, theirs).Body.String() if strings.Contains(other, "claude~") { t.Errorf("another person's agents page lists this person's agent:\n%s", other) } rec := post(t, a, "/agents/"+m[1]+"/revoke", nil, theirs...) if rec.Code != http.StatusNotFound { t.Errorf("revoking another person's agent: status %d, want 404", rec.Code) } } // TestAgentsLinkedFromTheAccountMenu: the screen exists, so the menu // every signed-in page carries has to reach it. func TestAgentsLinkedFromTheAccountMenu(t *testing.T) { a := newApp(t) cookies := signedIn(t, a, allowedAddress) page := mustGet(t, a, "/", http.StatusOK, cookies).Body.String() if !strings.Contains(page, `href="/agents"`) { t.Errorf("the account menu does not link to /agents:\n%s", page) } }