File
Blob: tests/worker/launcher.test.ts
| 1 | import { env } from "cloudflare:workers"; |
| 2 | import { beforeAll, beforeEach, describe, expect, it } from "vitest"; |
| 3 | |
| 4 | import { |
| 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 | |
| 16 | interface LauncherTile { |
| 17 | id: string; |
| 18 | name: string; |
| 19 | url: string; |
| 20 | icon: string | null; |
| 21 | tint: string | null; |
| 22 | } |
| 23 | |
| 24 | const 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 | |
| 43 | describe("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 | }); |