mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
a7aaa29c16
An unreadable or empty encryption-key file was treated the same as a missing one: regenerate and overwrite. That is the one case where regenerating is the worst possible move - every secret already at rest becomes undecryptable, permanently. Both now refuse to start and say which file to fix. The key-rotation script advertised re-encrypting all stored secrets while its hardcoded column lists had drifted: the AirTrail key, the per-user LLM key and the plugin OAuth tokens were never migrated, so they silently stopped decrypting after a rotation. The parity test now pins the full set against what the code actually encrypts, rather than two tables of it. The schema_version bump ran outside the migration's transaction, so a crash in between left the schema advanced and the version stale - the next boot replayed a non-idempotent step and exited, turning one crash into a boot loop. And the TICKETJSON receipt migration matched with LIKE, which SQLite treats case-insensitively, so a note somebody had typed starting 'ticketjson:' was cut up and its text dropped. Also: APP_VERSION was interpolated into migration SQL instead of bound, the capture-metadata backfill passed (photoId, userId) to a (userId, photoId) signature and its mock keyed on the wrong argument, TRUST_PROXY=0 was accepted and silently coerced to 1, demo-reset hardcoded the db path instead of honouring TREK_DB_FILE, and a demo instance with no DEMO_ADMIN_PASS set now says out loud that its admin password is the published default.
236 lines
9.3 KiB
TypeScript
236 lines
9.3 KiB
TypeScript
/**
|
|
* PhotoCaptureBackfillService (#1614) — asking the provider when and where a
|
|
* photo was taken, after the add the user was waiting on has already answered.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// exifr reads real files; the local branch is about which tags are picked and what
|
|
// is done with them, not about decoding a JPEG.
|
|
vi.mock('exifr', () => ({ default: { parse: vi.fn() } }));
|
|
import exifr from 'exifr';
|
|
import { PhotoCaptureBackfillService } from '../../../src/nest/memories/photo-capture-backfill.service';
|
|
import type { PhotoResolverService } from '../../../src/nest/memories/photo-resolver.service';
|
|
import type { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
|
|
import type { StorageService } from '../../../src/nest/storage/storage.service';
|
|
|
|
// The storage layer's job here is only to hand the EXIF reader a real path;
|
|
// materialization (local fast-path vs remote temp download) has its own tests.
|
|
const storageStub = {
|
|
withLocalFile: vi.fn(async (_category: string, name: string, fn: (absPath: string) => Promise<unknown>) => fn(`/uploads/journey/${name}`)),
|
|
} as unknown as StorageService;
|
|
|
|
type Row = { id: number; provider?: string; file_path?: string | null; taken_at?: string | null; lat?: number | null; lng?: number | null };
|
|
|
|
function build(rows: Row[], info: Record<number, unknown>) {
|
|
const recordCaptureMetadata = vi.fn();
|
|
// Keyed on the SECOND argument on purpose: the resolver's signature is
|
|
// (userId, photoId), and a mock that keys on the first one passes just as
|
|
// happily when the two are swapped at the call site.
|
|
const getPhotoInfo = vi.fn(async (_userId: number, id: number) =>
|
|
info[id] ? { success: true, data: info[id] } : { success: false, error: 'nope', status: 404 },
|
|
);
|
|
const photos = {
|
|
resolve: (id: number) => rows.find(r => r.id === id) ?? null,
|
|
recordCaptureMetadata,
|
|
} as unknown as TrekPhotosRepository;
|
|
const resolver = { getPhotoInfo } as unknown as PhotoResolverService;
|
|
return { svc: new PhotoCaptureBackfillService(resolver, photos, storageStub), recordCaptureMetadata, getPhotoInfo };
|
|
}
|
|
|
|
describe('PhotoCaptureBackfillService', () => {
|
|
it('CAPTURE-001: records what the provider knows', async () => {
|
|
const { svc, recordCaptureMetadata } = build(
|
|
[{ id: 7 }],
|
|
{ 7: { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 } },
|
|
);
|
|
|
|
await svc.run([7], 1);
|
|
|
|
expect(recordCaptureMetadata).toHaveBeenCalledWith(7, {
|
|
takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945,
|
|
});
|
|
});
|
|
|
|
it('CAPTURE-002: skips a row that already knows both, so an album import is not a provider call per photo', async () => {
|
|
const { svc, getPhotoInfo } = build(
|
|
[{ id: 7, taken_at: '2026-03-15T10:20:00Z', lat: 1, lng: 2 }],
|
|
{ 7: { takenAt: 'x' } },
|
|
);
|
|
|
|
await svc.run([7], 1);
|
|
|
|
expect(getPhotoInfo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('CAPTURE-003: still asks when only half is known', async () => {
|
|
const { svc, getPhotoInfo } = build(
|
|
[{ id: 7, taken_at: '2026-03-15T10:20:00Z' }],
|
|
{ 7: { takenAt: '2026-03-15T10:20:00Z', lat: 48.8, lng: 2.2 } },
|
|
);
|
|
|
|
await svc.run([7], 1);
|
|
|
|
expect(getPhotoInfo).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('CAPTURE-004: a provider that refuses leaves the row alone and does not throw', async () => {
|
|
const { svc, recordCaptureMetadata } = build([{ id: 7 }], {});
|
|
|
|
await expect(svc.run([7], 1)).resolves.toBeUndefined();
|
|
expect(recordCaptureMetadata).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('CAPTURE-005: one failing photo does not take down the rest of the batch', async () => {
|
|
const { svc, recordCaptureMetadata } = build(
|
|
[{ id: 7 }, { id: 8 }],
|
|
{ 8: { takenAt: '2026-03-16T08:00:00Z', lat: null, lng: null } },
|
|
);
|
|
// 7 has no info entry, so getPhotoInfo answers unsuccessfully for it.
|
|
|
|
await svc.run([7, 8], 1);
|
|
|
|
expect(recordCaptureMetadata).toHaveBeenCalledTimes(1);
|
|
expect(recordCaptureMetadata).toHaveBeenCalledWith(8, {
|
|
takenAt: '2026-03-16T08:00:00Z', lat: null, lng: null,
|
|
});
|
|
});
|
|
|
|
it('CAPTURE-006a: passes the acting user and the photo id in the order the resolver declares', async () => {
|
|
const { svc, getPhotoInfo } = build([{ id: 7 }], { 7: { takenAt: '2026-03-15T10:20:00Z' } });
|
|
|
|
await svc.run([7], 42);
|
|
|
|
expect(getPhotoInfo).toHaveBeenCalledWith(42, 7);
|
|
});
|
|
|
|
it('CAPTURE-006: an empty batch touches nothing', () => {
|
|
const { svc, getPhotoInfo } = build([], {});
|
|
svc.schedule([], 1);
|
|
expect(getPhotoInfo).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('PhotoCaptureBackfillService — local files', () => {
|
|
// Reset *and* give it a benign default. A bare mockReset leaves the mock with no
|
|
// implementation at all, and a later mockImplementation that throws then trips
|
|
// vitest's unhandled-error reporting instead of reaching the code's own catch.
|
|
beforeEach(() => {
|
|
vi.mocked(exifr.parse).mockReset();
|
|
vi.mocked(exifr.parse).mockResolvedValue({});
|
|
});
|
|
|
|
function localBuild(rows: Row[]) {
|
|
const recordCaptureMetadata = vi.fn();
|
|
const getPhotoInfo = vi.fn();
|
|
const photos = {
|
|
resolve: (id: number) => rows.find(r => r.id === id) ?? null,
|
|
recordCaptureMetadata,
|
|
} as unknown as TrekPhotosRepository;
|
|
const resolver = { getPhotoInfo } as unknown as PhotoResolverService;
|
|
return { svc: new PhotoCaptureBackfillService(resolver, photos, storageStub), recordCaptureMetadata, getPhotoInfo };
|
|
}
|
|
|
|
it('CAPTURE-007: reads a local file rather than asking a provider', async () => {
|
|
vi.mocked(exifr.parse).mockResolvedValue({
|
|
DateTimeOriginal: new Date('2026-03-15T10:20:00Z'),
|
|
latitude: 48.8584,
|
|
longitude: 2.2945,
|
|
});
|
|
const { svc, recordCaptureMetadata, getPhotoInfo } = localBuild([
|
|
{ id: 7, provider: 'local', file_path: 'journey/a.jpg' },
|
|
]);
|
|
|
|
await svc.run([7], 1);
|
|
|
|
expect(getPhotoInfo).not.toHaveBeenCalled();
|
|
expect(recordCaptureMetadata).toHaveBeenCalledWith(7, {
|
|
takenAt: '2026-03-15T10:20:00.000Z', lat: 48.8584, lng: 2.2945,
|
|
});
|
|
});
|
|
|
|
it('CAPTURE-008: falls back to CreateDate when the original timestamp is missing', async () => {
|
|
vi.mocked(exifr.parse).mockResolvedValue({ CreateDate: new Date('2026-03-16T08:00:00Z') });
|
|
const { svc, recordCaptureMetadata } = localBuild([
|
|
{ id: 7, provider: 'local', file_path: 'journey/a.jpg' },
|
|
]);
|
|
|
|
await svc.run([7], 1);
|
|
|
|
expect(recordCaptureMetadata).toHaveBeenCalledWith(7, {
|
|
takenAt: '2026-03-16T08:00:00.000Z', lat: null, lng: null,
|
|
});
|
|
});
|
|
|
|
it('CAPTURE-009: a file with nothing readable is left alone', async () => {
|
|
vi.mocked(exifr.parse).mockResolvedValue({});
|
|
const { svc, recordCaptureMetadata } = localBuild([
|
|
{ id: 7, provider: 'local', file_path: 'journey/a.jpg' },
|
|
]);
|
|
|
|
await svc.run([7], 1);
|
|
|
|
expect(recordCaptureMetadata).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('CAPTURE-010: an unreadable file is not an error', async () => {
|
|
// Throws synchronously. A mock that *rejects* leaves vitest recording the
|
|
// settlement of a promise nothing else owns, and the run fails on that even
|
|
// though the code under test caught it. The catch is the same either way.
|
|
vi.mocked(exifr.parse).mockImplementation((() => { throw new Error('not an image'); }) as never);
|
|
const { svc, recordCaptureMetadata } = localBuild([
|
|
{ id: 7, provider: 'local', file_path: 'journey/a.jpg' },
|
|
]);
|
|
|
|
await expect(svc.run([7], 1)).resolves.toBeUndefined();
|
|
expect(recordCaptureMetadata).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('CAPTURE-011: a stored path that climbs out of the uploads tree is refused', async () => {
|
|
const { svc, recordCaptureMetadata } = localBuild([
|
|
{ id: 7, provider: 'local', file_path: '../../../etc/passwd' },
|
|
]);
|
|
|
|
await svc.run([7], 1);
|
|
|
|
expect(exifr.parse).not.toHaveBeenCalled();
|
|
expect(recordCaptureMetadata).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('CAPTURE-012: a local row without a path is skipped', async () => {
|
|
const { svc, recordCaptureMetadata } = localBuild([{ id: 7, provider: 'local' }]);
|
|
|
|
await svc.run([7], 1);
|
|
|
|
expect(exifr.parse).not.toHaveBeenCalled();
|
|
expect(recordCaptureMetadata).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('CAPTURE-013: a row that no longer exists is skipped', async () => {
|
|
const { svc, recordCaptureMetadata } = localBuild([]);
|
|
await svc.run([99], 1);
|
|
expect(recordCaptureMetadata).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('CAPTURE-014: a throwing lookup is swallowed so the detached task survives', async () => {
|
|
const recordCaptureMetadata = vi.fn();
|
|
const photos = {
|
|
resolve: () => { throw new Error('db gone'); },
|
|
recordCaptureMetadata,
|
|
} as unknown as TrekPhotosRepository;
|
|
const svc = new PhotoCaptureBackfillService({} as PhotoResolverService, photos, storageStub);
|
|
|
|
await expect(svc.run([7], 1)).resolves.toBeUndefined();
|
|
expect(recordCaptureMetadata).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('CAPTURE-015: schedule kicks the run off for a non-empty batch', async () => {
|
|
vi.mocked(exifr.parse).mockResolvedValue({ DateTimeOriginal: new Date('2026-03-15T10:20:00Z') });
|
|
const { svc, recordCaptureMetadata } = localBuild([
|
|
{ id: 7, provider: 'local', file_path: 'journey/a.jpg' },
|
|
]);
|
|
|
|
svc.schedule([7], 1);
|
|
await vi.waitFor(() => expect(recordCaptureMetadata).toHaveBeenCalled());
|
|
});
|
|
});
|