Skip to content
164 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import type { User, Viewer } from "@g1t/contracts";
5
6import { liveAddress, openLive, setLiveViaToken } from "./live-socket.ts";
7import { TICKET_PARAM, TICKET_ROUTE, TICKET_SECONDS, issueTicket, openTicket, socketPath, ticketViewer } from "./socket-ticket.ts";
8import { tokenVerdict, websiteUser } from "./website-token.ts";
9
10const SECRET = "site-secret";
11const NOW = Date.UTC(2026, 9, 9, 12, 0, 0);
12
13const ada: User = {
14 id: "usr_ada",
15 username: "ada",
16 kind: "user",
17 verified: true,
18 workspaces: [{ slug: "acme", role: "owner" }],
19 token: { token_id: "tok_web", scopes: ["repo:read"], website: true },
20};
21
22/** identity's `user_for_access_token` as the site narrows it, over a table that tests change. */
23const table: Record<string, Viewer> = {};
24const lookup = async (token: string) => websiteUser(table[token] ?? null);
25
26function reset() {
27 for (const key of Object.keys(table)) delete table[key];
28 table.g1t_web = ada;
29}
30
31function upgrade(path: string, ticket: string | null, headers: Record<string, string> = { upgrade: "websocket" }): Request {
32 const url = new URL(`https://g1t.sh${path}`);
33 if (ticket) url.searchParams.set("ticket", ticket);
34 return new Request(url, { headers });
35}
36
37const CHAT = "/acme/-/chat/live";
38
39test("only the site's live sockets take a ticket, in the form the routes match", () => {
40 assert.deepEqual(socketPath("/-/live"), { path: "/-/live", workspace: null });
41 assert.deepEqual(socketPath("/Acme//-/chat/live/"), { path: "/acme/-/chat/live", workspace: "acme" });
42 assert.deepEqual(socketPath("/acme/-/artifacts/live"), { path: "/acme/-/artifacts/live", workspace: "acme" });
43 for (const path of ["/", "/acme", "/acme/-/chat", "/acme/-/inbox/live", "/-/-/chat/live", "/acme/web/-/chat/live", "/-/live/ticket", "/%E0%A4%A"]) {
44 assert.equal(socketPath(path), null, path);
45 }
46});
47
48test("a ticket opens the socket it was made for, as the token's owner", async () => {
49 reset();
50 const { ticket, expires_at } = await issueTicket(SECRET, { token: "g1t_web", userId: "usr_ada", path: CHAT }, NOW);
51 assert.match(ticket, /^st1\.[A-Za-z0-9_-]+$/);
52 assert.equal(expires_at, new Date(NOW + TICKET_SECONDS * 1000).toISOString());
53 assert.ok(!ticket.includes("g1t_web"), "the token is not readable in the ticket");
54 assert.deepEqual(await openTicket(SECRET, ticket, CHAT, NOW + 1000), { token: "g1t_web", userId: "usr_ada" });
55 const viewer = await ticketViewer(upgrade(`${CHAT}?channel=ch_1`, ticket), SECRET, lookup, NOW + 1000);
56 assert.equal(viewer?.id, "usr_ada");
57 // The path as the browser may spell it.
58 assert.equal((await ticketViewer(upgrade("/ACME/-/chat/live/", ticket), SECRET, lookup, NOW))?.id, "usr_ada");
59});
60
61test("a ticket past its minute opens nothing", async () => {
62 reset();
63 const { ticket } = await issueTicket(SECRET, { token: "g1t_web", userId: "usr_ada", path: CHAT }, NOW);
64 assert.ok(await openTicket(SECRET, ticket, CHAT, NOW + (TICKET_SECONDS - 1) * 1000));
65 assert.equal(await openTicket(SECRET, ticket, CHAT, NOW + TICKET_SECONDS * 1000), null);
66 assert.equal(await ticketViewer(upgrade(CHAT, ticket), SECRET, lookup, NOW + 5 * 60_000), null);
67});
68
69test("a ticket opens no other socket: another kind, or another workspace's", async () => {
70 reset();
71 const { ticket } = await issueTicket(SECRET, { token: "g1t_web", userId: "usr_ada", path: CHAT }, NOW);
72 for (const path of ["/acme/-/artifacts/live", "/-/live", "/other/-/chat/live"]) {
73 assert.equal(await openTicket(SECRET, ticket, path, NOW), null, path);
74 assert.equal(await ticketViewer(upgrade(path, ticket), SECRET, lookup, NOW), null, path);
75 }
76});
77
78test("a ticket changed in any way, or sealed with another key, opens nothing", async () => {
79 reset();
80 const { ticket } = await issueTicket(SECRET, { token: "g1t_web", userId: "usr_ada", path: CHAT }, NOW);
81 const body = ticket.slice(4);
82 for (let i = 0; i < body.length; i += 7) {
83 const swapped = body[i] === "A" ? "B" : "A";
84 const tampered = `st1.${body.slice(0, i)}${swapped}${body.slice(i + 1)}`;
85 assert.equal(await openTicket(SECRET, tampered, CHAT, NOW), null, `byte ${i}`);
86 }
87 for (const bad of ["", "st1.", "st1.!!!", `st2.${body}`, body, `st1.${body}x`, `st1.${"A".repeat(3000)}`]) {
88 assert.equal(await openTicket(SECRET, bad, CHAT, NOW), null, bad.slice(0, 20));
89 }
90 assert.equal(await openTicket("another-secret", ticket, CHAT, NOW), null);
91});
92
93test("the token is checked again when the socket opens: deleted, revoked or without the website permission, it opens nothing", async () => {
94 reset();
95 const { ticket } = await issueTicket(SECRET, { token: "g1t_web", userId: "usr_ada", path: CHAT }, NOW);
96 // Deleted, expired or revoked: identity knows it no more.
97 delete table.g1t_web;
98 assert.equal(await ticketViewer(upgrade(CHAT, ticket), SECRET, lookup, NOW), null);
99 // "Use the website as you" turned off since the page loaded.
100 table.g1t_web = { ...ada, token: { token_id: "tok_web", scopes: ["repo:read"], website: false } };
101 assert.equal(await ticketViewer(upgrade(CHAT, ticket), SECRET, lookup, NOW), null);
102 // A token that now names someone else.
103 table.g1t_web = { ...ada, id: "usr_bob", username: "bob" };
104 assert.equal(await ticketViewer(upgrade(CHAT, ticket), SECRET, lookup, NOW), null);
105 // A ticket made for something that is not a token.
106 const odd = await issueTicket(SECRET, { token: "a".repeat(64), userId: "usr_ada", path: CHAT }, NOW);
107 table["a".repeat(64)] = ada;
108 assert.equal(await ticketViewer(upgrade(CHAT, odd.ticket), SECRET, lookup, NOW), null);
109});
110
111test("a ticket is never taken by anything but a socket's upgrade", async () => {
112 reset();
113 const { ticket } = await issueTicket(SECRET, { token: "g1t_web", userId: "usr_ada", path: CHAT }, NOW);
114 // The socket's own address, asked without an upgrade.
115 assert.equal(await ticketViewer(upgrade(CHAT, ticket, {}), SECRET, lookup, NOW), null);
116 assert.equal(await ticketViewer(upgrade(CHAT, ticket, { upgrade: "h2c" }), SECRET, lookup, NOW), null);
117 // Pages, data requests and form posts: the token rules see no token, so the session cookie (or no one) decides.
118 for (const path of ["/acme", "/acme/-/chat", "/acme/-/chat.data", "/settings/tokens"]) {
119 const page = new Request(`https://g1t.sh${path}?ticket=${encodeURIComponent(ticket)}`);
120 assert.deepEqual(await tokenVerdict(page, async () => ada), { kind: "none" }, path);
121 const post = new Request(`https://g1t.sh${path}?ticket=${encodeURIComponent(ticket)}`, { method: "POST", body: new FormData() });
122 assert.deepEqual(await tokenVerdict(post, async () => ada), { kind: "none" }, path);
123 }
124 // An upgrade without a ticket is the session's business, as before.
125 assert.equal(await ticketViewer(upgrade(CHAT, null), SECRET, lookup, NOW), null);
126});
127
128test("a session's page opens its sockets at once and adds no ticket; a token's page asks for one first", async () => {
129 const site = new URL("https://g1t.sh/acme/-/chat");
130 const was = { location: globalThis.location, fetch: globalThis.fetch };
131 Object.defineProperty(globalThis, "location", { value: site, configurable: true });
132 const asked: string[] = [];
133 globalThis.fetch = (async (input: string) => {
134 asked.push(String(input));
135 return Response.json({ ticket: "st1.abc", expires_at: "2026-10-09T12:01:00.000Z" });
136 }) as typeof fetch;
137 try {
138 assert.equal(liveAddress(CHAT, { channel: "ch_1" }, null), "wss://g1t.sh/acme/-/chat/live?channel=ch_1");
139
140 setLiveViaToken(false);
141 const opened: string[] = [];
142 openLive(CHAT, () => ({ channel: "ch_1" }), (address) => opened.push(address), () => false);
143 assert.deepEqual(opened, ["wss://g1t.sh/acme/-/chat/live?channel=ch_1"], "synchronously, with no ticket");
144 assert.deepEqual(asked, []);
145
146 setLiveViaToken(true);
147 const viaToken = await new Promise<string>((resolve) => openLive("/-/live", () => ({ workspace: "acme" }), resolve, () => false));
148 assert.equal(viaToken, "wss://g1t.sh/-/live?workspace=acme&ticket=st1.abc");
149 assert.deepEqual(asked, ["/-/live/ticket?path=%2F-%2Flive"]);
150 // The page's copy of the route and parameter, as the server has them.
151 assert.equal(TICKET_ROUTE, "/-/live/ticket");
152 assert.equal(TICKET_PARAM, "ticket");
153
154 // Closed while the ticket was on its way: nothing opens.
155 let late = false;
156 openLive(CHAT, () => ({}), () => (late = true), () => true);
157 await new Promise((resolve) => setTimeout(resolve, 10));
158 assert.equal(late, false);
159 } finally {
160 setLiveViaToken(false);
161 Object.defineProperty(globalThis, "location", { value: was.location, configurable: true });
162 globalThis.fetch = was.fetch;
163 }
164});