184 lines
5.3 KiB
TypeScript
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);
|
|
}
|
|
});
|