| 1 | package main |
| 2 | |
| 3 | // Tests for the member-app surface (memberapps.go): the token-gated deploy |
| 4 | // API and its author/admin gates, and the sandbox frame page whose headers |
| 5 | // ARE the security boundary for member-app code. |
| 6 | |
| 7 | import ( |
| 8 | "crypto/sha256" |
| 9 | "encoding/base64" |
| 10 | "fmt" |
| 11 | "io" |
| 12 | "net/http" |
| 13 | "regexp" |
| 14 | "strings" |
| 15 | "sync" |
| 16 | "testing" |
| 17 | ) |
| 18 | |
| 19 | func TestMemberAppsCRUDAndGates(t *testing.T) { |
| 20 | ts, s := newTestServer(t) |
| 21 | tok1, uid1, _ := joinedMember(t, s, "Ana") |
| 22 | tok2, _, _ := joinedMember(t, s, "Bo") |
| 23 | |
| 24 | // Listing is member-gated. |
| 25 | if r, _ := http.Get(ts.URL + "/api/member-apps?token=bogus"); r.StatusCode != http.StatusForbidden { |
| 26 | t.Fatalf("bogus list: want 403, got %d", r.StatusCode) |
| 27 | } |
| 28 | r, _ := http.Get(ts.URL + "/api/member-apps?token=" + tok1) |
| 29 | var list struct{ Apps []MemberApp } |
| 30 | decodeJSON(t, r, &list) |
| 31 | if len(list.Apps) != 0 { |
| 32 | t.Fatalf("fresh instance should list no member apps, got %d", len(list.Apps)) |
| 33 | } |
| 34 | |
| 35 | // Deploy: id shape and token are enforced. |
| 36 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/UPPER", fmt.Sprintf(`{"token":"%s","blob":"x"}`, tok1)); r.StatusCode != http.StatusBadRequest { |
| 37 | t.Fatalf("bad id: want 400, got %d", r.StatusCode) |
| 38 | } |
| 39 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", `{"token":"bogus","blob":"x"}`); r.StatusCode != http.StatusForbidden { |
| 40 | t.Fatalf("bad token: want 403, got %d", r.StatusCode) |
| 41 | } |
| 42 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", fmt.Sprintf(`{"token":"%s","blob":""}`, tok1)); r.StatusCode != http.StatusBadRequest { |
| 43 | t.Fatalf("empty blob: want 400, got %d", r.StatusCode) |
| 44 | } |
| 45 | // A deploy reports the version it produced — the read-back the bot sidecar |
| 46 | // verifies against before announcing a build. |
| 47 | var put struct { |
| 48 | OK bool `json:"ok"` |
| 49 | Version int `json:"version"` |
| 50 | } |
| 51 | r = doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", fmt.Sprintf(`{"token":"%s","blob":"ciphertext-1"}`, tok1)) |
| 52 | if r.StatusCode != http.StatusOK { |
| 53 | t.Fatalf("deploy: want 200, got %d", r.StatusCode) |
| 54 | } |
| 55 | decodeJSON(t, r, &put) |
| 56 | if !put.OK || put.Version != 1 { |
| 57 | t.Fatalf("create should report version 1: %+v", put) |
| 58 | } |
| 59 | |
| 60 | // The list returns the blob and the author. |
| 61 | r, _ = http.Get(ts.URL + "/api/member-apps?token=" + tok2) |
| 62 | decodeJSON(t, r, &list) |
| 63 | if len(list.Apps) != 1 || list.Apps[0].Blob != "ciphertext-1" || list.Apps[0].AuthorID != uid1 || list.Apps[0].Version != 1 { |
| 64 | t.Fatalf("bad list: %+v", list.Apps) |
| 65 | } |
| 66 | |
| 67 | // Only the author may overwrite; an update bumps version, keeps the author. |
| 68 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", fmt.Sprintf(`{"token":"%s","blob":"evil"}`, tok2)); r.StatusCode != http.StatusForbidden { |
| 69 | t.Fatalf("non-author overwrite: want 403, got %d", r.StatusCode) |
| 70 | } |
| 71 | r = doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", fmt.Sprintf(`{"token":"%s","blob":"ciphertext-2"}`, tok1)) |
| 72 | if r.StatusCode != http.StatusOK { |
| 73 | t.Fatalf("author update: want 200, got %d", r.StatusCode) |
| 74 | } |
| 75 | decodeJSON(t, r, &put) |
| 76 | if put.Version != 2 { |
| 77 | t.Fatalf("update should report version 2: %+v", put) |
| 78 | } |
| 79 | app, ok, _ := s.db.getMemberApp("app-abc123") |
| 80 | if !ok || app.Blob != "ciphertext-2" || app.Version != 2 || app.AuthorID != uid1 { |
| 81 | t.Fatalf("after update: %+v ok=%v", app, ok) |
| 82 | } |
| 83 | |
| 84 | // Delete: strangers no; the author yes. |
| 85 | if r := doReq(t, "DELETE", ts.URL+"/api/member-apps/app-abc123?token="+tok2, ""); r.StatusCode != http.StatusForbidden { |
| 86 | t.Fatalf("non-author delete: want 403, got %d", r.StatusCode) |
| 87 | } |
| 88 | if r := doReq(t, "DELETE", ts.URL+"/api/member-apps/app-abc123?token="+tok1, ""); r.StatusCode != http.StatusOK { |
| 89 | t.Fatalf("author delete: want 200, got %d", r.StatusCode) |
| 90 | } |
| 91 | if _, ok, _ := s.db.getMemberApp("app-abc123"); ok { |
| 92 | t.Fatal("app still present after delete") |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | // A bot deploys an app it built for a member: the bot is the author, the member |
| 97 | // is the owner. The OWNER (not just the author) may update and delete it — the |
| 98 | // whole point of App Studio managing your Claude-built apps — while a stranger |
| 99 | // still can't. An update preserves author + owner (ownership never transfers). |
| 100 | func TestMemberAppsOwnerCanManage(t *testing.T) { |
| 101 | ts, s := newTestServer(t) |
| 102 | botTok, botUID, _ := joinedMember(t, s, "Claude") |
| 103 | if err := s.db.setUserAgent(botUID); err != nil { // only an agent may set owner_id |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | ownerTok, ownerUID, _ := joinedMember(t, s, "Ana") |
| 107 | strangerTok, _, _ := joinedMember(t, s, "Bo") |
| 108 | |
| 109 | deploy := fmt.Sprintf(`{"token":"%s","blob":"ciphertext","owner_id":"%s"}`, botTok, ownerUID) |
| 110 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-forana", deploy); r.StatusCode != http.StatusOK { |
| 111 | t.Fatalf("bot deploy for owner: want 200, got %d", r.StatusCode) |
| 112 | } |
| 113 | if app, ok, _ := s.db.getMemberApp("app-forana"); !ok || app.AuthorID != botUID || app.OwnerID != ownerUID { |
| 114 | t.Fatalf("author/owner not recorded: %+v ok=%v", app, ok) |
| 115 | } |
| 116 | // A stranger can neither update nor delete. |
| 117 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-forana", fmt.Sprintf(`{"token":"%s","blob":"evil"}`, strangerTok)); r.StatusCode != http.StatusForbidden { |
| 118 | t.Fatalf("stranger update: want 403, got %d", r.StatusCode) |
| 119 | } |
| 120 | if r := doReq(t, "DELETE", ts.URL+"/api/member-apps/app-forana?token="+strangerTok, ""); r.StatusCode != http.StatusForbidden { |
| 121 | t.Fatalf("stranger delete: want 403, got %d", r.StatusCode) |
| 122 | } |
| 123 | // The owner updates (not the author) — allowed. |
| 124 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-forana", fmt.Sprintf(`{"token":"%s","blob":"ciphertext-2"}`, ownerTok)); r.StatusCode != http.StatusOK { |
| 125 | t.Fatalf("owner update: want 200, got %d", r.StatusCode) |
| 126 | } |
| 127 | if app, _, _ := s.db.getMemberApp("app-forana"); app.AuthorID != botUID || app.OwnerID != ownerUID { |
| 128 | t.Fatalf("update must not transfer author/owner: %+v", app) |
| 129 | } |
| 130 | if r := doReq(t, "DELETE", ts.URL+"/api/member-apps/app-forana?token="+ownerTok, ""); r.StatusCode != http.StatusOK { |
| 131 | t.Fatalf("owner delete: want 200, got %d", r.StatusCode) |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | // An agent that is neither author nor owner may overwrite an existing app when |
| 136 | // it asserts the app's recorded owner — the server half of "ask a bot to fix an |
| 137 | // app you wrote in App Studio" (the sidecar checks, against the decrypted |
| 138 | // manifest, that the member asking IS that owner). Missing or wrong owner |
| 139 | // assertions, and owner-less legacy rows, all stay 403. |
| 140 | func TestMemberAppsAgentUpdatesForOwner(t *testing.T) { |
| 141 | ts, s := newTestServer(t) |
| 142 | ownerTok, ownerUID, _ := joinedMember(t, s, "Ana") |
| 143 | botTok, botUID, _ := joinedMember(t, s, "Claude") |
| 144 | if err := s.db.setUserAgent(botUID); err != nil { |
| 145 | t.Fatal(err) |
| 146 | } |
| 147 | otherBotTok, otherBotUID, _ := joinedMember(t, s, "Eleven") |
| 148 | if err := s.db.setUserAgent(otherBotUID); err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | _, boUID, _ := joinedMember(t, s, "Bo") |
| 152 | |
| 153 | // Ana deploys her own app (author = owner = Ana) — the App Studio shape. |
| 154 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-anasown", fmt.Sprintf(`{"token":"%s","blob":"v1"}`, ownerTok)); r.StatusCode != http.StatusOK { |
| 155 | t.Fatalf("deploy: want 200, got %d", r.StatusCode) |
| 156 | } |
| 157 | |
| 158 | // An agent asserting the recorded owner may overwrite… |
| 159 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-anasown", fmt.Sprintf(`{"token":"%s","blob":"v2","owner_id":"%s"}`, botTok, ownerUID)); r.StatusCode != http.StatusOK { |
| 160 | t.Fatalf("agent-for-owner update: want 200, got %d", r.StatusCode) |
| 161 | } |
| 162 | app, _, _ := s.db.getMemberApp("app-anasown") |
| 163 | if app.Blob != "v2" || app.Version != 2 || app.AuthorID != ownerUID || app.OwnerID != ownerUID { |
| 164 | t.Fatalf("update must land without transferring author/owner: %+v", app) |
| 165 | } |
| 166 | // …and so may a different agent (a member may ask whichever bot they like). |
| 167 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-anasown", fmt.Sprintf(`{"token":"%s","blob":"v3","owner_id":"%s"}`, otherBotTok, ownerUID)); r.StatusCode != http.StatusOK { |
| 168 | t.Fatalf("second agent update: want 200, got %d", r.StatusCode) |
| 169 | } |
| 170 | |
| 171 | // No owner assertion → the agent is just a stranger here. |
| 172 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-anasown", fmt.Sprintf(`{"token":"%s","blob":"evil"}`, botTok)); r.StatusCode != http.StatusForbidden { |
| 173 | t.Fatalf("agent without owner assertion: want 403, got %d", r.StatusCode) |
| 174 | } |
| 175 | // A wrong owner assertion is refused too. |
| 176 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-anasown", fmt.Sprintf(`{"token":"%s","blob":"evil","owner_id":"%s"}`, botTok, boUID)); r.StatusCode != http.StatusForbidden { |
| 177 | t.Fatalf("agent with wrong owner: want 403, got %d", r.StatusCode) |
| 178 | } |
| 179 | // An owner-less legacy row stays closed to agents — with nobody recorded, |
| 180 | // there is no owner an agent could be acting for. |
| 181 | if _, err := s.db.sql.Exec("INSERT INTO member_apps(id, blob, author_id, owner_id, version, created_at, updated_at) VALUES('app-legacy1','x','gone-uid','',1,0,0)"); err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-legacy1", fmt.Sprintf(`{"token":"%s","blob":"evil"}`, botTok)); r.StatusCode != http.StatusForbidden { |
| 185 | t.Fatalf("owner-less row: want 403, got %d", r.StatusCode) |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | // A non-agent member can't deploy an app owned by someone else — owner_id is |
| 190 | // forced to the caller, so a regular member can't plant apps in others' lists. |
| 191 | func TestMemberAppsNonAgentCantSetOwner(t *testing.T) { |
| 192 | ts, s := newTestServer(t) |
| 193 | anaTok, anaUID, _ := joinedMember(t, s, "Ana") |
| 194 | _, boUID, _ := joinedMember(t, s, "Bo") |
| 195 | deploy := fmt.Sprintf(`{"token":"%s","blob":"x","owner_id":"%s"}`, anaTok, boUID) |
| 196 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-spoof", deploy); r.StatusCode != http.StatusOK { |
| 197 | t.Fatalf("deploy: want 200, got %d", r.StatusCode) |
| 198 | } |
| 199 | if app, _, _ := s.db.getMemberApp("app-spoof"); app.OwnerID != anaUID { |
| 200 | t.Fatalf("non-agent owner_id must be forced to caller (%s), got %q", anaUID, app.OwnerID) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | // Every deploy records a version; the history is listable (author-or-owner), |
| 205 | // grows on update, and is gone when the app is deleted. |
| 206 | func TestMemberAppsVersionHistory(t *testing.T) { |
| 207 | ts, s := newTestServer(t) |
| 208 | tok, uid, _ := joinedMember(t, s, "Ana") |
| 209 | strangerTok, _, _ := joinedMember(t, s, "Bo") |
| 210 | |
| 211 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-hist", fmt.Sprintf(`{"token":"%s","blob":"v1"}`, tok)); r.StatusCode != http.StatusOK { |
| 212 | t.Fatalf("deploy v1: %d", r.StatusCode) |
| 213 | } |
| 214 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-hist", fmt.Sprintf(`{"token":"%s","blob":"v2"}`, tok)); r.StatusCode != http.StatusOK { |
| 215 | t.Fatalf("deploy v2: %d", r.StatusCode) |
| 216 | } |
| 217 | |
| 218 | // A stranger can't read the history. |
| 219 | if r, _ := http.Get(ts.URL + "/api/member-apps/app-hist/versions?token=" + strangerTok); r.StatusCode != http.StatusForbidden { |
| 220 | t.Fatalf("stranger history: want 403, got %d", r.StatusCode) |
| 221 | } |
| 222 | // The author sees both versions, newest first, with the current pointer. |
| 223 | r, _ := http.Get(ts.URL + "/api/member-apps/app-hist/versions?token=" + tok) |
| 224 | var hist struct { |
| 225 | Current int |
| 226 | Versions []MemberAppVersion |
| 227 | } |
| 228 | decodeJSON(t, r, &hist) |
| 229 | if hist.Current != 2 || len(hist.Versions) != 2 { |
| 230 | t.Fatalf("bad history: current=%d versions=%d", hist.Current, len(hist.Versions)) |
| 231 | } |
| 232 | if hist.Versions[0].Version != 2 || hist.Versions[0].Blob != "v2" || hist.Versions[0].AuthorID != uid { |
| 233 | t.Fatalf("newest version wrong: %+v", hist.Versions[0]) |
| 234 | } |
| 235 | if hist.Versions[1].Version != 1 || hist.Versions[1].Blob != "v1" { |
| 236 | t.Fatalf("oldest version wrong: %+v", hist.Versions[1]) |
| 237 | } |
| 238 | |
| 239 | // A revert deploys as a new version marked "based on" the source version. |
| 240 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-hist", fmt.Sprintf(`{"token":"%s","blob":"v1","based_on":1}`, tok)); r.StatusCode != http.StatusOK { |
| 241 | t.Fatalf("revert: %d", r.StatusCode) |
| 242 | } |
| 243 | vs, _ := s.db.listMemberAppVersions("app-hist") |
| 244 | if len(vs) != 3 || vs[0].Version != 3 || vs[0].BasedOn != 1 { |
| 245 | t.Fatalf("revert should record based_on=1 on v3: %+v", vs) |
| 246 | } |
| 247 | |
| 248 | // Deleting the app reclaims its history. |
| 249 | if r := doReq(t, "DELETE", ts.URL+"/api/member-apps/app-hist?token="+tok, ""); r.StatusCode != http.StatusOK { |
| 250 | t.Fatalf("delete: %d", r.StatusCode) |
| 251 | } |
| 252 | if v, err := s.db.listMemberAppVersions("app-hist"); err != nil || len(v) != 0 { |
| 253 | t.Fatalf("history should be gone: %v err=%v", v, err) |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | // An app deployed before version tracking (a member_apps row with no history) |
| 258 | // gets its current version backfilled, so History isn't empty until the next |
| 259 | // update. |
| 260 | func TestMemberAppVersionBackfill(t *testing.T) { |
| 261 | _, s := newTestServer(t) |
| 262 | if _, err := s.db.sql.Exec( |
| 263 | "INSERT INTO member_apps(id, blob, author_id, owner_id, version, created_at, updated_at) VALUES('app-old','oldblob','ua','ua',1,100,100)"); err != nil { |
| 264 | t.Fatal(err) |
| 265 | } |
| 266 | if v, _ := s.db.listMemberAppVersions("app-old"); len(v) != 0 { |
| 267 | t.Fatalf("precondition: want no history, got %d", len(v)) |
| 268 | } |
| 269 | if err := s.db.backfillMemberAppVersions(); err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | v, _ := s.db.listMemberAppVersions("app-old") |
| 273 | if len(v) != 1 || v[0].Version != 1 || v[0].Blob != "oldblob" { |
| 274 | t.Fatalf("backfill: %+v", v) |
| 275 | } |
| 276 | // Idempotent — a second run doesn't duplicate. |
| 277 | if err := s.db.backfillMemberAppVersions(); err != nil { |
| 278 | t.Fatal(err) |
| 279 | } |
| 280 | if v, _ := s.db.listMemberAppVersions("app-old"); len(v) != 1 { |
| 281 | t.Fatalf("backfill not idempotent: %d rows", len(v)) |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | func TestMemberAppsAdminDelete(t *testing.T) { |
| 286 | ts, s := newTestServer(t) |
| 287 | tok1, _, _ := joinedMember(t, s, "Ana") |
| 288 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-mod-me", fmt.Sprintf(`{"token":"%s","blob":"b"}`, tok1)); r.StatusCode != http.StatusOK { |
| 289 | t.Fatalf("deploy: %d", r.StatusCode) |
| 290 | } |
| 291 | // An admin can pull any member app (moderation), even one they can't read. |
| 292 | req, _ := http.NewRequest("DELETE", ts.URL+"/api/member-apps/app-mod-me", nil) |
| 293 | req.Header.Set("Authorization", "Bearer admintok") |
| 294 | resp, err := http.DefaultClient.Do(req) |
| 295 | if err != nil { |
| 296 | t.Fatal(err) |
| 297 | } |
| 298 | resp.Body.Close() |
| 299 | if resp.StatusCode != http.StatusOK { |
| 300 | t.Fatalf("admin delete: want 200, got %d", resp.StatusCode) |
| 301 | } |
| 302 | // Deleting a missing app with admin auth is a 404, not a silent 200. |
| 303 | req, _ = http.NewRequest("DELETE", ts.URL+"/api/member-apps/app-mod-me", nil) |
| 304 | req.Header.Set("Authorization", "Bearer admintok") |
| 305 | resp, _ = http.DefaultClient.Do(req) |
| 306 | resp.Body.Close() |
| 307 | if resp.StatusCode != http.StatusNotFound { |
| 308 | t.Fatalf("admin delete missing: want 404, got %d", resp.StatusCode) |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | func TestMemberAppsDeployCap(t *testing.T) { |
| 313 | ts, s := newTestServer(t) |
| 314 | tok, uid, _ := joinedMember(t, s, "Ana") |
| 315 | for i := 0; i < maxMemberApps; i++ { |
| 316 | if _, err := s.db.putMemberApp(fmt.Sprintf("app-cap-%03d", i), "b", uid, 0); err != nil { |
| 317 | t.Fatal(err) |
| 318 | } |
| 319 | } |
| 320 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-one-more", fmt.Sprintf(`{"token":"%s","blob":"b"}`, tok)); r.StatusCode != http.StatusForbidden { |
| 321 | t.Fatalf("over cap: want 403, got %d", r.StatusCode) |
| 322 | } |
| 323 | // Updating an existing app is still allowed at the cap. |
| 324 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-cap-000", fmt.Sprintf(`{"token":"%s","blob":"b2"}`, tok)); r.StatusCode != http.StatusOK { |
| 325 | t.Fatalf("update at cap: want 200, got %d", r.StatusCode) |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | // TestMemberAppsPerAuthorCap: one member can't starve the global cap — they're |
| 330 | // bounded to maxMemberAppsPerAuthor, and another member can still deploy. |
| 331 | func TestMemberAppsPerAuthorCap(t *testing.T) { |
| 332 | ts, s := newTestServer(t) |
| 333 | tokA, uidA, _ := joinedMember(t, s, "Ana") |
| 334 | tokB, _, _ := joinedMember(t, s, "Bo") |
| 335 | for i := 0; i < maxMemberAppsPerAuthor; i++ { |
| 336 | if _, err := s.db.putMemberApp(fmt.Sprintf("app-ana-%03d", i), "b", uidA, 0); err != nil { |
| 337 | t.Fatal(err) |
| 338 | } |
| 339 | } |
| 340 | // Ana is at her per-author cap even though the global cap has plenty of room. |
| 341 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-ana-extra", fmt.Sprintf(`{"token":"%s","blob":"b"}`, tokA)); r.StatusCode != http.StatusForbidden { |
| 342 | t.Fatalf("author cap: want 403, got %d", r.StatusCode) |
| 343 | } |
| 344 | // Bo is unaffected. |
| 345 | if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-bo-first", fmt.Sprintf(`{"token":"%s","blob":"b"}`, tokB)); r.StatusCode != http.StatusOK { |
| 346 | t.Fatalf("other author: want 200, got %d", r.StatusCode) |
| 347 | } |
| 348 | } |
| 349 | |
| 350 | // TestMemberAppsConcurrentCap: the count-and-insert is atomic, so a burst of |
| 351 | // concurrent new-id deploys can't push the table past the global cap. |
| 352 | func TestMemberAppsConcurrentCap(t *testing.T) { |
| 353 | ts, s := newTestServer(t) |
| 354 | // Distinct authors so the per-author cap isn't what stops them. |
| 355 | tokens := make([]string, 8) |
| 356 | for i := range tokens { |
| 357 | tokens[i], _, _ = joinedMember(t, s, fmt.Sprintf("U%d", i)) |
| 358 | } |
| 359 | // Seed to one below the global cap, then fire many concurrent new deploys. |
| 360 | for i := 0; i < maxMemberApps-1; i++ { |
| 361 | uid, _ := s.db.deviceTokenUser(tokens[i%len(tokens)]) |
| 362 | if _, err := s.db.putMemberApp(fmt.Sprintf("app-seed-%03d", i), "b", uid, 0); err != nil { |
| 363 | t.Fatal(err) |
| 364 | } |
| 365 | } |
| 366 | var wg sync.WaitGroup |
| 367 | for i := 0; i < 20; i++ { |
| 368 | wg.Add(1) |
| 369 | go func(i int) { |
| 370 | defer wg.Done() |
| 371 | doReq(t, "PUT", ts.URL+"/api/member-apps/"+fmt.Sprintf("app-race-%03d", i), |
| 372 | fmt.Sprintf(`{"token":"%s","blob":"b"}`, tokens[i%len(tokens)])).Body.Close() |
| 373 | }(i) |
| 374 | } |
| 375 | wg.Wait() |
| 376 | n, err := s.db.countMemberApps() |
| 377 | if err != nil { |
| 378 | t.Fatal(err) |
| 379 | } |
| 380 | if n > maxMemberApps { |
| 381 | t.Fatalf("global cap breached under concurrency: %d > %d", n, maxMemberApps) |
| 382 | } |
| 383 | } |
| 384 | |
| 385 | // TestAppSandboxHeaders pins the sandbox page's containment: the CSP that |
| 386 | // denies the frame all network reach, the eval allowance the runtime needs, |
| 387 | // and the frame-ancestors/XFO pair that lets (only) our own page embed it. |
| 388 | func TestAppSandboxHeaders(t *testing.T) { |
| 389 | ts, _ := newTestServer(t) |
| 390 | r, err := http.Get(ts.URL + "/app-sandbox") |
| 391 | if err != nil { |
| 392 | t.Fatal(err) |
| 393 | } |
| 394 | defer r.Body.Close() |
| 395 | if r.StatusCode != http.StatusOK { |
| 396 | t.Fatalf("want 200, got %d", r.StatusCode) |
| 397 | } |
| 398 | csp := r.Header.Get("Content-Security-Policy") |
| 399 | for _, want := range []string{ |
| 400 | "default-src 'none'", |
| 401 | "connect-src 'none'", |
| 402 | "'unsafe-eval'", |
| 403 | "script-src 'sha256-", |
| 404 | "frame-ancestors 'self'", |
| 405 | "form-action 'none'", |
| 406 | } { |
| 407 | if !strings.Contains(csp, want) { |
| 408 | t.Errorf("sandbox CSP missing %q: %s", want, csp) |
| 409 | } |
| 410 | } |
| 411 | // A nonce is deliberately NOT used — it is readable by app code and would |
| 412 | // let a nonced <script src> bypass connect-src 'none'. The hash must not |
| 413 | // coexist with a nonce or a host source in script-src. |
| 414 | if strings.Contains(csp, "'nonce-") { |
| 415 | t.Errorf("sandbox CSP must not use a nonce (readable by app code): %s", csp) |
| 416 | } |
| 417 | if xfo := r.Header.Get("X-Frame-Options"); xfo != "SAMEORIGIN" { |
| 418 | t.Errorf("X-Frame-Options: want SAMEORIGIN (embeddable by our page only), got %q", xfo) |
| 419 | } |
| 420 | body, _ := io.ReadAll(r.Body) |
| 421 | if !strings.Contains(string(body), "lchat-sandbox-init") { |
| 422 | t.Error("sandbox page doesn't contain the inlined runtime") |
| 423 | } |
| 424 | // The CSP hash must be the sha256 of the exact inlined bundle bytes |
| 425 | // (format.js + the frame runtime), or the browser refuses to run it. |
| 426 | bundle, err := sandboxBundle() |
| 427 | if err != nil { |
| 428 | t.Fatal(err) |
| 429 | } |
| 430 | sum := sha256.Sum256(bundle) |
| 431 | want := "'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'" |
| 432 | if !strings.Contains(csp, want) { |
| 433 | t.Errorf("CSP hash doesn't match the inlined bundle.\n want %s\n csp %s", want, csp) |
| 434 | } |
| 435 | } |
| 436 | |
| 437 | // TestSandboxFrameInlinable: both bundled files are inlined into a <script> |
| 438 | // tag, so the literal sequence "</script" anywhere in either would terminate |
| 439 | // the tag early and break (or worse, split) the document. |
| 440 | func TestSandboxFrameInlinable(t *testing.T) { |
| 441 | for _, f := range []string{"web/static/sandbox-frame.js", "web/static/format.js"} { |
| 442 | b, err := webFS.ReadFile(f) |
| 443 | if err != nil { |
| 444 | t.Fatal(err) |
| 445 | } |
| 446 | if strings.Contains(strings.ToLower(string(b)), "</script") { |
| 447 | t.Fatalf("%s contains \"</script\" — it cannot be safely inlined", f) |
| 448 | } |
| 449 | } |
| 450 | } |
| 451 | |
| 452 | // TestSandboxBundle: the served frame is format.js (its helpers, which member |
| 453 | // apps may import) followed by the runtime with its own format import removed — |
| 454 | // one self-contained module with no top-level name collisions. |
| 455 | func TestSandboxBundle(t *testing.T) { |
| 456 | bundle, err := sandboxBundle() |
| 457 | if err != nil { |
| 458 | t.Fatal(err) |
| 459 | } |
| 460 | s := string(bundle) |
| 461 | if !strings.Contains(s, "export function tokenizeText") { |
| 462 | t.Error("bundle missing format.js helpers") |
| 463 | } |
| 464 | if !strings.Contains(s, "function evalMemberApp") { |
| 465 | t.Error("bundle missing the frame runtime") |
| 466 | } |
| 467 | if strings.Contains(s, `from "./format.js"`) { |
| 468 | t.Error("frame's format.js import should be stripped in the bundle") |
| 469 | } |
| 470 | seen := map[string]bool{} |
| 471 | for _, m := range regexp.MustCompile(`(?m)^export function (\w+)`).FindAllStringSubmatch(s, -1) { |
| 472 | if seen[m[1]] { |
| 473 | t.Errorf("duplicate top-level export function %q in the bundle", m[1]) |
| 474 | } |
| 475 | seen[m[1]] = true |
| 476 | } |
| 477 | } |
| 478 | |
| 479 | // The chat page must be allowed to embed the sandbox frame (frame-src 'self'), |
| 480 | // and the guide the bot builds against is served. |
| 481 | func TestMemberAppSupportingSurfaces(t *testing.T) { |
| 482 | ts, _ := newTestServer(t) |
| 483 | r, _ := http.Get(ts.URL + "/") |
| 484 | r.Body.Close() |
| 485 | if csp := r.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "frame-src 'self'") { |
| 486 | t.Errorf("page CSP must allow same-origin frames for /app-sandbox: %s", csp) |
| 487 | } |
| 488 | r, _ = http.Get(ts.URL + "/api/member-app-guide") |
| 489 | body, _ := io.ReadAll(r.Body) |
| 490 | r.Body.Close() |
| 491 | if r.StatusCode != http.StatusOK || !strings.Contains(string(body), "member app") { |
| 492 | t.Fatalf("member-app guide: status %d", r.StatusCode) |
| 493 | } |
| 494 | } |
| 495 | |