Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/services/auth/magic-link.server.test.ts
T

225 lines
9.1 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { consumeMagicLinkToken, createSignedMagicLinkToken, issueMagicLink, verifySignedMagicLinkToken } from "./magic-link.server";
import { getAppConfiguration } from "~/utils/db/appConfiguration.db.server";
import { getMailTranslator, getMailTranslatorFromRequest } from "~/locale/mail-translator.server";
import { queueTransactionalEmail } from "~/modules/emails/services/TransactionalEmailQueue.server";
import { db } from "~/utils/db.server";
vi.mock("@toolctrl/config", () => ({
loadRequiredWebConfig: vi.fn(() => ({ cryptoSecret: "test-secret", sessionSecret: "session-secret" })),
}));
vi.mock("~/utils/db.server", () => ({
db: {
magicLinkToken: {
create: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
},
}));
vi.mock("~/utils/url.server", () => ({
getAbsoluteUrl: vi.fn((_request: Request, path: string) => `https://example.com${path}`),
}));
vi.mock("~/modules/emails/services/TransactionalEmailQueue.server", () => ({
queueTransactionalEmail: vi.fn(),
}));
vi.mock("~/utils/db/appConfiguration.db.server", () => ({
getAppConfiguration: vi.fn(),
}));
vi.mock("~/locale/mail-translator.server", () => ({
getMailTranslator: vi.fn(),
getMailTranslatorFromRequest: vi.fn(),
}));
const getAppConfigurationMock = vi.mocked(getAppConfiguration);
const getMailTranslatorMock = vi.mocked(getMailTranslator);
const getMailTranslatorFromRequestMock = vi.mocked(getMailTranslatorFromRequest);
const queueTransactionalEmailMock = vi.mocked(queueTransactionalEmail);
const magicLinkTokenCreateMock = vi.mocked(db.magicLinkToken.create);
const magicLinkTokenFindUniqueMock = vi.mocked(db.magicLinkToken.findUnique);
const magicLinkTokenUpdateMock = vi.mocked(db.magicLinkToken.update);
const userFindUniqueMock = vi.mocked(db.user.findUnique);
beforeEach(() => {
vi.clearAllMocks();
getAppConfigurationMock.mockResolvedValue({
app: { name: "Acme Portal" },
email: { fromName: "Acme Support", supportEmail: "support@acme.test" },
branding: { logo: "https://assets.example.com/logo.png", favicon: null },
} as Awaited<ReturnType<typeof getAppConfiguration>>);
getMailTranslatorMock.mockResolvedValue({
locale: "de",
t: (key, vars) => (key === "magicLink.subject" ? `Dein Anmeldelink für ${vars?.productName}` : key),
});
getMailTranslatorFromRequestMock.mockResolvedValue({
locale: "en",
t: (key) => key,
});
queueTransactionalEmailMock.mockResolvedValue({ enqueued: true, jobId: "job_1" });
magicLinkTokenCreateMock.mockResolvedValue({ id: "token_1" } as Awaited<ReturnType<typeof db.magicLinkToken.create>>);
magicLinkTokenFindUniqueMock.mockResolvedValue(null);
magicLinkTokenUpdateMock.mockResolvedValue({ id: "token_1" } as Awaited<ReturnType<typeof db.magicLinkToken.update>>);
userFindUniqueMock.mockResolvedValue({ locale: "de" } as Awaited<ReturnType<typeof db.user.findUnique>>);
});
describe("issueMagicLink", () => {
it("queues a magic-link mail rendered through the React-Email pipeline", async () => {
const result = await issueMagicLink({
request: new Request("https://example.com/forgot-password"),
userId: "user_1",
email: "ada@example.com",
name: "Ada",
now: new Date("2026-05-19T12:00:00.000Z"),
});
expect(result.token).toMatch(/\./);
// Translator is resolved against the user's locale for the subject.
expect(getMailTranslatorMock).toHaveBeenCalledWith("de");
expect(getMailTranslatorFromRequestMock).not.toHaveBeenCalled();
expect(queueTransactionalEmailMock).toHaveBeenCalledOnce();
expect(queueTransactionalEmailMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "ada@example.com",
alias: "magic-link",
subject: "Dein Anmeldelink für Acme Portal",
userId: "user_1",
data: expect.objectContaining({
action_url: expect.stringContaining("https://example.com/auth/magic-link?token="),
name: "Ada",
expires_in_minutes: "15",
}),
priorityOverride: "critical",
})
);
const queued = queueTransactionalEmailMock.mock.calls[0]?.[0];
expect(queued?.body).toContain("Click the button below to sign in to your Acme Portal account. No password required.");
expect(queued?.body).toContain("Sign in");
expect(queued?.body).toContain("This link expires in 15 minutes");
expect(queued?.body).not.toContain("{i18nT(");
});
});
describe("signed magic-link tokens", () => {
it("verifies a signed token payload and rejects malformed or tampered tokens", () => {
const issued = createSignedMagicLinkToken({
userId: "user_1",
tokenId: "token_1",
now: new Date("2026-05-19T12:00:00.000Z"),
purpose: "reset",
});
expect(verifySignedMagicLinkToken(issued.token)).toMatchObject({
sub: "user_1",
jti: "token_1",
p: "reset",
v: "v1",
});
expect(verifySignedMagicLinkToken("not-a-token")).toBeNull();
expect(verifySignedMagicLinkToken(`${issued.token}.extra`)).toBeNull();
expect(verifySignedMagicLinkToken(`${issued.token.slice(0, -1)}x`)).toBeNull();
});
});
describe("consumeMagicLinkToken", () => {
it("consumes a valid matching token exactly once", async () => {
const now = new Date("2026-05-19T12:00:00.000Z");
const issued = createSignedMagicLinkToken({ userId: "user_1", tokenId: "token_1", now, purpose: "login" });
magicLinkTokenFindUniqueMock.mockResolvedValue({
id: "token_1",
userId: "user_1",
tokenHash: issued.tokenHash,
expiresAt: issued.expiresAt,
consumedAt: null,
} as Awaited<ReturnType<typeof db.magicLinkToken.findUnique>>);
await expect(consumeMagicLinkToken(issued.token, { now, expectedPurpose: "login" })).resolves.toEqual({
ok: true,
userId: "user_1",
tokenId: "token_1",
purpose: "login",
});
expect(magicLinkTokenUpdateMock).toHaveBeenCalledWith({
where: { id: "token_1" },
data: { consumedAt: now },
});
});
it("rejects unknown or mismatched stored tokens without consuming them", async () => {
const now = new Date("2026-05-19T12:00:00.000Z");
const issued = createSignedMagicLinkToken({ userId: "user_1", tokenId: "token_1", now });
magicLinkTokenFindUniqueMock.mockResolvedValue(null);
await expect(consumeMagicLinkToken(issued.token, { now })).resolves.toEqual({ ok: false, reason: "not-found" });
expect(magicLinkTokenUpdateMock).not.toHaveBeenCalled();
});
it("rejects expired signed payloads before reading the database", async () => {
const issuedAt = new Date("2026-05-19T12:00:00.000Z");
const now = new Date("2026-05-19T12:16:00.000Z");
const issued = createSignedMagicLinkToken({ userId: "user_1", tokenId: "token_1", now: issuedAt });
await expect(consumeMagicLinkToken(issued.token, { now })).resolves.toEqual({ ok: false, reason: "expired" });
expect(magicLinkTokenFindUniqueMock).not.toHaveBeenCalled();
expect(magicLinkTokenUpdateMock).not.toHaveBeenCalled();
});
it("rejects stored tokens that expired after a valid signed payload was issued", async () => {
const now = new Date("2026-05-19T12:00:00.000Z");
const issued = createSignedMagicLinkToken({
userId: "user_1",
tokenId: "token_1",
now,
ttlMinutes: 60,
});
magicLinkTokenFindUniqueMock.mockResolvedValue({
id: "token_1",
userId: "user_1",
tokenHash: issued.tokenHash,
expiresAt: new Date("2026-05-19T11:59:59.999Z"),
consumedAt: null,
} as Awaited<ReturnType<typeof db.magicLinkToken.findUnique>>);
await expect(consumeMagicLinkToken(issued.token, { now })).resolves.toEqual({ ok: false, reason: "expired" });
expect(magicLinkTokenUpdateMock).not.toHaveBeenCalled();
});
it("rejects replayed tokens before writing a new consumedAt", async () => {
const now = new Date("2026-05-19T12:00:00.000Z");
const issued = createSignedMagicLinkToken({ userId: "user_1", tokenId: "token_1", now });
magicLinkTokenFindUniqueMock.mockResolvedValue({
id: "token_1",
userId: "user_1",
tokenHash: issued.tokenHash,
expiresAt: issued.expiresAt,
consumedAt: new Date("2026-05-19T12:01:00.000Z"),
} as Awaited<ReturnType<typeof db.magicLinkToken.findUnique>>);
await expect(consumeMagicLinkToken(issued.token, { now })).resolves.toEqual({ ok: false, reason: "replayed" });
expect(magicLinkTokenUpdateMock).not.toHaveBeenCalled();
});
it("rejects purpose mismatches without consuming the token", async () => {
const now = new Date("2026-05-19T12:00:00.000Z");
const issued = createSignedMagicLinkToken({ userId: "user_1", tokenId: "token_1", now, purpose: "reset" });
magicLinkTokenFindUniqueMock.mockResolvedValue({
id: "token_1",
userId: "user_1",
tokenHash: issued.tokenHash,
expiresAt: issued.expiresAt,
consumedAt: null,
} as Awaited<ReturnType<typeof db.magicLinkToken.findUnique>>);
await expect(consumeMagicLinkToken(issued.token, { now, expectedPurpose: "login" })).resolves.toEqual({ ok: false, reason: "purpose-mismatch" });
expect(magicLinkTokenUpdateMock).not.toHaveBeenCalled();
});
});