Skip to content
File

Blob: tests/worker/launcher.test.ts

typescript162 lines
1import { env } from "cloudflare:workers";
2import { beforeAll, beforeEach, describe, expect, it } from "vitest";
3 
4import {
5 DEFAULT_PASSWORD,
6 DEFAULT_REDIRECT_URI,
7 ISSUER,
8 SELF,
9 createOAuthClientAsAdmin,
10 signInForCookie,
11 signUpAdmin,
12 signUpTestUser,
13 type TestCredential,
14} from "./helpers";
15 
16interface LauncherTile {
17 id: string;
18 name: string;
19 url: string;
20 icon: string | null;
21 tint: string | null;
22}
23 
24const seedLauncherApp = async (row: {
25 id?: string;
26 name: string;
27 url: string;
28 icon?: string | null;
29 tint?: string | null;
30 enabled?: boolean;
31}): Promise<string> => {
32 const id = row.id ?? crypto.randomUUID();
33 const now = Date.now();
34 await env.DB.prepare(
35 `INSERT INTO launcher_apps (id, name, url, icon, tint, enabled, created_at, updated_at)
36 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
37 )
38 .bind(id, row.name, row.url, row.icon ?? null, row.tint ?? null, row.enabled === false ? 0 : 1, now, now)
39 .run();
40 return id;
41};
42 
43describe("public /api/launcher", () => {
44 const adminCred = {
45 email: "launcher-admin@example.com",
46 password: DEFAULT_PASSWORD,
47 name: "Launcher Admin",
48 } satisfies TestCredential;
49 const userCred = {
50 email: "launcher-user@example.com",
51 password: DEFAULT_PASSWORD,
52 name: "Launcher User",
53 } satisfies TestCredential;
54 
55 let userCookie = "";
56 let adminCookie = "";
57 
58 // Sign in once per role; per-test sign-ins burn through RL_AUTH's
59 // 10/min budget per IP and add ~2s of Argon2id work to every case.
60 // Two signups + two signIns run Argon2id four times, well beyond the
61 // default 10s hook timeout.
62 beforeAll(async () => {
63 await signUpAdmin(adminCred);
64 await signUpTestUser(userCred);
65 userCookie = await signInForCookie(userCred.email, userCred.password, "10.41.0.1");
66 adminCookie = await signInForCookie(adminCred.email, adminCred.password, "10.41.0.2");
67 }, 30_000);
68 
69 // Each test owns the visible-tile set; clear residue from earlier
70 // suites and earlier cases in this suite.
71 beforeEach(async () => {
72 await env.DB.prepare("DELETE FROM launcher_apps").run();
73 });
74 
75 it("requires a session", async () => {
76 const res = await SELF.fetch(`${ISSUER}/api/launcher`);
77 expect(res.status).toBe(401);
78 });
79 
80 it("returns enabled rows with the new shape", async () => {
81 await seedLauncherApp({ name: "anvil", url: "https://anvil.limic.dev", icon: "Hammer", tint: "sky" });
82 const res = await SELF.fetch(`${ISSUER}/api/launcher`, { headers: { cookie: userCookie } });
83 expect(res.status).toBe(200);
84 const tiles = (await res.json()) as LauncherTile[];
85 expect(tiles).toHaveLength(1);
86 const tile = tiles[0]!;
87 expect(tile.id).toBeTruthy();
88 expect(tile.name).toBe("anvil");
89 expect(tile.url).toBe("https://anvil.limic.dev");
90 expect(tile.icon).toBe("Hammer");
91 expect(tile.tint).toBe("sky");
92 // OAuth-shaped fields are not in the response.
93 expect(tile).not.toHaveProperty("client_id");
94 expect(tile).not.toHaveProperty("client_uri");
95 expect(tile).not.toHaveProperty("lucide");
96 });
97 
98 it("hides disabled rows", async () => {
99 await seedLauncherApp({ name: "anvil", url: "https://anvil.limic.dev", enabled: true });
100 await seedLauncherApp({ name: "bland", url: "https://bland.limic.dev", enabled: false });
101 const tiles = (await (
102 await SELF.fetch(`${ISSUER}/api/launcher`, { headers: { cookie: userCookie } })
103 ).json()) as LauncherTile[];
104 expect(tiles.map((t) => t.name)).toEqual(["anvil"]);
105 });
106 
107 it("ignores OAuth client metadata.launcher and skip_consent", async () => {
108 // Mint an OAuth client with skipConsent=true and a metadata.launcher
109 // blob, but no row in launcher_apps. The launcher must stay empty:
110 // visibility comes from the new table only.
111 const created = await createOAuthClientAsAdmin(adminCookie, {
112 name: "anvil-oauth",
113 redirectUris: [DEFAULT_REDIRECT_URI],
114 skipConsent: true,
115 uri: "https://anvil.limic.dev",
116 });
117 // The API doesn't accept arbitrary metadata; write the legacy blob
118 // directly so we can verify the launcher ignores it.
119 await env.DB.prepare("UPDATE oauth_clients SET metadata = ? WHERE client_id = ?")
120 .bind(JSON.stringify({ launcher: { lucide: "Hammer", tint: "sky" } }), created.client_id)
121 .run();
122 
123 const tiles = (await (
124 await SELF.fetch(`${ISSUER}/api/launcher`, { headers: { cookie: userCookie } })
125 ).json()) as LauncherTile[];
126 expect(tiles).toEqual([]);
127 });
128 
129 it("defensively filters unsafe URLs at read time", async () => {
130 // A row with an unsafe URL should never have been written via the
131 // admin API (write-time validation rejects it), but a manually
132 // edited DB row must not surface a `javascript:` href to the page.
133 await seedLauncherApp({ name: "xss", url: "javascript:alert(1)" });
134 await seedLauncherApp({ name: "anvil", url: "https://anvil.limic.dev" });
135 const tiles = (await (
136 await SELF.fetch(`${ISSUER}/api/launcher`, { headers: { cookie: userCookie } })
137 ).json()) as LauncherTile[];
138 expect(tiles.map((t) => t.name)).toEqual(["anvil"]);
139 });
140 
141 it("coerces unknown icon/tint to null instead of dropping the row", async () => {
142 await seedLauncherApp({ name: "anvil", url: "https://anvil.limic.dev", icon: "NotAnIcon", tint: "neon" });
143 const tiles = (await (
144 await SELF.fetch(`${ISSUER}/api/launcher`, { headers: { cookie: userCookie } })
145 ).json()) as LauncherTile[];
146 expect(tiles).toHaveLength(1);
147 expect(tiles[0]!.name).toBe("anvil");
148 expect(tiles[0]!.icon).toBeNull();
149 expect(tiles[0]!.tint).toBeNull();
150 });
151 
152 it("sorts by name", async () => {
153 await seedLauncherApp({ name: "ccccocc", url: "https://ccccocc.limic.dev" });
154 await seedLauncherApp({ name: "anvil", url: "https://anvil.limic.dev" });
155 await seedLauncherApp({ name: "bland", url: "https://bland.limic.dev" });
156 const tiles = (await (
157 await SELF.fetch(`${ISSUER}/api/launcher`, { headers: { cookie: userCookie } })
158 ).json()) as LauncherTile[];
159 expect(tiles.map((t) => t.name)).toEqual(["anvil", "bland", "ccccocc"]);
160 });
161});