Files
emdash-patch-imageupload/packages/core/tests/unit/astro/signup-rate-limit.test.ts
kunthawat 2d1be52177 Emdash source with visual editor image upload fix
Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
2026-05-03 10:44:54 +07:00

158 lines
5.0 KiB
TypeScript

/**
* Rate-limit enforcement on POST /_emdash/api/auth/signup/request.
*
* The signup request route must be rate-limited per IP, mirroring the
* existing protection on magic-link/send. Without a limit, a caller on
* Cloudflare can trigger unlimited signup verification emails for any
* allowed domain.
*
* Tests drive the route handler directly with a real in-memory SQLite
* database (so checkRateLimit actually persists) and a stubbed email
* pipeline to observe send counts.
*/
import { Role } from "@emdash-cms/auth";
import type { AuthAdapter } from "@emdash-cms/auth";
import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
import type { APIContext } from "astro";
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { POST as signupRequest } from "../../../src/astro/routes/api/auth/signup/request.js";
import type { Database } from "../../../src/database/types.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
// Simulate a Cloudflare request so getClientIp returns a value. Without the
// `cf` marker, the rate limiter short-circuits with null-IP and nothing is
// enforced.
function cfRequest(url: string, body: unknown): Request {
const req = new Request(url, {
method: "POST",
headers: {
"content-type": "application/json",
"cf-connecting-ip": "198.51.100.7",
},
body: JSON.stringify(body),
});
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- test harness
(req as unknown as { cf: Record<string, unknown> }).cf = { country: "US" };
return req;
}
interface StubEmail {
send: ReturnType<typeof vi.fn>;
isAvailable: () => boolean;
}
function buildEmail(): StubEmail {
return {
send: vi.fn().mockResolvedValue(undefined),
isAvailable: () => true,
};
}
function ctx(opts: {
db: Kysely<Database>;
email: StubEmail;
body: { email: string };
}): APIContext {
const url = "http://localhost/_emdash/api/auth/signup/request";
return {
request: cfRequest(url, opts.body),
locals: {
emdash: {
db: opts.db,
email: opts.email,
},
},
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub for tests
} as unknown as APIContext;
}
describe("POST /auth/signup/request rate limiting", () => {
let db: Kysely<Database>;
let adapter: AuthAdapter;
beforeEach(async () => {
db = await setupTestDatabase();
adapter = createKyselyAdapter(db);
await adapter.createAllowedDomain("allowed.com", Role.AUTHOR);
});
afterEach(async () => {
await teardownTestDatabase(db);
});
it("sends email on the first request from an IP", async () => {
const email = buildEmail();
const res = await signupRequest(ctx({ db, email, body: { email: "a@allowed.com" } }));
expect(res.status).toBe(200);
expect(email.send).toHaveBeenCalledTimes(1);
});
it("stops sending emails after the per-IP limit is exceeded", async () => {
const email = buildEmail();
// Use 4 distinct addresses so each one would normally send — if the
// limit is absent, the stub is called 4 times. With the fix, it's 3.
for (const local of ["a", "b", "c", "d"]) {
await signupRequest(ctx({ db, email, body: { email: `${local}@allowed.com` } }));
}
// Matches magic-link/send: 3 requests per 5 minutes per IP.
expect(email.send).toHaveBeenCalledTimes(3);
});
it("always returns 200 to avoid revealing the rate limit", async () => {
const email = buildEmail();
const responses = [];
for (const local of ["a", "b", "c", "d", "e"]) {
responses.push(
await signupRequest(ctx({ db, email, body: { email: `${local}@allowed.com` } })),
);
}
// All responses are 200 with the generic success envelope. The rate
// limit is invisible to the caller (which also keeps signup
// indistinguishable from disallowed-domain).
expect(responses.every((r) => r.status === 200)).toBe(true);
});
it("tracks the limit per IP, not globally", async () => {
const email = buildEmail();
const url = "http://localhost/_emdash/api/auth/signup/request";
function req(ip: string, addr: string): Request {
const r = new Request(url, {
method: "POST",
headers: {
"content-type": "application/json",
"cf-connecting-ip": ip,
},
body: JSON.stringify({ email: addr }),
});
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- test harness
(r as unknown as { cf: Record<string, unknown> }).cf = { country: "US" };
return r;
}
function makeCtx(request: Request): APIContext {
return {
request,
locals: { emdash: { db, email } },
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub
} as unknown as APIContext;
}
// Exhaust IP A
for (const local of ["a", "b", "c", "d"]) {
await signupRequest(makeCtx(req("198.51.100.7", `${local}@allowed.com`)));
}
expect(email.send).toHaveBeenCalledTimes(3);
// IP B still gets through
await signupRequest(makeCtx(req("198.51.100.8", "x@allowed.com")));
expect(email.send).toHaveBeenCalledTimes(4);
});
});