package bot import ( "os" "regexp" "strings" "testing" ) // A small valid member app, the shape the guide teaches. const validAppSource = ` import { defineApp, addItem, removeItem } from "../apps.js"; export const app = defineApp({ name: "notes", title: "Notes", glyph: "πŸ“", initialState: () => ({ items: [] }), events: { "notes.add": (s, e) => (e.id && e.text ? { items: addItem(s.items, { id: e.id, text: e.text }) } : s), "notes.remove": (s, e) => ({ items: removeItem(s.items, e.id) }), }, render(state, el, ctx) { const items = (state && state.items) || []; for (const it of items) { const row = document.createElement("div"); row.textContent = it.text; el.appendChild(row); } }, }); ` func TestValidateMemberAppSource(t *testing.T) { types, problems := validateMemberAppSource("notes", validAppSource) if len(problems) != 0 { t.Fatalf("valid app rejected: %v", problems) } if len(types) != 2 || types[0] != "notes.add" || types[1] != "notes.remove" { t.Fatalf("bad types: %v", types) } cases := []struct { name string src string want string // substring of a problem }{ {"network", strings.Replace(validAppSource, "document.createElement", "fetch(x); document.createElement", 1), "fetch"}, {"storage", strings.Replace(validAppSource, "document.createElement", "localStorage.x; document.createElement", 1), "localStorage"}, {"innerHTML", strings.Replace(validAppSource, "row.textContent = it.text", "row.innerHTML = it.text", 1), "innerHTML"}, {"bad import source", strings.Replace(validAppSource, `"../apps.js"`, `"../other.js"`, 1), `only "../apps.js" and "../format.js"`}, {"bad import name", strings.Replace(validAppSource, "addItem,", "foldEvent, addItem,", 1), "not importable"}, {"dynamic import", strings.Replace(validAppSource, "render(state, el, ctx) {", "render(state, el, ctx) { import(\"https://x\");", 1), "../apps.js"}, {"two defineApps", validAppSource + "\ndefineApp({});", "exactly once"}, {"wrong name", strings.Replace(validAppSource, `name: "notes"`, `name: "other"`, 1), "name field"}, {"setInterval", strings.Replace(validAppSource, "document.createElement", "setInterval(f, 100); document.createElement", 1), "ctx.interval"}, } for _, c := range cases { _, problems := validateMemberAppSource("notes", c.src) found := false for _, p := range problems { if strings.Contains(p, c.want) { found = true } } if !found { t.Errorf("%s: want a problem containing %q, got %v", c.name, c.want, problems) } } // Prose in comments/strings must not trip the token scan. commented := strings.Replace(validAppSource, "export const app", "// never call fetch( or use localStorage or innerHTML\nconst note = \"fetch( localStorage innerHTML import \";\nexport const app", 1) if _, problems := validateMemberAppSource("notes", commented); len(problems) != 0 { t.Errorf("comment/string mentions tripped the scan: %v", problems) } // Boundary-aware forbidden scan: legitimate identifiers that merely CONTAIN // a forbidden token as a substring must NOT be flagged (regression for the // substring-match false positive). for _, ident := range []string{ "const x = refetchList();", "function prefetch() {}", "const r = retrieval(q);", "const m = medieval(y);", } { clean := strings.Replace(validAppSource, "render(state, el, ctx) {", "render(state, el, ctx) { "+ident, 1) if _, problems := validateMemberAppSource("notes", clean); len(problems) != 0 { t.Errorf("legit identifier %q false-tripped the forbidden scan: %v", ident, problems) } } // A camelCase (uppercase-tail) event type must be CAUGHT, not silently // dropped β€” otherwise the manifest ships a subset and that event is dead. camel := strings.Replace(validAppSource, `"notes.remove"`, `"notes.markDone"`, 2) camelTypes, camelProblems := validateMemberAppSource("notes", camel) if !hasProblem(camelProblems, "markDone") { t.Errorf("camelCase type not flagged: types=%v problems=%v", camelTypes, camelProblems) } // Unsupported export forms the sandbox transform rejects must be caught here. starExport := strings.Replace(validAppSource, "export const app", `export * from "../apps.js";\nexport const app`, 1) if _, problems := validateMemberAppSource("notes", starExport); !hasProblem(problems, "export") { t.Errorf("`export *` not flagged: %v", problems) } // An unrelated earlier `name:` field must not shadow the real one. withOtherName := strings.Replace(validAppSource, "initialState: () => ({ items: [] }),", `initialState: () => ({ items: [], config: { name: "health" } }),`, 1) if _, problems := validateMemberAppSource("notes", withOtherName); hasProblem(problems, "name field") { t.Errorf("unrelated name: field false-rejected the correct app name: %v", problems) } } func hasProblem(problems []string, sub string) bool { for _, p := range problems { if strings.Contains(p, sub) { return true } } return false } func TestParseAppBuildReq(t *testing.T) { good := map[string]any{"name": "habits", "title": "Habit Tracker", "glyph": "🌱", "spec": "track habits"} if _, err := parseAppBuildReq(good); err != nil { t.Fatalf("good req rejected: %v", err) } bad := []map[string]any{ {"name": "Habits", "title": "T", "glyph": "🌱", "spec": "s"}, // uppercase name {"name": "9habits", "title": "T", "glyph": "🌱", "spec": "s"}, // starts with digit {"name": "habits", "title": "", "glyph": "🌱", "spec": "s"}, // empty title {"name": "habits", "title": "T", "glyph": "", "spec": "s"}, // empty glyph {"name": "habits", "title": "T", "glyph": "🌱", "spec": ""}, // empty spec {"name": "habits", "title": "T", "glyph": "🌱", "spec": "s", "app_id": "NO"}, // bad app_id } for i, in := range bad { if _, err := parseAppBuildReq(in); err == nil { t.Errorf("bad req %d accepted", i) } } } func TestExtractCodeBlock(t *testing.T) { if got := extractCodeBlock("```js\nconst a = 1;\n```"); got != "const a = 1;" { t.Fatalf("js fence: %q", got) } if got := extractCodeBlock("prose\n```\nshort\n```\nmore\n```javascript\na much longer block wins\n```"); got != "a much longer block wins" { t.Fatalf("largest block: %q", got) } if got := extractCodeBlock("no fence at all"); got != "" { t.Fatalf("no fence: %q", got) } } // TestAppImportsMatchSandbox pins the Go validator's allowed-import lists to // the sandbox runtime's ALLOWED_IMPORTS / ALLOWED_FMT (web/static/sandbox-frame.js) // β€” the same drift-proofing pattern as TestCommandsMatchJS. If either SDK // surface grows an export, both sides update in the same PR or this fails. func TestAppImportsMatchSandbox(t *testing.T) { raw, err := os.ReadFile("../../web/static/sandbox-frame.js") if err != nil { t.Fatal(err) } check := func(jsVar string, goList []string) { m := regexp.MustCompile(`(?s)export const ` + jsVar + ` = \[(.*?)\]`).FindSubmatch(raw) if m == nil { t.Fatalf("%s not found in sandbox-frame.js", jsVar) } var js []string for _, im := range regexp.MustCompile(`"([^"]+)"`).FindAllStringSubmatch(string(m[1]), -1) { js = append(js, im[1]) } if len(js) != len(goList) { t.Fatalf("%s: sandbox has %d, Go validator %d", jsVar, len(js), len(goList)) } for i := range js { if js[i] != goList[i] { t.Errorf("%s[%d]: sandbox %q, validator %q", jsVar, i, js[i], goList[i]) } } } check("ALLOWED_IMPORTS", allowedAppImports) // ALLOWED_FMT is `Object.keys(FMT)` in the sandbox, not a literal array, so // pin against the FMT object's keys. fm := regexp.MustCompile(`(?s)const FMT = \{(.*?)\};`).FindSubmatch(raw) if fm == nil { t.Fatal("FMT not found in sandbox-frame.js") } var fmtKeys []string for _, k := range regexp.MustCompile(`(?m)^\s*(\w+),`).FindAllStringSubmatch(string(fm[1]), -1) { fmtKeys = append(fmtKeys, k[1]) } if len(fmtKeys) != len(allowedFmtImports) { t.Fatalf("FMT: sandbox has %d keys, Go validator %d", len(fmtKeys), len(allowedFmtImports)) } for i := range fmtKeys { if fmtKeys[i] != allowedFmtImports[i] { t.Errorf("FMT[%d]: sandbox %q, validator %q", i, fmtKeys[i], allowedFmtImports[i]) } } } func TestValidatorAcceptsFormatImports(t *testing.T) { src := strings.Replace( validAppSource, `import { defineApp, addItem, removeItem } from "../apps.js";`, `import { defineApp, addItem, removeItem } from "../apps.js";`+"\n"+`import { tokenizeText, formatBytes as fb } from "../format.js";`, 1, ) if _, problems := validateMemberAppSource("notes", src); len(problems) != 0 { t.Errorf("valid format import rejected: %v", problems) } // A non-format name from format.js is rejected. bad := strings.Replace(src, "tokenizeText, formatBytes as fb", "membershipLine", 1) if _, problems := validateMemberAppSource("notes", bad); !hasProblem(problems, "not importable") { t.Errorf("membershipLine from format.js should be rejected: %v", problems) } } func TestSourceHasChatMessageAndCommands(t *testing.T) { if !sourceHasChatMessage(`chatMessage: (e) => null,`) { t.Error("chatMessage property not detected") } if !sourceHasChatMessage(`chatMessage(e) { return null; }`) { t.Error("chatMessage method not detected") } if sourceHasChatMessage(`// no chatMessage here\nconst x = "chatMessage: fake";`) { t.Error("chatMessage in comment/string should not count") } if !commandDefinedInSource(`commands: { "/habit": (a, c) => {} }`, "/habit") { t.Error("command handler not detected") } if commandDefinedInSource(`commands: { "/other": (a, c) => {} }`, "/habit") { t.Error("absent command wrongly detected") } } func TestParseAppBuildReqCommands(t *testing.T) { req, err := parseAppBuildReq(map[string]any{ "name": "habit", "title": "Habit", "glyph": "🌱", "spec": "track", "commands": []any{map[string]any{"name": "/habit", "desc": "add", "args": ""}}, }) if err != nil { t.Fatalf("valid commands rejected: %v", err) } if len(req.Commands) != 1 || req.Commands[0].Name != "/habit" || req.Commands[0].Desc != "add" { t.Fatalf("bad parsed commands: %+v", req.Commands) } // Bad command name. if _, err := parseAppBuildReq(map[string]any{ "name": "habit", "title": "H", "glyph": "🌱", "spec": "s", "commands": []any{map[string]any{"name": "habit"}}, }); err == nil { t.Error("command without leading slash accepted") } } // The in-flight guard is what stops a re-ask mid-build from deploying the app // twice: the second beginBuild for the same (thread, uid) must be refused until // endBuild releases it, while a different thread or requester is unaffected. func TestBuildInFlightGuard(t *testing.T) { r := &Runtime{} if !r.beginBuild("t1", "u1") { t.Fatal("first build should be allowed") } if r.beginBuild("t1", "u1") { t.Fatal("a duplicate concurrent build should be refused") } // A different requester, or a different thread, is its own slot. if !r.beginBuild("t1", "u2") { t.Fatal("a different requester should be allowed to build") } if !r.beginBuild("t2", "u1") { t.Fatal("the same requester in another thread should be allowed") } // Releasing t1/u1 lets a retry through; the others stay independent. r.endBuild("t1", "u1") if !r.beginBuild("t1", "u1") { t.Fatal("build should be allowed again after endBuild") } } // The progress/result line is a /me-style action line, not a bot bubble: the // payload must carry action:true (plus a stable name/ts) so the web renders it // as a centered line and edits keep it that way. func TestActionLinePayload(t *testing.T) { a := &actionLine{r: &Runtime{Session: &Session{Name: "Claude"}}, ts: 123} p := a.payload("building…") if p["action"] != true { t.Fatalf("action flag missing: %+v", p) } if p["name"] != "Claude" || p["text"] != "building…" || p["ts"] != int64(123) { t.Fatalf("bad payload: %+v", p) } } func TestSourceDefaultCommand(t *testing.T) { if got := sourceDefaultCommand(`defineApp({ name: "x", defaultCommand: "/add", commands: {} })`); got != "/add" { t.Fatalf("want /add, got %q", got) } if got := sourceDefaultCommand(`defineApp({ name: "x", commands: {} })`); got != "" { t.Fatalf("no default: want empty, got %q", got) } } func TestUniqueAppName(t *testing.T) { // Free name is returned unchanged. if got := uniqueAppName("habits", map[string]bool{"todo": true}); got != "habits" { t.Fatalf("free name: got %q", got) } // Taken name gets the first free numeric variant. taken := map[string]bool{"habits": true, "habits2": true} if got := uniqueAppName("habits", taken); got != "habits3" { t.Fatalf("collision: want habits3, got %q", got) } // A max-length base is trimmed so the suffix keeps it within 24 chars. long := "abcdefghijklmnopqrstuvwx" // 24 chars got := uniqueAppName(long, map[string]bool{long: true}) if len(got) > 24 || got == long { t.Fatalf("over-long variant %q (len %d)", got, len(got)) } if !appNameRe.MatchString(got) { t.Fatalf("variant %q doesn't match the name rule", got) } } // The apps note lists exactly the SENDER's apps β€” never another member's, // never owner-less rows β€” with the exact ids the tools need; no owned apps // means no note at all. func TestMemberAppsNoteText(t *testing.T) { apps := []deployedApp{ {ID: "app-aaaa11", Version: 3, Doc: memberAppDoc{Manifest: memberAppManifest{ Name: "habits", Title: "Habit Tracker", Glyph: "🌱", Owner: "u1", BuiltBy: "Claude"}}}, {ID: "app-bbbb22", Version: 1, Doc: memberAppDoc{Manifest: memberAppManifest{ Name: "notes", Title: "Notes", Glyph: "πŸ“", Owner: "u2"}}}, {ID: "app-cccc33", Version: 2, Doc: memberAppDoc{Manifest: memberAppManifest{ Name: "legacy", Title: "Legacy", Glyph: "πŸ—Ώ"}}}, // owner-less: nobody's } note := memberAppsNoteText(apps, "u1") for _, want := range []string{"app-aaaa11", "Habit Tracker", "version 3", "built by Claude", "create_app", "read_app_source"} { if !strings.Contains(note, want) { t.Errorf("note missing %q:\n%s", want, note) } } for _, banned := range []string{"app-bbbb22", "app-cccc33", "Notes", "Legacy"} { if strings.Contains(note, banned) { t.Errorf("note leaked another member's app (%q):\n%s", banned, note) } } if got := memberAppsNoteText(apps, "u3"); got != "" { t.Errorf("member with no apps should get no note, got:\n%s", got) } if got := memberAppsNoteText(nil, "u1"); got != "" { t.Errorf("empty list should give no note, got:\n%s", got) } // An empty uid must never match owner-less rows. if got := memberAppsNoteText(apps, ""); got != "" { t.Errorf("empty uid should give no note, got:\n%s", got) } } // runReadAppSource's pre-network gates: a malformed id and an unverifiable // sender are refused before any fetch happens (no Runtime client needed). func TestRunReadAppSourceGates(t *testing.T) { r := &Runtime{} if got := r.runReadAppSource(map[string]any{"app_id": "Not-An-Id!"}, "u1"); !strings.Contains(got, "bad app_id") { t.Errorf("malformed id: %q", got) } if got := r.runReadAppSource(map[string]any{}, "u1"); !strings.Contains(got, "bad app_id") { t.Errorf("missing id: %q", got) } if got := r.runReadAppSource(map[string]any{"app_id": "app-abc123"}, ""); !strings.Contains(got, "can't be verified") { t.Errorf("uid-less sender: %q", got) } } func TestStripJS(t *testing.T) { src := `const a = "fetch("; // fetch( in comment /* fetch( in block */ const b = ` + "`fetch(${x}`" + `; real(a);` out := stripJS(src) if strings.Contains(out, "fetch(") { t.Fatalf("strings/comments leaked: %q", out) } if !strings.Contains(out, "real(a)") { t.Fatalf("code lost: %q", out) } }