fix: answer 409 for a stale policy save also when another bundle made the config manager #1066

This commit is contained in:
Linus Rath
2026-10-31 23:17:36 +01:00
parent a048689179
commit ee5a107649
4 changed files with 35 additions and 4 deletions
@@ -101,4 +101,26 @@ describe('policy save with If-Match', () => {
expect(res.status).toBe(200);
expect((await stored()).features.calendarEnabled).toBe(false);
});
it('answers 409 when the config manager was made by another bundle\'s copy of the module', async () => {
// In the server the instrumentation creates the config manager, so its
// errors are of the instrumentation's classes, not the route's.
const KEY = Symbol.for('bulwark.admin.configManager');
const g = globalThis as Record<symbol, unknown>;
const mine = g[KEY];
try {
delete g[KEY];
vi.resetModules();
await (await import('@/lib/admin/config-manager')).configManager.load();
vi.resetModules();
const route = await import('@/app/api/admin/policy/route');
const res = await route.GET(new NextRequest(URL));
const etag = res.headers.get('ETag')!;
await writeFile(path.join(dir, 'policy.json'), JSON.stringify({ features: { calendarEnabled: false } }));
const stale = await route.PUT(new NextRequest(URL, { method: 'PUT', headers: { 'if-match': etag }, body: JSON.stringify(await res.json()) }));
expect(stale.status).toBe(409);
} finally {
g[KEY] = mine;
}
});
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { completePolicy, configManager, PolicyConflictError } from '@/lib/admin/config-manager';
import { completePolicy, configManager, isPolicyConflict } from '@/lib/admin/config-manager';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
@@ -71,7 +71,7 @@ export async function POST(request: NextRequest) {
await configManager.setPolicy(next, { ifVersion: version });
break;
} catch (error) {
if (!(error instanceof PolicyConflictError)) throw error;
if (!isPolicyConflict(error)) throw error;
if (attempt >= 3) return NextResponse.json({ error: error.message }, { status: 409 });
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { configManager, PolicyConflictError } from '@/lib/admin/config-manager';
import { configManager, isPolicyConflict } from '@/lib/admin/config-manager';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
@@ -135,7 +135,7 @@ export async function PUT(request: NextRequest) {
try {
version = await configManager.setPolicy(policy, { ifVersion: ifMatchVersion(request) });
} catch (error) {
if (error instanceof PolicyConflictError) {
if (isPolicyConflict(error)) {
return NextResponse.json({ error: error.message }, { status: 409 });
}
throw error;
+9
View File
@@ -59,6 +59,15 @@ export class PolicyConflictError extends Error {
}
}
/**
* Whether `error` is a PolicyConflictError. By name, not instanceof: the
* config manager on globalThis may come from another bundle's copy of this
* module (the instrumentation creates it), whose class is a different object.
*/
export function isPolicyConflict(error: unknown): error is PolicyConflictError {
return error instanceof Error && error.name === 'PolicyConflictError';
}
/** Policy writes in this process, one after another, so a version check and its write can't interleave. */
const POLICY_WRITE_QUEUE = 'admin-config\0policy.json';