From d165cdb9b2b2f0f638e71d03b6d91bdc4f740215 Mon Sep 17 00:00:00 2001 From: nesszerra <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:35:44 +0700 Subject: [PATCH] Fix small follow-ups after the strict TypeScript port (TSF5) - Sign-out reads only the cookie named exactly mini_session (shared sessionCookie). - An error thrown inside /auth/* gets the Worker's 503 answer (await handleAuth). - botDropText looks reasons up in a Map, so "constructor" reads as itself. - The demo overlay ignores postMessages without a demoPaused field. - The fighter page says a save may have gone through when a 2xx answer is unreadable (acceptedUnreadable). - port-check treats vite.config.ts as non-shipping, like vite.config.js. - Stale comments name vite.config.ts, src/admin.ts and tests/cosmetics.test.ts. - channel tests assert the alarm is set; the registry Twitch stub answers the moderators call. --- docs/CONTRACTS.md | 12 +++++++++--- docs/PAGES.md | 2 +- scripts/port-check.ts | 2 +- server/auth.ts | 7 +++++-- server/eventsub.ts | 33 ++++++++++++++++++--------------- server/worker.ts | 2 +- src/dashboard.ts | 9 +++++++++ src/parse.ts | 3 +++ src/served/cosmetics.ts | 2 +- src/served/overlay.ts | 5 +++-- src/served/parse.ts | 10 +++++++--- src/served/upload.ts | 4 ++-- tests/auth.test.ts | 25 +++++++++++++++++++++++++ tests/browser-parse.test.ts | 18 ++++++++++++++++++ tests/channel.test.ts | 10 +++++----- tests/port/port-check.test.ts | 19 +++++++++++++++++++ tests/registry.test.ts | 2 ++ tests/worker.test.ts | 1 + 18 files changed, 130 insertions(+), 36 deletions(-) diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 4f508f7..4e3ae1f 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -29,7 +29,9 @@ fighter (saving and buying stay closed). or missing, gets the error that route already gives when that upstream fails (the rows below), never a crash or a coerced value. A valid answer behaves as before. - Auth uses the `mini_session` cookie (HttpOnly), set by `/auth/callback`. It and its AuthStore `session:` record - last 30 days (`SESSION_S` in server/auth.ts), so viewers stay signed in between streams; signing out ends it. + last 30 days (`SESSION_S` in server/auth.ts), so viewers stay signed in between streams; signing out ends it. Only + a cookie named exactly `mini_session` counts, when reading the session and when signing out: one whose name ends in + it (`xmini_session`) is ignored. - Every response carries `Strict-Transport-Security: max-age=31536000; includeSubDomains` on https. Pages get a Content-Security-Policy with `script-src 'self'` plus Cloudflare's Web Analytics beacon (no inline script, no eval) and `frame-ancestors 'none'`; the overlay has the same without the beacon or `frame-ancestors` (OBS and the /start demo load it), and sends `Cache-Control: no-transform` so @@ -44,7 +46,7 @@ fighter (saving and buying stay closed). channel's own account), `moderator` (Helix moderator check, needs the stored broadcaster token) and `canManage = owner || broadcaster || moderator`. - Unknown errors return 503 `{error:"Service unavailable; check owner diagnostics"}` and are - logged for the developer page. + logged for the developer page. That includes an error inside `/auth/*` (AuthStore down during sign-out, say). ## 2. HTTP routes @@ -329,7 +331,8 @@ Bot replies and status (room table `bot_status`, one row): - The Worker sends the room's replies with Helix Send Chat Message, then posts `{results:[{sent, reason}]}` (10 max) to the room's internal `POST /bot-sent`. The room counts sent and dropped replies; a drop is logged as a `warn` "bot reply dropped: " with the - reason in words (`botDropText` in server/eventsub.ts: duplicate, slow mode, 429, ...). Reminder + reason in words (`botDropText` in server/eventsub.ts: duplicate, slow mode, 429, ...; any other reason, such as + `constructor`, reads as itself). Reminder posts do the same as "bot reminder sent" / "bot reminder dropped: ...". - Replies the room holds back are logged as `warn` "bot reply held back: ", at most once a minute per reason: `reply_limit` (command replies stop at 14 in 30 s; the last 4 of the cap of 18 are kept for the bot's own result and expired-challenge lines), `unknown_subscription` (a message @@ -547,9 +550,12 @@ Browser input is parsed once where it arrives, in the style of `server/parse.ts` // the overlay's saved chat looks in localStorage and the demo's postMessage export function parseArenaSnapshot(value), parseLooksMessage(value), parseLook(value), parseCosmeticSettings(text), parseDemoMessage(value), keepGood(list, kind, parse), warnOnce(kind, error) +// parseDemoMessage answers null for a message with no demoPaused field (a browser extension's), which the demo +// ignores without a warning; one with a demoPaused that isn't true or false still warns once // src/parse.ts (Lane B, bundled into the pages): /api/session, /api/access, the fighter page's profile, shop, state // config, leaderboard and stored draft, the mod page's /api/admin and snapshots, the owner page's lists export function parseAnswer(r, parse, kind) // an ok answer whose body fails -> {ok:false, data:{error: UNREADABLE}} +export function acceptedUnreadable(r) // true for that case (a 2xx it couldn't read): the write may have gone through ``` `roomFetch` adds the internal secret and channel headers. `room.ctx.storage.sql` is the DO's SQLite. diff --git a/docs/PAGES.md b/docs/PAGES.md index 4643a13..f4b0cbe 100644 --- a/docs/PAGES.md +++ b/docs/PAGES.md @@ -5,7 +5,7 @@ | Page | What it is | |---|---| | `/` | What PixFray is, with a duel playing. A returning viewer gets a "Back to " button. | -| `/?channel=` | That channel's fighter page. A signed-in viewer can turn a picture into their own sprite under **Your own sprite** (an optional AI redraw first). It joins their character list once a mod approves it. Fighters are saved per channel: with none saved here, the page offers the look the viewer saved on another channel. Signed out, the page says the fighter shown is a starting one, and edits made before signing in come back after it. Punctuation after the login (`?channel=miolafff,`, as a chat app may add to a link) is dropped. | +| `/?channel=` | That channel's fighter page. A signed-in viewer can turn a picture into their own sprite under **Your own sprite** (an optional AI redraw first). It joins their character list once a mod approves it. Fighters are saved per channel: with none saved here, the page offers the look the viewer saved on another channel. Signed out, the page says the fighter shown is a starting one, and edits made before signing in come back after it. When a save reaches the server but the page can't read the answer, it says the profile may have saved and to reload the page to check, instead of "Not saved". Punctuation after the login (`?channel=miolafff,`, as a chat app may add to a link) is dropped. | | `/play/` | Picks the channel. | | `/intro/` | Redirects to `/`. | | `/start/` | Streamer sign-up. | diff --git a/scripts/port-check.ts b/scripts/port-check.ts index 0cd50a4..d30e600 100644 --- a/scripts/port-check.ts +++ b/scripts/port-check.ts @@ -94,7 +94,7 @@ const TSCONFIG = /^(?!public\/)(?:.*\/)?tsconfig[^/]*\.json$/; /** Code that never ships: tests, scripts and the build configs. Their string literals may name a moved file's new * path (a test reading `src/ui.ts`, the Worker entrypoint in cloudflare.config.ts). */ -const NON_SHIPPING = /^(?:tests\/|scripts\/|cloudflare\.config\.ts$|vite\.config\.js$)/; +const NON_SHIPPING = /^(?:tests\/|scripts\/|cloudflare\.config\.ts$|vite\.config\.(?:js|ts)$)/; /** The build config: when the base or the head has it, both are built and their output compared. */ const BUILD_CONFIG = "cloudflare.config.ts"; diff --git a/server/auth.ts b/server/auth.ts index c791604..da02119 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -244,8 +244,11 @@ export const BOT_SCOPES = ["user:read:chat", "user:write:chat", "user:bot"]; export function configured(env: Env): boolean { return !!(env.TWITCH_CLIENT_ID && env.TWITCH_CLIENT_SECRET && env.AUTH_SECRET && env.INTERNAL_SECRET); } +// The raw mini_session cookie, matched at a cookie-name boundary so a cookie such as xmini_session isn't read as it. +const sessionCookie = (request: Request) => + request.headers.get("Cookie")?.match(/(?:^|;\s*)mini_session=([a-f0-9]{64})(?:;|$)/)?.[1]; export async function session(request: Request, env: Env): Promise { - const raw = request.headers.get("Cookie")?.match(/(?:^|;\s*)mini_session=([a-f0-9]{64})(?:;|$)/)?.[1]; + const raw = sessionCookie(request); return raw ? record(env, "session:" + (await digest(raw))) : null; } export async function isOwner(env: Env, user: TwitchUser | null | undefined): Promise { @@ -271,7 +274,7 @@ export async function handleAuth(request: Request, env: Env): Promise path = url.pathname; if (path === "/auth/logout") { if (request.method !== "POST") return new Response("Use POST", { status: 405 }); - const raw = request.headers.get("Cookie")?.match(/mini_session=([a-f0-9]{64})/)?.[1]; + const raw = sessionCookie(request); if (raw) await record(env, "session:" + (await digest(raw)), null); // the page reloads itself, so signing out stays on the same page and channel return Response.json({ ok: true }, { headers: { "Set-Cookie": cookie("mini_session", "", 0) } }); diff --git a/server/eventsub.ts b/server/eventsub.ts index b528bcf..55b5abc 100644 --- a/server/eventsub.ts +++ b/server/eventsub.ts @@ -464,23 +464,26 @@ export function botMessage(message: unknown): string { } // Twitch's reason for a line the bot couldn't post, in words for the owner log, the admin page and "!fray debug". // reason is a drop_reason code from Helix, or the error sendChatMessage threw ("... failed (403)"). -const DROP_TEXT: Record = { - msg_duplicate: "the same line twice within 30 s", - msg_rejected: "held by AutoMod", - msg_rejected_mandatory: "blocked by AutoMod or blocked terms", - msg_banned: "the bot is banned in this channel", - msg_timedout: "the bot is timed out", - msg_ratelimit: "Twitch rate limit", - msg_followersonly: "followers-only chat", - msg_subsonly: "subscriber-only chat", - msg_slowmode: "slow mode", - msg_emoteonly: "emote-only chat", - msg_verified_email: "the bot account needs a verified email", - empty: "nothing to send", -}; +const DROP_TEXT = new Map( + Object.entries({ + msg_duplicate: "the same line twice within 30 s", + msg_rejected: "held by AutoMod", + msg_rejected_mandatory: "blocked by AutoMod or blocked terms", + msg_banned: "the bot is banned in this channel", + msg_timedout: "the bot is timed out", + msg_ratelimit: "Twitch rate limit", + msg_followersonly: "followers-only chat", + msg_subsonly: "subscriber-only chat", + msg_slowmode: "slow mode", + msg_emoteonly: "emote-only chat", + msg_verified_email: "the bot account needs a verified email", + empty: "nothing to send", + }), +); export function botDropText(reason: string | null | undefined): string { reason = String(reason || ""); - if (DROP_TEXT[reason]) return DROP_TEXT[reason] + " (" + reason + ")"; + const text = DROP_TEXT.get(reason); + if (text) return text + " (" + reason + ")"; const status = /\((\d{3})\)/.exec(reason)?.[1]; if (status === "401") return "Twitch didn't accept the app token (401)"; if (status === "403") diff --git a/server/worker.ts b/server/worker.ts index 6bdb430..31f9db9 100644 --- a/server/worker.ts +++ b/server/worker.ts @@ -523,7 +523,7 @@ async function handle(request: Request, env: Env, ctx: ExecutionContext | undefi return json({ error: "Invalid dev token" }, 401); if (mutating && !dev && request.headers.get("Origin") !== url.origin) return json({ error: "Same-origin request required" }, 403); - if (path.startsWith("/auth/")) return handleAuth(request, env); + if (path.startsWith("/auth/")) return await handleAuth(request, env); if (path === "/robots.txt" || path === "/sitemap.xml") return seoFile(url, path); if (!path.startsWith("/api/")) return channelPageRedirect(env, url) || (await page(request, env, url)); // Public health check: up or not, nothing else. Version and Twitch setup are in the owner's /api/dev/diagnostics (worker.version, worker.twitchConfigured). diff --git a/src/dashboard.ts b/src/dashboard.ts index dd6c934..02a42cf 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -32,6 +32,7 @@ import type { voxThumb as voxThumbFn, voxAll as voxAllFn } from "./voxthumb.ts"; import type { Stat, Stats } from "../server/upgrades.ts"; import type { CosmeticKind } from "../server/cosmetics.ts"; import { + acceptedUnreadable, parseAnswer, parseBought, parseDraft, @@ -1677,6 +1678,14 @@ form.addEventListener("submit", async (event) => { ); return; } + if (acceptedUnreadable(r)) { + setStatus( + status, + "Your profile may have saved, but this page couldn't read the answer. Reload the page to check.", + "error", + ); + return; + } if (!r.ok) { setStatus(status, "Not saved: " + (REASONS[r.data?.error] || errorText(r)), "error"); return; diff --git a/src/parse.ts b/src/parse.ts index 3dece71..0b9ae60 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -42,6 +42,9 @@ export function parseAnswer( return { ok: false, status: r.status, data: { error: UNREADABLE } }; } +/** An answer the server accepted (2xx) whose body this page couldn't read, so the write it answers may have gone through. */ +export const acceptedUnreadable = (r: Answer): boolean => !r.ok && r.status >= 200 && r.status < 300; + // ---------- field checks ---------- type Check = (v: unknown) => boolean; diff --git a/src/served/cosmetics.ts b/src/served/cosmetics.ts index bdab868..df20c1d 100644 --- a/src/served/cosmetics.ts +++ b/src/served/cosmetics.ts @@ -1,5 +1,5 @@ // Cosmetics drawn on stream (overlay.ts) and in the website preview (src/ui.ts). Looks only, no stat changes. -// The lists, prices and ownership live in server/cosmetics.ts; the ids here must match (tests/channel.test.mjs). +// The lists, prices and ownership live in server/cosmetics.ts; the ids here must match (tests/cosmetics.test.ts). import { headFor } from "./hats.ts"; import type { Frame, Head } from "./hats.ts"; diff --git a/src/served/overlay.ts b/src/served/overlay.ts index 1e162ac..50101a9 100644 --- a/src/served/overlay.ts +++ b/src/served/overlay.ts @@ -215,8 +215,8 @@ export function readableColor(hex: string, minContrast = NAME_MIN_CONTRAST) { return "#" + out.map((v) => v.toString(16).padStart(2, "0")).join(""); } -// The overlay's own code version: vite.config.js (versionPublicModules) writes ?v= on the overlay's -// script URL in the built overlay.html, so the hash changes only when overlay code changes, not on every deploy. +// The overlay's own code version: vite.config.ts (servedBuild) writes ?v= on the overlay's +// script URL in the built overlay.html, so the hash changes only when served code changes, not on every deploy. export const overlayVersionFromUrl = (url: string) => { try { return (new URL(url).searchParams.get("v") || "").match(/^[0-9a-f]{6,64}$/i)?.[0] || ""; @@ -1920,6 +1920,7 @@ async function start() { addEventListener("message", (e) => { if (e.origin !== location.origin) return; // another site's message: not for the demo const message = parseDemoMessage(e.data); + if (!message) return; // not the demo's message (an extension's, say) if (!message.ok) return warnOnce("a demo message", message.error); demoPaused = message.value.demoPaused; if (!demoPaused && !drawing) { diff --git a/src/served/parse.ts b/src/served/parse.ts index 92e798f..8f45465 100644 --- a/src/served/parse.ts +++ b/src/served/parse.ts @@ -328,8 +328,12 @@ export function parseCosmeticSettings(text: string | null): Parsed { - if (!isObject(value) || typeof value.demoPaused !== "boolean") return bad("demo message has no demoPaused switch"); +/** + * The site page that embeds the demo overlay posts { demoPaused } while it scrolls the demo out of view. Answers null + * for a message without a demoPaused field: browser extensions post their own messages to the page. + */ +export function parseDemoMessage(value: unknown): Parsed<{ demoPaused: boolean }> | null { + if (!isObject(value) || !("demoPaused" in value)) return null; + if (typeof value.demoPaused !== "boolean") return bad("demo message has no demoPaused switch"); return ok({ demoPaused: value.demoPaused }); } diff --git a/src/served/upload.ts b/src/served/upload.ts index 8375663..67a479f 100644 --- a/src/served/upload.ts +++ b/src/served/upload.ts @@ -1,6 +1,6 @@ -// Custom character uploader (Lane D). Browser only; no build step needed (served as-is from public/). +// Custom character uploader (Lane D). Browser only; vite.config.ts (servedBuild) serves it as /upload.js. // -// Mount API, used by the /admin page (src/admin.js): +// Mount API, used by the /admin page (src/admin.ts): // const { mountUpload } = await import('/upload.js'); // const handle = await mountUpload(root, { channel, usage, limits, items, refresh }); // root Element to render into (its children are replaced). diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 66e88e0..2b207b9 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -474,6 +474,31 @@ test("sign-out clears the session and leaves the page where it is (the page relo assert.match(must(r.headers.get("Set-Cookie")), /mini_session=;.*Max-Age=0/); assert.equal(f.entries.has("session:" + (await digest("1".repeat(64)))), false); }); +test("sign-out reads only the cookie named mini_session, not one that ends in it", async () => { + const f = environment(), + cookie = await signedIn(f), + other = "2".repeat(64); + f.entries.set("session:" + (await digest(other)), { user: { id: "9", login: "other", displayName: "Other" } }); + const r = await worker.fetch(req("/auth/logout", "POST", {}, "xmini_session=" + other + "; " + cookie), f.env); + assert.equal(r.status, 200); + assert.equal(f.entries.has("session:" + (await digest("1".repeat(64)))), false, "the real session is gone"); + assert.equal(f.entries.has("session:" + (await digest(other))), true, "the look-alike cookie's session stays"); +}); +test("an auth route that throws gets the Worker's error answer, not a rejected fetch", async () => { + const f = environment(), + cookie = await signedIn(f); + // Object.assign swaps in a throwing store without restating the binding's full type + Object.assign(f.env.AUTH, { + get: () => ({ + fetch: async () => { + throw new Error("auth store down"); + }, + }), + }); + const r = await worker.fetch(req("/auth/logout", "POST", {}, cookie), f.env); + assert.equal(r.status, 503); + assert.deepEqual(await r.json(), { error: "Service unavailable; check owner diagnostics" }); +}); test("pages: / is the intro, /?channel= the fighter page, /play/ the picker, and /intro/ moves to /", async () => { const f = environment(); // a fake asset binding that echoes the path it was asked for diff --git a/tests/browser-parse.test.ts b/tests/browser-parse.test.ts index f652e56..da0d6f0 100644 --- a/tests/browser-parse.test.ts +++ b/tests/browser-parse.test.ts @@ -5,6 +5,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { UNREADABLE, + acceptedUnreadable, parseAccess, parseAdmin, parseAdminSnapshot, @@ -144,6 +145,8 @@ test("overlay looks push, saved chat looks and demo messages", (t) => { ok: false, error: "demo message has no demoPaused switch", }); + for (const other of ["hello", { source: "react-devtools-content-script" }, null, [true]]) + assert.equal(parseDemoMessage(other), null, "a message that isn't the demo's is ignored: " + JSON.stringify(other)); }); test("parseAnswer: an ok body that fails turns into a failed answer; a failed answer keeps its body", (t) => { @@ -166,6 +169,21 @@ test("parseAnswer: an ok body that fails turns into a failed answer; a failed an }); }); +test("acceptedUnreadable: only a 2xx answer whose body failed may have gone through", (t) => { + t.mock.method(console, "warn", () => {}); + const save = (r: { ok: boolean; status: number; data: unknown }) => + acceptedUnreadable(parseAnswer(r, parseProfileSaved, "the saved profile")); + assert.equal(save({ ok: true, status: 200, data: null }), true, "the save went through, the body didn't parse"); + assert.equal(save({ ok: true, status: 200, data: { profile: "x" } }), true); + assert.equal( + save({ ok: true, status: 200, data: { profile: { wins: 0, losses: 0, elo: 1000, dollars: 0 } } }), + false, + "a save read in full", + ); + assert.equal(save({ ok: false, status: 400, data: { error: "invalid_profile" } }), false, "a refused save"); + assert.equal(save({ ok: false, status: 0, data: { error: "Network error" } }), false, "no answer at all"); +}); + test("session and access", () => { const session = { user: { id: "1", login: "ann" }, owner: false, channels: ["x"] }; assert.deepEqual(parseSession(session), { ok: true, value: session }); diff --git a/tests/channel.test.ts b/tests/channel.test.ts index 67ecfd0..70f1b1b 100644 --- a/tests/channel.test.ts +++ b/tests/channel.test.ts @@ -426,7 +426,7 @@ test("the hourly alarm re-checks the Helix subscription and disconnects only on return helix(); }); await r.connectChat(); - assert.ok((r.ctx.storage.alarm ?? 0) <= Date.now() + 3_600_000 + 50, "the check is scheduled at most an hour out"); + assert.ok(must(r.ctx.storage.alarm) <= Date.now() + 3_600_000 + 50, "the check is scheduled at most an hour out"); await r.alarm(); assert.equal(calls.length, 0, "not due yet: no Helix call"); const age = () => { @@ -474,7 +474,7 @@ test("a subscription stuck awaiting verification is re-checked within minutes an ); await r.connectChat("sub-1", "webhook_callback_verification_pending"); assert.ok( - (r.ctx.storage.alarm ?? 0) <= Date.now() + 5 * 60_000, + must(r.ctx.storage.alarm) <= Date.now() + 5 * 60_000, "a pending subscription is checked within minutes, not an hour", ); const age = () => { @@ -2415,7 +2415,7 @@ test("chat bot reminder: off by default; on, it posts the !fray line as the bot const due = r.reminderDue(r.readState("nesszerra")); assert.ok(due > Date.now() + 29 * 60000 && due <= Date.now() + 30 * 60000, "first one a full interval out"); await r.scheduleAlarm(r.readState("nesszerra")); - assert.ok((r.ctx.storage.alarm ?? 0) <= due); + assert.ok(must(r.ctx.storage.alarm) <= due); // Offline: nothing posted, and it waits another interval. await r.call("/dev-live", { method: "POST", body: { live: false } }); await r.postReminder("nesszerra", due); @@ -2490,7 +2490,7 @@ test("chat bot: after the stream has played a duel, the bot says who won and the const duel = must(r.readState("nesszerra").duels.find((d) => d.status === "completed")) as Revealed; assert.ok(duel.revealAt > Date.now()); await r.scheduleAlarm(r.readState("nesszerra")); - assert.ok((r.ctx.storage.alarm ?? 0) <= duel.revealAt + 1, "the alarm wakes for the reveal"); + assert.ok(must(r.ctx.storage.alarm) <= duel.revealAt + 1, "the alarm wakes for the reveal"); await r.alarm(); assert.equal(tw.sent.length, 0, "nothing before the stream has shown it"); const s = r.readState("nesszerra"); @@ -2548,7 +2548,7 @@ test("chat bot: a result line survives a full reply cap, a Twitch outage, a miss await duel(); await r.alarm(); assert.equal(results(), 1); - assert.ok((r.ctx.storage.alarm ?? 0) <= Date.now() + 5000, "retried soon"); + assert.ok(must(r.ctx.storage.alarm) <= Date.now() + 5000, "retried soon"); tw.down = false; await r.alarm(); assert.equal(results(), 2); diff --git a/tests/port/port-check.test.ts b/tests/port/port-check.test.ts index a8ef07a..1dca4b8 100644 --- a/tests/port/port-check.test.ts +++ b/tests/port/port-check.test.ts @@ -1370,6 +1370,25 @@ describe("port-check", { concurrency: true }, () => { }); }); + test("vite.config.ts may update a string naming a ported file, like vite.config.js", async () => { + const dir = await repo({ + "tsconfig.worker.json": strictList(), + "src/ui.js": "export const ui = 1;\n", + "vite.config.ts": 'export const SERVED: string[] = ["src/ui.js"];\nexport default {};\n', + }); + await git(dir, "mv", "src/ui.js", "src/ui.ts"); + write(dir, { + "src/ui.ts": "export const ui: number = 1;\n", + "vite.config.ts": 'export const SERVED: string[] = ["src/ui.ts"];\nexport default {};\n', + "tsconfig.worker.json": strictList("src/ui.ts", "vite.config.ts"), + }); + assert.deepEqual(await portCheck(dir), { + status: 0, + stdout: "allowed: tsconfig.worker.json (no build config reads it)\n2 files compared, 0 different\n", + stderr: "", + }); + }); + test("a test string that named the .ts path before the port passes unchanged", async () => { const dir = await repo({ "tsconfig.worker.json": strictList(), diff --git a/tests/registry.test.ts b/tests/registry.test.ts index 42b983c..ec34aa7 100644 --- a/tests/registry.test.ts +++ b/tests/registry.test.ts @@ -113,6 +113,8 @@ function twitch(t: TestContext, as: Account, scopes: string[]) { if (u.endsWith("/oauth2/validate")) return Response.json({ client_id: "test-app", user_id: as.id, scopes }); if (u.includes("login=nesszerra")) return Response.json({ data: [{ id: "1", login: "nesszerra", display_name: "nesszerra" }] }); + // nobody here is a channel mod: the access checks in these tests turn on who owns the channel + if (u.includes("/helix/moderation/moderators")) return Response.json({ data: [] }); if (u.includes("/helix/videos")) return as.videos === "fail" ? new Response("", { status: 503 }) diff --git a/tests/worker.test.ts b/tests/worker.test.ts index a45467b..3825add 100644 --- a/tests/worker.test.ts +++ b/tests/worker.test.ts @@ -1184,6 +1184,7 @@ test("chat bot: drop reasons read as words", () => { assert.match(botDropText("Twitch send chat message failed (403)"), /may not chat here \(403\)/); assert.equal(botDropText("Twitch send chat message failed (500)"), "Twitch send chat message failed (500)"); assert.equal(botDropText(""), "unknown"); + for (const key of ["constructor", "__proto__", "toString"]) assert.equal(botDropText(key), key); }); test("chat bot: a signed-up channel gets its verification, and the bot answers on its own channel", async (t) => {