import { load } from "$std/dotenv/mod.ts"; import { assert, assertEquals } from "$std/assert/mod.ts"; import { and, eq } from "drizzle-orm"; import { db } from "../../db/db.ts"; import { stateKeys, users, userTokens } from "../../db/schema.ts"; import { kvStore } from "../../modules/KvCacheStore.ts"; import { handler as authCallbackHandler } from "../../routes/auth/callback.ts"; import type { User } from "../../services/userService.ts"; await load({ export: true, allowEmptyValues: true }); const REQUIRED_ENV_KEYS = [ "POSTGRES_OPTIONS", ] as const; const CALLBACK_ENV_KEYS = [ "TOKEN_URL", "CLIENT_ID", "CLIENT_SECRET", "REDIRECT_HOME", ] as const; function encodeBase64Url(value: string): string { return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace( /=+$/g, "", ); } function buildUnsignedJwt(payload: Record): string { const header = { alg: "RS256", typ: "JWT" }; return [ encodeBase64Url(JSON.stringify(header)), encodeBase64Url(JSON.stringify(payload)), encodeBase64Url("signature"), ].join("."); } async function cleanupRecords(userId: string, stateId: string): Promise { await db.delete(userTokens).where( and(eq(userTokens.user_id, userId), eq(userTokens.state_id, stateId)), ); await db.delete(stateKeys).where(eq(stateKeys.state_id, stateId)); await db.delete(users).where(eq(users.id, userId)); await kvStore.delete(["users", userId]); await kvStore.delete(["user_tokens", userId]); } Deno.test("auth callback upserts SQL user before storing tokens for stale KV users", async () => { for (const key of REQUIRED_ENV_KEYS) { assert( Deno.env.get(key), `${key} must be set for auth callback DB integration test`, ); } const userId = `oauth-regression-${crypto.randomUUID()}`; const stateId = crypto.randomUUID(); const accessToken = "test-access-token"; const refreshToken = "test-refresh-token"; const envBackup = new Map(); for (const key of CALLBACK_ENV_KEYS) { envBackup.set(key, Deno.env.get(key)); } const originalFetch = globalThis.fetch; const originalKvGet = kvStore.get.bind(kvStore); try { Deno.env.set("TOKEN_URL", "https://oauth.mock/token"); Deno.env.set("CLIENT_ID", "test-client-id"); Deno.env.set("CLIENT_SECRET", "test-client-secret"); Deno.env.set("REDIRECT_HOME", "http://localhost:8080"); const staleCachedUser: User = { id: userId, name: "Stale KV User", email: "stale.kv.user@example.com", phone: "", address_1: "", address_2: "", city: "", state: "", zip: "", country: "", user_type: "tenant", onboarded_landlord: false, onboarded_tenant: false, email_verified: null, banking_setup_complete: null, onboarding_step: null, encrypted_data: null, started_on: Date.now(), updated_on: Date.now(), }; // Simulate stale cache state: KV says user exists, SQL has no user row. kvStore.get = (async (key: Deno.KvKey): Promise => { if (key[0] === "users" && key[1] === userId) { return staleCachedUser as T; } return await originalKvGet(key); }) as typeof kvStore.get; const beforeRows = await db.select().from(users).where( eq(users.id, userId), ); assertEquals(beforeRows.length, 0); const idToken = buildUnsignedJwt({ oid: userId, sub: userId, email: "stale.kv.user@example.com", name: "Stale KV User", roles: ["tenant"], }); globalThis.fetch = (() => Promise.resolve( new Response( JSON.stringify({ access_token: accessToken, id_token: idToken, refresh_token: refreshToken, }), { status: 200, headers: { "Content-Type": "application/json" }, }, ), )) as typeof fetch; const request = new Request( `http://localhost:8080/auth/callback?code=test-code&xx-state-id=${stateId}`, { headers: { cookie: "return_path=%2Flandlord%2Fdashboard", }, }, ); const callbackContext = { req: request, } as Parameters[0]; const response = await authCallbackHandler(callbackContext); assertEquals(response.status, 302); assertEquals( response.headers.get("location"), "http://localhost:8080/", ); const userRows = await db.select().from(users).where(eq(users.id, userId)); assertEquals(userRows.length, 1); const tokenRows = await db.select().from(userTokens).where( and(eq(userTokens.user_id, userId), eq(userTokens.state_id, stateId)), ); assertEquals(tokenRows.length, 1); assertEquals(tokenRows[0].access_token, accessToken); assertEquals(tokenRows[0].refresh_token, refreshToken); const stateKeyRows = await db.select().from(stateKeys).where( eq(stateKeys.state_id, stateId), ); assert(stateKeyRows.length > 0, "expected callback to persist state key"); } finally { globalThis.fetch = originalFetch; kvStore.get = originalKvGet; for (const [key, value] of envBackup) { if (value === undefined) { Deno.env.delete(key); } else { Deno.env.set(key, value); } } await cleanupRecords(userId, stateId); } });