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
This commit is contained in:
157
packages/core/tests/unit/astro/signup-rate-limit.test.ts
Normal file
157
packages/core/tests/unit/astro/signup-rate-limit.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user