Auth Fix
CI / ci (push) Canceled after 0s

Fixed an issue where the user would be redirected to a localhost address because an auth url was not set in the env.
This commit is contained in:
Stefan Lazic committed 2026-09-06 21:01:54 +01:00
1 parent e9e28a243f
commit cbeccc6c1f
8 files changed
+420 -71

No files matched your search

@@ -0,0 +1,194 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/services/scheduling/__tests__/fixtures";
import { authorizeCredentials } from "@/lib/auth/authorize-credentials";
import { requestOtp, verifyOtp } from "@/lib/auth/otp.service";
import { MockEmailProvider } from "@/services/notifications/providers/mock-email-provider";
import { installApplication } from "../installer.service";
import type { InstallInput } from "../schema";
/**
* Regression coverage for a real production bug: installApplication's own
* transaction consumes the administrator's OTP row via finalizeOtp (see
* installer.service.ts) — but src/auth.ts's Credentials provider used to
* call finalizeOtp a *second* time on its `{finalize:"true"}` sign-in
* path, which always fails (finalizeOtp is one-time-use) and turned every
* successful install into a CredentialsSignin error on the very last
* step. installer.service.integration.test.ts and
* otp.service.integration.test.ts each separately proved their own half
* correctly; neither exercised the interaction between them, which is
* exactly where this broke. This file exercises that interaction, end to
* end, against a real MySQL database.
*/
const INSTALLATION_TOKEN = "auth-finalize-test-install-token";
function buildInput(overrides: Partial<InstallInput> = {}): InstallInput {
return {
installationToken: INSTALLATION_TOKEN,
administrator: {
firstName: "Test",
lastName: "Owner",
email: `auth-finalize-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`,
phone: "",
},
business: { name: "Auth Finalize Test Barbershop", shortName: "", description: "", email: "", phone: "", website: "" },
location: {
addressLine1: "1 Test Street",
addressLine2: "",
city: "Testville",
region: "",
postalCode: "T1 1TT",
country: "GB",
timezone: "Europe/London",
currency: "GBP",
locale: "en-GB",
},
hours: (["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"] as const).map((dayOfWeek) => ({
dayOfWeek,
isClosed: dayOfWeek === "SUNDAY",
opensAtMinutes: dayOfWeek === "SUNDAY" ? undefined : 540,
closesAtMinutes: dayOfWeek === "SUNDAY" ? undefined : 1080,
})),
bookingRules: {
allowGuestBooking: false,
minNoticeMinutes: 120,
maxAdvanceDays: 60,
cancellationWindowHours: 24,
rescheduleWindowHours: 24,
slotIntervalMinutes: 30,
bufferBeforeMinutes: 0,
bufferAfterMinutes: 5,
maxAppointmentsPerDay: null,
maxAppointmentsPerStaffPerDay: null,
customerCanChooseStaff: true,
requireApproval: false,
},
branding: { logoUrl: "", coverImageUrl: "", themeColor: "", backgroundColor: "" },
starterServices: [
{ categoryName: "Haircuts", name: "Classic Cut", description: "", price: "20.00", durationMinutes: 30, active: true, allowOnlineBooking: true },
],
...overrides,
};
}
async function verifyAdministratorEmail(email: string): Promise<void> {
const mockProvider = new MockEmailProvider();
await requestOtp(prisma, { email, purpose: "install", emailProvider: mockProvider });
const sent = mockProvider.sentEmails[0];
const code = /verification code is (\d{6})/.exec(sent?.text ?? "")?.[1];
if (!code) throw new Error("Test setup failed: couldn't extract the OTP code from the captured email.");
const result = await verifyOtp(prisma, email, code);
if (!result.ok) throw new Error(`Test setup failed: OTP verification failed (${result.reason}).`);
}
async function buildVerifiedInput(overrides: Partial<InstallInput> = {}): Promise<InstallInput> {
const input = buildInput(overrides);
await verifyAdministratorEmail(input.administrator.email);
return input;
}
async function resetInstallationSingleton(): Promise<void> {
await prisma.applicationInstallation.deleteMany({ where: { id: "singleton" } });
}
async function cleanupInstalledBusiness(businessId: string | undefined): Promise<void> {
if (!businessId) return;
const staff = await prisma.staffProfile.findMany({ where: { businessId }, select: { id: true, userId: true } });
const userIds = staff.map((s) => s.userId);
await prisma.staffService.deleteMany({ where: { staffId: { in: staff.map((s) => s.id) } } });
await prisma.staffProfile.deleteMany({ where: { businessId } });
await prisma.service.deleteMany({ where: { businessId } });
await prisma.serviceCategory.deleteMany({ where: { businessId } });
await prisma.rolePermission.deleteMany({ where: { role: { businessId } } });
await prisma.role.deleteMany({ where: { businessId } });
await prisma.auditLog.deleteMany({ where: { entityId: businessId } });
await prisma.loyaltyProgramme.deleteMany({ where: { businessId } });
await prisma.bookingRule.deleteMany({ where: { businessId } });
await prisma.businessSettings.deleteMany({ where: { businessId } });
await prisma.businessHours.deleteMany({ where: { businessId } });
await prisma.businessLocation.deleteMany({ where: { businessId } });
await prisma.business.deleteMany({ where: { id: businessId } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await resetInstallationSingleton();
}
describe("authorizeCredentials — post-install finalize sign-in (integration)", () => {
let createdBusinessId: string | undefined;
let originalToken: string | undefined;
beforeEach(async () => {
await resetInstallationSingleton();
createdBusinessId = undefined;
originalToken = process.env.INSTALLATION_TOKEN;
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
});
afterEach(async () => {
await cleanupInstalledBusiness(createdBusinessId);
process.env.INSTALLATION_TOKEN = originalToken;
});
it("signs the administrator in immediately after a real installApplication() success — the exact sequence submitInstallationAction runs", async () => {
const input = await buildVerifiedInput();
const result = await installApplication(prisma, { input, installedVersion: "test" });
expect(result.success).toBe(true);
if (!result.success) return;
createdBusinessId = result.businessId;
// This is the reported bug: before the fix, this call always returned
// null (CredentialsSignin) because installApplication already
// consumed the OTP row and this path tried to finalize it again.
const user = await authorizeCredentials({
email: input.administrator.email,
finalize: "true",
token: INSTALLATION_TOKEN,
});
expect(user).not.toBeNull();
expect(user?.id).toBe(result.userId);
expect(user?.email).toBe(input.administrator.email);
});
it("rejects the finalize path with a wrong installation token, even right after a real install", async () => {
const input = await buildVerifiedInput();
const result = await installApplication(prisma, { input, installedVersion: "test" });
expect(result.success).toBe(true);
if (!result.success) return;
createdBusinessId = result.businessId;
const user = await authorizeCredentials({ email: input.administrator.email, finalize: "true", token: "wrong-token" });
expect(user).toBeNull();
});
it("rejects the finalize path entirely if no INSTALLATION_TOKEN is configured", async () => {
const input = await buildVerifiedInput();
const result = await installApplication(prisma, { input, installedVersion: "test" });
expect(result.success).toBe(true);
if (!result.success) return;
createdBusinessId = result.businessId;
delete process.env.INSTALLATION_TOKEN;
const user = await authorizeCredentials({ email: input.administrator.email, finalize: "true", token: INSTALLATION_TOKEN });
expect(user).toBeNull();
});
it("the ordinary {email, code} sign-in path is unaffected by this fix — still verifies and consumes its own fresh code", async () => {
const email = `auth-finalize-normal-${Date.now()}@example.com`;
const mockProvider = new MockEmailProvider();
await requestOtp(prisma, { email, purpose: "login", emailProvider: mockProvider });
const code = /verification code is (\d{6})/.exec(mockProvider.sentEmails[0]?.text ?? "")?.[1];
if (!code) throw new Error("Test setup failed: couldn't extract the OTP code.");
await prisma.user.upsert({
where: { email },
update: {},
create: { email, name: "Normal Login User" },
});
const user = await authorizeCredentials({ email, code });
expect(user).not.toBeNull();
expect(user?.email).toBe(email);
await prisma.user.deleteMany({ where: { email } });
});
});