eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package 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
7import (
8 "crypto/sha256"
9 "encoding/base64"
10 "fmt"
11 "io"
12 "net/http"
13 "regexp"
14 "strings"
15 "sync"
16 "testing"
17)
18
19func 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).
100func 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.
140func 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.
191func 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.
206func 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.
260func 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
285func 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
312func 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.
331func 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.
352func 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.
388func 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.
440func 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.
455func 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.
481func 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