oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1package slidestest
2
3import (
4 "crypto/sha256"
5 "encoding/hex"
6 "net/http"
7 "net/url"
8 "regexp"
9 "strings"
10 "testing"
11)
12
13// agentToken finds the token on the page the create form answers with.
14// It is shown once, in that one response, and never again.
15//
16// That response is the POST's own body rather than a redirect it
17// carried the token into: a URL with a live credential in it lands in
18// the browser's history, in a Referer and in any log that records
19// paths, and none of those is somewhere a token should be resting.
20var agentToken = regexp.MustCompile(`(sl_[A-Za-z0-9]{40})`)
21
22// agentRevokeAction finds an agent row's revoke form on /agents.
23var agentRevokeAction = regexp.MustCompile(`/agents/(\d+)/revoke`)
24
25// mintAgent posts the create form on /agents and returns the token it
26// was shown once.
27func mintAgent(t *testing.T, a *testApp, cookies []*http.Cookie, name string) string {
28 t.Helper()
29 rec := post(t, a, "/agents", url.Values{"name": {name}}, cookies...)
30 if rec.Code != http.StatusOK {
31 t.Fatalf("POST /agents %q: status %d, want 200\n%s", name, rec.Code, rec.Body)
32 }
33 m := agentToken.FindStringSubmatch(rec.Body.String())
34 if m == nil {
35 t.Fatalf("POST /agents %q: the answer carries no new token\n%s", name, rec.Body)
36 }
37 return m[1]
38}
39
40// TestAgentTokenShownOnceAndHashed: minting shows the token once, in a
41// read-only field; coming back never shows it again; and what the
42// database holds is the fingerprint, never the token.
43func TestAgentTokenShownOnceAndHashed(t *testing.T) {
44 a := newApp(t)
45 cookies := signedIn(t, a, allowedAddress)
46
47 empty := mustGet(t, a, "/agents", http.StatusOK, cookies).Body.String()
48 if !strings.Contains(empty, "No agents yet") {
49 t.Errorf("a person with no agents is not told so:\n%s", empty)
50 }
51
52 // The POST answers with the page itself, token and all: the one
53 // moment it is readable.
54 minted := post(t, a, "/agents", url.Values{"name": {"claude"}}, cookies...)
55 if minted.Code != http.StatusOK {
56 t.Fatalf("POST /agents: status %d, want 200\n%s", minted.Code, minted.Body)
57 }
58 if loc := minted.Header().Get("Location"); loc != "" {
59 t.Errorf("POST /agents redirects to %q; a token must not ride a URL", loc)
60 }
61 shown := minted.Body.String()
62 m := agentToken.FindStringSubmatch(shown)
63 if m == nil {
64 t.Fatalf("POST /agents shows no token:\n%s", shown)
65 }
66 token := m[1]
67 if !strings.Contains(shown, "readonly") {
68 t.Errorf("the new token is not in a read-only field:\n%s", shown)
69 }
70 // A page carrying a credential is not one a cache — shared, or the
71 // browser's own back/forward store — may keep or hand to anybody
72 // else.
73 if cc := minted.Header().Get("Cache-Control"); cc != "no-store" {
74 t.Errorf("the page showing a token has Cache-Control %q, want %q", cc, "no-store")
75 }
76 // The principal is what a person recognises the agent by in an
77 // activity list, so the page that mints it has to name it.
78 if want := "claude~" + allowedAddress; !strings.Contains(shown, want) {
79 t.Errorf("the agents page does not show the principal %q:\n%s", want, shown)
80 }
81
82 again := mustGet(t, a, "/agents", http.StatusOK, cookies).Body.String()
83 if strings.Contains(again, token) {
84 t.Errorf("coming back to /agents shows the token again:\n%s", again)
85 }
86 // And there is no URL that will show it either: the query
87 // parameter that used to carry it is not read any more, so a token
88 // out of somebody's history cannot be replayed into the page.
89 replayed := mustGet(t, a, "/agents?new="+token, http.StatusOK, cookies).Body.String()
90 if strings.Contains(replayed, token) {
91 t.Errorf("/agents?new=<token> shows the token:\n%s", replayed)
92 }
93
94 // Every column concatenated, so this is an assertion about the
95 // whole row rather than about the one column that was remembered.
96 var whole []string
97 err := a.db.G.Raw(`SELECT COALESCE(id,'') || COALESCE(name,'') || COALESCE(operator_id,'') ||
98 COALESCE(token_hash,'') || COALESCE(created_at,'') || COALESCE(revoked_at,'')
99 FROM agents`).Scan(&whole).Error
100 if err != nil {
101 t.Fatalf("read agents: %v", err)
102 }
103 if len(whole) != 1 {
104 t.Fatalf("agents holds %d rows, want 1", len(whole))
105 }
106 if strings.Contains(whole[0], token) {
107 t.Errorf("agents holds the plaintext token: %s", whole[0])
108 }
109
110 var stored struct {
111 Name string
112 TokenHash string
113 }
114 if err := a.db.G.Raw("SELECT name, token_hash FROM agents").Scan(&stored).Error; err != nil {
115 t.Fatalf("read agents: %v", err)
116 }
117 if stored.Name != "claude" {
118 t.Errorf("agents.name = %q, want %q", stored.Name, "claude")
119 }
120 sum := sha256.Sum256([]byte(token))
121 if want := hex.EncodeToString(sum[:]); stored.TokenHash != want {
122 t.Errorf("agents.token_hash = %q, want the SHA-256 hex %s", stored.TokenHash, want)
123 }
124}
125
126// TestRevokedTokenIs401: a token that worked stops working the moment
127// its agent is revoked, and the page says the agent is off rather than
128// dropping the row.
129func TestRevokedTokenIs401(t *testing.T) {
130 a := newApp(t)
131 cookies := signedIn(t, a, allowedAddress)
132 token := mintAgent(t, a, cookies, "claude")
133
134 apiJSON(t, a, http.MethodGet, "/api/v1/decks", token, "", http.StatusOK)
135
136 page := mustGet(t, a, "/agents", http.StatusOK, cookies).Body.String()
137 m := agentRevokeAction.FindStringSubmatch(page)
138 if m == nil {
139 t.Fatalf("no revoke form on /agents:\n%s", page)
140 }
141 rec := post(t, a, "/agents/"+m[1]+"/revoke", nil, cookies...)
142 if rec.Code != http.StatusSeeOther {
143 t.Fatalf("POST /agents/%s/revoke: status %d, want 303\n%s", m[1], rec.Code, rec.Body)
144 }
145
146 body := apiJSON(t, a, http.MethodGet, "/api/v1/decks", token, "", http.StatusUnauthorized)
147 if body["error"] != "unauthorized" {
148 t.Errorf("a revoked token: error = %v, want %q", body["error"], "unauthorized")
149 }
150
151 after := mustGet(t, a, "/agents", http.StatusOK, cookies).Body.String()
152 if !strings.Contains(after, "Revoked") {
153 t.Errorf("/agents does not say the agent is revoked:\n%s", after)
154 }
155}
156
157// TestAgentNameIsChecked: the name is a principal component, so it is
158// held to the shape a principal can carry, and one person cannot have
159// two live agents answering to the same name.
160func TestAgentNameIsChecked(t *testing.T) {
161 a := newApp(t)
162 cookies := signedIn(t, a, allowedAddress)
163
164 for _, name := range []string{"", "x", "Claude", "my agent", strings.Repeat("a", 33)} {
165 rec := post(t, a, "/agents", url.Values{"name": {name}}, cookies...)
166 if rec.Code != http.StatusUnprocessableEntity {
167 t.Errorf("POST /agents name=%q: status %d, want 422", name, rec.Code)
168 }
169 }
170
171 mintAgent(t, a, cookies, "claude")
172 rec := post(t, a, "/agents", url.Values{"name": {"claude"}}, cookies...)
173 if rec.Code != http.StatusUnprocessableEntity {
174 t.Errorf("POST /agents with a name already live: status %d, want 422\n%s", rec.Code, rec.Body)
175 }
176}
177
178// TestAgentsAreTheirOperatorsAlone: /agents lists the person's own
179// agents and nobody else's, and a revoke aimed at another person's
180// agent is a 404 rather than a revocation.
181func TestAgentsAreTheirOperatorsAlone(t *testing.T) {
182 a := newSharedApp(t)
183 mine := signedIn(t, a, allowedAddress)
184 theirs := signedIn(t, a, otherAddress)
185
186 mintAgent(t, a, mine, "claude")
187 page := mustGet(t, a, "/agents", http.StatusOK, mine).Body.String()
188 m := agentRevokeAction.FindStringSubmatch(page)
189 if m == nil {
190 t.Fatalf("no revoke form on /agents:\n%s", page)
191 }
192
193 other := mustGet(t, a, "/agents", http.StatusOK, theirs).Body.String()
194 if strings.Contains(other, "claude~") {
195 t.Errorf("another person's agents page lists this person's agent:\n%s", other)
196 }
197 rec := post(t, a, "/agents/"+m[1]+"/revoke", nil, theirs...)
198 if rec.Code != http.StatusNotFound {
199 t.Errorf("revoking another person's agent: status %d, want 404", rec.Code)
200 }
201}
202
203// TestAgentsLinkedFromTheAccountMenu: the screen exists, so the menu
204// every signed-in page carries has to reach it.
205func TestAgentsLinkedFromTheAccountMenu(t *testing.T) {
206 a := newApp(t)
207 cookies := signedIn(t, a, allowedAddress)
208 page := mustGet(t, a, "/", http.StatusOK, cookies).Body.String()
209 if !strings.Contains(page, `href="/agents"`) {
210 t.Errorf("the account menu does not link to /agents:\n%s", page)
211 }
212}
213