Skip to content
File

Blob: tests/worker/admin-launcher-apps.test.ts

typescript258 lines
1import { env } from "cloudflare:workers";
2import { beforeAll, describe, expect, it } from "vitest";
3 
4import {
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 
16interface LauncherAppApiShape {
17 id: string;
18 name: string;
19 url: string;
20 icon: string | null;
21 tint: string | null;
22 enabled: boolean;
23}
24 
25describe("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});