225 lines
9.1 KiB
TypeScript
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();
|
|
});
|
|
});
|