project-nomad/admin/tests/unit/superseded_resource.spec.ts
Chris Sherwood bbd62d8ed1
fix(content): remove superseded curated map/ZIM files when a new version installs
Only Wikipedia had version cleanup; every other curated map and non-Wikipedia
ZIM left its prior version on disk when a newer one installed, so users silently
accumulated orphaned content (potentially hundreds of GB). (#634)

The install paths already record each resource via InstalledResource
{resource_id, resource_type, version, file_path}, so the authoritative old-file
path for a resource is known. On install of a new version we now capture the
prior row before updateOrCreate repoints it, then delete the old file — gated
behind a pure, fully unit-tested decision function with strict safety rails:

  - tracked-only: requires a prior InstalledResource row for the same
    resource_id, so sideloaded/untracked files are never touched
  - genuine replacement: old and new file paths must differ
  - new-file-verified: the new file must be confirmed on disk first
  - strictly-newer: a re-install or downgrade can't wipe a newer file
  - within-storage-dir: the old path must resolve under the content store

ZIM cleanup deletes the old file directly (NOT via this.delete(), which would
drop the InstalledResource row by resource_id that updateOrCreate just
repointed) and rebuilds the Kiwix library only if a file was actually removed,
so its XML never references a deleted ZIM. Maps need no library step. Wikipedia
keeps its own existing cleanup path. All deletions are best-effort and logged;
a failure never breaks the install.

Closes #634

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:47:01 +00:00

119 lines
3.5 KiB
TypeScript

import * as assert from 'node:assert/strict'
import { test } from 'node:test'
import { join } from 'node:path'
import { decideSupersededDeletion } from '../../app/utils/superseded_resource.js'
const STORAGE = join('/app', 'storage', 'zim')
const OLD = join(STORAGE, 'ifixit_en_all_2026-03.zim')
const NEW = join(STORAGE, 'ifixit_en_all_2026-05.zim')
test('first install (no prior row) deletes nothing', () => {
const d = decideSupersededDeletion({
existing: null,
newFilePath: NEW,
newVersion: '2026-05',
newFileExists: true,
storageBaseDir: STORAGE,
})
assert.equal(d.delete, false)
assert.equal(d.reason, 'first_install')
})
test('superseded prior version is deleted', () => {
const d = decideSupersededDeletion({
existing: { file_path: OLD, version: '2026-03' },
newFilePath: NEW,
newVersion: '2026-05',
newFileExists: true,
storageBaseDir: STORAGE,
})
assert.equal(d.delete, true)
assert.equal(d.reason, 'superseded')
assert.equal(d.path, OLD)
})
test('same file path is never deleted (re-download)', () => {
const d = decideSupersededDeletion({
existing: { file_path: NEW, version: '2026-05' },
newFilePath: NEW,
newVersion: '2026-05',
newFileExists: true,
storageBaseDir: STORAGE,
})
assert.equal(d.delete, false)
assert.equal(d.reason, 'same_file')
})
test('old file is NOT deleted until the new file is confirmed on disk', () => {
const d = decideSupersededDeletion({
existing: { file_path: OLD, version: '2026-03' },
newFilePath: NEW,
newVersion: '2026-05',
newFileExists: false,
storageBaseDir: STORAGE,
})
assert.equal(d.delete, false)
assert.equal(d.reason, 'new_file_missing')
})
test('downgrade / reinstall of an older version does not wipe the newer file', () => {
const d = decideSupersededDeletion({
existing: { file_path: NEW, version: '2026-05' },
newFilePath: OLD,
newVersion: '2026-03',
newFileExists: true,
storageBaseDir: STORAGE,
})
assert.equal(d.delete, false)
assert.equal(d.reason, 'not_newer')
})
test('equal version is not considered newer', () => {
const d = decideSupersededDeletion({
existing: { file_path: join(STORAGE, 'ifixit_en_all_2026-05a.zim'), version: '2026-05' },
newFilePath: NEW,
newVersion: '2026-05',
newFileExists: true,
storageBaseDir: STORAGE,
})
assert.equal(d.delete, false)
assert.equal(d.reason, 'not_newer')
})
test('refuses to delete a path outside the storage directory', () => {
const d = decideSupersededDeletion({
existing: { file_path: '/etc/passwd', version: '2026-03' },
newFilePath: NEW,
newVersion: '2026-05',
newFileExists: true,
storageBaseDir: STORAGE,
})
assert.equal(d.delete, false)
assert.equal(d.reason, 'outside_storage')
})
test('refuses a traversal escape that only looks like it is under storage', () => {
const d = decideSupersededDeletion({
existing: { file_path: join(STORAGE, '..', 'mysql', 'data.ibd'), version: '2026-03' },
newFilePath: NEW,
newVersion: '2026-05',
newFileExists: true,
storageBaseDir: STORAGE,
})
assert.equal(d.delete, false)
assert.equal(d.reason, 'outside_storage')
})
test('YYYY-MM-DD versions order correctly against YYYY-MM', () => {
const d = decideSupersededDeletion({
existing: { file_path: OLD, version: '2026-05' },
newFilePath: NEW,
newVersion: '2026-05-12',
newFileExists: true,
storageBaseDir: STORAGE,
})
assert.equal(d.delete, true)
assert.equal(d.reason, 'superseded')
})