kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 389 lines 11 kB view raw
1import { createHash, randomUUID } from "node:crypto"; 2import { eq } from "drizzle-orm"; 3import { beforeEach, describe, expect, it } from "vitest"; 4import db, { schema } from "../../apps/api/src/database"; 5import { createApp } from "../../apps/api/src/index"; 6import { resetTestDatabase } from "./helpers/database"; 7import { createWorkspaceMember } from "./helpers/fixtures"; 8 9const origin = "http://localhost:5173"; 10 11function mergeCookieJar(cookieJar: string, res: Response): string { 12 const incoming = res.headers.getSetCookie?.() ?? []; 13 if (incoming.length === 0) { 14 return cookieJar; 15 } 16 const pairs = incoming.map((c) => c.split(";")[0]).filter(Boolean); 17 const prefix = cookieJar ? `${cookieJar}; ` : ""; 18 return `${prefix}${pairs.join("; ")}`; 19} 20 21async function hashApiKeyForTest(key: string): Promise<string> { 22 const hash = createHash("sha256").update(key).digest(); 23 return hash 24 .toString("base64") 25 .replace(/\+/g, "-") 26 .replace(/\//g, "_") 27 .replace(/=/g, ""); 28} 29 30async function signUpAndGetCookie( 31 app: ReturnType<typeof createApp>["app"], 32 email: string, 33 password: string, 34): Promise<string> { 35 let jar = "csrf=1"; 36 const signUp = await app.request("/api/auth/sign-up/email", { 37 method: "POST", 38 headers: { 39 "content-type": "application/json", 40 Origin: origin, 41 Cookie: jar, 42 }, 43 body: JSON.stringify({ 44 name: "Device flow user", 45 email, 46 password, 47 }), 48 }); 49 50 expect(signUp.status).toBe(200); 51 jar = mergeCookieJar(jar, signUp); 52 53 const signIn = await app.request("/api/auth/sign-in/email", { 54 method: "POST", 55 headers: { 56 "content-type": "application/json", 57 Origin: origin, 58 Cookie: jar, 59 }, 60 body: JSON.stringify({ email, password }), 61 }); 62 63 expect(signIn.status).toBe(200); 64 jar = mergeCookieJar(jar, signIn); 65 return jar; 66} 67 68describe("API integration: device authorization (RFC 8628)", () => { 69 beforeEach(async () => { 70 await resetTestDatabase(); 71 }); 72 73 it("returns device and user codes for an allowed client_id", async () => { 74 const { app } = createApp(); 75 76 const res = await app.request("/api/auth/device/code", { 77 method: "POST", 78 headers: { 79 "content-type": "application/json", 80 Origin: origin, 81 }, 82 body: JSON.stringify({ client_id: "kaneo-cli" }), 83 }); 84 85 expect(res.status).toBe(200); 86 const body = (await res.json()) as Record<string, unknown>; 87 expect(body.device_code).toEqual(expect.any(String)); 88 expect(body.user_code).toEqual(expect.any(String)); 89 expect(body.verification_uri).toEqual(expect.any(String)); 90 expect(body.interval).toEqual(expect.any(Number)); 91 expect(body.expires_in).toEqual(expect.any(Number)); 92 }); 93 94 it("rejects disallowed client_id", async () => { 95 const { app } = createApp(); 96 97 const res = await app.request("/api/auth/device/code", { 98 method: "POST", 99 headers: { 100 "content-type": "application/json", 101 Origin: origin, 102 }, 103 body: JSON.stringify({ client_id: "unknown-client" }), 104 }); 105 106 expect(res.status).toBe(400); 107 const body = (await res.json()) as { error?: string }; 108 expect(body.error).toBe("invalid_client"); 109 }); 110 111 it("returns authorization_pending before approval", async () => { 112 const { app } = createApp(); 113 114 const codeRes = await app.request("/api/auth/device/code", { 115 method: "POST", 116 headers: { 117 "content-type": "application/json", 118 Origin: origin, 119 }, 120 body: JSON.stringify({ client_id: "kaneo-cli" }), 121 }); 122 const { device_code } = (await codeRes.json()) as { device_code: string }; 123 124 const tokenRes = await app.request("/api/auth/device/token", { 125 method: "POST", 126 headers: { 127 "content-type": "application/json", 128 Origin: origin, 129 }, 130 body: JSON.stringify({ 131 grant_type: "urn:ietf:params:oauth:grant-type:device_code", 132 device_code, 133 client_id: "kaneo-cli", 134 }), 135 }); 136 137 expect(tokenRes.status).toBe(400); 138 const body = (await tokenRes.json()) as { error: string }; 139 expect(body.error).toBe("authorization_pending"); 140 }); 141 142 it("issues an access token after approval and allows API access with Bearer", async () => { 143 const email = `device-${randomUUID()}@example.com`; 144 const password = "device-flow-password-12345"; 145 146 const { app } = createApp(); 147 const cookieJar = await signUpAndGetCookie(app, email, password); 148 149 const sessionRes = await app.request("/api/auth/get-session", { 150 headers: { 151 Cookie: cookieJar, 152 Origin: origin, 153 }, 154 }); 155 expect(sessionRes.status).toBe(200); 156 const sessionJson = (await sessionRes.json()) as { 157 user?: { id: string }; 158 }; 159 const userId = sessionJson.user?.id; 160 if (!userId) { 161 throw new Error("expected session user id after sign-in"); 162 } 163 164 const workspaceId = `ws-${randomUUID()}`; 165 await db.insert(schema.workspaceTable).values({ 166 id: workspaceId, 167 name: "Device test workspace", 168 slug: `slug-${randomUUID()}`, 169 createdAt: new Date(), 170 }); 171 await db.insert(schema.workspaceUserTable).values({ 172 workspaceId, 173 userId, 174 role: "owner", 175 joinedAt: new Date(), 176 }); 177 178 const codeRes = await app.request("/api/auth/device/code", { 179 method: "POST", 180 headers: { 181 "content-type": "application/json", 182 Origin: origin, 183 }, 184 body: JSON.stringify({ client_id: "kaneo-cli" }), 185 }); 186 expect(codeRes.status).toBe(200); 187 const devicePayload = (await codeRes.json()) as { 188 device_code: string; 189 user_code: string; 190 interval: number; 191 }; 192 193 const approveRes = await app.request("/api/auth/device/approve", { 194 method: "POST", 195 headers: { 196 "content-type": "application/json", 197 Origin: origin, 198 Cookie: cookieJar, 199 }, 200 body: JSON.stringify({ 201 userCode: devicePayload.user_code, 202 }), 203 }); 204 expect(approveRes.status).toBe(200); 205 206 let accessToken: string | undefined; 207 const maxAttempts = 40; 208 for (let i = 0; i < maxAttempts; i++) { 209 if (i > 0) { 210 await new Promise((r) => 211 setTimeout(r, devicePayload.interval * 1000 + 50), 212 ); 213 } 214 const tokenRes = await app.request("/api/auth/device/token", { 215 method: "POST", 216 headers: { 217 "content-type": "application/json", 218 Origin: origin, 219 }, 220 body: JSON.stringify({ 221 grant_type: "urn:ietf:params:oauth:grant-type:device_code", 222 device_code: devicePayload.device_code, 223 client_id: "kaneo-cli", 224 }), 225 }); 226 if (tokenRes.status === 200) { 227 const t = (await tokenRes.json()) as { access_token?: string }; 228 accessToken = t.access_token; 229 break; 230 } 231 const err = (await tokenRes.json()) as { error: string }; 232 if (err.error !== "authorization_pending" && err.error !== "slow_down") { 233 throw new Error(`Unexpected token error: ${err.error}`); 234 } 235 } 236 237 expect(accessToken).toBeTruthy(); 238 239 const organizationsRes = await app.request("/api/auth/organization/list", { 240 headers: { 241 Authorization: `Bearer ${accessToken}`, 242 }, 243 }); 244 expect(organizationsRes.status).toBe(200); 245 const organizations = (await organizationsRes.json()) as unknown[]; 246 expect(Array.isArray(organizations)).toBe(true); 247 248 const projectsRes = await app.request( 249 `/api/project?workspaceId=${encodeURIComponent(workspaceId)}`, 250 { 251 headers: { 252 Authorization: `Bearer ${accessToken}`, 253 }, 254 }, 255 ); 256 expect(projectsRes.status).toBe(200); 257 const projects = (await projectsRes.json()) as unknown[]; 258 expect(Array.isArray(projects)).toBe(true); 259 }); 260 261 it("still authenticates with a valid API key Bearer", async () => { 262 const member = await createWorkspaceMember(); 263 264 const rawKey = `kaneo_test_${randomUUID()}`; 265 const hashed = await hashApiKeyForTest(rawKey); 266 const now = new Date(); 267 268 await db.insert(schema.apikeyTable).values({ 269 referenceId: member.user.id, 270 userId: member.user.id, 271 key: hashed, 272 name: "integration device test", 273 start: rawKey.slice(0, 12), 274 prefix: "kaneo", 275 createdAt: now, 276 updatedAt: now, 277 }); 278 279 const { app } = createApp(); 280 281 const res = await app.request( 282 `/api/project?workspaceId=${encodeURIComponent(member.workspace.id)}`, 283 { 284 headers: { 285 Authorization: `Bearer ${rawKey}`, 286 }, 287 }, 288 ); 289 290 expect(res.status).toBe(200); 291 292 const rows = await db 293 .select() 294 .from(schema.apikeyTable) 295 .where(eq(schema.apikeyTable.key, hashed)); 296 expect(rows.length).toBe(1); 297 }); 298 299 it("accepts a created API key Bearer on auth routes", async () => { 300 const member = await createWorkspaceMember(); 301 const rawKey = 302 randomUUID().replace(/-/g, "") + randomUUID().replace(/-/g, ""); 303 const hashed = await hashApiKeyForTest(rawKey); 304 const now = new Date(); 305 306 await db.insert(schema.apikeyTable).values({ 307 referenceId: member.user.id, 308 userId: member.user.id, 309 key: hashed, 310 name: "auth route api key", 311 start: rawKey.slice(0, 12), 312 createdAt: now, 313 updatedAt: now, 314 }); 315 316 const { app } = createApp(); 317 318 const authRouteRes = await app.request("/api/auth/organization/list", { 319 headers: { 320 Authorization: `Bearer ${rawKey}`, 321 }, 322 }); 323 324 expect(authRouteRes.status).toBe(200); 325 }); 326 327 it("rejects an invalid Bearer token even when a valid session cookie is present", async () => { 328 const email = `device-bearer-${randomUUID()}@example.com`; 329 const password = "device-flow-password-12345"; 330 331 const { app } = createApp(); 332 const cookieJar = await signUpAndGetCookie(app, email, password); 333 334 const sessionRes = await app.request("/api/auth/get-session", { 335 headers: { 336 Cookie: cookieJar, 337 Origin: origin, 338 }, 339 }); 340 expect(sessionRes.status).toBe(200); 341 const sessionJson = (await sessionRes.json()) as { 342 user?: { id: string }; 343 }; 344 const userId = sessionJson.user?.id; 345 if (!userId) { 346 throw new Error("expected session user id after sign-in"); 347 } 348 349 const workspaceId = `ws-${randomUUID()}`; 350 await db.insert(schema.workspaceTable).values({ 351 id: workspaceId, 352 name: "Bearer fallback workspace", 353 slug: `slug-${randomUUID()}`, 354 createdAt: new Date(), 355 }); 356 await db.insert(schema.workspaceUserTable).values({ 357 workspaceId, 358 userId, 359 role: "owner", 360 joinedAt: new Date(), 361 }); 362 363 const res = await app.request( 364 `/api/project?workspaceId=${encodeURIComponent(workspaceId)}`, 365 { 366 headers: { 367 Authorization: "Bearer definitely-not-a-real-token", 368 Cookie: cookieJar, 369 Origin: origin, 370 }, 371 }, 372 ); 373 374 expect(res.status).toBe(401); 375 376 const lowercaseSchemeRes = await app.request( 377 `/api/project?workspaceId=${encodeURIComponent(workspaceId)}`, 378 { 379 headers: { 380 authorization: "bearer definitely-not-a-real-token", 381 Cookie: cookieJar, 382 Origin: origin, 383 }, 384 }, 385 ); 386 387 expect(lowercaseSchemeRes.status).toBe(401); 388 }); 389});