fix(10): guard shared-calendar designation against non-existent target (CR-01)
PUT /api/admin/calendars/:id/shared cleared the current shared calendar then set the target in two non-transactional UPDATEs without checking the target exists — a bad/stale id wiped the family shared lane and still returned ok. Verify the target inside a transaction; return 404 when absent. Adds a regression test (RED→GREEN).
This commit is contained in:
@@ -153,11 +153,28 @@ adminRouter.put('/calendars/:id/shared', async (c) => {
|
|||||||
return c.json({ error: 'Invalid calendar id' }, 400);
|
return c.json({ error: 'Invalid calendar id' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Clear is_shared on any currently-shared calendar
|
// CR-01: verify the target exists and flip the shared lane atomically.
|
||||||
await db.update(calendars).set({ isShared: false }).where(eq(calendars.isShared, true));
|
// Without the existence check + transaction, a bad/stale id would clear the
|
||||||
|
// current shared calendar in step 1 and update 0 rows in step 2 — silently
|
||||||
|
// leaving the household with NO shared calendar while still returning ok.
|
||||||
|
const found = await db.transaction(async (tx) => {
|
||||||
|
const [target] = await tx
|
||||||
|
.select({ id: calendars.id })
|
||||||
|
.from(calendars)
|
||||||
|
.where(eq(calendars.id, targetId))
|
||||||
|
.limit(1);
|
||||||
|
if (!target) return false;
|
||||||
|
|
||||||
// Step 2: Set is_shared on the target calendar
|
// Step 1: Clear is_shared on any currently-shared calendar
|
||||||
await db.update(calendars).set({ isShared: true }).where(eq(calendars.id, targetId));
|
await tx.update(calendars).set({ isShared: false }).where(eq(calendars.isShared, true));
|
||||||
|
// Step 2: Set is_shared on the target calendar (D-06 single-select)
|
||||||
|
await tx.update(calendars).set({ isShared: true }).where(eq(calendars.id, targetId));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
return c.json({ error: 'Calendar not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({ ok: true }, 200);
|
return c.json({ ok: true }, 200);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -456,6 +456,27 @@ describe('PUT /api/admin/calendars/:id/shared', () => {
|
|||||||
expect(sharedIds).toContain(calB);
|
expect(sharedIds).toContain(calB);
|
||||||
expect(sharedIds).not.toContain(calA);
|
expect(sharedIds).not.toContain(calA);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 404 for a non-existent target and does NOT clear the existing shared calendar (CR-01)', async () => {
|
||||||
|
const adminId = await seedUser('admin-shared-missing', true);
|
||||||
|
const calA = await seedCalendar(adminId, 'cal-a-keep', true); // currently the shared family lane
|
||||||
|
currentDevUserId = adminId;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
// PUT a target id that does not exist. The handler must verify the target
|
||||||
|
// exists BEFORE clearing the current shared lane, so a bad/stale id can
|
||||||
|
// never silently wipe the family's shared calendar (BLOCKER CR-01).
|
||||||
|
const res = await app.fetch(jsonRequest('PUT', '/api/admin/calendars/99999999/shared'));
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
|
||||||
|
// calA must STILL be shared — the no-op target must not have cleared it.
|
||||||
|
const [rowA] = await db
|
||||||
|
.select({ isShared: calendars.isShared })
|
||||||
|
.from(calendars)
|
||||||
|
.where(eq(calendars.id, calA))
|
||||||
|
.limit(1);
|
||||||
|
expect(rowA.isShared).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user