diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index c775eb4f..a3c5a88e 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -8,7 +8,8 @@ "start": "next start", "lint": "next lint", "check": "tsc --noEmit", - "tsc": "tsc --noEmit" + "tsc": "tsc --noEmit", + "test": "deno test --parallel -A --no-check --sloppy-imports --import-map=test.importmap.json src" }, "dependencies": { "@ai-sdk/react": "catalog:", @@ -106,6 +107,8 @@ "zod": "catalog:" }, "devDependencies": { + "@std/expect": "jsr:^1.0.19", + "@std/testing": "jsr:^1.0.19", "@tailwindcss/postcss": "catalog:", "@tailwindcss/typography": "catalog:", "@types/dom-speech-recognition": "catalog:", diff --git a/apps/dashboard/src/lib/auth/providers.test.ts b/apps/dashboard/src/lib/auth/providers.test.ts new file mode 100644 index 00000000..734f8242 --- /dev/null +++ b/apps/dashboard/src/lib/auth/providers.test.ts @@ -0,0 +1,69 @@ +import "@/test-preload"; +import { AuthError } from "@auth/core/errors"; +import { redis } from "@openstatus/upstash"; +import { expect } from "@std/expect"; +import { afterEach, describe, test } from "@std/testing/bdd"; +import { type Stub, stub } from "@std/testing/mock"; + +import { ResendProvider } from "./providers"; + +let evalStub: Stub | undefined; +let logStub: Stub | undefined; + +afterEach(() => { + evalStub?.restore(); + logStub?.restore(); +}); + +function sendTo(identifier: string, ip = "1.2.3.4") { + // `Resend()` keeps overrides under `options`; Auth.js merges them at init. + const send = ResendProvider.options?.sendVerificationRequest; + if (!send) throw new Error("sendVerificationRequest override missing"); + return send({ + identifier, + url: "https://app.openstatus.dev/api/auth/callback/resend?token=t", + token: "t", + expires: new Date(Date.now() + 60_000), + provider: ResendProvider, + request: new Request("https://app.openstatus.dev/api/auth/signin/resend", { + headers: { "x-forwarded-for": ip }, + }), + theme: {}, + }); +} + +describe("ResendProvider.sendVerificationRequest", () => { + test("refuses disposable domains before touching the rate limit", async () => { + evalStub = stub(redis, "eval"); + + await expect(sendTo("a@mailinator.com")).rejects.toThrow(AuthError); + await expect(sendTo("a@mailinator.com")).rejects.toThrow( + "disposable domain", + ); + expect(evalStub.calls).toHaveLength(0); + }); + + test("refuses throttled addresses", async () => { + evalStub = stub(redis, "eval", () => Promise.resolve([1, 4])); + + await expect(sendTo("gilfoyle@piedpiper.dev")).rejects.toThrow( + "rate limited", + ); + }); + + test("sends when screening passes, keyed by client ip and address", async () => { + evalStub = stub(redis, "eval", () => Promise.resolve([1, 1])); + logStub = stub(console, "log"); + + await sendTo("gilfoyle@piedpiper.dev", "9.9.9.9"); + + expect(evalStub.calls[0]?.args[1]).toEqual([ + "ratelimit:magic-link:ip:9.9.9.9", + "ratelimit:magic-link:email:gilfoyle@piedpiper.dev", + ]); + const printed = logStub.calls.map((c) => String(c.args[0])).join("\n"); + expect(printed).toContain( + "https://app.openstatus.dev/api/auth/callback/resend?token=t", + ); + }); +}); diff --git a/apps/dashboard/src/lib/auth/providers.ts b/apps/dashboard/src/lib/auth/providers.ts index f4f9ef70..a19e02cb 100644 --- a/apps/dashboard/src/lib/auth/providers.ts +++ b/apps/dashboard/src/lib/auth/providers.ts @@ -1,6 +1,7 @@ +import { AuthError } from "@auth/core/errors"; import { EmailClient } from "@openstatus/emails"; import { resolveClientIp } from "@openstatus/services/page-access"; -import { AuthError, type Profile } from "next-auth"; +import type { Profile } from "next-auth"; import type { OIDCConfig } from "next-auth/providers"; import GitHub from "next-auth/providers/github"; import Google from "next-auth/providers/google"; diff --git a/apps/dashboard/src/lib/rate-limit/magic-link.test.ts b/apps/dashboard/src/lib/rate-limit/magic-link.test.ts new file mode 100644 index 00000000..35e35f54 --- /dev/null +++ b/apps/dashboard/src/lib/rate-limit/magic-link.test.ts @@ -0,0 +1,57 @@ +import "@/test-preload"; +import { redis } from "@openstatus/upstash"; +import { expect } from "@std/expect"; +import { afterEach, describe, test } from "@std/testing/bdd"; +import { type Stub, stub } from "@std/testing/mock"; + +import { magicLinkRateLimit } from "./magic-link"; + +let evalStub: Stub | undefined; +let warnStub: Stub | undefined; + +function stubCounts(counts: number[] | Error) { + evalStub = stub(redis, "eval", () => + counts instanceof Error ? Promise.reject(counts) : Promise.resolve(counts), + ); +} + +afterEach(() => { + evalStub?.restore(); + warnStub?.restore(); +}); + +describe("magicLinkRateLimit", () => { + test("keys the counters by ip and email", async () => { + stubCounts([1, 1]); + + await magicLinkRateLimit({ ip: "1.2.3.4", email: "a@b.c" }); + + expect(evalStub?.calls[0]?.args[1]).toEqual([ + "ratelimit:magic-link:ip:1.2.3.4", + "ratelimit:magic-link:email:a@b.c", + ]); + }); + + test("allows at the limits", async () => { + stubCounts([10, 3]); + expect(await magicLinkRateLimit({ ip: "ip", email: "e" })).toBe(true); + }); + + test("refuses past the ip limit", async () => { + stubCounts([11, 1]); + expect(await magicLinkRateLimit({ ip: "ip", email: "e" })).toBe(false); + }); + + test("refuses past the email limit", async () => { + stubCounts([1, 4]); + expect(await magicLinkRateLimit({ ip: "ip", email: "e" })).toBe(false); + }); + + test("fails open with a warning when redis is down", async () => { + stubCounts(new Error("redis down")); + warnStub = stub(console, "warn"); + + expect(await magicLinkRateLimit({ ip: "ip", email: "e" })).toBe(true); + expect(warnStub.calls).toHaveLength(1); + }); +}); diff --git a/apps/dashboard/src/lib/rate-limit/magic-link.ts b/apps/dashboard/src/lib/rate-limit/magic-link.ts index 8d541443..d69e2245 100644 --- a/apps/dashboard/src/lib/rate-limit/magic-link.ts +++ b/apps/dashboard/src/lib/rate-limit/magic-link.ts @@ -1,4 +1,4 @@ -import { incrWithTtl } from "./incr-with-ttl"; +import { incrWithTtl, redis } from "@openstatus/upstash"; const WINDOW_SECONDS = 60 * 10; const MAX_PER_IP = 10; @@ -14,6 +14,7 @@ export async function magicLinkRateLimit(args: { }): Promise { try { const [byIp, byEmail] = await incrWithTtl( + redis, [ `ratelimit:magic-link:ip:${args.ip}`, `ratelimit:magic-link:email:${args.email}`, diff --git a/apps/dashboard/src/lib/rate-limit/sso-lookup.ts b/apps/dashboard/src/lib/rate-limit/sso-lookup.ts index eab9369f..e63d7d27 100644 --- a/apps/dashboard/src/lib/rate-limit/sso-lookup.ts +++ b/apps/dashboard/src/lib/rate-limit/sso-lookup.ts @@ -1,4 +1,4 @@ -import { incrWithTtl } from "./incr-with-ttl"; +import { incrWithTtl, redis } from "@openstatus/upstash"; const WINDOW_SECONDS = 60 * 10; const MAX_ATTEMPTS = 10; @@ -10,6 +10,7 @@ const MAX_ATTEMPTS = 10; export async function ssoLookupRateLimit(ip: string): Promise { try { const [count] = await incrWithTtl( + redis, [`ratelimit:sso-lookup:${ip}`], WINDOW_SECONDS, ); diff --git a/apps/dashboard/src/test-preload.ts b/apps/dashboard/src/test-preload.ts new file mode 100644 index 00000000..b471ee4a --- /dev/null +++ b/apps/dashboard/src/test-preload.ts @@ -0,0 +1,5 @@ +// Import FIRST in every test file: `@openstatus/emails` validates +// RESEND_API_KEY and snapshots NODE_ENV on import. Development keeps +// EmailClient off the network. +Object.assign(process.env, { NODE_ENV: "development" }); +process.env.RESEND_API_KEY ??= "test-key"; diff --git a/apps/dashboard/src/test/upstash.mock.ts b/apps/dashboard/src/test/upstash.mock.ts new file mode 100644 index 00000000..c20a9af7 --- /dev/null +++ b/apps/dashboard/src/test/upstash.mock.ts @@ -0,0 +1,11 @@ +// Test double for @openstatus/upstash, swapped in via --import-map. The real +// client is a pipelining Proxy that `stub()` cannot intercept. +export { incrWithTtl } from "../../../../packages/upstash/src/redis/incr-with-ttl"; + +export const redis = { + eval: ( + _script: string, + _keys: string[], + _args: unknown[], + ): Promise => Promise.resolve([1, 1]), +}; diff --git a/apps/dashboard/test.importmap.json b/apps/dashboard/test.importmap.json new file mode 100644 index 00000000..b9dd92bc --- /dev/null +++ b/apps/dashboard/test.importmap.json @@ -0,0 +1,6 @@ +{ + "imports": { + "@/": "./src/", + "@openstatus/upstash": "./src/test/upstash.mock.ts" + } +} diff --git a/deno.lock b/deno.lock index af5e05d4..212bdcce 100644 --- a/deno.lock +++ b/deno.lock @@ -60,6 +60,14 @@ ] }, "members": { + "apps/dashboard": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, "apps/server": { "packageJson": { "dependencies": [ @@ -292,6 +300,14 @@ ] } }, + "packages/upstash": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, "packages/utils": { "packageJson": { "dependencies": [ diff --git a/packages/emails/emails/dashboard-magic-link.tsx b/packages/emails/emails/dashboard-magic-link.tsx index ece4e8ed..e1f824ad 100644 --- a/packages/emails/emails/dashboard-magic-link.tsx +++ b/packages/emails/emails/dashboard-magic-link.tsx @@ -1,9 +1,13 @@ /** @jsxRuntime automatic @jsxImportSource react */ +import { Text } from "react-email"; + import { Actions } from "./_components/actions"; +import { CodeBlock } from "./_components/code-block"; import { Footer } from "./_components/footer"; import { Heading } from "./_components/heading"; import { Layout } from "./_components/layout"; +import { styles } from "./_components/styles"; export interface DashboardMagicLinkProps { link: string; @@ -23,6 +27,10 @@ const DashboardMagicLinkEmail = ({ link }: DashboardMagicLinkProps) => { once. + + If the button doesn’t work, copy this link into your browser: + + {link} ); }; diff --git a/packages/emails/src/templates.test.tsx b/packages/emails/src/templates.test.tsx index 8ad1da96..5bc2d159 100644 --- a/packages/emails/src/templates.test.tsx +++ b/packages/emails/src/templates.test.tsx @@ -668,6 +668,8 @@ describe("account and status page mail", () => { expect(html).toContain("Sign in to openstatus"); expect(html).toContain("24 hours"); expect(html.split(`href="${link}"`).length - 1).toBe(1); + // raw link rendered as copyable text, not a second anchor + expect(html).toContain(`>${link}

`); expect(html).toContain('href="https://www.openstatus.dev"'); }); }); diff --git a/packages/upstash/package.json b/packages/upstash/package.json index 68403d46..fd768f2e 100644 --- a/packages/upstash/package.json +++ b/packages/upstash/package.json @@ -4,7 +4,8 @@ "license": "MIT", "main": "./src/index.ts", "scripts": { - "check": "deno check --sloppy-imports ." + "check": "deno check --sloppy-imports .", + "test": "deno test --parallel -A --no-check --sloppy-imports" }, "dependencies": { "@upstash/qstash": "catalog:", @@ -12,6 +13,8 @@ }, "devDependencies": { "@openstatus/tsconfig": "workspace:*", + "@std/expect": "jsr:^1.0.19", + "@std/testing": "jsr:^1.0.19", "@types/node": "catalog:", "tsup": "catalog:", "typescript": "catalog:" diff --git a/packages/upstash/src/index.ts b/packages/upstash/src/index.ts index 4ecbe4f1..9b7ec401 100644 --- a/packages/upstash/src/index.ts +++ b/packages/upstash/src/index.ts @@ -1,2 +1,3 @@ export * from "./redis/client"; +export * from "./redis/incr-with-ttl"; export * from "@upstash/redis"; diff --git a/packages/upstash/src/redis/incr-with-ttl.test.ts b/packages/upstash/src/redis/incr-with-ttl.test.ts new file mode 100644 index 00000000..03183e50 --- /dev/null +++ b/packages/upstash/src/redis/incr-with-ttl.test.ts @@ -0,0 +1,47 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { incrWithTtl } from "./incr-with-ttl"; + +function fakeClient(counts: number[]) { + const calls: { script: string; keys: string[]; args: unknown[] }[] = []; + const client = { + eval: (script: string, keys: string[], args: unknown[]) => { + calls.push({ script, keys, args }); + return Promise.resolve(counts); + }, + }; + return { client: client as never, calls }; +} + +describe("incrWithTtl", () => { + test("sends every key in one eval with the shared window", async () => { + const { client, calls } = fakeClient([3, 1]); + + const counts = await incrWithTtl(client, ["a", "b"], 600); + + expect(counts).toEqual([3, 1]); + expect(calls).toHaveLength(1); + expect(calls[0]?.keys).toEqual(["a", "b"]); + expect(calls[0]?.args).toEqual([600]); + }); + + test("the script sets the TTL only on the key's first hit", () => { + const { client, calls } = fakeClient([1]); + incrWithTtl(client, ["a"], 600); + + const script = calls[0]?.script ?? ""; + expect(script).toContain("for i, key in ipairs(KEYS)"); + expect(script).toContain("redis.call('INCR', key)"); + expect(script).toContain("if count == 1 then"); + expect(script).toContain("redis.call('EXPIRE', key, tonumber(ARGV[1]))"); + }); + + test("propagates client failures", async () => { + const client = { + eval: () => Promise.reject(new Error("redis down")), + } as never; + + await expect(incrWithTtl(client, ["a"], 600)).rejects.toThrow("redis down"); + }); +}); diff --git a/apps/dashboard/src/lib/rate-limit/incr-with-ttl.ts b/packages/upstash/src/redis/incr-with-ttl.ts similarity index 65% rename from apps/dashboard/src/lib/rate-limit/incr-with-ttl.ts rename to packages/upstash/src/redis/incr-with-ttl.ts index caebda9c..221ed63c 100644 --- a/apps/dashboard/src/lib/rate-limit/incr-with-ttl.ts +++ b/packages/upstash/src/redis/incr-with-ttl.ts @@ -1,4 +1,4 @@ -import { redis } from "@openstatus/upstash"; +import type { Redis } from "@upstash/redis"; // INCR + conditional EXPIRE in one round-trip so a crash between the two can't // leave a TTL-less key. Every key shares the window. @@ -15,6 +15,10 @@ const INCR_WITH_TTL = ` `; /** Current count per key after this hit, in `keys` order. */ -export function incrWithTtl(keys: string[], windowSeconds: number) { - return redis.eval<[number], number[]>(INCR_WITH_TTL, keys, [windowSeconds]); +export function incrWithTtl( + client: Pick, + keys: string[], + windowSeconds: number, +) { + return client.eval<[number], number[]>(INCR_WITH_TTL, keys, [windowSeconds]); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99315954..f370ecea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -965,6 +965,12 @@ importers: specifier: 'catalog:' version: 4.6.5 devDependencies: + '@std/expect': + specifier: jsr:^1.0.19 + version: '@jsr/std__expect@1.0.20' + '@std/testing': + specifier: jsr:^1.0.19 + version: '@jsr/std__testing@1.0.20' '@tailwindcss/postcss': specifier: 'catalog:' version: 4.3.3 @@ -3302,6 +3308,12 @@ importers: '@openstatus/tsconfig': specifier: workspace:* version: link:../tsconfig + '@std/expect': + specifier: jsr:^1.0.19 + version: '@jsr/std__expect@1.0.20' + '@std/testing': + specifier: jsr:^1.0.19 + version: '@jsr/std__testing@1.0.20' '@types/node': specifier: 'catalog:' version: 26.6.2