import { env } from "cloudflare:workers"; import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { DEFAULT_PASSWORD, DEFAULT_REDIRECT_URI, ISSUER, SELF, createOAuthClientAsAdmin, signInForCookie, signUpAdmin, signUpTestUser, type TestCredential, } from "./helpers"; interface LauncherTile { id: string; name: string; url: string; icon: string | null; tint: string | null; } const seedLauncherApp = async (row: { id?: string; name: string; url: string; icon?: string | null; tint?: string | null; enabled?: boolean; }): Promise => { const id = row.id ?? crypto.randomUUID(); const now = Date.now(); await env.DB.prepare( `INSERT INTO launcher_apps (id, name, url, icon, tint, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind(id, row.name, row.url, row.icon ?? null, row.tint ?? null, row.enabled === false ? 0 : 1, now, now) .run(); return id; }; describe("public /api/launcher", () => { const adminCred = { email: "launcher-admin@example.com", password: DEFAULT_PASSWORD, name: "Launcher Admin", } satisfies TestCredential; const userCred = { email: "launcher-user@example.com", password: DEFAULT_PASSWORD, name: "Launcher User", } satisfies TestCredential; let userCookie = ""; let adminCookie = ""; // Sign in once per role; per-test sign-ins burn through RL_AUTH's // 10/min budget per IP and add ~2s of Argon2id work to every case. // Two signups + two signIns run Argon2id four times, well beyond the // default 10s hook timeout. beforeAll(async () => { await signUpAdmin(adminCred); await signUpTestUser(userCred); userCookie = await signInForCookie(userCred.email, userCred.password, "10.41.0.1"); adminCookie = await signInForCookie(adminCred.email, adminCred.password, "10.41.0.2"); }, 30_000); // Each test owns the visible-tile set; clear residue from earlier // suites and earlier cases in this suite. beforeEach(async () => { await env.DB.prepare("DELETE FROM launcher_apps").run(); }); it("requires a session", async () => { const res = await SELF.fetch(`${ISSUER}/api/launcher`); expect(res.status).toBe(401); }); it("returns enabled rows with the new shape", async () => { await seedLauncherApp({ name: "anvil", url: "https://anvil.limic.dev", icon: "Hammer", tint: "sky" }); const res = await SELF.fetch(`${ISSUER}/api/launcher`, { headers: { cookie: userCookie } }); expect(res.status).toBe(200); const tiles = (await res.json()) as LauncherTile[]; expect(tiles).toHaveLength(1); const tile = tiles[0]!; expect(tile.id).toBeTruthy(); expect(tile.name).toBe("anvil"); expect(tile.url).toBe("https://anvil.limic.dev"); expect(tile.icon).toBe("Hammer"); expect(tile.tint).toBe("sky"); // OAuth-shaped fields are not in the response. expect(tile).not.toHaveProperty("client_id"); expect(tile).not.toHaveProperty("client_uri"); expect(tile).not.toHaveProperty("lucide"); }); it("hides disabled rows", async () => { await seedLauncherApp({ name: "anvil", url: "https://anvil.limic.dev", enabled: true }); await seedLauncherApp({ name: "bland", url: "https://bland.limic.dev", enabled: false }); const tiles = (await ( await SELF.fetch(`${ISSUER}/api/launcher`, { headers: { cookie: userCookie } }) ).json()) as LauncherTile[]; expect(tiles.map((t) => t.name)).toEqual(["anvil"]); }); it("ignores OAuth client metadata.launcher and skip_consent", async () => { // Mint an OAuth client with skipConsent=true and a metadata.launcher // blob, but no row in launcher_apps. The launcher must stay empty: // visibility comes from the new table only. const created = await createOAuthClientAsAdmin(adminCookie, { name: "anvil-oauth", redirectUris: [DEFAULT_REDIRECT_URI], skipConsent: true, uri: "https://anvil.limic.dev", }); // The API doesn't accept arbitrary metadata; write the legacy blob // directly so we can verify the launcher ignores it. await env.DB.prepare("UPDATE oauth_clients SET metadata = ? WHERE client_id = ?") .bind(JSON.stringify({ launcher: { lucide: "Hammer", tint: "sky" } }), created.client_id) .run(); const tiles = (await ( await SELF.fetch(`${ISSUER}/api/launcher`, { headers: { cookie: userCookie } }) ).json()) as LauncherTile[]; expect(tiles).toEqual([]); }); it("defensively filters unsafe URLs at read time", async () => { // A row with an unsafe URL should never have been written via the // admin API (write-time validation rejects it), but a manually // edited DB row must not surface a `javascript:` href to the page. await seedLauncherApp({ name: "xss", url: "javascript:alert(1)" }); await seedLauncherApp({ name: "anvil", url: "https://anvil.limic.dev" }); const tiles = (await ( await SELF.fetch(`${ISSUER}/api/launcher`, { headers: { cookie: userCookie } }) ).json()) as LauncherTile[]; expect(tiles.map((t) => t.name)).toEqual(["anvil"]); }); it("coerces unknown icon/tint to null instead of dropping the row", async () => { await seedLauncherApp({ name: "anvil", url: "https://anvil.limic.dev", icon: "NotAnIcon", tint: "neon" }); const tiles = (await ( await SELF.fetch(`${ISSUER}/api/launcher`, { headers: { cookie: userCookie } }) ).json()) as LauncherTile[]; expect(tiles).toHaveLength(1); expect(tiles[0]!.name).toBe("anvil"); expect(tiles[0]!.icon).toBeNull(); expect(tiles[0]!.tint).toBeNull(); }); it("sorts by name", async () => { await seedLauncherApp({ name: "ccccocc", url: "https://ccccocc.limic.dev" }); await seedLauncherApp({ name: "anvil", url: "https://anvil.limic.dev" }); await seedLauncherApp({ name: "bland", url: "https://bland.limic.dev" }); const tiles = (await ( await SELF.fetch(`${ISSUER}/api/launcher`, { headers: { cookie: userCookie } }) ).json()) as LauncherTile[]; expect(tiles.map((t) => t.name)).toEqual(["anvil", "bland", "ccccocc"]); }); });