import { promises as fs } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { getDb, insertActivityLog, insertUser, listAdminUsersWithUsage } from './db' import { listImageIndexRowsForUser, updateImageThumb, updateImageTranscode } from './image-index' import { ensureDefaultBackends } from './storage-backends' import { getActiveBackend } from './storage/resolver' import { putImage } from './storage' import { createTag } from './tags' import { clearUserStorage, deleteUserCurrentData } from './user-storage' import { createBackupJob, getBackupJob, markJobDone } from './backup/jobs' import { getBackupsDir } from './backup/paths' describe('clearUserStorage', () => { it('deletes only the selected user media, tags, logs and cache', async () => { ensureDefaultBackends() const now = new Date().toISOString() const userA = insertUser({ username: 'clear-user-a', passwordHash: 'hash-a', role: 'user', createdAt: now }) const userB = insertUser({ username: 'clear-user-b', passwordHash: 'hash-b', role: 'user', createdAt: now }) const keyA = 'videos/2026/10/clear-a.mp4' const thumbA = 'videos/2026/10/clear-a.webp' const hlsA = 'videos/2026/10/clear-a/index.m3u8' const segmentA = 'videos/2026/10/clear-a/seg000.ts' const keyB = 'images/2026/10/clear-b.webp' const videoMeta = { originalName: 'clear-a.mp4', uploadedAt: now, contentType: 'video/mp4', size: 1, userId: userA, mediaType: 'video' as const } await putImage(keyA, new Uint8Array([1]), videoMeta) const backend = await getActiveBackend() await backend.put(thumbA, new Uint8Array([2]), videoMeta) await backend.put( hlsA, new TextEncoder().encode('#EXTM3U\n#EXTINF:6,\nseg000.ts\n'), videoMeta ) await backend.put(segmentA, new Uint8Array([3]), videoMeta) updateImageThumb(keyA, thumbA) updateImageTranscode(keyA, { status: 'done', hlsKey: hlsA, progress: 100 }) await putImage(keyB, new Uint8Array([4]), { originalName: 'clear-b.webp', uploadedAt: now, contentType: 'image/webp', size: 1, userId: userB, mediaType: 'image' }) const tagA = createTag(userA, 'A tag') const tagB = createTag(userB, 'B tag') getDb().prepare('INSERT INTO image_tags (image_key, tag_id) VALUES (?, ?)').run(keyA, tagA.id) getDb().prepare('INSERT INTO image_tags (image_key, tag_id) VALUES (?, ?)').run(keyB, tagB.id) insertActivityLog({ action: 'upload', key: keyA, originalName: 'clear-a.mp4', size: 1, contentType: 'video/mp4', source: 'web', userId: userA }) insertActivityLog({ action: 'upload', key: keyB, originalName: 'clear-b.webp', size: 1, contentType: 'image/webp', source: 'web', userId: userB }) const cacheDir = await fs.mkdtemp(join(tmpdir(), `pichost-hls-download-${userA}-`)) const jobA = createBackupJob({ type: 'full_export', userId: userA }) const jobB = createBackupJob({ type: 'full_export', userId: userB }) const archiveA = join(getBackupsDir(), `clear-a-${jobA.id}.phost.tar.gz`) const archiveB = join(getBackupsDir(), `clear-b-${jobB.id}.phost.tar.gz`) await fs.mkdir(getBackupsDir(), { recursive: true }) await fs.writeFile(archiveA, 'a') await fs.writeFile(archiveB, 'b') markJobDone(jobA.id, { outputPath: archiveA }) markJobDone(jobB.id, { outputPath: archiveB }) const result = await clearUserStorage(userA) expect(result.failed).toEqual([]) expect(result.deletedMedia).toBe(1) expect(result.deletedObjects).toBe(4) expect(result.deletedJobs).toBe(1) expect(listImageIndexRowsForUser(userA)).toHaveLength(0) expect(listImageIndexRowsForUser(userB)).toHaveLength(1) expect(await backend.head(keyB)).not.toBeNull() expect(await backend.head(keyA)).toBeNull() expect(await backend.head(thumbA)).toBeNull() expect(await backend.head(hlsA)).toBeNull() expect(await backend.head(segmentA)).toBeNull() await expect(fs.access(cacheDir)).rejects.toThrow() const db = getDb() expect(db.prepare('SELECT COUNT(*) AS count FROM tags WHERE user_id = ?').get(userA)) .toEqual({ count: 0 }) expect(db.prepare('SELECT COUNT(*) AS count FROM tags WHERE user_id = ?').get(userB)) .toEqual({ count: 1 }) expect(db.prepare('SELECT COUNT(*) AS count FROM activity_logs WHERE user_id = ?').get(userA)) .toEqual({ count: 0 }) expect(db.prepare('SELECT COUNT(*) AS count FROM activity_logs WHERE user_id = ?').get(userB)) .toEqual({ count: 1 }) expect(db.prepare('SELECT COUNT(*) AS count FROM image_tags WHERE image_key = ?').get(keyA)) .toEqual({ count: 0 }) expect(db.prepare('SELECT COUNT(*) AS count FROM image_tags WHERE image_key = ?').get(keyB)) .toEqual({ count: 1 }) expect(getBackupJob(jobA.id, userA)).toBeNull() expect(getBackupJob(jobB.id, userB)).not.toBeNull() await expect(fs.access(archiveA)).rejects.toThrow() await expect(fs.access(archiveB)).resolves.toBeUndefined() }) it('deletes current user data without deleting backups or admin audit data', async () => { ensureDefaultBackends() const now = new Date().toISOString() const admin = insertUser({ username: 'delete-admin', passwordHash: 'admin-hash', role: 'admin', createdAt: now }) const user = insertUser({ username: 'delete-user', passwordHash: 'user-hash', role: 'user', createdAt: now }) const key = 'images/2026/10/delete-user.webp' const target = await getActiveBackend() await putImage(key, new Uint8Array([1]), { originalName: 'delete-user.webp', uploadedAt: now, contentType: 'image/webp', size: 1, userId: user, mediaType: 'image' }) const tag = createTag(user, 'delete tag') getDb().prepare('INSERT INTO image_tags (image_key, tag_id) VALUES (?, ?)') .run(key, tag.id) getDb().prepare(` INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?) `).run('delete-session', user, 'hash', now, now) insertActivityLog({ action: 'upload', key, originalName: 'delete-user.webp', size: 1, contentType: 'image/webp', source: 'web', userId: user }) insertActivityLog({ action: 'settings', key: `users/${user}/clear-password`, originalName: 'delete-user', size: 0, contentType: '', source: 'admin', userId: admin }) const job = createBackupJob({ type: 'full_export', userId: user }) markJobDone(job.id, {}) const listed = listAdminUsersWithUsage().find(item => item.id === user) expect(listed?.usage.imageBytes).toBe(1) const result = await deleteUserCurrentData(user) expect(result.failed).toEqual([]) expect(result.deletedMedia).toBe(1) expect(result.deletedTags).toBe(1) expect(await target.head(key)).toBeNull() expect(getDb().prepare('SELECT 1 FROM users WHERE id = ?').get(user)).toBeUndefined() expect(getDb().prepare('SELECT 1 FROM sessions WHERE user_id = ?').get(user)).toBeUndefined() expect(getDb().prepare('SELECT 1 FROM images WHERE user_id = ?').get(user)).toBeUndefined() expect(getDb().prepare('SELECT 1 FROM tags WHERE user_id = ?').get(user)).toBeUndefined() expect(getDb().prepare('SELECT 1 FROM activity_logs WHERE user_id = ?').get(user)).toBeUndefined() expect(getDb().prepare(` SELECT original_name FROM activity_logs WHERE user_id = ? AND key = ? `).get(admin, `users/${user}/clear-password`)).toEqual({ original_name: '已注销用户' }) expect(getDb().prepare('SELECT 1 FROM backup_jobs WHERE id = ?').get(job.id)).toBeDefined() await expect(deleteUserCurrentData(admin)).rejects.toThrow('ADMIN_USER_DELETE_FORBIDDEN') }) })