fix: Paket 0 – data integrity, security hardening and field usability
Backend - Backup covers every Prisma model (bundle v3); a guard test fails for any new model that is neither backed up nor explicitly excluded. Unsealed bundles may only add rows to 27 legacy master/field tables and never overwrite; counters, ledgers, settings, roles etc. need a sealed file. Shared import limit BACKUP_MAX_IMPORT_BYTES (default 50 MB, max 256 MB) with warnings in log and UI; replaced document files are removed after commit. - Post-commit side effects (automation, calendar sync, webhooks) run via runAfterCommit and never turn a committed write into a 500, so retries with the same Idempotency-Key no longer create duplicates. - Calendar outbox is multi-replica safe: per-entry claims with stale takeover, revision-based settle, new Graph event only on 404/410, transactionId on create, 30 s Graph timeout, URL-encoded ids, GUID validation for groupId, "run now" in background and resets FAILED. - Automation tasks are deduplicated under a row lock on the rule. - Update DTOs follow one null rule: omitted = unchanged, null clears a nullable field, null on a required field is a 400. - Varroa mites/day is recomputed on correction; withdrawal status is checked against the harvest date (?at=), bounded colonyIds; inspection create is audited; updates re-enqueue calendar entries. - Dashboard, statistics and apiary/colony guards treat WEAK, CONSPICUOUS and QUEENLESS colonies as living. - Prisma errors and body-parser errors are logged without query values or request bodies. - Migration 20261012100000_calendar_sync_claims; schema index name aligned with the existing database object. Frontend - Sticky save bar sticks again (no scroll container ancestors, card uses overflow-clip), safe-area insets, scroll-padding, FAB hidden on forms, sticky desktop sidebar. - Inspections record yes / no / not checked instead of silent defaults. - All living colonies are selectable; shared RecordSelect, EnumSelect, ChoiceGroup and TriStateField replace per-page copies; selects show names instead of ids; labels point at real controls. - Emptied optional fields are sent as null (emptyToNull) so every field can be cleared; dates default to the local day and are sent via toApiDate. - Harvest withdrawal warning uses the harvest date; backup page warns when a backup outgrows the import limit and lists skipped tables. Build and docs - Docker builds fail unless lint, typecheck and unit tests pass. - ARCHITECTURE.md updated (assumptions 9-12, 19-21, section 8) and spec docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md for the backbone (api/worker/job roles, domain event outbox) and the feature roadmap T3-T7. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
f5b1964223
commit
10cbe9bf2d
310 files changed
+12177
-3746
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { GraphClientService } from './graph-client.service';
|
||||
import { GraphClientService, GraphRequestError } from './graph-client.service';
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return {
|
||||
@@ -53,10 +53,11 @@ describe('GraphClientService', () => {
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(201, { id: 'graph-event-1' }));
|
||||
|
||||
const result = await service.createEvent('group-1', {
|
||||
title: 'Durchsicht V-001',
|
||||
date: new Date(Date.UTC(2026, 2, 7)),
|
||||
});
|
||||
const result = await service.createEvent(
|
||||
'group-1',
|
||||
{ title: 'Durchsicht V-001', date: new Date(Date.UTC(2026, 2, 7)) },
|
||||
'tx-durchsicht',
|
||||
);
|
||||
|
||||
expect(result).toBe('graph-event-1');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
@@ -82,8 +83,11 @@ describe('GraphClientService', () => {
|
||||
isAllDay: boolean;
|
||||
start: { dateTime: string };
|
||||
end: { dateTime: string };
|
||||
transactionId: string;
|
||||
};
|
||||
expect(body.subject).toBe('Durchsicht V-001');
|
||||
// Lets Graph drop a repeated create after a timed-out first attempt.
|
||||
expect(body.transactionId).toBe('tx-durchsicht');
|
||||
expect(body.isAllDay).toBe(true);
|
||||
expect(body.start.dateTime).toBe('2026-03-07T00:00:00.0000000');
|
||||
expect(body.end.dateTime).toBe('2026-03-08T00:00:00.0000000');
|
||||
@@ -97,14 +101,16 @@ describe('GraphClientService', () => {
|
||||
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-a' }))
|
||||
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-b' }));
|
||||
|
||||
await service.createEvent('group-1', {
|
||||
title: 'A',
|
||||
date: new Date(Date.UTC(2026, 2, 7)),
|
||||
});
|
||||
await service.createEvent('group-1', {
|
||||
title: 'B',
|
||||
date: new Date(Date.UTC(2026, 2, 8)),
|
||||
});
|
||||
await service.createEvent(
|
||||
'group-1',
|
||||
{ title: 'A', date: new Date(Date.UTC(2026, 2, 7)) },
|
||||
'tx-a',
|
||||
);
|
||||
await service.createEvent(
|
||||
'group-1',
|
||||
{ title: 'B', date: new Date(Date.UTC(2026, 2, 8)) },
|
||||
'tx-b',
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
@@ -117,14 +123,99 @@ describe('GraphClientService', () => {
|
||||
.mockResolvedValueOnce(jsonResponse(403, { error: 'Forbidden' }));
|
||||
|
||||
await expect(
|
||||
service.createEvent('group-1', {
|
||||
title: 'A',
|
||||
date: new Date(Date.UTC(2026, 2, 7)),
|
||||
}),
|
||||
service.createEvent(
|
||||
'group-1',
|
||||
{ title: 'A', date: new Date(Date.UTC(2026, 2, 7)) },
|
||||
'tx-a',
|
||||
),
|
||||
).rejects.toThrow(/createEvent fehlgeschlagen \(403\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateEvent', () => {
|
||||
it('never sends a transactionId, which Graph does not let an update change', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(200, {}));
|
||||
|
||||
await service.updateEvent('group-1', 'event-1', {
|
||||
title: 'A',
|
||||
date: new Date(Date.UTC(2026, 2, 7)),
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[1] as [string, { body: string }];
|
||||
expect(JSON.parse(init.body)).not.toHaveProperty('transactionId');
|
||||
});
|
||||
|
||||
it('rejects with a GraphRequestError carrying the HTTP status', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(404, { error: 'NotFound' }));
|
||||
|
||||
const update = service.updateEvent('group-1', 'event-1', {
|
||||
title: 'A',
|
||||
date: new Date(Date.UTC(2026, 2, 7)),
|
||||
});
|
||||
|
||||
await expect(update).rejects.toBeInstanceOf(GraphRequestError);
|
||||
await expect(update).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
// The outbox claim expires after a fixed time; a Graph call hanging longer
|
||||
// would let a second pod take over the same row while this one still works.
|
||||
it('bounds every request to Microsoft with a timeout', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-1' }))
|
||||
.mockResolvedValueOnce(jsonResponse(200, {}))
|
||||
.mockResolvedValueOnce(jsonResponse(204, {}));
|
||||
const input = { title: 'A', date: new Date(Date.UTC(2026, 2, 7)) };
|
||||
|
||||
await service.createEvent('group-1', input, 'tx-1');
|
||||
await service.updateEvent('group-1', 'event-1', input);
|
||||
await service.deleteEvent('group-1', 'event-1');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
for (const [, init] of fetchMock.mock.calls as [string, RequestInit][]) {
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
}
|
||||
});
|
||||
|
||||
// Ids end up in the URL path: a value with "/", "?" or "#" must not be
|
||||
// able to point the request at another Graph resource.
|
||||
it('encodes group and event ids in every request path', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-1' }))
|
||||
.mockResolvedValueOnce(jsonResponse(200, {}))
|
||||
.mockResolvedValueOnce(jsonResponse(204, {}));
|
||||
const input = { title: 'A', date: new Date(Date.UTC(2026, 2, 7)) };
|
||||
const groupId = '../users/x?$top=1#';
|
||||
const eventId = 'AAMk/abc+def=';
|
||||
|
||||
await service.createEvent(groupId, input, 'tx-1');
|
||||
await service.updateEvent(groupId, eventId, input);
|
||||
await service.deleteEvent(groupId, eventId);
|
||||
|
||||
const urls = (fetchMock.mock.calls as [string][]).map(([url]) => url);
|
||||
const group = '..%2Fusers%2Fx%3F%24top%3D1%23';
|
||||
const event = 'AAMk%2Fabc%2Bdef%3D';
|
||||
expect(urls.slice(1)).toEqual([
|
||||
`https://graph.microsoft.com/v1.0/groups/${group}/events`,
|
||||
`https://graph.microsoft.com/v1.0/groups/${group}/events/${event}`,
|
||||
`https://graph.microsoft.com/v1.0/groups/${group}/events/${event}`,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('deleteEvent', () => {
|
||||
it('treats a 404 as success (idempotent delete)', async () => {
|
||||
fetchMock
|
||||
|
||||
Reference in new issue
Block a user