integratedhub/tests/integration/auth-callback-db.test.ts
2026-02-14 02:20:19 +00:00

184 lines
5.3 KiB
TypeScript

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, unknown>): 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<void> {
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<string, string | undefined>();
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 <T = unknown>(key: Deno.KvKey): Promise<T | null> => {
if (key[0] === "users" && key[1] === userId) {
return staleCachedUser as T;
}
return await originalKvGet<T>(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<typeof authCallbackHandler>[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);
}
});