refactor: keep the size, cast and error-code guards where they were

The fixes above grew six files over 800 lines and three long functions, added
casts in tests and one error code the catalogue did not list. The claimable
profile check moves into its own module, the guardian count is reused by the
revoke path, the lifecycle fallback lives beside the lifecycle rules, the
key-needs list is its own component, the coach sweep wraps its filters with
ownedByActiveAccount, comments are tightened, and the test fixtures are typed
instead of cast. The enumeration guard reads a write keyed by one handed id
(id: runId, id: job.data.jobId) as scoped, so those need no marker.
account.deletion.no_guardian joins the catalogue; two baselines go down.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 18:06:32 +01:00
1 parent 42940c3a98
commit d2b8da7839
27 files changed
+302 -279

No files matched your search

@@ -124,23 +124,11 @@ describe("PUT /api/admin/users/[id]", () => {
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/last admin/i);
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
});
// An admin pending deletion cannot sign in and is erased when the window
// ends; counted, they let the last usable admin step down.
it("counts only the other admins who are not pending deletion, under the roster lock", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "u1",
role: "ADMIN",
} as never);
vi.mocked(prisma.user.count).mockResolvedValue(0);
await PUT(jsonReq({ role: "USER" }), params("u1"));
// Only the other admins who are not pending deletion count (one pending
// deletion cannot sign in and is erased when the window ends), under the
// roster lock an admin's own deletion takes too.
expect(vi.mocked(prisma.user.count).mock.calls[0]?.[0]).toEqual({
where: {
role: "ADMIN",
id: { not: "u1" },
deletionRequestedAt: null,
},
where: { role: "ADMIN", id: { not: "u1" }, deletionRequestedAt: null },
});
expect(prisma.$queryRaw).toHaveBeenCalled();
});
@@ -30,6 +30,9 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` }));
vi.mock("@/lib/account-deletion/pending", () => ({
pendingDeletionOf: vi.fn(async () => null),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
checkAuthSurfaceRateLimit: vi.fn(),
@@ -67,6 +70,7 @@ import { checkAuthSurfaceRateLimit, checkRateLimit } from "@/lib/rate-limit";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { auditLog } from "@/lib/auth/audit";
import { pendingDeletionOf } from "@/lib/account-deletion/pending";
function postReq(
body: unknown,
@@ -98,7 +102,6 @@ beforeEach(() => {
revoked: false,
expiresAt: null,
lastUsedAt: null,
user: { deletionRequestedAt: null },
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
@@ -210,7 +213,6 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.
revoked: false,
expiresAt: null,
lastUsedAt: null,
user: { deletionRequestedAt: null },
} as never);
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
@@ -375,8 +377,7 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.
revoked: false,
expiresAt: null,
lastUsedAt: null,
user: { deletionRequestedAt: null },
} as never);
} as Awaited<ReturnType<typeof prisma.apiToken.findUnique>>);
}
it("gives a one-medication token the same 403 for an unknown and an out-of-scope name", async () => {
@@ -539,7 +540,6 @@ describe("POST /api/ingest/medication — the rate-limit bucket", () => {
revoked: false,
expiresAt: null,
lastUsedAt: null,
user: { deletionRequestedAt: null },
} as never)
.mockResolvedValueOnce({
id: "tok-button",
@@ -548,7 +548,6 @@ describe("POST /api/ingest/medication — the rate-limit bucket", () => {
revoked: false,
expiresAt: null,
lastUsedAt: null,
user: { deletionRequestedAt: null },
} as never);
await POST(postReq({ medicationName: "A", idempotencyKey: "k-aaaa-001" }));
@@ -608,19 +607,15 @@ describe("POST /api/ingest/medication — the rate-limit bucket", () => {
// The deletion revokes every token in its own transaction; one issued in
// the same instant would slip past that, and is refused here as well.
it("refuses the token of an account pending deletion", async () => {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "tok-1",
userId: "user-1",
permissions: ["*"],
revoked: false,
expiresAt: null,
lastUsedAt: null,
user: { deletionRequestedAt: new Date() },
} as never);
vi.mocked(pendingDeletionOf).mockResolvedValueOnce({
requestedAt: new Date(),
purgeAt: null,
});
const res = await POST(
postReq({ medicationName: "Metformin", idempotencyKey: "k-pending-001" }),
);
expect(res.status).toBe(401);
expect(pendingDeletionOf).toHaveBeenCalledWith("user-1");
});
it("falls back to the per-IP bucket when the token does not resolve", async () => {
+10 -20
View File
@@ -10,6 +10,7 @@ import {
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { hashToken } from "@/lib/auth/hmac";
import { pendingDeletionOf } from "@/lib/account-deletion/pending";
import {
checkAuthSurfaceRateLimit,
checkRateLimit,
@@ -103,18 +104,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
revoked: true,
expiresAt: true,
lastUsedAt: true,
user: { select: { deletionRequestedAt: true } },
},
});
// A deletion revokes the account's tokens in its own transaction; a token
// issued in the same instant would slip past that, so the pending account
// is refused here too, as `resolveBearerToken` does.
if (
!apiToken ||
apiToken.revoked ||
apiToken.user.deletionRequestedAt !== null
) {
// A pending account's token is refused, as `resolveBearerToken` does.
const pending = apiToken && (await pendingDeletionOf(apiToken.userId));
if (!apiToken || apiToken.revoked || pending) {
return refuseUnauthenticated(request, "Invalid or revoked token");
}
@@ -122,17 +117,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
return refuseUnauthenticated(request, "Token expired");
}
// A bridge is identified by its TOKEN, not by the address it dials from.
// The bucket used to be `ingest:${ip}`, so every integration behind one
// NAT shared a single 60/min cell and one chatty bridge starved the rest —
// and a household running a chat bridge, a button and a home-automation
// rule is the normal deployment here, not an edge case.
//
// The identity is only known after the lookup, so the lookup now runs
// before the limiter: one indexed single-row read ahead of what was
// already an upsert against `rate_limits`. A caller whose token does not
// resolve is still capped on the per-IP bucket it was always capped on,
// one step earlier than it reaches anything expensive.
// A bridge is identified by its TOKEN, not by the address it dials from:
// keyed `ingest:${ip}`, every integration behind one NAT (a chat bridge, a
// button, a home-automation rule: the normal household) shared one 60/min
// cell and one chatty bridge starved the rest. So the lookup runs before
// the limiter, one indexed read ahead of the `rate_limits` upsert; a token
// that does not resolve is still capped on the per-IP bucket, earlier.
const rl = await checkRateLimit(
`ingest:token:${apiToken.id}`,
RATE_LIMIT,