| 1 | package slidestest |
| 2 | |
| 3 | import ( |
| 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. |
| 20 | var agentToken = regexp.MustCompile(`(sl_[A-Za-z0-9]{40})`) |
| 21 | |
| 22 | // agentRevokeAction finds an agent row's revoke form on /agents. |
| 23 | var agentRevokeAction = regexp.MustCompile(`/agents/(\d+)/revoke`) |
| 24 | |
| 25 | // mintAgent posts the create form on /agents and returns the token it |
| 26 | // was shown once. |
| 27 | func 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. |
| 43 | func 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. |
| 129 | func 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. |
| 160 | func 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. |
| 181 | func 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. |
| 205 | func 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 | |