Files
pi-perplexity/test/auth/otp-flow.test.ts
T
Ivan Pereira bbb5623f25 refactor(runtime): replace Bun dependency with Node fetch
Run Perplexity search and auth requests through pi's Node runtime instead of
shelling out to Bun. Move development and CI commands to npm, add a Node test
build path, and keep OTP auth fail-fast when Set-Cookie headers are not
available.
2026-06-23 15:51:16 +01:00

339 lines
10 KiB
TypeScript

/**
* OTP login flow tests derived from real captured request/response data.
* See scripts/debug-login-dump.json for the raw fixture.
*/
import { afterEach, describe, expect, mock, test } from "../test-helpers.js";
import { AuthError, type StoredToken } from "../../src/search/types.js";
const originalFetch = globalThis.fetch;
const originalBorrow = process.env.PI_AUTH_NO_BORROW;
const originalEmail = process.env.PI_PERPLEXITY_EMAIL;
const originalOtp = process.env.PI_PERPLEXITY_OTP;
// --- Fixtures from real Perplexity responses (scripts/debug-login-dump.json) ---
/** Fake JWE token — same structure as real Perplexity tokens (alg=dir, enc=A256GCM) but not a valid credential */
const REAL_JWE_TOKEN =
"eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..AAAAAAAAAAAAAAAA.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.AAAAAAAAAAAAAAAAAAAA";
const CSRF_TOKEN = "0e4f8cc491e3197788492604ad32577f2022747fe30e0f51ba3ba235f07cc9ee";
const TEST_EMAIL = "user@test.com";
const TEST_OTP = "9f3e2-knzol";
const CSRF_COOKIES = [
"next-auth.csrf-token=csrf-cookie; Path=/; HttpOnly",
"cf_clearance=clearance-cookie; Path=/; Secure",
];
const CSRF_COOKIE_HEADER = "next-auth.csrf-token=csrf-cookie; cf_clearance=clearance-cookie";
// ---
function jsonHeaders(cookies: string[] = []): [string, string][] {
return [
["content-type", "application/json; charset=utf-8"],
...cookies.map((cookie): [string, string] => ["set-cookie", cookie]),
];
}
async function importLoginModule() {
return import(`../../src/auth/login.js?test=${crypto.randomUUID()}`);
}
function restoreEnv(): void {
for (const [key, original] of [
["PI_AUTH_NO_BORROW", originalBorrow],
["PI_PERPLEXITY_EMAIL", originalEmail],
["PI_PERPLEXITY_OTP", originalOtp],
] as const) {
if (original === undefined) {
delete process.env[key];
} else {
process.env[key] = original;
}
}
}
function mockStorage() {
const loadTokenMock = mock(async () => null);
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
const clearTokenMock = mock(async () => undefined);
mock.module("../../src/auth/storage.js", () => ({
loadToken: loadTokenMock,
saveToken: saveTokenMock,
clearToken: clearTokenMock,
}));
return { loadTokenMock, saveTokenMock, clearTokenMock };
}
/** Build a fetch mock that replays real Perplexity response shapes. */
function buildReplayFetchMock(options?: {
/** Override the OTP response body (default: real token+status response) */
otpResponseBody?: unknown;
}) {
const calls: { url: string; init?: RequestInit }[] = [];
const otpBody = options?.otpResponseBody ?? { token: REAL_JWE_TOKEN, status: "success" };
const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const entry: { url: string; init?: RequestInit } = { url };
if (init !== undefined) entry.init = init;
calls.push(entry);
if (url.endsWith("/csrf")) {
return new Response(JSON.stringify({ csrfToken: CSRF_TOKEN }), {
status: 200,
headers: jsonHeaders(CSRF_COOKIES),
});
}
if (url.endsWith("/signin-email")) {
return new Response(JSON.stringify({ success: "Email sign in triggered" }), {
status: 200,
headers: jsonHeaders(),
});
}
if (url.endsWith("/signin-otp")) {
return new Response(JSON.stringify(otpBody), {
status: 200,
headers: jsonHeaders(),
});
}
return new Response("not found", { status: 404 });
});
return { fetchMock, calls };
}
afterEach(() => {
mock.restore();
globalThis.fetch = originalFetch;
restoreEnv();
});
describe("OTP login flow (from real captured responses)", () => {
test("full flow: CSRF → email → OTP, extracts JWE token from response body", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
const { saveTokenMock } = mockStorage();
const { fetchMock } = buildReplayFetchMock();
globalThis.fetch = fetchMock as unknown as typeof fetch;
const { authenticate } = await importLoginModule();
const token = await authenticate({
promptForEmail: async () => TEST_EMAIL,
promptForOtp: async () => TEST_OTP,
});
expect(token).toBe(REAL_JWE_TOKEN);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(saveTokenMock).toHaveBeenCalledTimes(1);
const saved = saveTokenMock.mock.calls[0]?.[0] as StoredToken;
expect(saved.type).toBe("oauth");
expect(saved.access).toBe(REAL_JWE_TOKEN);
});
test("exactly 3 requests: no /session fallback when token is in body", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
mockStorage();
const { fetchMock, calls } = buildReplayFetchMock();
globalThis.fetch = fetchMock as unknown as typeof fetch;
const { authenticate } = await importLoginModule();
await authenticate({
promptForEmail: async () => TEST_EMAIL,
promptForOtp: async () => TEST_OTP,
});
expect(calls).toHaveLength(3);
expect(calls[0].url).toContain("/csrf");
expect(calls[1].url).toContain("/signin-email");
expect(calls[2].url).toContain("/signin-otp");
});
test("request bodies match expected shape", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
mockStorage();
const { fetchMock, calls } = buildReplayFetchMock();
globalThis.fetch = fetchMock as unknown as typeof fetch;
const { authenticate } = await importLoginModule();
await authenticate({
promptForEmail: async () => TEST_EMAIL,
promptForOtp: async () => TEST_OTP,
});
// CSRF is GET, no body
expect(calls[0].init?.method ?? "GET").toBe("GET");
expect(calls[0].init?.body).toBeFalsy();
// signin-email: POST with email + csrfToken + CSRF cookies
expect(calls[1].init?.method).toBe("POST");
expect(new Headers(calls[1].init?.headers).get("Cookie")).toBe(CSRF_COOKIE_HEADER);
expect(JSON.parse(String(calls[1].init?.body))).toEqual({
email: TEST_EMAIL,
csrfToken: CSRF_TOKEN,
});
// signin-otp: POST with email + otp + csrfToken + CSRF cookies
expect(calls[2].init?.method).toBe("POST");
expect(new Headers(calls[2].init?.headers).get("Cookie")).toBe(CSRF_COOKIE_HEADER);
expect(JSON.parse(String(calls[2].init?.body))).toEqual({
email: TEST_EMAIL,
otp: TEST_OTP,
csrfToken: CSRF_TOKEN,
});
});
test("env vars PI_PERPLEXITY_EMAIL and PI_PERPLEXITY_OTP bypass prompts", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
process.env.PI_PERPLEXITY_EMAIL = TEST_EMAIL;
process.env.PI_PERPLEXITY_OTP = TEST_OTP;
mockStorage();
const { fetchMock } = buildReplayFetchMock();
globalThis.fetch = fetchMock as unknown as typeof fetch;
const { authenticate } = await importLoginModule();
const promptForEmail = mock(async () => "should-not-be-called@test.com");
const promptForOtp = mock(async () => "should-not-be-called");
const token = await authenticate({ promptForEmail, promptForOtp });
expect(token).toBe(REAL_JWE_TOKEN);
expect(promptForEmail).toHaveBeenCalledTimes(0);
expect(promptForOtp).toHaveBeenCalledTimes(0);
});
test("throws AuthError NO_TOKEN when email prompt returns undefined", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
mockStorage();
const { authenticate } = await importLoginModule();
let thrown: unknown;
try {
await authenticate({
promptForEmail: async () => undefined,
});
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(AuthError);
expect((thrown as AuthError).code).toBe("NO_TOKEN");
});
test("throws AuthError NO_TOKEN when OTP prompt returns undefined", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
mockStorage();
const { fetchMock } = buildReplayFetchMock();
globalThis.fetch = fetchMock as unknown as typeof fetch;
const { authenticate } = await importLoginModule();
let thrown: unknown;
try {
await authenticate({
promptForEmail: async () => TEST_EMAIL,
promptForOtp: async () => undefined,
});
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(AuthError);
expect((thrown as AuthError).code).toBe("NO_TOKEN");
});
test("throws when CSRF response does not include auth cookies", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
mockStorage();
const fetchMock = mock(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/csrf")) {
return new Response(JSON.stringify({ csrfToken: CSRF_TOKEN }), {
status: 200,
headers: jsonHeaders(),
});
}
return new Response("should not continue", { status: 500 });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const { authenticate } = await importLoginModule();
await expect(authenticate({ promptForEmail: async () => TEST_EMAIL })).rejects.toMatchObject({
name: "AuthError",
code: "EXTRACTION_FAILED",
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test("throws when OTP verification returns non-200", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
mockStorage();
const fetchMock = mock(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/csrf")) {
return new Response(JSON.stringify({ csrfToken: CSRF_TOKEN }), {
status: 200,
headers: jsonHeaders(CSRF_COOKIES),
});
}
if (url.endsWith("/signin-email")) {
return new Response(JSON.stringify({ success: "Email sign in triggered" }), {
status: 200,
headers: jsonHeaders(),
});
}
if (url.endsWith("/signin-otp")) {
return new Response("Unauthorized", { status: 401 });
}
return new Response("not found", { status: 404 });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const { authenticate } = await importLoginModule();
let thrown: unknown;
try {
await authenticate({
promptForEmail: async () => TEST_EMAIL,
promptForOtp: async () => TEST_OTP,
});
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(AuthError);
expect((thrown as AuthError).code).toBe("EXTRACTION_FAILED");
expect((thrown as AuthError).message).toContain("OTP verification failed");
});
});