Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

230 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import type { User, Viewer } from "@g1t/contracts";
5
6import { liveAddress, offerTicket, openLive, setLiveViaToken, takeOfferedTicket } from "./live-socket.ts";
7import { TICKET_PARAM, TICKET_ROUTE, TICKET_SECONDS, issueTicket, openTicket, socketPath, ticketViewer } from "./socket-ticket.ts";
8import { socketUser, 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/-/library/artifacts/live"), { path: "/acme/-/library/artifacts/live", workspace: "acme" });
43 // A page opened before artifacts moved into the Library still connects where they were.
44 assert.deepEqual(socketPath("/acme/-/artifacts/live"), { path: "/acme/-/artifacts/live", workspace: "acme" });
45 for (const path of ["/", "/acme", "/acme/-/chat", "/acme/-/inbox/live", "/-/-/chat/live", "/acme/web/-/chat/live", "/-/live/ticket", "/%E0%A4%A"]) {
46 assert.equal(socketPath(path), null, path);
47 }
48});
49
50test("a ticket opens the socket it was made for, as the token's owner", async () => {
51 reset();
52 const { ticket, expires_at } = await issueTicket(SECRET, { token: "g1t_web", userId: "usr_ada", path: CHAT }, NOW);
53 assert.match(ticket, /^st1\.[A-Za-z0-9_-]+$/);
54 assert.equal(expires_at, new Date(NOW + TICKET_SECONDS * 1000).toISOString());
55 assert.ok(!ticket.includes("g1t_web"), "the token is not readable in the ticket");
56 assert.deepEqual(await openTicket(SECRET, ticket, CHAT, NOW + 1000), { token: "g1t_web", userId: "usr_ada" });
57 const viewer = await ticketViewer(upgrade(`${CHAT}?channel=ch_1`, ticket), SECRET, lookup, NOW + 1000);
58 assert.equal(viewer?.id, "usr_ada");
59 // The path as the browser may spell it.
60 assert.equal((await ticketViewer(upgrade("/ACME/-/chat/live/", ticket), SECRET, lookup, NOW))?.id, "usr_ada");
61});
62
63test("a ticket past its minute opens nothing", async () => {
64 reset();
65 const { ticket } = await issueTicket(SECRET, { token: "g1t_web", userId: "usr_ada", path: CHAT }, NOW);
66 assert.ok(await openTicket(SECRET, ticket, CHAT, NOW + (TICKET_SECONDS - 1) * 1000));
67 assert.equal(await openTicket(SECRET, ticket, CHAT, NOW + TICKET_SECONDS * 1000), null);
68 assert.equal(await ticketViewer(upgrade(CHAT, ticket), SECRET, lookup, NOW + 5 * 60_000), null);
69});
70
71test("a ticket opens no other socket: another kind, or another workspace's", async () => {
72 reset();
73 const { ticket } = await issueTicket(SECRET, { token: "g1t_web", userId: "usr_ada", path: CHAT }, NOW);
74 for (const path of ["/acme/-/library/artifacts/live", "/-/live", "/other/-/chat/live"]) {
75 assert.equal(await openTicket(SECRET, ticket, path, NOW), null, path);
76 assert.equal(await ticketViewer(upgrade(path, ticket), SECRET, lookup, NOW), null, path);
77 }
78});
79
80test("a ticket changed in any way, or sealed with another key, opens nothing", async () => {
81 reset();
82 const { ticket } = await issueTicket(SECRET, { token: "g1t_web", userId: "usr_ada", path: CHAT }, NOW);
83 const body = ticket.slice(4);
84 for (let i = 0; i < body.length; i += 7) {
85 const swapped = body[i] === "A" ? "B" : "A";
86 const tampered = `st1.${body.slice(0, i)}${swapped}${body.slice(i + 1)}`;
87 assert.equal(await openTicket(SECRET, tampered, CHAT, NOW), null, `byte ${i}`);
88 }
89 for (const bad of ["", "st1.", "st1.!!!", `st2.${body}`, body, `st1.${body}x`, `st1.${"A".repeat(3000)}`]) {
90 assert.equal(await openTicket(SECRET, bad, CHAT, NOW), null, bad.slice(0, 20));
91 }
92 assert.equal(await openTicket("another-secret", ticket, CHAT, NOW), null);
93});
94
95test("the token is checked again when the socket opens: deleted, revoked or without the website permission, it opens nothing", async () => {
96 reset();
97 const { ticket } = await issueTicket(SECRET, { token: "g1t_web", userId: "usr_ada", path: CHAT }, NOW);
98 // Deleted, expired or revoked: identity knows it no more.
99 delete table.g1t_web;
100 assert.equal(await ticketViewer(upgrade(CHAT, ticket), SECRET, lookup, NOW), null);
101 // "Use the website as you" turned off since the page loaded.
102 table.g1t_web = { ...ada, token: { token_id: "tok_web", scopes: ["repo:read"], website: false } };
103 assert.equal(await ticketViewer(upgrade(CHAT, ticket), SECRET, lookup, NOW), null);
104 // A token that now names someone else.
105 table.g1t_web = { ...ada, id: "usr_bob", username: "bob" };
106 assert.equal(await ticketViewer(upgrade(CHAT, ticket), SECRET, lookup, NOW), null);
107 // A ticket made for something that is not a token.
108 const odd = await issueTicket(SECRET, { token: "a".repeat(64), userId: "usr_ada", path: CHAT }, NOW);
109 table["a".repeat(64)] = ada;
110 assert.equal(await ticketViewer(upgrade(CHAT, odd.ticket), SECRET, lookup, NOW), null);
111});
112
113test("a ticket is never taken by anything but a socket's upgrade", async () => {
114 reset();
115 const { ticket } = await issueTicket(SECRET, { token: "g1t_web", userId: "usr_ada", path: CHAT }, NOW);
116 // The socket's own address, asked without an upgrade.
117 assert.equal(await ticketViewer(upgrade(CHAT, ticket, {}), SECRET, lookup, NOW), null);
118 assert.equal(await ticketViewer(upgrade(CHAT, ticket, { upgrade: "h2c" }), SECRET, lookup, NOW), null);
119 // Pages, data requests and form posts: the token rules see no token, so the session cookie (or no one) decides.
120 for (const path of ["/acme", "/acme/-/chat", "/acme/-/chat.data", "/settings/tokens"]) {
121 const page = new Request(`https://g1t.sh${path}?ticket=${encodeURIComponent(ticket)}`);
122 assert.deepEqual(await tokenVerdict(page, async () => ada), { kind: "none" }, path);
123 const post = new Request(`https://g1t.sh${path}?ticket=${encodeURIComponent(ticket)}`, { method: "POST", body: new FormData() });
124 assert.deepEqual(await tokenVerdict(post, async () => ada), { kind: "none" }, path);
125 }
126 // An upgrade without a ticket is the session's business, as before.
127 assert.equal(await ticketViewer(upgrade(CHAT, null), SECRET, lookup, NOW), null);
128});
129
130test("a session's page opens its sockets at once and adds no ticket; a token's page asks for one first", async () => {
131 const site = new URL("https://g1t.sh/acme/-/chat");
132 const was = { location: globalThis.location, fetch: globalThis.fetch };
133 Object.defineProperty(globalThis, "location", { value: site, configurable: true });
134 const asked: string[] = [];
135 globalThis.fetch = (async (input: string) => {
136 asked.push(String(input));
137 return Response.json({ ticket: "st1.abc", expires_at: "2026-10-09T12:01:00.000Z" });
138 }) as typeof fetch;
139 try {
140 assert.equal(liveAddress(CHAT, { channel: "ch_1" }, null), "wss://g1t.sh/acme/-/chat/live?channel=ch_1");
141
142 setLiveViaToken(false);
143 const opened: string[] = [];
144 openLive(
145 CHAT,
146 () => ({ channel: "ch_1" }),
147 (address) => opened.push(address),
148 () => false,
149 );
150 assert.deepEqual(opened, ["wss://g1t.sh/acme/-/chat/live?channel=ch_1"], "synchronously, with no ticket");
151 assert.deepEqual(asked, []);
152
153 setLiveViaToken(true);
154 const viaToken = await new Promise<string>((resolve) =>
155 openLive(
156 "/-/live",
157 () => ({ workspace: "acme" }),
158 resolve,
159 () => false,
160 ),
161 );
162 assert.equal(viaToken, "wss://g1t.sh/-/live?workspace=acme&ticket=st1.abc");
163 assert.deepEqual(asked, ["/-/live/ticket?path=%2F-%2Flive"]);
164 // The page's copy of the route and parameter, as the server has them.
165 assert.equal(TICKET_ROUTE, "/-/live/ticket");
166 assert.equal(TICKET_PARAM, "ticket");
167
168 // Closed while the ticket was on its way: nothing opens.
169 let late = false;
170 openLive(
171 CHAT,
172 () => ({}),
173 () => (late = true),
174 () => true,
175 );
176 await new Promise((resolve) => setTimeout(resolve, 10));
177 assert.equal(late, false);
178
179 // A ticket the page's loader minted opens the first socket with no
180 // request; it is used once, and not when its minute is nearly up.
181 asked.length = 0;
182 offerTicket(CHAT, { ticket: "st1.minted", expires_at: new Date(Date.now() + 50_000).toISOString() });
183 const minted: string[] = [];
184 openLive(
185 CHAT,
186 () => ({ channel: "ch_2" }),
187 (address) => minted.push(address),
188 () => false,
189 );
190 assert.deepEqual(minted, ["wss://g1t.sh/acme/-/chat/live?channel=ch_2&ticket=st1.minted"], "synchronously, from the offered ticket");
191 assert.deepEqual(asked, []);
192 const again = await new Promise<string>((resolve) =>
193 openLive(
194 CHAT,
195 () => ({ channel: "ch_2" }),
196 resolve,
197 () => false,
198 ),
199 );
200 assert.equal(again, "wss://g1t.sh/acme/-/chat/live?channel=ch_2&ticket=st1.abc", "the next socket asks, as the offer was used");
201 assert.deepEqual(asked, [`/-/live/ticket?path=${encodeURIComponent(CHAT)}`]);
202 offerTicket(CHAT, { ticket: "st1.stale", expires_at: new Date(Date.now() + 2_000).toISOString() });
203 assert.equal(takeOfferedTicket(CHAT), null, "an offer about to expire is not used");
204 offerTicket(CHAT, null);
205 assert.equal(takeOfferedTicket(CHAT), null);
206 } finally {
207 setLiveViaToken(false);
208 Object.defineProperty(globalThis, "location", { value: was.location, configurable: true });
209 globalThis.fetch = was.fetch;
210 }
211});
212
213test("a ticket for a chat-scoped token opens Chat's socket and the feed, checked again as it opens", async () => {
214 const app: User = { ...ada, token: { token_id: "tok_app", scopes: ["chat:read"] } };
215 const tokens: Record<string, Viewer> = { g1t_app: app };
216 // As the site checks it: identity's answer narrowed by `socketUser` for the socket's path.
217 const bySocket = async (token: string, path: string) => socketUser(tokens[token] ?? null, path);
218 for (const path of [CHAT, "/-/live"]) {
219 const { ticket } = await issueTicket(SECRET, { token: "g1t_app", userId: "usr_ada", path }, NOW);
220 assert.equal((await ticketViewer(upgrade(path, ticket), SECRET, bySocket, NOW))?.id, "usr_ada", path);
221 }
222 // An artifact's socket is the website's alone, even with a genuine ticket.
223 const artifacts = "/acme/-/library/artifacts/live";
224 const { ticket } = await issueTicket(SECRET, { token: "g1t_app", userId: "usr_ada", path: artifacts }, NOW);
225 assert.equal(await ticketViewer(upgrade(artifacts, ticket), SECRET, bySocket, NOW), null);
226 // Chat taken away since the ticket was made: the socket opens nothing.
227 const chat = await issueTicket(SECRET, { token: "g1t_app", userId: "usr_ada", path: CHAT }, NOW);
228 tokens.g1t_app = { ...app, token: { token_id: "tok_app", scopes: ["notifications:read"] } };
229 assert.equal(await ticketViewer(upgrade(CHAT, chat.ticket), SECRET, bySocket, NOW), null);
230});