|
|
@@ -0,0 +1,143 @@ |
| 1 |
|
| 2 |
|
| 3 |
import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 4 |
import { mkdtemp, rm } from "node:fs/promises"; |
| 5 |
import { tmpdir } from "node:os"; |
| 6 |
import { join } from "node:path"; |
| 7 |
import { createApp, type App } from "../src/app.ts"; |
| 8 |
import { createRepo } from "../src/actions/repos.ts"; |
| 9 |
import { createApp as insertApp } from "../src/core/apps.ts"; |
| 10 |
import { createUser } from "../src/core/users.ts"; |
| 11 |
import { serve, type Servers } from "../src/http/server.ts"; |
| 12 |
import { quietLogs } from "./helpers/globals.ts"; |
| 13 |
|
| 14 |
quietLogs(); |
| 15 |
|
| 16 |
let dataDir: string; |
| 17 |
let app: App; |
| 18 |
let servers: Servers; |
| 19 |
let cookie = ""; |
| 20 |
|
| 21 |
beforeAll(async () => { |
| 22 |
dataDir = await mkdtemp(join(tmpdir(), "forge-share-tell-")); |
| 23 |
app = createApp({ dataDir, port: 0, host: "forge.test" }); |
| 24 |
servers = await serve(app); |
| 25 |
const made = await createUser(app.db, { |
| 26 |
origin: "seed", trusted: true, username: "alice", email: "[email protected]", |
| 27 |
password: "a-good-passphrase", adminIfFirst: true, |
| 28 |
}); |
| 29 |
if (!made.ok) throw new Error(made.error); |
| 30 |
const res = await fetch(`${servers.url}/signin`, { |
| 31 |
method: "POST", |
| 32 |
body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), |
| 33 |
redirect: "manual", |
| 34 |
}); |
| 35 |
cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; |
| 36 |
}, 120_000); |
| 37 |
|
| 38 |
afterAll(async () => { |
| 39 |
servers.stop(); |
| 40 |
app.close(); |
| 41 |
await rm(dataDir, { recursive: true, force: true }); |
| 42 |
}); |
| 43 |
|
| 44 |
const thoughts = () => app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM thought").get()!.n; |
| 45 |
|
| 46 |
|
| 47 |
async function share(fields: Record<string, string>, headers: Record<string, string> = { cookie }) { |
| 48 |
const m = (await (await fetch(`${servers.url}/manifest.webmanifest`)).json()) as { |
| 49 |
share_target: { action: string; method: string; params: Record<string, string> }; |
| 50 |
}; |
| 51 |
const q = new URLSearchParams(); |
| 52 |
for (const [k, v] of Object.entries(fields)) q.set(m.share_target.params[k]!, v); |
| 53 |
expect(m.share_target.method).toBe("GET"); |
| 54 |
return fetch(`${servers.url}${m.share_target.action}?${q}`, { headers, redirect: "manual" }); |
| 55 |
} |
| 56 |
|
| 57 |
test("a shared link opens a prefilled composer and files nothing", async () => { |
| 58 |
const before = thoughts(); |
| 59 |
const res = await share({ title: "A <b>page</b>", url: "https://example.com/a?x=1&y=2" }); |
| 60 |
expect(res.status).toBe(200); |
| 61 |
const out = await res.text(); |
| 62 |
expect(/<textarea[^>]*name="text"[^>]*>([^<]*)<\/textarea>/.exec(out)?.[1]).toBe( |
| 63 |
"A <b>page</b>\nhttps://example.com/a?x=1&y=2", |
| 64 |
); |
| 65 |
expect(out).toContain('action="/tell"'); |
| 66 |
expect(/name="once" value="[0-9a-f]{24}"/.test(out)).toBe(true); |
| 67 |
expect(thoughts()).toBe(before); |
| 68 |
}); |
| 69 |
|
| 70 |
test("the last repository told is the one chosen", async () => { |
| 71 |
const alice = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'alice'").get()!; |
| 72 |
const user = (await import("../src/core/users.ts")).getUserById(app.db, alice.id)!; |
| 73 |
const ids: string[] = []; |
| 74 |
for (const name of ["one", "two"]) { |
| 75 |
const r = await createRepo(app, user, { name, visibility: "private" }); |
| 76 |
if (!r.ok) throw new Error("repo"); |
| 77 |
insertApp(app.db, { repoId: r.value.id, slug: name, access: "private" }); |
| 78 |
ids.push(r.value.id); |
| 79 |
} |
| 80 |
const at = ["2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"]; |
| 81 |
ids.forEach((rid, i) => |
| 82 |
app.db.run("INSERT INTO issue (id, repo_id, number, title, author_id, created_at) VALUES (?,?,1,'t',?,?)", [`i${i}`, rid, alice.id, at[i]!]), |
| 83 |
); |
| 84 |
const out = await (await share({ text: "hello" })).text(); |
| 85 |
expect(out).toContain('<option value="alice/two" selected>'); |
| 86 |
expect(out).not.toContain('<option value="alice/one" selected>'); |
| 87 |
}); |
| 88 |
|
| 89 |
test("a share that cannot be a tell is refused with a sentence", async () => { |
| 90 |
const cases: [Record<string, string>, RegExp][] = [ |
| 91 |
[{ title: " " }, /Nothing came through/], |
| 92 |
[{ text: "x".repeat(2001) }, /too long/], |
| 93 |
[{ title: "t", url: "javascript:alert(1)" }, /not a web address/], |
| 94 |
[{ title: "t", url: "ftp://example.com/" }, /not a web address/], |
| 95 |
[{ title: "t", url: "not a url" }, /not a web address/], |
| 96 |
]; |
| 97 |
for (const [fields, words] of cases) { |
| 98 |
const res = await share(fields); |
| 99 |
expect(res.status).toBe(400); |
| 100 |
const out = await res.text(); |
| 101 |
expect(out).toMatch(words); |
| 102 |
expect(out).not.toContain("at /"); |
| 103 |
} |
| 104 |
}); |
| 105 |
|
| 106 |
test("a signed-out share is sent to sign in and back", async () => { |
| 107 |
const res = await share({ text: "hi" }, {}); |
| 108 |
expect(res.status).toBe(303); |
| 109 |
expect(res.headers.get("location")).toContain("/signin?next=" + encodeURIComponent("/tell?text=hi")); |
| 110 |
}); |
| 111 |
|
| 112 |
test("Send files the tell; a cross-origin post files nothing", async () => { |
| 113 |
const body = () => new URLSearchParams({ text: "from my phone", app: "forge" }); |
| 114 |
const before = thoughts(); |
| 115 |
const forged = await fetch(`${servers.url}/tell`, { |
| 116 |
method: "POST", headers: { cookie, origin: "https://evil.example" }, body: body(), redirect: "manual", |
| 117 |
}); |
| 118 |
expect(forged.status).toBe(403); |
| 119 |
expect(thoughts()).toBe(before); |
| 120 |
await fetch(`${servers.url}/tell`, { method: "POST", headers: { cookie, origin: servers.url }, body: body(), redirect: "manual" }); |
| 121 |
expect(thoughts()).toBe(before + 1); |
| 122 |
}); |
| 123 |
|
| 124 |
test("where the last tell went decides: a tell to the forge after an issue chooses the forge", async () => { |
| 125 |
const alice = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'alice'").get()!; |
| 126 |
const { lastTold, TOLD_FORGE } = await import("../src/core/last_told.ts"); |
| 127 |
const repos = app.db.query<{ id: string }, []>("SELECT id FROM repo").all().map((r) => r.id); |
| 128 |
app.db.run("INSERT INTO issue (id, repo_id, number, title, author_id, created_at) VALUES ('i9', ?, 9, 't', ?, '2028-01-01T00:00:00Z')", [repos[0]!, alice.id]); |
| 129 |
expect(lastTold(app.db, alice.id, repos)).toBe(repos[0]!); |
| 130 |
app.db.run("INSERT INTO thought (id, author, text, source, received_at) VALUES ('t1', ?, 'x', 'composer', '2029-01-01T00:00:00Z')", [alice.id]); |
| 131 |
expect(lastTold(app.db, alice.id, repos)).toBe(TOLD_FORGE); |
| 132 |
const out = await (await share({ text: "hello" })).text(); |
| 133 |
expect(out).toContain('<option value="forge" selected>'); |
| 134 |
expect(out).not.toContain('<option value="alice/two" selected>'); |
| 135 |
}); |
| 136 |
|
| 137 |
test("a share the composer cannot hold whole is refused, not cut", async () => { |
| 138 |
const res = await share({ text: "y".repeat(2001) }); |
| 139 |
expect(res.status).toBe(400); |
| 140 |
expect(await res.text()).toContain("none of it was kept"); |
| 141 |
const ok = await (await share({ text: "y".repeat(2000) })).text(); |
| 142 |
expect(ok).toContain("y".repeat(2000)); |
| 143 |
}); |
Last-used comes from
issue.author_id, but a tell is a thought, not an issue. This preselects the wrong repo for anyone who tells without filing issues. Read from the tell record and move the SQL into core.