345 lines
14 KiB
TypeScript
345 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
consumeMagicLinkToken,
|
|
createSignedMagicLinkToken,
|
|
issueMagicLink,
|
|
MagicLinkDisabledError,
|
|
verifySignedMagicLinkToken,
|
|
} from "./magic-link.server";
|
|
import { loadRequiredWebConfig } from "@hivegrid/config";
|
|
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";
|
|
import { getRlsExecutionContext, runWithRlsContext } from "@hivegrid/db/rls-context.server";
|
|
|
|
vi.mock("@hivegrid/config", () => ({
|
|
loadRequiredWebConfig: vi.fn(() => ({
|
|
cryptoSecret: "test-secret",
|
|
sessionSecret: "session-secret",
|
|
magicLink: { enabled: true },
|
|
})),
|
|
}));
|
|
|
|
vi.mock("~/utils/db.server", () => ({
|
|
db: {
|
|
magicLinkToken: {
|
|
create: vi.fn(),
|
|
findUnique: vi.fn(),
|
|
updateMany: 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 magicLinkTokenUpdateManyMock = vi.mocked(db.magicLinkToken.updateMany);
|
|
const userFindUniqueMock = vi.mocked(db.user.findUnique);
|
|
const loadRequiredWebConfigMock = vi.mocked(loadRequiredWebConfig);
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
loadRequiredWebConfigMock.mockReturnValue({
|
|
cryptoSecret: "test-secret",
|
|
sessionSecret: "session-secret",
|
|
magicLink: { enabled: true },
|
|
} as ReturnType<typeof loadRequiredWebConfig>);
|
|
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) => {
|
|
const copy: Record<string, string> = {
|
|
"magicLink.subject": `Dein Anmeldelink für ${vars?.productName}`,
|
|
"magicLink.previewText": `Sicher bei ${vars?.productName} anmelden`,
|
|
"magicLink.eyebrow": "Anmeldelink",
|
|
"magicLink.title_named": `Hallo, ${vars?.name}!`,
|
|
"magicLink.title": "Hallo!",
|
|
"magicLink.intro": `Nutze diesen Link, um dich bei deinem ${vars?.productName}-Konto anzumelden.`,
|
|
"magicLink.actionLabel": "Anmelden",
|
|
"magicLink.ignoreIfUnrequested": "Wenn du diesen Anmeldelink nicht angefordert hast, kannst du diese E-Mail ignorieren.",
|
|
"magicLink.outro": `Dieser Link läuft in ${vars?.expiresInMinutes} Minuten ab und kann nur einmal verwendet werden.`,
|
|
"common.signoff.thanks": "Danke,",
|
|
"common.legal.privacyLabel": "Datenschutz",
|
|
"common.legal.termsLabel": "AGB",
|
|
"common.legal.imprintLabel": "Impressum",
|
|
"common.legal.unsubscribeLabel": "Abmelden",
|
|
"common.support.htmlBefore": "Bei Fragen kannst du uns gerne",
|
|
"common.support.emailLinkLabel": "eine E-Mail schreiben",
|
|
"common.support.htmlAfter": ".",
|
|
};
|
|
return copy[key] ?? 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);
|
|
magicLinkTokenUpdateManyMock.mockResolvedValue({ count: 1 });
|
|
userFindUniqueMock.mockResolvedValue({ locale: "de" } as Awaited<ReturnType<typeof db.user.findUnique>>);
|
|
});
|
|
|
|
describe("issueMagicLink", () => {
|
|
it("does not persist or send a token while the provider is disabled", async () => {
|
|
loadRequiredWebConfigMock.mockReturnValue({
|
|
cryptoSecret: "test-secret",
|
|
sessionSecret: "session-secret",
|
|
magicLink: { enabled: false },
|
|
} as ReturnType<typeof loadRequiredWebConfig>);
|
|
|
|
await expect(
|
|
issueMagicLink({
|
|
request: new Request("https://example.com/login"),
|
|
userId: "user_1",
|
|
email: "ada@example.com",
|
|
})
|
|
).rejects.toBeInstanceOf(MagicLinkDisabledError);
|
|
|
|
expect(magicLinkTokenCreateMock).not.toHaveBeenCalled();
|
|
expect(queueTransactionalEmailMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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 complete mail.
|
|
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("Nutze diesen Link, um dich bei deinem Acme Portal-Konto anzumelden.");
|
|
expect(queued?.body).toContain("Anmelden");
|
|
expect(queued?.body).toContain("Dieser Link läuft in 15 Minuten ab");
|
|
expect(queued?.body).toContain("Datenschutz");
|
|
expect(queued?.body).toContain('<html lang="de">');
|
|
expect(queued?.body).not.toContain("Click the button below");
|
|
expect(queued?.body).not.toContain("This link expires");
|
|
expect(queued?.body).not.toContain("{i18nT(");
|
|
});
|
|
|
|
it("reuses an already-bound matching user RLS context", async () => {
|
|
magicLinkTokenCreateMock.mockImplementationOnce(async () => {
|
|
expect(getRlsExecutionContext()).toEqual({ userId: "user_1" });
|
|
return { id: "token_1" } as Awaited<ReturnType<typeof db.magicLinkToken.create>>;
|
|
});
|
|
|
|
await runWithRlsContext({ userId: "user_1" }, () =>
|
|
issueMagicLink({
|
|
request: new Request("https://example.com/login"),
|
|
userId: "user_1",
|
|
email: "ada@example.com",
|
|
}),
|
|
);
|
|
|
|
expect(getRlsExecutionContext()).toBeNull();
|
|
});
|
|
});
|
|
|
|
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("does not read or consume outstanding tokens while the provider is disabled", async () => {
|
|
const issued = createSignedMagicLinkToken({ userId: "user_1", tokenId: "token_1" });
|
|
loadRequiredWebConfigMock.mockReturnValue({
|
|
cryptoSecret: "test-secret",
|
|
sessionSecret: "session-secret",
|
|
magicLink: { enabled: false },
|
|
} as ReturnType<typeof loadRequiredWebConfig>);
|
|
|
|
await expect(consumeMagicLinkToken(issued.token, { expectedPurpose: "login" })).resolves.toEqual({ ok: false, reason: "invalid" });
|
|
expect(magicLinkTokenFindUniqueMock).not.toHaveBeenCalled();
|
|
expect(magicLinkTokenUpdateManyMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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(magicLinkTokenUpdateManyMock).toHaveBeenCalledWith({
|
|
where: {
|
|
id: "token_1",
|
|
userId: "user_1",
|
|
tokenHash: issued.tokenHash,
|
|
consumedAt: null,
|
|
expiresAt: { gt: now },
|
|
},
|
|
data: { consumedAt: now },
|
|
});
|
|
});
|
|
|
|
it("allows only one winner when concurrent requests race to consume the same token", 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: null,
|
|
} as Awaited<ReturnType<typeof db.magicLinkToken.findUnique>>);
|
|
magicLinkTokenUpdateManyMock.mockResolvedValueOnce({ count: 1 }).mockResolvedValueOnce({ count: 0 });
|
|
|
|
const results = await Promise.all([consumeMagicLinkToken(issued.token, { now }), consumeMagicLinkToken(issued.token, { now })]);
|
|
|
|
expect(results).toEqual(
|
|
expect.arrayContaining([
|
|
{ ok: true, userId: "user_1", tokenId: "token_1", purpose: "login" },
|
|
{ ok: false, reason: "replayed" },
|
|
])
|
|
);
|
|
});
|
|
|
|
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(magicLinkTokenUpdateManyMock).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(magicLinkTokenUpdateManyMock).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(magicLinkTokenUpdateManyMock).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(magicLinkTokenUpdateManyMock).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(magicLinkTokenUpdateManyMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|