brain/gateway: prohibit mission_tasks.status as a write source (M4-3a phase 1) (#1479)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
This commit was merged in pull request #1479.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createMissionTasksRepo } from './mission-tasks.js';
|
||||
|
||||
/**
|
||||
* SHARED-CONTRACT §5.5 "mission_tasks.status write prohibition": this repo is
|
||||
* the sole path that authors mission_tasks.status from caller input (storage
|
||||
* tier migration is row transport and preserves stored values; the generic
|
||||
* storage adapters have no mission_tasks caller), and it must never forward a
|
||||
* caller-supplied status to the database on create or update. Callers keep
|
||||
* working (the field is accepted and ignored), so these tests assert on what
|
||||
* reaches the Drizzle chain, not on rejection.
|
||||
*/
|
||||
|
||||
function makeInsertDb(returned: unknown[]) {
|
||||
const values = vi.fn((_v: unknown) => ({ returning: vi.fn().mockResolvedValue(returned) }));
|
||||
return { db: { insert: vi.fn(() => ({ values })) }, values };
|
||||
}
|
||||
|
||||
function makeUpdateDb(returned: unknown[]) {
|
||||
const set = vi.fn((_v: unknown) => ({
|
||||
where: vi.fn(() => ({ returning: vi.fn().mockResolvedValue(returned) })),
|
||||
}));
|
||||
return { db: { update: vi.fn(() => ({ set })) }, set };
|
||||
}
|
||||
|
||||
describe('createMissionTasksRepo — status write prohibition', () => {
|
||||
it('create strips a caller-supplied status before insert', async () => {
|
||||
const { db, values } = makeInsertDb([{ id: 'mt1', status: 'not-started' }]);
|
||||
const repo = createMissionTasksRepo(db as never);
|
||||
|
||||
const result = await repo.create({
|
||||
missionId: 'm1',
|
||||
userId: 'u1',
|
||||
status: 'done',
|
||||
description: 'd',
|
||||
} as never);
|
||||
|
||||
expect(values).toHaveBeenCalledTimes(1);
|
||||
const inserted = values.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect('status' in inserted).toBe(false);
|
||||
expect(inserted.missionId).toBe('m1');
|
||||
expect(inserted.description).toBe('d');
|
||||
expect(result.id).toBe('mt1');
|
||||
});
|
||||
|
||||
it('create without status still inserts (DB default applies)', async () => {
|
||||
const { db, values } = makeInsertDb([{ id: 'mt2' }]);
|
||||
const repo = createMissionTasksRepo(db as never);
|
||||
|
||||
await repo.create({ missionId: 'm1', userId: 'u1' } as never);
|
||||
|
||||
const inserted = values.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect('status' in inserted).toBe(false);
|
||||
});
|
||||
|
||||
it('update strips a caller-supplied status but keeps the other fields', async () => {
|
||||
const { db, set } = makeUpdateDb([{ id: 'mt1', notes: 'n' }]);
|
||||
const repo = createMissionTasksRepo(db as never);
|
||||
|
||||
const result = await repo.update('mt1', { status: 'done', notes: 'n' } as never);
|
||||
|
||||
expect(set).toHaveBeenCalledTimes(1);
|
||||
const updated = set.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect('status' in updated).toBe(false);
|
||||
expect(updated.notes).toBe('n');
|
||||
expect(updated.updatedAt).toBeInstanceOf(Date);
|
||||
expect(result?.id).toBe('mt1');
|
||||
});
|
||||
|
||||
it('update with only status degenerates to a timestamp-only update', async () => {
|
||||
const { db, set } = makeUpdateDb([{ id: 'mt1' }]);
|
||||
const repo = createMissionTasksRepo(db as never);
|
||||
|
||||
await repo.update('mt1', { status: 'blocked' } as never);
|
||||
|
||||
const updated = set.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(Object.keys(updated)).toEqual(['updatedAt']);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,24 @@ import { eq, and, type Db, missionTasks } from '@mosaicstack/db';
|
||||
export type MissionTask = typeof missionTasks.$inferSelect;
|
||||
export type NewMissionTask = typeof missionTasks.$inferInsert;
|
||||
|
||||
// SHARED-CONTRACT §5.1 phase 1 / §5.4: mission_tasks.status is prohibited as a
|
||||
// write source through the N-1 window. This repo is the sole path that authors
|
||||
// status from caller input, so the field is stripped here — accepted and
|
||||
// ignored rather than rejected, because the legacy surface is frozen with
|
||||
// existing consumers kept working (tool-gateway-mapping.md §3.2). Two other
|
||||
// surfaces touch the column and are deliberately NOT stripped:
|
||||
// packages/storage/migrate-tier.ts copies whole rows between storage tiers and
|
||||
// must preserve the stored value verbatim, and the generic table-keyed storage
|
||||
// adapters register mission_tasks but have no caller that targets it (runtime
|
||||
// callers use fixed collection constants). Neither authors a new status. The
|
||||
// column keeps its DB default, stays declared and readable, and is retired
|
||||
// only after no readers remain.
|
||||
function stripStatus<T extends { status?: unknown }>(data: T): Omit<T, 'status'> {
|
||||
const rest = { ...data };
|
||||
delete rest.status;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export function createMissionTasksRepo(db: Db) {
|
||||
return {
|
||||
async findByMission(missionId: string): Promise<MissionTask[]> {
|
||||
@@ -30,14 +48,14 @@ export function createMissionTasksRepo(db: Db) {
|
||||
},
|
||||
|
||||
async create(data: NewMissionTask): Promise<MissionTask> {
|
||||
const rows = await db.insert(missionTasks).values(data).returning();
|
||||
const rows = await db.insert(missionTasks).values(stripStatus(data)).returning();
|
||||
return rows[0]!;
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<NewMissionTask>): Promise<MissionTask | undefined> {
|
||||
const rows = await db
|
||||
.update(missionTasks)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.set({ ...stripStatus(data), updatedAt: new Date() })
|
||||
.where(eq(missionTasks.id, id))
|
||||
.returning();
|
||||
return rows[0];
|
||||
|
||||
Reference in New Issue
Block a user