mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Finish the contract ratchet, the upload bridge and the client leftovers
BODY_CONTRACT_ALLOW_LIST is empty. The last seventeen handlers type their
body with a createZodDto class, so the fail-closed boot gate has nothing left
to forgive; it still refuses a boot for a new handler that reads @Body()
without one.
Several of those contracts describe rather than constrain, and that is the
whole care in this change. The pipe runs before the handler, so a schema
stricter than the handler moves a user-visible answer:
- TagsController.create answers { error: 'Tag name is required' }, pinned
byte-for-byte in two tests, and the pipe formats as `field: message`. The
DTO leaves name optional and the handler keeps the check that owns the
message. Same shape for the booking-import confirm body, which additionally
gains per-item validation the handler never did.
- PluginsController.activate and install answer 503 when plugins are disabled,
and check that BEFORE looking at the body. A schema that rejected first
would turn that into a 400 for an operator whose server has plugins off.
- setEgressHosts reads a non-array `hosts` as "clear the list", which is the
documented reset. Typing it as string[] would have removed a working admin
action rather than tightened anything.
- PluginRoutesController.route answers { route: null } at 200 for everything
it dislikes, so the client falls back to straight lines. A 400 there would
turn a graceful degradation into a visible error.
files.bridge is gone. The multer options are built by
MulterModule.registerAsync from an AllowedFileTypesService leaf, so the
fileFilter reads the operator's extension list from the container instead of
from a second FilesService built outside it. Nest injects
MULTER_MODULE_OPTIONS with @Optional(), so a token that fails to resolve is
not a boot error: it silently falls back to defaults, with no filter and no
size cap. FILE-020 already proved that for trip files by uploading an .svg
over HTTP and expecting 400; journey uploads had no such case, and now do.
VIDEO_UPLOAD is untouched, including the filename callback that forces .jpg
regardless of what the client sends, which guards the public share proxy.
PluginFrame gets an onError. A frame whose document fails to load left a
blank element at full height, which reads as a hung app rather than as a
plugin that is gone.
typecheck:tests runs in CI now, with the test tree at zero errors. tz-lookup
is left as it is: the build puts it in a lazy helpers chunk, not the entry
chunk, and the remaining split would trade a synchronous render for an async
one to move 172 kB that is already off the critical path.
This commit is contained in:
@@ -5,6 +5,7 @@ import { UnifiedMemoriesController } from '../../../src/nest/memories/unified.co
|
||||
import { ImmichMemoriesController } from '../../../src/nest/memories/immich.controller';
|
||||
import { SynologyMemoriesController } from '../../../src/nest/memories/synology.controller';
|
||||
import type { MemoriesService } from '../../../src/nest/memories/memories.service';
|
||||
import type { AddTripPhotosDto, SynologySettingsDto } from '../../../src/nest/memories/memories.dto';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const { getClientIp } = vi.hoisted(() => ({ getClientIp: vi.fn(() => '1.2.3.4') }));
|
||||
@@ -16,6 +17,16 @@ function makeService(overrides: Partial<MemoriesService> = {}): MemoriesService
|
||||
return { ...overrides } as unknown as MemoriesService;
|
||||
}
|
||||
|
||||
/**
|
||||
* A DTO type describes a body that already passed the Zod pipe. The two cases
|
||||
* that use this hand a controller a value the pipe would reject, because what
|
||||
* they pin is the hand-rolled coercion running after validation — the leniency
|
||||
* the Express routers had, which the controllers still carry.
|
||||
*/
|
||||
function offContractBody<T>(body: Record<string, unknown>): T {
|
||||
return body as unknown as T;
|
||||
}
|
||||
|
||||
type MockRes = Response & {
|
||||
status: ReturnType<typeof vi.fn>;
|
||||
json: ReturnType<typeof vi.fn>;
|
||||
@@ -78,7 +89,13 @@ describe('UnifiedMemoriesController (parity with /api/integrations/memories/unif
|
||||
it('ignores a non-array selections payload', async () => {
|
||||
const addTripPhotos = vi.fn().mockResolvedValue({ data: { added: 0 } });
|
||||
const svc = makeService({ addTripPhotos });
|
||||
await new UnifiedMemoriesController(svc).addPhotos(user, '5', { selections: 'nope', shared: true }, 'sock', makeRes());
|
||||
await new UnifiedMemoriesController(svc).addPhotos(
|
||||
user,
|
||||
'5',
|
||||
offContractBody<AddTripPhotosDto>({ selections: 'nope', shared: true }),
|
||||
'sock',
|
||||
makeRes(),
|
||||
);
|
||||
expect(addTripPhotos).toHaveBeenCalledWith('5', 7, true, [], 'sock');
|
||||
});
|
||||
|
||||
@@ -567,7 +584,11 @@ describe('SynologyMemoriesController (parity with /api/integrations/memories/syn
|
||||
expect(synologyUpdateSettings).toHaveBeenCalledWith(7, 'u', 'a', '', true);
|
||||
|
||||
const svc2 = makeService({ synologyUpdateSettings: vi.fn().mockResolvedValue({ success: true, data: {} }) });
|
||||
await new SynologyMemoriesController(svc2).putSettings(user, { synology_url: 'u', synology_username: 'a', synology_skip_ssl: 'no' }, makeRes());
|
||||
await new SynologyMemoriesController(svc2).putSettings(
|
||||
user,
|
||||
offContractBody<SynologySettingsDto>({ synology_url: 'u', synology_username: 'a', synology_skip_ssl: 'no' }),
|
||||
makeRes(),
|
||||
);
|
||||
expect(svc2.synologyUpdateSettings).toHaveBeenCalledWith(7, 'u', 'a', '', false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user