Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
branches: [main]
pull_request:

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
Expand Down
83 changes: 83 additions & 0 deletions app/src/components/launchpad/image-upload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import test from "node:test";
import { fileURLToPath } from "node:url";

type PreviewInput = { value: string; compact?: boolean };

/**
* Render the real client component and React's attribute serializer. The main
* test command selects react-server exports, so this child deliberately starts
* without that condition: useState/useRef and react-dom/server need the normal
* React entry points. Static rendering performs no image requests or uploads.
*/
function renderPreviews(inputs: PreviewInput[]): string[] {
const result = spawnSync(process.execPath, ["--import", "tsx", "--eval", `
const { readFileSync } = require("node:fs");
const { createElement } = require("react");
const { renderToStaticMarkup } = require("react-dom/server");
const ImageUpload = require("./src/components/launchpad/ImageUpload.tsx").default;
const inputs = JSON.parse(readFileSync(0, "utf8"));
const output = inputs.map((props) => renderToStaticMarkup(createElement(ImageUpload, {
...props, wallet: undefined, onChange() { throw new Error("Rendering must not upload or change the URL"); },
})));
process.stdout.write(JSON.stringify(output));
`], {
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
input: JSON.stringify(inputs),
encoding: "utf8",
timeout: 10_000,
windowsHide: true,
});
assert.ifError(result.error);
assert.equal(result.status, 0, result.stderr);
return JSON.parse(result.stdout) as string[];
}

test("image previews retain HTTP(S), trim whitespace, and preserve compact sizing", () => {
const [https, http, compact] = renderPreviews([
{ value: " https://images.example/logo.png " },
{ value: "http://images.example/logo.gif" },
{ value: "https://images.example/logo.webp", compact: true },
]);
assert.ok(https.includes('<img src="https://images.example/logo.png"'));
assert.ok(http.includes('<img src="http://images.example/logo.gif"'));
assert.ok(compact.includes('<img src="https://images.example/logo.webp"'));
assert.ok(https.includes('width="88" height="88"'));
assert.ok(compact.includes('width="64" height="64"'));
for (const html of [https, http, compact]) {
assert.ok(html.includes('referrerPolicy="no-referrer"'));
assert.ok(html.includes(">Change image</p>"));
}
});

test("non-HTTP(S) input never becomes an image preview", () => {
const values = [
"", " ",
"javascript:alert(1)", " \tjavascript:alert(1)\n",
"JaVaScRiPt:alert(1)", "java\nscript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"data:image/svg+xml,<svg onload=alert(1)></svg>",
"//images.example/logo.png", "/logo.png", "https:images.example/logo.png",
'"/><img src=x onerror="alert(1)">',
];
const output = renderPreviews(values.map((value) => ({ value })));
for (const [index, html] of output.entries()) {
assert.equal(html.includes("<img"), false, `Unexpected preview for ${JSON.stringify(values[index])}`);
assert.equal(html.includes("<script"), false);
assert.ok(html.includes(">Upload image</p>"));
}
});

test("quotes and markup in an HTTP(S) URL remain encoded attribute data", () => {
const value = 'https://images.example/logo.png?x=" onerror="alert(1)"><script>alert(2)</script>&y=\'test\'';
const escaped = "https://images.example/logo.png?x=&quot; onerror=&quot;alert(1)&quot;&gt;&lt;script&gt;alert(2)&lt;/script&gt;&amp;y=&#x27;test&#x27;";
const [html] = renderPreviews([{ value }]);
// Assert the actual serialized sink, not a duplicate sanitization function or
// a source-code pattern. React must keep the entire value in one src attribute.
assert.ok(html.includes(`<img src="${escaped}" alt=""`));
assert.ok(html.includes(`value="${escaped}"`));
assert.equal(html.includes(' onerror="'), false);
assert.equal(html.includes("<script>"), false);
assert.equal(html.includes("</script>"), false);
});
11 changes: 11 additions & 0 deletions app/src/lib/ci-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";

test("build-only CI declares read-only token permissions for both jobs", () => {
const workflow = readFileSync(new URL("../../../.github/workflows/ci.yml", import.meta.url), "utf8");
const permissions = workflow.match(/^permissions:[ \t]*\r?\n((?:[ \t]+[^\r\n]*\r?\n)+)/m)?.[1];
assert.ok(permissions, "CI must explicitly declare workflow-level token permissions");
assert.deepEqual(permissions.trim().split(/\r?\n/).map((line) => line.trim()), ["contents: read"]);
assert.equal([...workflow.matchAll(/^[ \t]*permissions:/gm)].length, 1, "both jobs inherit the read-only policy without overrides");
});
44 changes: 44 additions & 0 deletions app/src/lib/launchpad/posts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,50 @@ test("post message binds chain, token, wallet, reply, nonce, time and body", ()
assert.ok(buildModMessage({ action: "hide", target: "post:3", wallet: "0xCD", nonce: "n", ts: 0 }).includes("Action: hide"));
});

test("validateBody preserves every code unit except the intended controls and CR normalization", () => {
for (let unit = 0; unit <= 0xffff; unit++) {
const character = String.fromCharCode(unit);
const stripped = unit <= 0x08 || unit === 0x0b || unit === 0x0c
|| (unit >= 0x0e && unit <= 0x1f) || unit === 0x7f;
const expected = stripped ? "" : unit === 0x0d ? "\n" : character;
// Sentinels keep whitespace inside the body, independent of edge trimming.
assert.deepEqual(validateBody(`a${character}z`), { ok: true, body: `a${expected}z` }, `code unit ${unit.toString(16)}`);
}
});

test("validateBody retains text whitespace and Unicode while normalizing line endings", () => {
assert.deepEqual(validateBody(" \tfirst\tsecond\r\nthird\rfourth\n\n\nfifth\t "), {
ok: true,
body: "first\tsecond\nthird\nfourth\n\nfifth",
});
const unicode = "தமிழ் café e\u0301 👩‍💻 🚀 — \u200b\u0085\u009f";
assert.deepEqual(validateBody(unicode), { ok: true, body: unicode });
assert.deepEqual(validateBody("a\u0000b\u0007c\u0008d\u000be\u000cf\u000eg\u001fh\u007f"), {
ok: true,
body: "abcdefgh",
});
assert.deepEqual(validateBody("\u0000\u0008\u000b\u000c\u000e\u001f\u007f"), { ok: false, error: "empty post" });
assert.deepEqual(validateBody("x".repeat(500) + "\u0000\u001f\u007f"), { ok: true, body: "x".repeat(500) });
});

test("normalized post bodies retain their byte-exact signed message", () => {
const result = validateBody(" \tgm\u0000\tfrens\r\nதமிழ் 🚀 — open\u007f ");
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(buildPostMessage({ chain: "base", token: "0xAB", wallet: "0xCD", nonce: "n", ts: 0, parentId: 7, body: result.body }), [
"openlaunch.lol \u2014 sign to post. Free, no transaction.",
"",
"Chain: base",
"Token: 0xab",
"Wallet: 0xcd",
"Reply to: 7",
"Nonce: n",
"Time: 1970-01-01T00:00:00.000Z",
"",
"gm\tfrens\nதமிழ் 🚀 — open",
].join("\n"));
});

test("copy cleanup preserves byte-exact wallet signature headers and user text", () => {
const body = "hello \u2014 world";
const message = buildPostMessage({ chain: "base", token: "0xAB", wallet: "0xCD", nonce: "n", ts: 0, parentId: null, body });
Expand Down
Binary file modified app/src/lib/launchpad/posts.ts
Binary file not shown.
4 changes: 3 additions & 1 deletion app/src/lib/seo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ test("static sitemap covers every public route and skips /admin", () => {
assert.ok(!paths.includes("/admin"), "/admin must stay out of the sitemap");
const entries = staticSitemapEntries(SITE, "2026-09-06T00:00:00.000Z");
assert.equal(entries.length, STATIC_SITEMAP_ROUTES.length);
assert.ok(entries.every((e) => e.url.startsWith(SITE)));
for (const entry of entries) {
assert.equal(new URL(entry.url).origin, new URL(SITE).origin);
}
assert.equal(entries[0].url, `${SITE}/`);
});

Expand Down
Loading