package main // Tests for the member-app surface (memberapps.go): the token-gated deploy // API and its author/admin gates, and the sandbox frame page whose headers // ARE the security boundary for member-app code. import ( "crypto/sha256" "encoding/base64" "fmt" "io" "net/http" "regexp" "strings" "sync" "testing" ) func TestMemberAppsCRUDAndGates(t *testing.T) { ts, s := newTestServer(t) tok1, uid1, _ := joinedMember(t, s, "Ana") tok2, _, _ := joinedMember(t, s, "Bo") // Listing is member-gated. if r, _ := http.Get(ts.URL + "/api/member-apps?token=bogus"); r.StatusCode != http.StatusForbidden { t.Fatalf("bogus list: want 403, got %d", r.StatusCode) } r, _ := http.Get(ts.URL + "/api/member-apps?token=" + tok1) var list struct{ Apps []MemberApp } decodeJSON(t, r, &list) if len(list.Apps) != 0 { t.Fatalf("fresh instance should list no member apps, got %d", len(list.Apps)) } // Deploy: id shape and token are enforced. if r := doReq(t, "PUT", ts.URL+"/api/member-apps/UPPER", fmt.Sprintf(`{"token":"%s","blob":"x"}`, tok1)); r.StatusCode != http.StatusBadRequest { t.Fatalf("bad id: want 400, got %d", r.StatusCode) } if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", `{"token":"bogus","blob":"x"}`); r.StatusCode != http.StatusForbidden { t.Fatalf("bad token: want 403, got %d", r.StatusCode) } if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", fmt.Sprintf(`{"token":"%s","blob":""}`, tok1)); r.StatusCode != http.StatusBadRequest { t.Fatalf("empty blob: want 400, got %d", r.StatusCode) } // A deploy reports the version it produced — the read-back the bot sidecar // verifies against before announcing a build. var put struct { OK bool `json:"ok"` Version int `json:"version"` } r = doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", fmt.Sprintf(`{"token":"%s","blob":"ciphertext-1"}`, tok1)) if r.StatusCode != http.StatusOK { t.Fatalf("deploy: want 200, got %d", r.StatusCode) } decodeJSON(t, r, &put) if !put.OK || put.Version != 1 { t.Fatalf("create should report version 1: %+v", put) } // The list returns the blob and the author. r, _ = http.Get(ts.URL + "/api/member-apps?token=" + tok2) decodeJSON(t, r, &list) if len(list.Apps) != 1 || list.Apps[0].Blob != "ciphertext-1" || list.Apps[0].AuthorID != uid1 || list.Apps[0].Version != 1 { t.Fatalf("bad list: %+v", list.Apps) } // Only the author may overwrite; an update bumps version, keeps the author. if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", fmt.Sprintf(`{"token":"%s","blob":"evil"}`, tok2)); r.StatusCode != http.StatusForbidden { t.Fatalf("non-author overwrite: want 403, got %d", r.StatusCode) } r = doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", fmt.Sprintf(`{"token":"%s","blob":"ciphertext-2"}`, tok1)) if r.StatusCode != http.StatusOK { t.Fatalf("author update: want 200, got %d", r.StatusCode) } decodeJSON(t, r, &put) if put.Version != 2 { t.Fatalf("update should report version 2: %+v", put) } app, ok, _ := s.db.getMemberApp("app-abc123") if !ok || app.Blob != "ciphertext-2" || app.Version != 2 || app.AuthorID != uid1 { t.Fatalf("after update: %+v ok=%v", app, ok) } // Delete: strangers no; the author yes. if r := doReq(t, "DELETE", ts.URL+"/api/member-apps/app-abc123?token="+tok2, ""); r.StatusCode != http.StatusForbidden { t.Fatalf("non-author delete: want 403, got %d", r.StatusCode) } if r := doReq(t, "DELETE", ts.URL+"/api/member-apps/app-abc123?token="+tok1, ""); r.StatusCode != http.StatusOK { t.Fatalf("author delete: want 200, got %d", r.StatusCode) } if _, ok, _ := s.db.getMemberApp("app-abc123"); ok { t.Fatal("app still present after delete") } } // A bot deploys an app it built for a member: the bot is the author, the member // is the owner. The OWNER (not just the author) may update and delete it — the // whole point of App Studio managing your Claude-built apps — while a stranger // still can't. An update preserves author + owner (ownership never transfers). func TestMemberAppsOwnerCanManage(t *testing.T) { ts, s := newTestServer(t) botTok, botUID, _ := joinedMember(t, s, "Claude") if err := s.db.setUserAgent(botUID); err != nil { // only an agent may set owner_id t.Fatal(err) } ownerTok, ownerUID, _ := joinedMember(t, s, "Ana") strangerTok, _, _ := joinedMember(t, s, "Bo") deploy := fmt.Sprintf(`{"token":"%s","blob":"ciphertext","owner_id":"%s"}`, botTok, ownerUID) if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-forana", deploy); r.StatusCode != http.StatusOK { t.Fatalf("bot deploy for owner: want 200, got %d", r.StatusCode) } if app, ok, _ := s.db.getMemberApp("app-forana"); !ok || app.AuthorID != botUID || app.OwnerID != ownerUID { t.Fatalf("author/owner not recorded: %+v ok=%v", app, ok) } // A stranger can neither update nor delete. if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-forana", fmt.Sprintf(`{"token":"%s","blob":"evil"}`, strangerTok)); r.StatusCode != http.StatusForbidden { t.Fatalf("stranger update: want 403, got %d", r.StatusCode) } if r := doReq(t, "DELETE", ts.URL+"/api/member-apps/app-forana?token="+strangerTok, ""); r.StatusCode != http.StatusForbidden { t.Fatalf("stranger delete: want 403, got %d", r.StatusCode) } // The owner updates (not the author) — allowed. if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-forana", fmt.Sprintf(`{"token":"%s","blob":"ciphertext-2"}`, ownerTok)); r.StatusCode != http.StatusOK { t.Fatalf("owner update: want 200, got %d", r.StatusCode) } if app, _, _ := s.db.getMemberApp("app-forana"); app.AuthorID != botUID || app.OwnerID != ownerUID { t.Fatalf("update must not transfer author/owner: %+v", app) } if r := doReq(t, "DELETE", ts.URL+"/api/member-apps/app-forana?token="+ownerTok, ""); r.StatusCode != http.StatusOK { t.Fatalf("owner delete: want 200, got %d", r.StatusCode) } } // An agent that is neither author nor owner may overwrite an existing app when // it asserts the app's recorded owner — the server half of "ask a bot to fix an // app you wrote in App Studio" (the sidecar checks, against the decrypted // manifest, that the member asking IS that owner). Missing or wrong owner // assertions, and owner-less legacy rows, all stay 403. func TestMemberAppsAgentUpdatesForOwner(t *testing.T) { ts, s := newTestServer(t) ownerTok, ownerUID, _ := joinedMember(t, s, "Ana") botTok, botUID, _ := joinedMember(t, s, "Claude") if err := s.db.setUserAgent(botUID); err != nil { t.Fatal(err) } otherBotTok, otherBotUID, _ := joinedMember(t, s, "Eleven") if err := s.db.setUserAgent(otherBotUID); err != nil { t.Fatal(err) } _, boUID, _ := joinedMember(t, s, "Bo") // Ana deploys her own app (author = owner = Ana) — the App Studio shape. if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-anasown", fmt.Sprintf(`{"token":"%s","blob":"v1"}`, ownerTok)); r.StatusCode != http.StatusOK { t.Fatalf("deploy: want 200, got %d", r.StatusCode) } // An agent asserting the recorded owner may overwrite… 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 { t.Fatalf("agent-for-owner update: want 200, got %d", r.StatusCode) } app, _, _ := s.db.getMemberApp("app-anasown") if app.Blob != "v2" || app.Version != 2 || app.AuthorID != ownerUID || app.OwnerID != ownerUID { t.Fatalf("update must land without transferring author/owner: %+v", app) } // …and so may a different agent (a member may ask whichever bot they like). 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 { t.Fatalf("second agent update: want 200, got %d", r.StatusCode) } // No owner assertion → the agent is just a stranger here. if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-anasown", fmt.Sprintf(`{"token":"%s","blob":"evil"}`, botTok)); r.StatusCode != http.StatusForbidden { t.Fatalf("agent without owner assertion: want 403, got %d", r.StatusCode) } // A wrong owner assertion is refused too. 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 { t.Fatalf("agent with wrong owner: want 403, got %d", r.StatusCode) } // An owner-less legacy row stays closed to agents — with nobody recorded, // there is no owner an agent could be acting for. 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 { t.Fatal(err) } if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-legacy1", fmt.Sprintf(`{"token":"%s","blob":"evil"}`, botTok)); r.StatusCode != http.StatusForbidden { t.Fatalf("owner-less row: want 403, got %d", r.StatusCode) } } // A non-agent member can't deploy an app owned by someone else — owner_id is // forced to the caller, so a regular member can't plant apps in others' lists. func TestMemberAppsNonAgentCantSetOwner(t *testing.T) { ts, s := newTestServer(t) anaTok, anaUID, _ := joinedMember(t, s, "Ana") _, boUID, _ := joinedMember(t, s, "Bo") deploy := fmt.Sprintf(`{"token":"%s","blob":"x","owner_id":"%s"}`, anaTok, boUID) if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-spoof", deploy); r.StatusCode != http.StatusOK { t.Fatalf("deploy: want 200, got %d", r.StatusCode) } if app, _, _ := s.db.getMemberApp("app-spoof"); app.OwnerID != anaUID { t.Fatalf("non-agent owner_id must be forced to caller (%s), got %q", anaUID, app.OwnerID) } } // Every deploy records a version; the history is listable (author-or-owner), // grows on update, and is gone when the app is deleted. func TestMemberAppsVersionHistory(t *testing.T) { ts, s := newTestServer(t) tok, uid, _ := joinedMember(t, s, "Ana") strangerTok, _, _ := joinedMember(t, s, "Bo") if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-hist", fmt.Sprintf(`{"token":"%s","blob":"v1"}`, tok)); r.StatusCode != http.StatusOK { t.Fatalf("deploy v1: %d", r.StatusCode) } if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-hist", fmt.Sprintf(`{"token":"%s","blob":"v2"}`, tok)); r.StatusCode != http.StatusOK { t.Fatalf("deploy v2: %d", r.StatusCode) } // A stranger can't read the history. if r, _ := http.Get(ts.URL + "/api/member-apps/app-hist/versions?token=" + strangerTok); r.StatusCode != http.StatusForbidden { t.Fatalf("stranger history: want 403, got %d", r.StatusCode) } // The author sees both versions, newest first, with the current pointer. r, _ := http.Get(ts.URL + "/api/member-apps/app-hist/versions?token=" + tok) var hist struct { Current int Versions []MemberAppVersion } decodeJSON(t, r, &hist) if hist.Current != 2 || len(hist.Versions) != 2 { t.Fatalf("bad history: current=%d versions=%d", hist.Current, len(hist.Versions)) } if hist.Versions[0].Version != 2 || hist.Versions[0].Blob != "v2" || hist.Versions[0].AuthorID != uid { t.Fatalf("newest version wrong: %+v", hist.Versions[0]) } if hist.Versions[1].Version != 1 || hist.Versions[1].Blob != "v1" { t.Fatalf("oldest version wrong: %+v", hist.Versions[1]) } // A revert deploys as a new version marked "based on" the source version. 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 { t.Fatalf("revert: %d", r.StatusCode) } vs, _ := s.db.listMemberAppVersions("app-hist") if len(vs) != 3 || vs[0].Version != 3 || vs[0].BasedOn != 1 { t.Fatalf("revert should record based_on=1 on v3: %+v", vs) } // Deleting the app reclaims its history. if r := doReq(t, "DELETE", ts.URL+"/api/member-apps/app-hist?token="+tok, ""); r.StatusCode != http.StatusOK { t.Fatalf("delete: %d", r.StatusCode) } if v, err := s.db.listMemberAppVersions("app-hist"); err != nil || len(v) != 0 { t.Fatalf("history should be gone: %v err=%v", v, err) } } // An app deployed before version tracking (a member_apps row with no history) // gets its current version backfilled, so History isn't empty until the next // update. func TestMemberAppVersionBackfill(t *testing.T) { _, s := newTestServer(t) if _, err := s.db.sql.Exec( "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 { t.Fatal(err) } if v, _ := s.db.listMemberAppVersions("app-old"); len(v) != 0 { t.Fatalf("precondition: want no history, got %d", len(v)) } if err := s.db.backfillMemberAppVersions(); err != nil { t.Fatal(err) } v, _ := s.db.listMemberAppVersions("app-old") if len(v) != 1 || v[0].Version != 1 || v[0].Blob != "oldblob" { t.Fatalf("backfill: %+v", v) } // Idempotent — a second run doesn't duplicate. if err := s.db.backfillMemberAppVersions(); err != nil { t.Fatal(err) } if v, _ := s.db.listMemberAppVersions("app-old"); len(v) != 1 { t.Fatalf("backfill not idempotent: %d rows", len(v)) } } func TestMemberAppsAdminDelete(t *testing.T) { ts, s := newTestServer(t) tok1, _, _ := joinedMember(t, s, "Ana") if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-mod-me", fmt.Sprintf(`{"token":"%s","blob":"b"}`, tok1)); r.StatusCode != http.StatusOK { t.Fatalf("deploy: %d", r.StatusCode) } // An admin can pull any member app (moderation), even one they can't read. req, _ := http.NewRequest("DELETE", ts.URL+"/api/member-apps/app-mod-me", nil) req.Header.Set("Authorization", "Bearer admintok") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("admin delete: want 200, got %d", resp.StatusCode) } // Deleting a missing app with admin auth is a 404, not a silent 200. req, _ = http.NewRequest("DELETE", ts.URL+"/api/member-apps/app-mod-me", nil) req.Header.Set("Authorization", "Bearer admintok") resp, _ = http.DefaultClient.Do(req) resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("admin delete missing: want 404, got %d", resp.StatusCode) } } func TestMemberAppsDeployCap(t *testing.T) { ts, s := newTestServer(t) tok, uid, _ := joinedMember(t, s, "Ana") for i := 0; i < maxMemberApps; i++ { if _, err := s.db.putMemberApp(fmt.Sprintf("app-cap-%03d", i), "b", uid, 0); err != nil { t.Fatal(err) } } if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-one-more", fmt.Sprintf(`{"token":"%s","blob":"b"}`, tok)); r.StatusCode != http.StatusForbidden { t.Fatalf("over cap: want 403, got %d", r.StatusCode) } // Updating an existing app is still allowed at the cap. if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-cap-000", fmt.Sprintf(`{"token":"%s","blob":"b2"}`, tok)); r.StatusCode != http.StatusOK { t.Fatalf("update at cap: want 200, got %d", r.StatusCode) } } // TestMemberAppsPerAuthorCap: one member can't starve the global cap — they're // bounded to maxMemberAppsPerAuthor, and another member can still deploy. func TestMemberAppsPerAuthorCap(t *testing.T) { ts, s := newTestServer(t) tokA, uidA, _ := joinedMember(t, s, "Ana") tokB, _, _ := joinedMember(t, s, "Bo") for i := 0; i < maxMemberAppsPerAuthor; i++ { if _, err := s.db.putMemberApp(fmt.Sprintf("app-ana-%03d", i), "b", uidA, 0); err != nil { t.Fatal(err) } } // Ana is at her per-author cap even though the global cap has plenty of room. if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-ana-extra", fmt.Sprintf(`{"token":"%s","blob":"b"}`, tokA)); r.StatusCode != http.StatusForbidden { t.Fatalf("author cap: want 403, got %d", r.StatusCode) } // Bo is unaffected. if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-bo-first", fmt.Sprintf(`{"token":"%s","blob":"b"}`, tokB)); r.StatusCode != http.StatusOK { t.Fatalf("other author: want 200, got %d", r.StatusCode) } } // TestMemberAppsConcurrentCap: the count-and-insert is atomic, so a burst of // concurrent new-id deploys can't push the table past the global cap. func TestMemberAppsConcurrentCap(t *testing.T) { ts, s := newTestServer(t) // Distinct authors so the per-author cap isn't what stops them. tokens := make([]string, 8) for i := range tokens { tokens[i], _, _ = joinedMember(t, s, fmt.Sprintf("U%d", i)) } // Seed to one below the global cap, then fire many concurrent new deploys. for i := 0; i < maxMemberApps-1; i++ { uid, _ := s.db.deviceTokenUser(tokens[i%len(tokens)]) if _, err := s.db.putMemberApp(fmt.Sprintf("app-seed-%03d", i), "b", uid, 0); err != nil { t.Fatal(err) } } var wg sync.WaitGroup for i := 0; i < 20; i++ { wg.Add(1) go func(i int) { defer wg.Done() doReq(t, "PUT", ts.URL+"/api/member-apps/"+fmt.Sprintf("app-race-%03d", i), fmt.Sprintf(`{"token":"%s","blob":"b"}`, tokens[i%len(tokens)])).Body.Close() }(i) } wg.Wait() n, err := s.db.countMemberApps() if err != nil { t.Fatal(err) } if n > maxMemberApps { t.Fatalf("global cap breached under concurrency: %d > %d", n, maxMemberApps) } } // TestAppSandboxHeaders pins the sandbox page's containment: the CSP that // denies the frame all network reach, the eval allowance the runtime needs, // and the frame-ancestors/XFO pair that lets (only) our own page embed it. func TestAppSandboxHeaders(t *testing.T) { ts, _ := newTestServer(t) r, err := http.Get(ts.URL + "/app-sandbox") if err != nil { t.Fatal(err) } defer r.Body.Close() if r.StatusCode != http.StatusOK { t.Fatalf("want 200, got %d", r.StatusCode) } csp := r.Header.Get("Content-Security-Policy") for _, want := range []string{ "default-src 'none'", "connect-src 'none'", "'unsafe-eval'", "script-src 'sha256-", "frame-ancestors 'self'", "form-action 'none'", } { if !strings.Contains(csp, want) { t.Errorf("sandbox CSP missing %q: %s", want, csp) } } // A nonce is deliberately NOT used — it is readable by app code and would // let a nonced