File
Blob: tests/worker/admin-launcher-apps.test.ts
| 1 | import { env } from "cloudflare:workers"; |
| 2 | import { beforeAll, describe, expect, it } from "vitest"; |
| 3 | |
| 4 | import { |
| 5 | DEFAULT_PASSWORD, |
| 6 | ISSUER, |
| 7 | SELF, |
| 8 | signInForCookie, |
| 9 | signUpAdmin, |
| 10 | signUpTestUser, |
| 11 | testHeaders, |
| 12 | type JsonErrorBody, |
| 13 | type TestCredential, |
| 14 | } from "./helpers"; |
| 15 | |
| 16 | interface LauncherAppApiShape { |
| 17 | id: string; |
| 18 | name: string; |
| 19 | url: string; |
| 20 | icon: string | null; |
| 21 | tint: string | null; |
| 22 | enabled: boolean; |
| 23 | } |
| 24 | |
| 25 | describe("admin launcher-apps CRUD", () => { |
| 26 | const adminCred = { |
| 27 | email: "admin-launcher@example.com", |
| 28 | password: DEFAULT_PASSWORD, |
| 29 | name: "Admin Launcher Tester", |
| 30 | } satisfies TestCredential; |
| 31 | const userCred = { |
| 32 | email: "user-launcher@example.com", |
| 33 | password: DEFAULT_PASSWORD, |
| 34 | name: "Regular Launcher User", |
| 35 | } satisfies TestCredential; |
| 36 | |
| 37 | beforeAll(async () => { |
| 38 | await signUpAdmin(adminCred); |
| 39 | await signUpTestUser(userCred); |
| 40 | }); |
| 41 | |
| 42 | it("rejects unauthenticated requests with 401 on every method", async () => { |
| 43 | const get = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps`); |
| 44 | expect(get.status).toBe(401); |
| 45 | const post = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps`, { |
| 46 | method: "POST", |
| 47 | headers: testHeaders(), |
| 48 | body: JSON.stringify({ name: "anvil", url: "https://anvil.limic.dev" }), |
| 49 | }); |
| 50 | expect(post.status).toBe(401); |
| 51 | const patch = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps/abc`, { |
| 52 | method: "PATCH", |
| 53 | headers: testHeaders(), |
| 54 | body: JSON.stringify({ name: "anvil" }), |
| 55 | }); |
| 56 | expect(patch.status).toBe(401); |
| 57 | const del = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps/abc`, { |
| 58 | method: "DELETE", |
| 59 | headers: testHeaders(), |
| 60 | }); |
| 61 | expect(del.status).toBe(401); |
| 62 | }); |
| 63 | |
| 64 | it("rejects non-admin users with 403", async () => { |
| 65 | const cookie = await signInForCookie(userCred.email, userCred.password, "10.40.0.1"); |
| 66 | const list = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps`, { headers: { cookie } }); |
| 67 | expect(list.status).toBe(403); |
| 68 | const create = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps`, { |
| 69 | method: "POST", |
| 70 | headers: testHeaders({ cookie }), |
| 71 | body: JSON.stringify({ name: "anvil", url: "https://anvil.limic.dev" }), |
| 72 | }); |
| 73 | expect(create.status).toBe(403); |
| 74 | }); |
| 75 | |
| 76 | it("creates, lists (including disabled), updates, and deletes a launcher app", async () => { |
| 77 | const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.40.0.1"); |
| 78 | |
| 79 | const createRes = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps`, { |
| 80 | method: "POST", |
| 81 | headers: testHeaders({ cookie }), |
| 82 | body: JSON.stringify({ |
| 83 | name: "anvil", |
| 84 | url: "https://anvil.limic.dev", |
| 85 | icon: "Hammer", |
| 86 | tint: "sky", |
| 87 | }), |
| 88 | }); |
| 89 | expect(createRes.status).toBe(201); |
| 90 | const created = (await createRes.json()) as LauncherAppApiShape; |
| 91 | expect(created.id).toBeTruthy(); |
| 92 | expect(created.enabled).toBe(true); |
| 93 | expect(created.icon).toBe("Hammer"); |
| 94 | expect(created.tint).toBe("sky"); |
| 95 | |
| 96 | const disableRes = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps/${encodeURIComponent(created.id)}`, { |
| 97 | method: "PATCH", |
| 98 | headers: testHeaders({ cookie }), |
| 99 | body: JSON.stringify({ enabled: false }), |
| 100 | }); |
| 101 | expect(disableRes.status).toBe(200); |
| 102 | const disabled = (await disableRes.json()) as LauncherAppApiShape; |
| 103 | expect(disabled.enabled).toBe(false); |
| 104 | |
| 105 | const listRes = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps`, { headers: { cookie } }); |
| 106 | expect(listRes.status).toBe(200); |
| 107 | const listed = (await listRes.json()) as LauncherAppApiShape[]; |
| 108 | const ours = listed.find((row) => row.id === created.id); |
| 109 | expect(ours).toBeDefined(); |
| 110 | expect(ours?.enabled).toBe(false); |
| 111 | |
| 112 | const renameRes = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps/${encodeURIComponent(created.id)}`, { |
| 113 | method: "PATCH", |
| 114 | headers: testHeaders({ cookie }), |
| 115 | body: JSON.stringify({ name: "anvil-renamed", icon: null, enabled: true }), |
| 116 | }); |
| 117 | expect(renameRes.status).toBe(200); |
| 118 | const renamed = (await renameRes.json()) as LauncherAppApiShape; |
| 119 | expect(renamed.name).toBe("anvil-renamed"); |
| 120 | expect(renamed.icon).toBeNull(); |
| 121 | expect(renamed.enabled).toBe(true); |
| 122 | // tint not in PATCH body -> unchanged |
| 123 | expect(renamed.tint).toBe("sky"); |
| 124 | |
| 125 | const delRes = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps/${encodeURIComponent(created.id)}`, { |
| 126 | method: "DELETE", |
| 127 | headers: testHeaders({ cookie }), |
| 128 | }); |
| 129 | expect(delRes.status).toBe(204); |
| 130 | |
| 131 | const listAfter = (await ( |
| 132 | await SELF.fetch(`${ISSUER}/api/admin/launcher-apps`, { headers: { cookie } }) |
| 133 | ).json()) as LauncherAppApiShape[]; |
| 134 | expect(listAfter.some((r) => r.id === created.id)).toBe(false); |
| 135 | }); |
| 136 | |
| 137 | it("accepts localhost subdomain URLs with ports", async () => { |
| 138 | const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.40.0.2"); |
| 139 | const createRes = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps`, { |
| 140 | method: "POST", |
| 141 | headers: testHeaders({ cookie }), |
| 142 | body: JSON.stringify({ name: "local acme", url: "http://acme.localhost:5176" }), |
| 143 | }); |
| 144 | expect(createRes.status).toBe(201); |
| 145 | const created = (await createRes.json()) as LauncherAppApiShape; |
| 146 | expect(created.url).toBe("http://acme.localhost:5176"); |
| 147 | |
| 148 | const patchRes = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps/${encodeURIComponent(created.id)}`, { |
| 149 | method: "PATCH", |
| 150 | headers: testHeaders({ cookie }), |
| 151 | body: JSON.stringify({ url: "https://acme.localhost:5176" }), |
| 152 | }); |
| 153 | expect(patchRes.status).toBe(200); |
| 154 | const patched = (await patchRes.json()) as LauncherAppApiShape; |
| 155 | expect(patched.url).toBe("https://acme.localhost:5176"); |
| 156 | |
| 157 | await env.DB.prepare("DELETE FROM launcher_apps WHERE id = ?").bind(created.id).run(); |
| 158 | }); |
| 159 | |
| 160 | it("rejects unsafe URL schemes with 400", async () => { |
| 161 | const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.40.0.1"); |
| 162 | const res = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps`, { |
| 163 | method: "POST", |
| 164 | headers: testHeaders({ cookie }), |
| 165 | body: JSON.stringify({ name: "xss", url: "javascript:alert(1)" }), |
| 166 | }); |
| 167 | expect(res.status).toBe(400); |
| 168 | expect(((await res.json()) as JsonErrorBody).error).toBe("invalid_url"); |
| 169 | }); |
| 170 | |
| 171 | it("rejects unknown icon and tint names with 400", async () => { |
| 172 | const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.40.0.1"); |
| 173 | |
| 174 | const badIcon = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps`, { |
| 175 | method: "POST", |
| 176 | headers: testHeaders({ cookie }), |
| 177 | body: JSON.stringify({ name: "bad-icon", url: "https://example.com", icon: "NotAnIcon" }), |
| 178 | }); |
| 179 | expect(badIcon.status).toBe(400); |
| 180 | expect(((await badIcon.json()) as JsonErrorBody).error).toBe("invalid_icon"); |
| 181 | |
| 182 | const badTint = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps`, { |
| 183 | method: "POST", |
| 184 | headers: testHeaders({ cookie }), |
| 185 | body: JSON.stringify({ name: "bad-tint", url: "https://example.com", tint: "neon" }), |
| 186 | }); |
| 187 | expect(badTint.status).toBe(400); |
| 188 | expect(((await badTint.json()) as JsonErrorBody).error).toBe("invalid_tint"); |
| 189 | }); |
| 190 | |
| 191 | it("rejects non-boolean enabled values with 400", async () => { |
| 192 | const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.40.0.1"); |
| 193 | |
| 194 | const badCreate = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps`, { |
| 195 | method: "POST", |
| 196 | headers: testHeaders({ cookie }), |
| 197 | body: JSON.stringify({ name: "bad-enabled", url: "https://example.com", enabled: "false" }), |
| 198 | }); |
| 199 | expect(badCreate.status).toBe(400); |
| 200 | expect(((await badCreate.json()) as JsonErrorBody).error).toBe("invalid_enabled"); |
| 201 | |
| 202 | // Set up a valid row, then try to PATCH it with a non-boolean |
| 203 | // `enabled`. The truthy-string regression would coerce "false" to |
| 204 | // true; the strict check rejects with 400 instead. |
| 205 | const okCreate = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps`, { |
| 206 | method: "POST", |
| 207 | headers: testHeaders({ cookie }), |
| 208 | body: JSON.stringify({ name: "patch-bad-enabled", url: "https://example.com" }), |
| 209 | }); |
| 210 | expect(okCreate.status).toBe(201); |
| 211 | const created = (await okCreate.json()) as LauncherAppApiShape; |
| 212 | |
| 213 | const badPatch = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps/${encodeURIComponent(created.id)}`, { |
| 214 | method: "PATCH", |
| 215 | headers: testHeaders({ cookie }), |
| 216 | body: JSON.stringify({ enabled: "false" }), |
| 217 | }); |
| 218 | expect(badPatch.status).toBe(400); |
| 219 | expect(((await badPatch.json()) as JsonErrorBody).error).toBe("invalid_enabled"); |
| 220 | |
| 221 | // cleanup |
| 222 | await env.DB.prepare("DELETE FROM launcher_apps WHERE id = ?").bind(created.id).run(); |
| 223 | }); |
| 224 | |
| 225 | it("rejects empty PATCH bodies with 400", async () => { |
| 226 | const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.40.0.1"); |
| 227 | const createRes = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps`, { |
| 228 | method: "POST", |
| 229 | headers: testHeaders({ cookie }), |
| 230 | body: JSON.stringify({ name: "empty-patch", url: "https://example.com" }), |
| 231 | }); |
| 232 | expect(createRes.status).toBe(201); |
| 233 | const created = (await createRes.json()) as LauncherAppApiShape; |
| 234 | |
| 235 | const res = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps/${encodeURIComponent(created.id)}`, { |
| 236 | method: "PATCH", |
| 237 | headers: testHeaders({ cookie }), |
| 238 | body: JSON.stringify({}), |
| 239 | }); |
| 240 | expect(res.status).toBe(400); |
| 241 | expect(((await res.json()) as JsonErrorBody).error).toBe("invalid_body"); |
| 242 | |
| 243 | // cleanup |
| 244 | await env.DB.prepare("DELETE FROM launcher_apps WHERE id = ?").bind(created.id).run(); |
| 245 | }); |
| 246 | |
| 247 | it("returns 404 when updating a non-existent launcher app", async () => { |
| 248 | const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.40.0.1"); |
| 249 | const res = await SELF.fetch(`${ISSUER}/api/admin/launcher-apps/nope-not-real`, { |
| 250 | method: "PATCH", |
| 251 | headers: testHeaders({ cookie }), |
| 252 | body: JSON.stringify({ name: "ghost" }), |
| 253 | }); |
| 254 | expect(res.status).toBe(404); |
| 255 | expect(((await res.json()) as JsonErrorBody).error).toBe("not_found"); |
| 256 | }); |
| 257 | }); |