Skip to content

Commit 82045b6

Browse files
fix(api): handle FK violation in version restore endpoint (#1115)
The restore endpoint (POST /api/pages/[pageId]/versions/[versionId]) was missing isForeignKeyViolationError handling when inserting a snapshot into page_versions. If the page was deleted between auth check and insert (race condition during E2E teardown or concurrent user sessions), the FK constraint violation was sent to Sentry instead of returning 404. The sibling creation endpoint already handled this correctly. Closes #1114 Co-authored-by: Ona <no-reply@ona.com>
1 parent 22e259e commit 82045b6

2 files changed

Lines changed: 37 additions & 1 deletion

File tree

src/app/api/pages/[pageId]/versions/[versionId]/route.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ const captureApiErrorMock = vi.fn();
5656
vi.mock("@/lib/sentry", () => ({
5757
captureApiError: (...args: unknown[]) => captureApiErrorMock(...args),
5858
captureSupabaseError: vi.fn(),
59+
isForeignKeyViolationError: (err: Error & { code?: string }) =>
60+
err.code === "23503" ||
61+
err.message?.includes("violates foreign key constraint"),
5962
isInsufficientPrivilegeError: (err: Error & { code?: string }) =>
6063
err.code === "42501" ||
6164
err.message?.includes("violates row-level security policy"),
@@ -320,4 +323,31 @@ describe("POST /api/pages/[pageId]/versions/[versionId] (restore)", () => {
320323
"Version restore temporarily unavailable, please try again",
321324
);
322325
});
326+
327+
it("returns 404 when snapshot insert hits FK violation (page deleted, #1114)", async () => {
328+
mockGetUser.mockReset();
329+
mockGetUser.mockResolvedValue({ data: { user: { id: "user-1" } } });
330+
getVersionResult = { data: { content: { root: {} } }, error: null };
331+
getPageResult = { data: { content: { root: {} } }, error: null };
332+
insertVersionResult = {
333+
data: null,
334+
error: {
335+
code: "23503",
336+
message: 'insert or update on table "page_versions" violates foreign key constraint "page_versions_page_id_fkey"',
337+
details: "Key (page_id)=(40cd1f0a-63a0-4b11-aee6-6fc5b6dab600) is not present in table \"pages\".",
338+
hint: null,
339+
},
340+
};
341+
342+
const res = await POST(
343+
makeRequest("/api/pages/page-123/versions/version-456", {
344+
method: "POST",
345+
body: JSON.stringify({ action: "restore" }),
346+
}),
347+
{ params: mockParams },
348+
);
349+
expect(res.status).toBe(404);
350+
const body = await res.json();
351+
expect(body.error).toBe("Page not found");
352+
});
323353
});

src/app/api/pages/[pageId]/versions/[versionId]/route.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { NextResponse, type NextRequest } from "next/server";
22
import { createClient } from "@/lib/supabase/server";
3-
import { captureApiError, captureSupabaseError, isInsufficientPrivilegeError, isTransientNetworkError } from "@/lib/sentry";
3+
import { captureApiError, captureSupabaseError, isForeignKeyViolationError, isInsufficientPrivilegeError, isTransientNetworkError } from "@/lib/sentry";
44
import { retryOnNetworkError } from "@/lib/retry";
55
import { withRateLimit, getClientIp } from "@/lib/rate-limit";
66

@@ -158,6 +158,9 @@ async function postHandler(
158158
);
159159

160160
if (snapshotError) {
161+
if (isForeignKeyViolationError(snapshotError)) {
162+
return NextResponse.json({ error: "Page not found" }, { status: 404 });
163+
}
161164
captureSupabaseError(snapshotError, "page-versions:restore-snapshot");
162165
return NextResponse.json(
163166
{ error: "Failed to save current version before restore" },
@@ -185,6 +188,9 @@ async function postHandler(
185188
if (error instanceof Error && isInsufficientPrivilegeError(error)) {
186189
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
187190
}
191+
if (error instanceof Error && isForeignKeyViolationError(error)) {
192+
return NextResponse.json({ error: "Page not found" }, { status: 404 });
193+
}
188194
captureApiError(error, "page-versions:restore");
189195

190196
const isTransient =

0 commit comments

Comments
 (0)