mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
Cap the profile email-address conflict answer
Changing the profile address asks whether another account already holds it, and the answer is 409 versus 200 — an existence check over every address on the instance, open to any signed-in caller. Neither route that reaches it had a ceiling of any kind and the API wrapper supplies no default, so the only bound on a sweep was the network. Both routes now share one per-account bucket, ten an hour, charged before the question is asked so a refused caller learns nothing. Only a request whose address actually differs from the one on file is counted, so re-saving a settings form that carries the unchanged address stays free. The refusal is the standard 429 with Retry-After and the X-RateLimit triple, carries `profile.update.emailRateLimited`, and is published on both operations.
This commit is contained in:
1 parent
6a5fb338b1
commit
fe420f76d8
17 files changed
+596
-181
No files matched your search
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* The profile email-address ceiling is the one the contract describes.
|
||||
*
|
||||
* Changing the address asks whether another account already holds it, and the
|
||||
* answer is a 409 versus a 200 — an existence check over every address on the
|
||||
* instance, available to any signed-in caller, including the native client's
|
||||
* own wildcard token. Neither route that reaches it had a ceiling of any kind,
|
||||
* and `apiHandler` supplies no default, so the sweep was bounded only by the
|
||||
* network.
|
||||
*
|
||||
* A published number is worth reading only if it cannot drift from the code,
|
||||
* so this pulls the bucket key and the window out of `prisma.$queryRaw` — the
|
||||
* technique `rate-limit-auth-surface.test.ts` and the record-write contract
|
||||
* both use — and holds the emitted OpenAPI description to what came back.
|
||||
* Reading both from the same constant would prove nothing: the sentence is
|
||||
* hand-written, deliberately, and this is what keeps the hand-written half
|
||||
* honest.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
PROFILE_EMAIL_BUCKET_PREFIX,
|
||||
PROFILE_EMAIL_LIMIT,
|
||||
PROFILE_EMAIL_WINDOW_MS,
|
||||
checkProfileEmailRateLimit,
|
||||
} from "@/lib/rate-limit";
|
||||
import { buildOpenApiDocument } from "@/lib/openapi/registry";
|
||||
import { profileEmailRateLimitResponse } from "@/lib/openapi/routes/shared";
|
||||
import { ERROR_CODE_CATALOGUE } from "@/lib/openapi/error-codes";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: { $queryRaw: vi.fn() } }));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** The published sentence, read from the object the route modules spread. */
|
||||
const published = profileEmailRateLimitResponse["429"].description;
|
||||
|
||||
/** Both doors onto the one handler that answers the conflict. */
|
||||
const OPERATIONS: Array<[path: string, method: "put" | "patch"]> = [
|
||||
["/api/auth/profile", "put"],
|
||||
["/api/user/profile", "patch"],
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.$queryRaw).mockReset();
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
{ count: 1, reset_at: new Date(Date.now() + 60_000) },
|
||||
] as never);
|
||||
});
|
||||
|
||||
describe("the profile email-address bucket", () => {
|
||||
it("keys on the acting account under the name the contract publishes", async () => {
|
||||
await checkProfileEmailRateLimit("account-7");
|
||||
|
||||
const call = vi.mocked(prisma.$queryRaw).mock.calls.at(-1)!;
|
||||
// `$queryRaw`INSERT … VALUES (${key}, …)`` lands the key at index 1 and
|
||||
// the window interval at index 2. Read from the query rather than from
|
||||
// the constant, so a renamed prefix that never reached the SQL fails.
|
||||
expect(call[1]).toBe("profile-email:account-7");
|
||||
expect(call[2]).toBe(`${PROFILE_EMAIL_WINDOW_MS} milliseconds`);
|
||||
expect(`${PROFILE_EMAIL_BUCKET_PREFIX}:account-7`).toBe(call[1]);
|
||||
expect(published).toContain("`profile-email:<accountId>`");
|
||||
});
|
||||
|
||||
it("gives a different account a different bucket, not a shared one", async () => {
|
||||
await checkProfileEmailRateLimit("account-7");
|
||||
const first = vi.mocked(prisma.$queryRaw).mock.calls.at(-1)![1];
|
||||
await checkProfileEmailRateLimit("account-8");
|
||||
const second = vi.mocked(prisma.$queryRaw).mock.calls.at(-1)![1];
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
|
||||
it("refuses at the cap the contract publishes", async () => {
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
|
||||
{ count: PROFILE_EMAIL_LIMIT, reset_at: new Date(Date.now() + 60_000) },
|
||||
] as never);
|
||||
await expect(
|
||||
checkProfileEmailRateLimit("account-7"),
|
||||
).resolves.toMatchObject({
|
||||
allowed: true,
|
||||
limit: PROFILE_EMAIL_LIMIT,
|
||||
remaining: 0,
|
||||
});
|
||||
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
|
||||
{
|
||||
count: PROFILE_EMAIL_LIMIT + 1,
|
||||
reset_at: new Date(Date.now() + 60_000),
|
||||
},
|
||||
] as never);
|
||||
await expect(
|
||||
checkProfileEmailRateLimit("account-7"),
|
||||
).resolves.toMatchObject({ allowed: false });
|
||||
|
||||
const windowSeconds = PROFILE_EMAIL_WINDOW_MS / 1000;
|
||||
expect(published).toContain(
|
||||
`${PROFILE_EMAIL_LIMIT} requests per ${windowSeconds} seconds`,
|
||||
);
|
||||
});
|
||||
|
||||
it("says the headers describe that bucket, and declares them", () => {
|
||||
expect(Object.keys(profileEmailRateLimitResponse["429"].headers)).toEqual([
|
||||
"Retry-After",
|
||||
"X-RateLimit-Limit",
|
||||
"X-RateLimit-Remaining",
|
||||
"X-RateLimit-Reset",
|
||||
]);
|
||||
expect(published).toContain("Retry-After");
|
||||
});
|
||||
|
||||
it("publishes a code a client can branch on, and registers it", () => {
|
||||
expect(published).toContain("profile.update.emailRateLimited");
|
||||
expect(ERROR_CODE_CATALOGUE.profile).toContain(
|
||||
"profile.update.emailRateLimited",
|
||||
);
|
||||
});
|
||||
|
||||
it("is published on both operations that answer it", () => {
|
||||
const doc = buildOpenApiDocument() as unknown as {
|
||||
paths: Record<
|
||||
string,
|
||||
Record<string, { responses?: Record<string, { description?: string }> }>
|
||||
>;
|
||||
};
|
||||
const unpublished = OPERATIONS.filter(
|
||||
([path, method]) =>
|
||||
doc.paths[path]?.[method]?.responses?.["429"]?.description !==
|
||||
published,
|
||||
).map(([path]) => path);
|
||||
expect(unpublished).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user