Files
Jyotisha/frontend/tests/identity-auth-integration.test.ts
T
2026-07-29 17:49:19 +08:00

363 lines
11 KiB
TypeScript

import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { toNextJsHandler } from "better-auth/next-js";
import {
createIdentityAuthServices,
createIdentityPool,
} from "../src/modules/identity/auth.ts";
import type { SelfHostedIdentityConfig } from "../src/modules/identity/config.ts";
import { FakeEmailOtpSender } from "../src/modules/identity/email/fake-email-otp-sender.ts";
import { createHostIsolatedAuthHandlers } from "../src/modules/identity/host.ts";
import {
GET as getPasswordStatus,
POST as setAccountPassword,
} from "../src/app/api/account/password/route.ts";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const runnerPath = fileURLToPath(
new URL("../scripts/db-migrate.mjs", import.meta.url),
);
const migrationsDirectory = fileURLToPath(
new URL("../db/migrations", import.meta.url),
);
const userHost = "staging.jyotisha.chat";
const adminHost = "admin.staging.jyotisha.chat";
function request(
host: string,
path: string,
body?: Record<string, unknown>,
cookie?: string,
): Request {
const headers: Record<string, string> = {
host,
origin: `https://${host}`,
};
if (body) headers["content-type"] = "application/json";
if (cookie) headers.cookie = cookie;
return new Request(`https://${host}${path}`, {
method: body ? "POST" : "GET",
headers,
body: body ? JSON.stringify(body) : undefined,
});
}
function sessionCookie(response: Response): string {
return (response.headers.get("set-cookie") ?? "").split(";", 1)[0];
}
const envKeys = [
"AUTH_PROVIDER",
"SELF_HOSTED_IDENTITY_ENABLED",
"IDENTITY_DATABASE_URL",
"AUTH_USER_ORIGIN",
"AUTH_ADMIN_ORIGIN",
"BETTER_AUTH_USER_SECRET",
"BETTER_AUTH_ADMIN_SECRET",
"RESEND_API_KEY",
"RESEND_FROM_EMAIL",
] as const;
test("Better Auth supports user OTP/password flows and password-only admin login", async () => {
const fixture = startPostgresFixture();
const migration = spawnSync(process.execPath, [runnerPath], {
encoding: "utf8",
env: {
...process.env,
MIGRATIONS_DIRECTORY: migrationsDirectory,
SCHEMA_DATABASE_URL: fixture.connectionUrl(
"schema_owner",
"schema-owner-test-password",
),
},
});
assert.equal(migration.status, 0, migration.stderr);
const config: SelfHostedIdentityConfig = {
provider: "self-hosted",
databaseUrl: fixture.connectionUrl(
"identity_runtime",
"identity-runtime-test-password",
),
userOrigin: `https://${userHost}`,
adminOrigin: `https://${adminHost}`,
userSecret: "user-secret-that-is-at-least-32-bytes-long",
adminSecret: "admin-secret-that-is-at-least-32-bytes-long",
resendApiKey: "re_test",
resendFrom: "Jyotisha <login@staging.jyotisha.chat>",
};
const previousEnv = new Map(
envKeys.map((key) => [key, process.env[key]] as const),
);
Object.assign(process.env, {
AUTH_PROVIDER: "self-hosted",
SELF_HOSTED_IDENTITY_ENABLED: "true",
IDENTITY_DATABASE_URL: config.databaseUrl,
AUTH_USER_ORIGIN: config.userOrigin,
AUTH_ADMIN_ORIGIN: config.adminOrigin,
BETTER_AUTH_USER_SECRET: config.userSecret,
BETTER_AUTH_ADMIN_SECRET: config.adminSecret,
RESEND_API_KEY: config.resendApiKey,
RESEND_FROM_EMAIL: config.resendFrom,
});
const identityGlobal = globalThis as typeof globalThis & {
jyotishaIdentityAuth?: ReturnType<typeof createIdentityAuthServices>;
};
delete identityGlobal.jyotishaIdentityAuth;
const sender = new FakeEmailOtpSender();
const pool = createIdentityPool(config.databaseUrl);
const services = createIdentityAuthServices(config, {
pool,
emailSender: sender,
});
const handlers = createHostIsolatedAuthHandlers(config, {
user: toNextJsHandler(services.user),
admin: toNextJsHandler(services.admin),
});
async function otpSignIn(email: string): Promise<string> {
const send = await handlers.POST(
request(userHost, "/api/auth/email-otp/send-verification-otp", {
email,
type: "sign-in",
}),
);
assert.equal(send.status, 200);
const message = sender.messages.at(-1);
assert.equal(message?.email, email);
assert.equal(message?.type, "sign-in");
const signIn = await handlers.POST(
request(userHost, "/api/auth/sign-in/email-otp", {
email,
otp: message?.otp,
}),
);
assert.equal(signIn.status, 200);
const cookie = sessionCookie(signIn);
assert.match(cookie, /^(?:__Secure-)?jyotisha-user\.session_token=/);
return cookie;
}
async function passwordSignIn(
email: string,
password: string,
): Promise<Response> {
return handlers.POST(
request(userHost, "/api/auth/sign-in/email", { email, password }),
);
}
try {
const unauthenticatedSet = await setAccountPassword(
request(userHost, "/api/account/password", {
newPassword: "not-authorized",
}),
);
assert.equal(unauthenticatedSet.status, 401);
const newEmail = "new-user@example.com";
const firstPassword = "first-password";
const resetPassword = "reset-password";
const newUserOtpCookie = await otpSignIn(newEmail);
const initialStatus = await getPasswordStatus(
request(userHost, "/api/account/password", undefined, newUserOtpCookie),
);
assert.equal(initialStatus.status, 200);
assert.deepEqual(await initialStatus.json(), { hasPassword: false });
const firstSet = await setAccountPassword(
request(
userHost,
"/api/account/password",
{ newPassword: firstPassword },
newUserOtpCookie,
),
);
assert.equal(firstSet.status, 200);
const secondSet = await setAccountPassword(
request(
userHost,
"/api/account/password",
{ newPassword: "must-not-overwrite" },
newUserOtpCookie,
),
);
assert.equal(secondSet.status, 409);
const storedHash = fixture.psql(
"select password from identity.accounts where provider_id = 'credential' and user_id = (select id from identity.users where email = 'new-user@example.com')",
);
assert.notEqual(storedHash, firstPassword);
assert.match(storedHash, /^[0-9a-f]{32}:[0-9a-f]{128}$/);
const passwordLogin = await passwordSignIn(newEmail, firstPassword);
assert.equal(passwordLogin.status, 200);
const passwordCookie = sessionCookie(passwordLogin);
assert.match(passwordCookie, /^(?:__Secure-)?jyotisha-user\.session_token=/);
const wrongPassword = await passwordSignIn(newEmail, "wrong-password");
assert.notEqual(wrongPassword.status, 200);
assert.equal(wrongPassword.headers.has("set-cookie"), false);
const otpLoginCookie = await otpSignIn(newEmail);
assert.match(otpLoginCookie, /^(?:__Secure-)?jyotisha-user\.session_token=/);
const oldOtpEmail = "otp-only@example.com";
const firstOldOtpCookie = await otpSignIn(oldOtpEmail);
const signOut = await handlers.POST(
request(
userHost,
"/api/auth/sign-out",
{},
firstOldOtpCookie,
),
);
assert.equal(signOut.status, 200);
const returningOldOtpCookie = await otpSignIn(oldOtpEmail);
const oldOtpStatus = await getPasswordStatus(
request(
userHost,
"/api/account/password",
undefined,
returningOldOtpCookie,
),
);
assert.deepEqual(await oldOtpStatus.json(), { hasPassword: false });
const oldOtpSet = await setAccountPassword(
request(
userHost,
"/api/account/password",
{ newPassword: "old-user-password" },
returningOldOtpCookie,
),
);
assert.equal(oldOtpSet.status, 200);
assert.equal(
(await passwordSignIn(oldOtpEmail, "old-user-password")).status,
200,
);
const unknownResetMessageCount = sender.messages.length;
const unknownReset = await handlers.POST(
request(userHost, "/api/auth/email-otp/request-password-reset", {
email: "missing@example.com",
}),
);
assert.equal(unknownReset.status, 200);
assert.equal(sender.messages.length, unknownResetMessageCount);
const resetRequest = await handlers.POST(
request(userHost, "/api/auth/email-otp/request-password-reset", {
email: newEmail,
}),
);
assert.equal(resetRequest.status, 200);
const resetMessage = sender.messages.at(-1);
assert.equal(resetMessage?.type, "forget-password");
const reset = await handlers.POST(
request(userHost, "/api/auth/email-otp/reset-password", {
email: newEmail,
otp: resetMessage?.otp,
password: resetPassword,
}),
);
assert.equal(reset.status, 200);
const newUserId = fixture.psql(
"select id from identity.users where email = 'new-user@example.com'",
);
assert.equal(
fixture.psql(
`select count(*) from identity.sessions where user_id = '${newUserId}'`,
),
"0",
);
for (const cookie of [newUserOtpCookie, passwordCookie, otpLoginCookie]) {
assert.equal(
await services.user.api.getSession({
headers: new Headers({ cookie }),
}),
null,
);
}
const oldPasswordAfterReset = await passwordSignIn(newEmail, firstPassword);
assert.notEqual(oldPasswordAfterReset.status, 200);
assert.equal(oldPasswordAfterReset.headers.has("set-cookie"), false);
const newPasswordAfterReset = await passwordSignIn(newEmail, resetPassword);
assert.equal(newPasswordAfterReset.status, 200);
assert.match(
sessionCookie(newPasswordAfterReset),
/^(?:__Secure-)?jyotisha-user\.session_token=/,
);
const nonAdminPasswordLogin = await handlers.POST(
request(adminHost, "/api/auth/sign-in/email", {
email: newEmail,
password: resetPassword,
}),
);
assert.notEqual(nonAdminPasswordLogin.status, 200);
assert.equal(nonAdminPasswordLogin.headers.has("set-cookie"), false);
fixture.psqlAs(
"identity_runtime",
"identity-runtime-test-password",
"update identity.users set role = 'user,admin' where email = 'new-user@example.com'",
);
const adminPasswordLogin = await handlers.POST(
request(adminHost, "/api/auth/sign-in/email", {
email: newEmail,
password: resetPassword,
}),
);
assert.equal(adminPasswordLogin.status, 200);
assert.match(
sessionCookie(adminPasswordLogin),
/^(?:__Secure-)?jyotisha-admin\.session_token=/,
);
const sentMessageCount = sender.messages.length;
const adminSend = await handlers.POST(
request(adminHost, "/api/auth/email-otp/send-verification-otp", {
email: newEmail,
type: "sign-in",
}),
);
assert.notEqual(adminSend.status, 200);
assert.equal(sender.messages.length, sentMessageCount);
const adminPasswordRoute = await setAccountPassword(
request(
adminHost,
"/api/account/password",
{ newPassword: "admin-must-not-set-password" },
sessionCookie(adminPasswordLogin),
),
);
assert.equal(adminPasswordRoute.status, 401);
} finally {
const globalServices = (globalThis as typeof identityGlobal).jyotishaIdentityAuth;
if (globalServices) {
await globalServices.pool.end();
delete identityGlobal.jyotishaIdentityAuth;
}
await pool.end();
fixture.stop();
for (const key of envKeys) {
const value = previousEnv.get(key);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});