- 新增管理员注销用户接口,要求输入用户名二次确认 - 注销时清理用户当前图片、视频、文件、标签、会话和自身日志 - 保留备份任务、管理员审计数据和全局配置 - 用户列表展示图片、视频、文件占用,管理员行隐藏使用情况和操作按钮 - 补充注销流程与用量统计测试
224 lines
7.9 KiB
TypeScript
224 lines
7.9 KiB
TypeScript
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')
|
|
})
|
|
})
|