| 1 | package bot |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "regexp" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // A small valid member app, the shape the guide teaches. |
| 11 | const validAppSource = ` |
| 12 | import { defineApp, addItem, removeItem } from "../apps.js"; |
| 13 | |
| 14 | export const app = defineApp({ |
| 15 | name: "notes", |
| 16 | title: "Notes", |
| 17 | glyph: "📝", |
| 18 | initialState: () => ({ items: [] }), |
| 19 | events: { |
| 20 | "notes.add": (s, e) => (e.id && e.text ? { items: addItem(s.items, { id: e.id, text: e.text }) } : s), |
| 21 | "notes.remove": (s, e) => ({ items: removeItem(s.items, e.id) }), |
| 22 | }, |
| 23 | render(state, el, ctx) { |
| 24 | const items = (state && state.items) || []; |
| 25 | for (const it of items) { |
| 26 | const row = document.createElement("div"); |
| 27 | row.textContent = it.text; |
| 28 | el.appendChild(row); |
| 29 | } |
| 30 | }, |
| 31 | }); |
| 32 | ` |
| 33 | |
| 34 | func TestValidateMemberAppSource(t *testing.T) { |
| 35 | types, problems := validateMemberAppSource("notes", validAppSource) |
| 36 | if len(problems) != 0 { |
| 37 | t.Fatalf("valid app rejected: %v", problems) |
| 38 | } |
| 39 | if len(types) != 2 || types[0] != "notes.add" || types[1] != "notes.remove" { |
| 40 | t.Fatalf("bad types: %v", types) |
| 41 | } |
| 42 | |
| 43 | cases := []struct { |
| 44 | name string |
| 45 | src string |
| 46 | want string // substring of a problem |
| 47 | }{ |
| 48 | {"network", strings.Replace(validAppSource, "document.createElement", "fetch(x); document.createElement", 1), "fetch"}, |
| 49 | {"storage", strings.Replace(validAppSource, "document.createElement", "localStorage.x; document.createElement", 1), "localStorage"}, |
| 50 | {"innerHTML", strings.Replace(validAppSource, "row.textContent = it.text", "row.innerHTML = it.text", 1), "innerHTML"}, |
| 51 | {"bad import source", strings.Replace(validAppSource, `"../apps.js"`, `"../other.js"`, 1), `only "../apps.js" and "../format.js"`}, |
| 52 | {"bad import name", strings.Replace(validAppSource, "addItem,", "foldEvent, addItem,", 1), "not importable"}, |
| 53 | {"dynamic import", strings.Replace(validAppSource, "render(state, el, ctx) {", "render(state, el, ctx) { import(\"https://x\");", 1), "../apps.js"}, |
| 54 | {"two defineApps", validAppSource + "\ndefineApp({});", "exactly once"}, |
| 55 | {"wrong name", strings.Replace(validAppSource, `name: "notes"`, `name: "other"`, 1), "name field"}, |
| 56 | {"setInterval", strings.Replace(validAppSource, "document.createElement", "setInterval(f, 100); document.createElement", 1), "ctx.interval"}, |
| 57 | } |
| 58 | for _, c := range cases { |
| 59 | _, problems := validateMemberAppSource("notes", c.src) |
| 60 | found := false |
| 61 | for _, p := range problems { |
| 62 | if strings.Contains(p, c.want) { |
| 63 | found = true |
| 64 | } |
| 65 | } |
| 66 | if !found { |
| 67 | t.Errorf("%s: want a problem containing %q, got %v", c.name, c.want, problems) |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | // Prose in comments/strings must not trip the token scan. |
| 72 | commented := strings.Replace(validAppSource, "export const app", |
| 73 | "// never call fetch( or use localStorage or innerHTML\nconst note = \"fetch( localStorage innerHTML import \";\nexport const app", 1) |
| 74 | if _, problems := validateMemberAppSource("notes", commented); len(problems) != 0 { |
| 75 | t.Errorf("comment/string mentions tripped the scan: %v", problems) |
| 76 | } |
| 77 | |
| 78 | // Boundary-aware forbidden scan: legitimate identifiers that merely CONTAIN |
| 79 | // a forbidden token as a substring must NOT be flagged (regression for the |
| 80 | // substring-match false positive). |
| 81 | for _, ident := range []string{ |
| 82 | "const x = refetchList();", |
| 83 | "function prefetch() {}", |
| 84 | "const r = retrieval(q);", |
| 85 | "const m = medieval(y);", |
| 86 | } { |
| 87 | clean := strings.Replace(validAppSource, "render(state, el, ctx) {", "render(state, el, ctx) { "+ident, 1) |
| 88 | if _, problems := validateMemberAppSource("notes", clean); len(problems) != 0 { |
| 89 | t.Errorf("legit identifier %q false-tripped the forbidden scan: %v", ident, problems) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // A camelCase (uppercase-tail) event type must be CAUGHT, not silently |
| 94 | // dropped — otherwise the manifest ships a subset and that event is dead. |
| 95 | camel := strings.Replace(validAppSource, `"notes.remove"`, `"notes.markDone"`, 2) |
| 96 | camelTypes, camelProblems := validateMemberAppSource("notes", camel) |
| 97 | if !hasProblem(camelProblems, "markDone") { |
| 98 | t.Errorf("camelCase type not flagged: types=%v problems=%v", camelTypes, camelProblems) |
| 99 | } |
| 100 | |
| 101 | // Unsupported export forms the sandbox transform rejects must be caught here. |
| 102 | starExport := strings.Replace(validAppSource, "export const app", `export * from "../apps.js";\nexport const app`, 1) |
| 103 | if _, problems := validateMemberAppSource("notes", starExport); !hasProblem(problems, "export") { |
| 104 | t.Errorf("`export *` not flagged: %v", problems) |
| 105 | } |
| 106 | |
| 107 | // An unrelated earlier `name:` field must not shadow the real one. |
| 108 | withOtherName := strings.Replace(validAppSource, "initialState: () => ({ items: [] }),", |
| 109 | `initialState: () => ({ items: [], config: { name: "health" } }),`, 1) |
| 110 | if _, problems := validateMemberAppSource("notes", withOtherName); hasProblem(problems, "name field") { |
| 111 | t.Errorf("unrelated name: field false-rejected the correct app name: %v", problems) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | func hasProblem(problems []string, sub string) bool { |
| 116 | for _, p := range problems { |
| 117 | if strings.Contains(p, sub) { |
| 118 | return true |
| 119 | } |
| 120 | } |
| 121 | return false |
| 122 | } |
| 123 | |
| 124 | func TestParseAppBuildReq(t *testing.T) { |
| 125 | good := map[string]any{"name": "habits", "title": "Habit Tracker", "glyph": "🌱", "spec": "track habits"} |
| 126 | if _, err := parseAppBuildReq(good); err != nil { |
| 127 | t.Fatalf("good req rejected: %v", err) |
| 128 | } |
| 129 | bad := []map[string]any{ |
| 130 | {"name": "Habits", "title": "T", "glyph": "🌱", "spec": "s"}, // uppercase name |
| 131 | {"name": "9habits", "title": "T", "glyph": "🌱", "spec": "s"}, // starts with digit |
| 132 | {"name": "habits", "title": "", "glyph": "🌱", "spec": "s"}, // empty title |
| 133 | {"name": "habits", "title": "T", "glyph": "", "spec": "s"}, // empty glyph |
| 134 | {"name": "habits", "title": "T", "glyph": "🌱", "spec": ""}, // empty spec |
| 135 | {"name": "habits", "title": "T", "glyph": "🌱", "spec": "s", "app_id": "NO"}, // bad app_id |
| 136 | } |
| 137 | for i, in := range bad { |
| 138 | if _, err := parseAppBuildReq(in); err == nil { |
| 139 | t.Errorf("bad req %d accepted", i) |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | func TestExtractCodeBlock(t *testing.T) { |
| 145 | if got := extractCodeBlock("```js\nconst a = 1;\n```"); got != "const a = 1;" { |
| 146 | t.Fatalf("js fence: %q", got) |
| 147 | } |
| 148 | if got := extractCodeBlock("prose\n```\nshort\n```\nmore\n```javascript\na much longer block wins\n```"); got != "a much longer block wins" { |
| 149 | t.Fatalf("largest block: %q", got) |
| 150 | } |
| 151 | if got := extractCodeBlock("no fence at all"); got != "" { |
| 152 | t.Fatalf("no fence: %q", got) |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | // TestAppImportsMatchSandbox pins the Go validator's allowed-import lists to |
| 157 | // the sandbox runtime's ALLOWED_IMPORTS / ALLOWED_FMT (web/static/sandbox-frame.js) |
| 158 | // — the same drift-proofing pattern as TestCommandsMatchJS. If either SDK |
| 159 | // surface grows an export, both sides update in the same PR or this fails. |
| 160 | func TestAppImportsMatchSandbox(t *testing.T) { |
| 161 | raw, err := os.ReadFile("../../web/static/sandbox-frame.js") |
| 162 | if err != nil { |
| 163 | t.Fatal(err) |
| 164 | } |
| 165 | check := func(jsVar string, goList []string) { |
| 166 | m := regexp.MustCompile(`(?s)export const ` + jsVar + ` = \[(.*?)\]`).FindSubmatch(raw) |
| 167 | if m == nil { |
| 168 | t.Fatalf("%s not found in sandbox-frame.js", jsVar) |
| 169 | } |
| 170 | var js []string |
| 171 | for _, im := range regexp.MustCompile(`"([^"]+)"`).FindAllStringSubmatch(string(m[1]), -1) { |
| 172 | js = append(js, im[1]) |
| 173 | } |
| 174 | if len(js) != len(goList) { |
| 175 | t.Fatalf("%s: sandbox has %d, Go validator %d", jsVar, len(js), len(goList)) |
| 176 | } |
| 177 | for i := range js { |
| 178 | if js[i] != goList[i] { |
| 179 | t.Errorf("%s[%d]: sandbox %q, validator %q", jsVar, i, js[i], goList[i]) |
| 180 | } |
| 181 | } |
| 182 | } |
| 183 | check("ALLOWED_IMPORTS", allowedAppImports) |
| 184 | // ALLOWED_FMT is `Object.keys(FMT)` in the sandbox, not a literal array, so |
| 185 | // pin against the FMT object's keys. |
| 186 | fm := regexp.MustCompile(`(?s)const FMT = \{(.*?)\};`).FindSubmatch(raw) |
| 187 | if fm == nil { |
| 188 | t.Fatal("FMT not found in sandbox-frame.js") |
| 189 | } |
| 190 | var fmtKeys []string |
| 191 | for _, k := range regexp.MustCompile(`(?m)^\s*(\w+),`).FindAllStringSubmatch(string(fm[1]), -1) { |
| 192 | fmtKeys = append(fmtKeys, k[1]) |
| 193 | } |
| 194 | if len(fmtKeys) != len(allowedFmtImports) { |
| 195 | t.Fatalf("FMT: sandbox has %d keys, Go validator %d", len(fmtKeys), len(allowedFmtImports)) |
| 196 | } |
| 197 | for i := range fmtKeys { |
| 198 | if fmtKeys[i] != allowedFmtImports[i] { |
| 199 | t.Errorf("FMT[%d]: sandbox %q, validator %q", i, fmtKeys[i], allowedFmtImports[i]) |
| 200 | } |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | func TestValidatorAcceptsFormatImports(t *testing.T) { |
| 205 | src := strings.Replace( |
| 206 | validAppSource, |
| 207 | `import { defineApp, addItem, removeItem } from "../apps.js";`, |
| 208 | `import { defineApp, addItem, removeItem } from "../apps.js";`+"\n"+`import { tokenizeText, formatBytes as fb } from "../format.js";`, |
| 209 | 1, |
| 210 | ) |
| 211 | if _, problems := validateMemberAppSource("notes", src); len(problems) != 0 { |
| 212 | t.Errorf("valid format import rejected: %v", problems) |
| 213 | } |
| 214 | // A non-format name from format.js is rejected. |
| 215 | bad := strings.Replace(src, "tokenizeText, formatBytes as fb", "membershipLine", 1) |
| 216 | if _, problems := validateMemberAppSource("notes", bad); !hasProblem(problems, "not importable") { |
| 217 | t.Errorf("membershipLine from format.js should be rejected: %v", problems) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | func TestSourceHasChatMessageAndCommands(t *testing.T) { |
| 222 | if !sourceHasChatMessage(`chatMessage: (e) => null,`) { |
| 223 | t.Error("chatMessage property not detected") |
| 224 | } |
| 225 | if !sourceHasChatMessage(`chatMessage(e) { return null; }`) { |
| 226 | t.Error("chatMessage method not detected") |
| 227 | } |
| 228 | if sourceHasChatMessage(`// no chatMessage here\nconst x = "chatMessage: fake";`) { |
| 229 | t.Error("chatMessage in comment/string should not count") |
| 230 | } |
| 231 | if !commandDefinedInSource(`commands: { "/habit": (a, c) => {} }`, "/habit") { |
| 232 | t.Error("command handler not detected") |
| 233 | } |
| 234 | if commandDefinedInSource(`commands: { "/other": (a, c) => {} }`, "/habit") { |
| 235 | t.Error("absent command wrongly detected") |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | func TestParseAppBuildReqCommands(t *testing.T) { |
| 240 | req, err := parseAppBuildReq(map[string]any{ |
| 241 | "name": "habit", "title": "Habit", "glyph": "🌱", "spec": "track", |
| 242 | "commands": []any{map[string]any{"name": "/habit", "desc": "add", "args": "<t>"}}, |
| 243 | }) |
| 244 | if err != nil { |
| 245 | t.Fatalf("valid commands rejected: %v", err) |
| 246 | } |
| 247 | if len(req.Commands) != 1 || req.Commands[0].Name != "/habit" || req.Commands[0].Desc != "add" { |
| 248 | t.Fatalf("bad parsed commands: %+v", req.Commands) |
| 249 | } |
| 250 | // Bad command name. |
| 251 | if _, err := parseAppBuildReq(map[string]any{ |
| 252 | "name": "habit", "title": "H", "glyph": "🌱", "spec": "s", |
| 253 | "commands": []any{map[string]any{"name": "habit"}}, |
| 254 | }); err == nil { |
| 255 | t.Error("command without leading slash accepted") |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | // The in-flight guard is what stops a re-ask mid-build from deploying the app |
| 260 | // twice: the second beginBuild for the same (thread, uid) must be refused until |
| 261 | // endBuild releases it, while a different thread or requester is unaffected. |
| 262 | func TestBuildInFlightGuard(t *testing.T) { |
| 263 | r := &Runtime{} |
| 264 | if !r.beginBuild("t1", "u1") { |
| 265 | t.Fatal("first build should be allowed") |
| 266 | } |
| 267 | if r.beginBuild("t1", "u1") { |
| 268 | t.Fatal("a duplicate concurrent build should be refused") |
| 269 | } |
| 270 | // A different requester, or a different thread, is its own slot. |
| 271 | if !r.beginBuild("t1", "u2") { |
| 272 | t.Fatal("a different requester should be allowed to build") |
| 273 | } |
| 274 | if !r.beginBuild("t2", "u1") { |
| 275 | t.Fatal("the same requester in another thread should be allowed") |
| 276 | } |
| 277 | // Releasing t1/u1 lets a retry through; the others stay independent. |
| 278 | r.endBuild("t1", "u1") |
| 279 | if !r.beginBuild("t1", "u1") { |
| 280 | t.Fatal("build should be allowed again after endBuild") |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | // The progress/result line is a /me-style action line, not a bot bubble: the |
| 285 | // payload must carry action:true (plus a stable name/ts) so the web renders it |
| 286 | // as a centered line and edits keep it that way. |
| 287 | func TestActionLinePayload(t *testing.T) { |
| 288 | a := &actionLine{r: &Runtime{Session: &Session{Name: "Claude"}}, ts: 123} |
| 289 | p := a.payload("building…") |
| 290 | if p["action"] != true { |
| 291 | t.Fatalf("action flag missing: %+v", p) |
| 292 | } |
| 293 | if p["name"] != "Claude" || p["text"] != "building…" || p["ts"] != int64(123) { |
| 294 | t.Fatalf("bad payload: %+v", p) |
| 295 | } |
| 296 | } |
| 297 | |
| 298 | func TestSourceDefaultCommand(t *testing.T) { |
| 299 | if got := sourceDefaultCommand(`defineApp({ name: "x", defaultCommand: "/add", commands: {} })`); got != "/add" { |
| 300 | t.Fatalf("want /add, got %q", got) |
| 301 | } |
| 302 | if got := sourceDefaultCommand(`defineApp({ name: "x", commands: {} })`); got != "" { |
| 303 | t.Fatalf("no default: want empty, got %q", got) |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | func TestUniqueAppName(t *testing.T) { |
| 308 | // Free name is returned unchanged. |
| 309 | if got := uniqueAppName("habits", map[string]bool{"todo": true}); got != "habits" { |
| 310 | t.Fatalf("free name: got %q", got) |
| 311 | } |
| 312 | // Taken name gets the first free numeric variant. |
| 313 | taken := map[string]bool{"habits": true, "habits2": true} |
| 314 | if got := uniqueAppName("habits", taken); got != "habits3" { |
| 315 | t.Fatalf("collision: want habits3, got %q", got) |
| 316 | } |
| 317 | // A max-length base is trimmed so the suffix keeps it within 24 chars. |
| 318 | long := "abcdefghijklmnopqrstuvwx" // 24 chars |
| 319 | got := uniqueAppName(long, map[string]bool{long: true}) |
| 320 | if len(got) > 24 || got == long { |
| 321 | t.Fatalf("over-long variant %q (len %d)", got, len(got)) |
| 322 | } |
| 323 | if !appNameRe.MatchString(got) { |
| 324 | t.Fatalf("variant %q doesn't match the name rule", got) |
| 325 | } |
| 326 | } |
| 327 | |
| 328 | // The apps note lists exactly the SENDER's apps — never another member's, |
| 329 | // never owner-less rows — with the exact ids the tools need; no owned apps |
| 330 | // means no note at all. |
| 331 | func TestMemberAppsNoteText(t *testing.T) { |
| 332 | apps := []deployedApp{ |
| 333 | {ID: "app-aaaa11", Version: 3, Doc: memberAppDoc{Manifest: memberAppManifest{ |
| 334 | Name: "habits", Title: "Habit Tracker", Glyph: "🌱", Owner: "u1", BuiltBy: "Claude"}}}, |
| 335 | {ID: "app-bbbb22", Version: 1, Doc: memberAppDoc{Manifest: memberAppManifest{ |
| 336 | Name: "notes", Title: "Notes", Glyph: "📝", Owner: "u2"}}}, |
| 337 | {ID: "app-cccc33", Version: 2, Doc: memberAppDoc{Manifest: memberAppManifest{ |
| 338 | Name: "legacy", Title: "Legacy", Glyph: "🗿"}}}, // owner-less: nobody's |
| 339 | } |
| 340 | note := memberAppsNoteText(apps, "u1") |
| 341 | for _, want := range []string{"app-aaaa11", "Habit Tracker", "version 3", "built by Claude", "create_app", "read_app_source"} { |
| 342 | if !strings.Contains(note, want) { |
| 343 | t.Errorf("note missing %q:\n%s", want, note) |
| 344 | } |
| 345 | } |
| 346 | for _, banned := range []string{"app-bbbb22", "app-cccc33", "Notes", "Legacy"} { |
| 347 | if strings.Contains(note, banned) { |
| 348 | t.Errorf("note leaked another member's app (%q):\n%s", banned, note) |
| 349 | } |
| 350 | } |
| 351 | if got := memberAppsNoteText(apps, "u3"); got != "" { |
| 352 | t.Errorf("member with no apps should get no note, got:\n%s", got) |
| 353 | } |
| 354 | if got := memberAppsNoteText(nil, "u1"); got != "" { |
| 355 | t.Errorf("empty list should give no note, got:\n%s", got) |
| 356 | } |
| 357 | // An empty uid must never match owner-less rows. |
| 358 | if got := memberAppsNoteText(apps, ""); got != "" { |
| 359 | t.Errorf("empty uid should give no note, got:\n%s", got) |
| 360 | } |
| 361 | } |
| 362 | |
| 363 | // runReadAppSource's pre-network gates: a malformed id and an unverifiable |
| 364 | // sender are refused before any fetch happens (no Runtime client needed). |
| 365 | func TestRunReadAppSourceGates(t *testing.T) { |
| 366 | r := &Runtime{} |
| 367 | if got := r.runReadAppSource(map[string]any{"app_id": "Not-An-Id!"}, "u1"); !strings.Contains(got, "bad app_id") { |
| 368 | t.Errorf("malformed id: %q", got) |
| 369 | } |
| 370 | if got := r.runReadAppSource(map[string]any{}, "u1"); !strings.Contains(got, "bad app_id") { |
| 371 | t.Errorf("missing id: %q", got) |
| 372 | } |
| 373 | if got := r.runReadAppSource(map[string]any{"app_id": "app-abc123"}, ""); !strings.Contains(got, "can't be verified") { |
| 374 | t.Errorf("uid-less sender: %q", got) |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | func TestStripJS(t *testing.T) { |
| 379 | src := `const a = "fetch("; // fetch( in comment |
| 380 | /* fetch( in block */ const b = ` + "`fetch(${x}`" + `; real(a);` |
| 381 | out := stripJS(src) |
| 382 | if strings.Contains(out, "fetch(") { |
| 383 | t.Fatalf("strings/comments leaked: %q", out) |
| 384 | } |
| 385 | if !strings.Contains(out, "real(a)") { |
| 386 | t.Fatalf("code lost: %q", out) |
| 387 | } |
| 388 | } |
| 389 | |