Files
TREK/server/tests/integration/storage-release.test.ts
T
Maurice 1c6a175ebc fix(storage): queue what a delete left unnamed past every undo, and tell a copied database apart
A delete or a replace no longer deletes inside the request. The owner's call
queues the keys in storage_release_queue, due after RELEASE_DELAY_MS (26 h:
the assistant's 24 h approval window plus a margin), and StorageReleaseJob
takes the due rows every ten minutes, at most 200 a tick and 50 keys a
question, asks every reference source again and deletes what none names.
It refuses what the nightly pass refuses (StorageReclaimGuardService: the
ledger, the required sources, a shared location, another installation's
owner marker, cached per category), and waits while a restore or a backup
runs or another storage job holds the slot. An assistant undo or a late
write that names the old value again keeps its object.

The install id lives in data/.trek-install-id, outside any backup and never
written by a restore. At boot and after every reopen the database is
reconciled with it: a database naming another installation (a boot restore,
a restart after a failed restore, a copied travel.db) loses its switch,
confirmations, reports, marks and queue before anything reads them.

A confirmation now stores the count the admin was shown and when; it covers
up to one and a half times that count (at least 100 more) for 14 days, is
used up once the share is back under a fifth, and a count the last report
no longer holds is refused as reclaim_confirm_stale. The admin state folds
a live confirmation in, so the prompt goes at once.

A pass reads each source once for every category and drops each category's
keys once judged; right before a batch it reads the category's sources
again, whole and in memory, instead of a LIKE per key. A narrowed read holds
at most 50 LIKE terms. A pass held off by a backup reports backup_running,
and a category the ledger never reclaims reports not_reclaimed even after a
stop.
2026-11-01 18:11:43 +01:00

262 lines
12 KiB
TypeScript

/**
* The deferred release over the full app (#2924): what a user's replace or
* delete leaves unnamed is queued by the request and left in place, then
* deleted by the release job once it is due, through every owner's real
* reference source, while an object another row still names (a trip copy, a
* journey, a saved place, a value an undo wrote back) stays. The objects are
* written straight into the test run's uploads folder under random names and
* taken back afterwards, the owner markers the job wrote with them.
* RELI-001 to RELI-009.
*/
import { buildApp } from '../../src/bootstrap';
import { Addons } from '../../src/db/entities/Addons.entity';
import { StorageReleaseQueue } from '../../src/db/entities/StorageReleaseQueue.entity';
import { Users } from '../../src/db/entities/Users.entity';
import { withRequestContext } from '../../src/nest/database/request-context';
import { StorageInstallService } from '../../src/nest/storage/storage-install.service';
import { STORAGE_RECLAIMABLE_CATEGORIES, storageOwnerMarkerKey } from '../../src/nest/storage/storage-reclaim.types';
import { RELEASE_DELAY_MS, StorageReleaseService } from '../../src/nest/storage/storage-release.service';
import { StorageService } from '../../src/nest/storage/storage.service';
import { authCookie } from '../helpers/auth';
import { makeCollection, makeCollectionPlace } from '../helpers/factories/collections';
import type { FactoryOrm } from '../helpers/factories/context';
import { makeJourney } from '../helpers/factories/journeys';
import { makePlace } from '../helpers/factories/places';
import { countRows, updateRows, upsertRow } from '../helpers/factories/rows';
import { makeTrip } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { MikroORM } from '@mikro-orm/core';
import type { INestApplication } from '@nestjs/common';
import type { Application } from 'express';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const UPLOADS = path.join(__dirname, '../../uploads');
const written: string[] = [];
/** An object of `category` under a fresh random name, as an upload would leave it. */
function stored(category: 'covers' | 'places' | 'avatars', ext = '.jpg'): string {
const name = `${randomUUID()}${ext}`;
const file = path.join(UPLOADS, category, name);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, 'bytes');
written.push(file);
return name;
}
function exists(category: string, name: string): boolean {
return fs.existsSync(path.join(UPLOADS, category, name));
}
let nestApp: INestApplication;
let app: Application;
let orm: FactoryOrm;
/** One tick of the release job at `at` (by default once everything queued so far is due). */
async function tick(at: Date = new Date(Date.now() + RELEASE_DELAY_MS + 60_000)) {
return await withRequestContext(nestApp.get(MikroORM), () => nestApp.get(StorageReleaseService).processDue(at));
}
async function queued(category: string, key: string): Promise<number> {
return await countRows(orm, StorageReleaseQueue, { category, key });
}
beforeAll(async () => {
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
orm = nestApp.get(MikroORM);
});
afterAll(async () => {
for (const file of written) fs.rmSync(file, { force: true });
// The uploads folder is the test run's shared one: take back the owner markers the job wrote.
const installId = nestApp.get(StorageInstallService).installId();
if (installId) {
const storage = nestApp.get(StorageService);
await Promise.all(
STORAGE_RECLAIMABLE_CATEGORIES.map((category) =>
storage.delete(category, storageOwnerMarkerKey(installId)).catch(() => undefined),
),
);
}
await nestApp.close();
});
describe('the deferred release of what a change left unnamed', () => {
it('RELI-001: a replaced trip cover waits in the queue, then goes, but not while a trip copy still shows it', async () => {
const { user } = await makeUser(orm);
const old = stored('covers');
const trip = await makeTrip(orm, user.id, { cover_image: `/uploads/covers/${old}` });
const copy = await makeTrip(orm, user.id, { cover_image: `/uploads/covers/${old}` });
const next = stored('covers');
const first = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id))
.send({ cover_image: `/uploads/covers/${next}` });
expect(first.status).toBe(200);
expect(await queued('covers', old)).toBe(1);
await tick();
expect(exists('covers', old)).toBe(true);
expect(await queued('covers', old)).toBe(0);
const second = await request(app)
.put(`/api/trips/${copy.id}`)
.set('Cookie', authCookie(user.id))
.send({ cover_image: `/uploads/covers/${next}` });
expect(second.status).toBe(200);
// The request itself deletes nothing.
expect(exists('covers', old)).toBe(true);
await tick();
expect(exists('covers', old)).toBe(false);
expect(exists('covers', next)).toBe(true);
});
it('RELI-002: a deleted trip takes its cover along a day later, unless a journey inherited it', async () => {
const { user } = await makeUser(orm);
const alone = stored('covers');
const inherited = stored('covers');
const tripA = await makeTrip(orm, user.id, { cover_image: `/uploads/covers/${alone}` });
const tripB = await makeTrip(orm, user.id, { cover_image: `/uploads/covers/${inherited}` });
await makeJourney(orm, user.id, { cover_image: `covers/${inherited}` });
const deleteTrip = (id: number) => request(app).delete(`/api/trips/${id}`).set('Cookie', authCookie(user.id));
expect((await deleteTrip(tripA.id)).status).toBe(200);
expect((await deleteTrip(tripB.id)).status).toBe(200);
expect(exists('covers', alone)).toBe(true);
await tick();
expect(exists('covers', alone)).toBe(false);
expect(exists('covers', inherited)).toBe(true);
});
it('RELI-003: a replaced place image goes once due, one a saved place still names stays', async () => {
const { user } = await makeUser(orm);
const trip = await makeTrip(orm, user.id);
const own = stored('places');
const saved = stored('places');
const placeA = await makePlace(orm, trip.id, { image_url: `/uploads/places/${own}` });
const placeB = await makePlace(orm, trip.id, { image_url: `/uploads/places/${saved}?v=2` });
const collection = await makeCollection(orm, user.id);
await makeCollectionPlace(orm, collection.id, user.id, {
image_url: `https://trip.example.test/uploads/places/${saved}`,
});
const clearImage = (id: number) =>
request(app)
.put(`/api/trips/${trip.id}/places/${id}`)
.set('Cookie', authCookie(user.id))
.send({ image_url: null });
expect((await clearImage(placeA.id)).status).toBe(200);
expect((await clearImage(placeB.id)).status).toBe(200);
await tick();
expect(exists('places', own)).toBe(false);
expect(exists('places', saved)).toBe(true);
});
it('RELI-004: a deleted place takes its image along once due, not before', async () => {
const { user } = await makeUser(orm);
const trip = await makeTrip(orm, user.id);
const image = stored('places');
const place = await makePlace(orm, trip.id, { image_url: `/uploads/places/${image}` });
const res = await request(app)
.delete(`/api/trips/${trip.id}/places/${place.id}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
await tick(new Date());
expect(exists('places', image)).toBe(true);
expect(await queued('places', image)).toBe(1);
await tick();
expect(exists('places', image)).toBe(false);
expect(await queued('places', image)).toBe(0);
});
it('RELI-005: a deleted avatar goes once due; an avatar another account still names stays', async () => {
const { user } = await makeUser(orm);
const { user: other } = await makeUser(orm);
const own = stored('avatars', '.png');
const shared = stored('avatars', '.png');
await updateRows(orm, Users, { id: user.id }, { avatar: own });
await updateRows(orm, Users, { id: other.id }, { avatar: shared });
const res = await request(app).delete('/api/auth/avatar').set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
await tick();
expect(exists('avatars', own)).toBe(false);
await updateRows(orm, Users, { id: user.id }, { avatar: shared });
await request(app).delete('/api/auth/avatar').set('Cookie', authCookie(user.id));
await tick();
expect(exists('avatars', shared)).toBe(true);
});
it('RELI-006: the release never fails the request, whatever the stored value holds', async () => {
const { user } = await makeUser(orm);
const trip = await makeTrip(orm, user.id, { cover_image: '/uploads/covers/../../etc/passwd' });
const res = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id))
.send({ cover_image: 'https://images.example.test/cover.jpg' });
expect(res.status).toBe(200);
const tooLong = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id))
.send({ cover_image: `/uploads/covers/${'a'.repeat(2048)}` });
expect(tooLong.status).toBe(400);
});
it('RELI-007: a cover an undo writes back before it is due stays, and its row goes', async () => {
const { user } = await makeUser(orm);
const old = stored('covers');
const trip = await makeTrip(orm, user.id, { cover_image: `/uploads/covers/${old}` });
const put = (cover: string | null) =>
request(app).put(`/api/trips/${trip.id}`).set('Cookie', authCookie(user.id)).send({ cover_image: cover });
expect((await put(null)).status).toBe(200);
expect(await queued('covers', old)).toBe(1);
expect((await put(`/uploads/covers/${old}`)).status).toBe(200);
await tick();
expect(exists('covers', old)).toBe(true);
expect(await queued('covers', old)).toBe(0);
});
it('RELI-008: a PUT that sends the cover a trip already has queues nothing', async () => {
const { user } = await makeUser(orm);
const same = stored('covers');
const trip = await makeTrip(orm, user.id, { cover_image: `/uploads/covers/${same}` });
const res = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id))
.send({ cover_image: `/uploads/covers/${same}` });
expect(res.status).toBe(200);
expect(await queued('covers', same)).toBe(0);
});
it('RELI-009: a replaced journey cover is queued, never deleted by the request; an unchanged one queues nothing', async () => {
await upsertRow(orm, Addons, {
id: 'journey',
name: 'Journey',
description: 'Travel journal',
type: 'global',
icon: 'Compass',
enabled: true,
sort_order: 35,
});
const { user } = await makeUser(orm);
const old = stored('covers');
const journey = await makeJourney(orm, user.id, { cover_image: `covers/${old}` });
const patch = (cover: string) =>
request(app).patch(`/api/journeys/${journey.id}`).set('Cookie', authCookie(user.id)).send({ cover_image: cover });
expect((await patch(`covers/${old}`)).status).toBe(200);
expect(await queued('covers', old)).toBe(0);
const next = stored('covers');
expect((await patch(`covers/${next}`)).status).toBe(200);
expect(await queued('covers', old)).toBe(1);
// What the assistant's undo writes back within the window keeps its object.
expect((await patch(`covers/${old}`)).status).toBe(200);
await tick();
expect(exists('covers', old)).toBe(true);
expect(exists('covers', next)).toBe(false);
});
});