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

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();
});
});