From d9b979700cc9fc9aa98bc4ea1ce68cd01efb630d Mon Sep 17 00:00:00 2001 From: Cloudwhile <86512509+Cloudwhile@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:48:02 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(storage):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=80=A7=E5=AE=A1=E8=AE=A1=E5=B9=B6=E5=BC=BA?= =?UTF-8?q?=E5=8C=96=E6=96=87=E4=BB=B6=E5=B1=82=E7=BA=A7=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 49 + backend/src/app.module.ts | 2 + .../file-node-parent-integrity.spec.ts | 182 +++ backend/src/database/prisma.service.spec.ts | 92 ++ backend/src/database/prisma.service.ts | 148 +++ backend/src/main.ts | 1 + .../storage-integrity.controller.spec.ts | 163 +++ .../storage-integrity.controller.ts | 133 +++ .../storage-integrity.module.ts | 10 + .../file-download-preview.service.spec.ts | 7 + .../src/modules/files/file-integrity.spec.ts | 43 + backend/src/modules/files/file-integrity.ts | 54 + .../files/file-node-hierarchy-write.spec.ts | 79 ++ .../files/file-node-hierarchy-write.ts | 166 +++ .../files/file-node-public-response.spec.ts | 12 + .../files/file-node-public-response.ts | 7 +- .../file-node-versions.repository.spec.ts | 954 +++++++++++++++ .../files/file-node-versions.repository.ts | 718 ++++++++--- backend/src/modules/files/file-nodes.dto.ts | 22 + .../file-nodes.repository.spec-helpers.ts | 77 +- .../files/file-nodes.repository.spec.ts | 1059 ++++++++--------- .../modules/files/file-nodes.repository.ts | 497 +++++--- .../file-nodes.repository.upload.spec.ts | 768 ++++++++++++ .../modules/files/file-nodes.service.spec.ts | 61 +- .../src/modules/files/file-nodes.service.ts | 94 +- .../file-upload-completion-write.spec.ts | 50 + .../files/file-upload-completion-write.ts | 78 +- .../files/file-upload-test-fixtures.helper.ts | 15 + .../file-upload-test-repository.helper.ts | 20 + .../src/modules/files/file-upload.service.ts | 5 +- backend/src/modules/logs/audit-events.ts | 7 + .../shares/shares.service.spec-harness.ts | 5 + ...tegrity-acknowledgement.repository.spec.ts | 247 ++++ ...ge-integrity-acknowledgement.repository.ts | 152 +++ ...-integrity-acknowledgement.service.spec.ts | 38 + ...orage-integrity-acknowledgement.service.ts | 25 + .../storage/storage-integrity-audit.ts | 73 ++ .../storage/storage-integrity-result.ts | 36 + .../storage-integrity-target.repository.ts | 387 ++++++ ...rage-integrity-task-runner.service.spec.ts | 411 +++++++ .../storage-integrity-task-runner.service.ts | 423 +++++++ .../storage-integrity-task.audit.spec.ts | 208 ++++ .../storage-integrity-task.dto.spec.ts | 31 + .../storage/storage-integrity-task.dto.ts | 243 ++++ .../storage-integrity-task.repository.spec.ts | 780 ++++++++++++ ...y-task.repository.sqlite-lifecycle.spec.ts | 310 +++++ ...e-integrity-task.repository.sqlite.spec.ts | 122 ++ .../storage-integrity-task.repository.ts | 967 +++++++++++++++ .../storage-integrity-task.service.spec.ts | 415 +++++++ .../storage/storage-integrity-task.service.ts | 412 +++++++ .../storage/storage-integrity-task.types.ts | 25 + .../storage/storage-integrity.minio.spec.ts | 155 +++ .../storage/storage-integrity.service.spec.ts | 334 ++++++ .../storage/storage-integrity.service.ts | 121 ++ .../storage/storage-object.service.spec.ts | 103 ++ .../modules/storage/storage-object.service.ts | 32 +- backend/src/modules/storage/storage.module.ts | 19 +- .../src/modules/storage/storage.service.ts | 1 + .../migration.sql | 13 + .../migration.sql | 99 ++ .../migration.sql | 8 + database/schema.prisma | 85 ++ 62 files changed, 10918 insertions(+), 935 deletions(-) create mode 100644 backend/src/database/file-node-parent-integrity.spec.ts create mode 100644 backend/src/modules/admin/storage-integrity/storage-integrity.controller.spec.ts create mode 100644 backend/src/modules/admin/storage-integrity/storage-integrity.controller.ts create mode 100644 backend/src/modules/admin/storage-integrity/storage-integrity.module.ts create mode 100644 backend/src/modules/files/file-integrity.spec.ts create mode 100644 backend/src/modules/files/file-integrity.ts create mode 100644 backend/src/modules/files/file-node-hierarchy-write.spec.ts create mode 100644 backend/src/modules/files/file-node-hierarchy-write.ts create mode 100644 backend/src/modules/files/file-node-versions.repository.spec.ts create mode 100644 backend/src/modules/files/file-nodes.repository.upload.spec.ts create mode 100644 backend/src/modules/files/file-upload-completion-write.spec.ts create mode 100644 backend/src/modules/storage/storage-integrity-acknowledgement.repository.spec.ts create mode 100644 backend/src/modules/storage/storage-integrity-acknowledgement.repository.ts create mode 100644 backend/src/modules/storage/storage-integrity-acknowledgement.service.spec.ts create mode 100644 backend/src/modules/storage/storage-integrity-acknowledgement.service.ts create mode 100644 backend/src/modules/storage/storage-integrity-audit.ts create mode 100644 backend/src/modules/storage/storage-integrity-result.ts create mode 100644 backend/src/modules/storage/storage-integrity-target.repository.ts create mode 100644 backend/src/modules/storage/storage-integrity-task-runner.service.spec.ts create mode 100644 backend/src/modules/storage/storage-integrity-task-runner.service.ts create mode 100644 backend/src/modules/storage/storage-integrity-task.audit.spec.ts create mode 100644 backend/src/modules/storage/storage-integrity-task.dto.spec.ts create mode 100644 backend/src/modules/storage/storage-integrity-task.dto.ts create mode 100644 backend/src/modules/storage/storage-integrity-task.repository.spec.ts create mode 100644 backend/src/modules/storage/storage-integrity-task.repository.sqlite-lifecycle.spec.ts create mode 100644 backend/src/modules/storage/storage-integrity-task.repository.sqlite.spec.ts create mode 100644 backend/src/modules/storage/storage-integrity-task.repository.ts create mode 100644 backend/src/modules/storage/storage-integrity-task.service.spec.ts create mode 100644 backend/src/modules/storage/storage-integrity-task.service.ts create mode 100644 backend/src/modules/storage/storage-integrity-task.types.ts create mode 100644 backend/src/modules/storage/storage-integrity.minio.spec.ts create mode 100644 backend/src/modules/storage/storage-integrity.service.spec.ts create mode 100644 backend/src/modules/storage/storage-integrity.service.ts create mode 100644 database/migrations/20260813120000_storage_object_integrity/migration.sql create mode 100644 database/migrations/20260813123000_blob_integrity_tasks/migration.sql create mode 100644 database/migrations/20260813124000_file_node_parent_integrity/migration.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12098b6..16e302b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,20 @@ on: jobs: verify: runs-on: ubuntu-latest + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_DB: icedr_ci + POSTGRES_PASSWORD: icedr_ci_password + POSTGRES_USER: icedr_ci + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U icedr_ci -d icedr_ci" + --health-interval 5s + --health-timeout 5s + --health-retries 10 steps: - name: Checkout @@ -30,6 +44,11 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Verify PostgreSQL migrations + run: pnpm --filter backend exec prisma migrate deploy --schema ../database/schema.prisma --config ../prisma.config.ts + env: + DATABASE_URL: postgresql://icedr_ci:icedr_ci_password@127.0.0.1:5432/icedr_ci + - name: Install Playwright browser run: pnpm --filter frontend exec playwright install --with-deps chromium @@ -39,8 +58,38 @@ jobs: - name: Build run: pnpm build + - name: Start MinIO service + run: | + docker run --detach --rm --name icedr-ci-minio \ + --publish 9000:9000 \ + --env MINIO_ROOT_USER=icedr-ci-minio \ + --env MINIO_ROOT_PASSWORD=icedr-ci-minio-secret \ + quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z server /data + for attempt in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:9000/minio/health/live >/dev/null; then + exit 0 + fi + if ! docker inspect icedr-ci-minio >/dev/null 2>&1; then + docker logs icedr-ci-minio || true + exit 1 + fi + sleep 1 + done + docker logs icedr-ci-minio || true + exit 1 + - name: Test run: pnpm test + env: + STORAGE_INTEGRITY_MINIO_ACCESS_KEY: icedr-ci-minio + STORAGE_INTEGRITY_MINIO_ENDPOINT: http://127.0.0.1:9000 + STORAGE_INTEGRITY_MINIO_REGION: us-east-1 + STORAGE_INTEGRITY_MINIO_SECRET_KEY: icedr-ci-minio-secret + STORAGE_INTEGRITY_MINIO_TEST: "1" + + - name: Stop MinIO service + if: always() + run: docker rm --force icedr-ci-minio || true - name: E2E smoke run: pnpm test:e2e diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 9947b51..3c26c55 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -22,6 +22,7 @@ import { StorageModule } from './modules/storage/storage.module'; import { TransfersModule } from './modules/downloads/transfers/transfers.module'; import { WorkerModule } from './modules/downloads/worker/worker.module'; import { WorkspacesModule } from './modules/admin/workspaces/workspaces.module'; +import { StorageIntegrityModule } from './modules/admin/storage-integrity/storage-integrity.module'; @Module({ imports: [ @@ -45,6 +46,7 @@ import { WorkspacesModule } from './modules/admin/workspaces/workspaces.module'; SharesModule, AuditModule, StorageModule, + StorageIntegrityModule, TransfersModule, QueueModule, WorkerModule, diff --git a/backend/src/database/file-node-parent-integrity.spec.ts b/backend/src/database/file-node-parent-integrity.spec.ts new file mode 100644 index 0000000..991acb7 --- /dev/null +++ b/backend/src/database/file-node-parent-integrity.spec.ts @@ -0,0 +1,182 @@ +import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'; +import { randomUUID } from 'node:crypto'; +import { existsSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { PrismaClient } from '../generated/prisma-sqlite/client'; +import { PrismaService } from './prisma.service'; + +function sqliteClient(databasePath: string) { + return new PrismaClient({ + adapter: new PrismaBetterSqlite3( + { url: databasePath }, + { timestampFormat: 'iso8601' }, + ), + }); +} + +function sqliteService(client: PrismaClient) { + const service = Object.create(PrismaService.prototype) as PrismaService; + Object.assign(service as unknown as Record, { + activeClient: client, + activeSource: { provider: 'sqlite' }, + }); + return service; +} + +async function ensureParentIntegrity(service: PrismaService) { + await ( + service as unknown as { + ensureSqliteFileNodeParentIntegrity: () => Promise; + } + ).ensureSqliteFileNodeParentIntegrity(); +} + +describe('FileNode parent integrity', () => { + let client: PrismaClient; + let databasePath: string; + + beforeEach(async () => { + databasePath = join( + tmpdir(), + `icedr-file-node-parent-integrity-${randomUUID()}.sqlite`, + ); + client = sqliteClient(databasePath); + await client.$connect(); + await client.$executeRawUnsafe(` + CREATE TABLE "file_nodes" ( + "id" TEXT NOT NULL PRIMARY KEY, + "parent_node_id" TEXT, + "name" TEXT NOT NULL + ) + `); + await client.$executeRawUnsafe(` + CREATE TABLE "file_versions" ( + "id" TEXT NOT NULL PRIMARY KEY, + "node_id" TEXT NOT NULL, + CONSTRAINT "file_versions_node_id_fkey" + FOREIGN KEY ("node_id") REFERENCES "file_nodes" ("id") + ON DELETE CASCADE ON UPDATE CASCADE + ) + `); + }); + + afterEach(async () => { + await client.$disconnect(); + for (const suffix of ['', '-shm', '-wal']) { + const path = `${databasePath}${suffix}`; + if (existsSync(path)) rmSync(path, { force: true }); + } + }); + + it('guards inserts and parent changes in an existing SQLite database', async () => { + await ensureParentIntegrity(sqliteService(client)); + await client.$executeRawUnsafe( + 'INSERT INTO "file_nodes" ("id", "parent_node_id", "name") VALUES (?, ?, ?)', + 'parent', + null, + 'Parent', + ); + await client.$executeRawUnsafe( + 'INSERT INTO "file_nodes" ("id", "parent_node_id", "name") VALUES (?, ?, ?)', + 'child', + 'parent', + 'Child', + ); + + await expect( + client.$executeRawUnsafe( + 'INSERT INTO "file_nodes" ("id", "parent_node_id", "name") VALUES (?, ?, ?)', + 'orphan', + 'missing-parent', + 'Orphan', + ), + ).rejects.toThrow(/missing node/i); + await expect( + client.$executeRawUnsafe( + 'UPDATE "file_nodes" SET "parent_node_id" = ? WHERE "id" = ?', + 'missing-parent', + 'child', + ), + ).rejects.toThrow(/missing node/i); + }); + + it('refuses startup when an existing SQLite database contains an orphan', async () => { + await client.$executeRawUnsafe( + 'INSERT INTO "file_nodes" ("id", "parent_node_id", "name") VALUES (?, ?, ?)', + 'orphan', + 'missing-parent', + 'Orphan', + ); + + await expect(ensureParentIntegrity(sqliteService(client))).rejects.toThrow( + 'orphan -> missing-parent', + ); + }); + + it('cascades deletion through every descendant in an existing SQLite database', async () => { + await ensureParentIntegrity(sqliteService(client)); + for (const [id, parentNodeId] of [ + ['parent', null], + ['child', 'parent'], + ['grandchild', 'child'], + ] as const) { + await client.$executeRawUnsafe( + 'INSERT INTO "file_nodes" ("id", "parent_node_id", "name") VALUES (?, ?, ?)', + id, + parentNodeId, + id, + ); + } + await client.$executeRawUnsafe( + 'INSERT INTO "file_versions" ("id", "node_id") VALUES (?, ?), (?, ?)', + 'child-version', + 'child', + 'grandchild-version', + 'grandchild', + ); + + await client.$executeRawUnsafe( + 'DELETE FROM "file_nodes" WHERE "id" = ?', + 'parent', + ); + + await expect( + client.$queryRawUnsafe('SELECT "id" FROM "file_nodes" ORDER BY "id"'), + ).resolves.toEqual([]); + await expect( + client.$queryRawUnsafe('SELECT "id" FROM "file_versions" ORDER BY "id"'), + ).resolves.toEqual([]); + }); + + it('declares the same cascading parent relationship for PostgreSQL', () => { + const workspaceRoot = resolve(__dirname, '..', '..', '..'); + const schema = readFileSync( + join(workspaceRoot, 'database', 'schema.prisma'), + 'utf8', + ); + const migration = readFileSync( + join( + workspaceRoot, + 'database', + 'migrations', + '20260813124000_file_node_parent_integrity', + 'migration.sql', + ), + 'utf8', + ); + + expect(schema).toContain( + '@relation("FileNodeHierarchy", fields: [parentNodeId], references: [id], onDelete: Cascade, onUpdate: Cascade)', + ); + expect(migration).toContain('CONSTRAINT "file_nodes_parent_node_id_fkey"'); + expect(migration).toContain( + 'FOREIGN KEY ("parent_node_id") REFERENCES "file_nodes"("id")', + ); + expect(migration).toContain('ON DELETE CASCADE ON UPDATE CASCADE'); + expect(migration).toContain('NOT VALID'); + expect(migration).toContain( + 'VALIDATE CONSTRAINT "file_nodes_parent_node_id_fkey"', + ); + }); +}); diff --git a/backend/src/database/prisma.service.spec.ts b/backend/src/database/prisma.service.spec.ts index 4f3d4c4..0a59588 100644 --- a/backend/src/database/prisma.service.spec.ts +++ b/backend/src/database/prisma.service.spec.ts @@ -188,6 +188,76 @@ describe('PrismaService', () => { expect(client.$executeRawUnsafe).toHaveBeenCalledWith( 'ALTER TABLE "file_nodes" ADD COLUMN "name_key" TEXT NOT NULL DEFAULT \'\'', ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + 'ALTER TABLE "file_nodes" ADD COLUMN "checksum_algorithm" TEXT', + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + 'ALTER TABLE "file_nodes" ADD COLUMN "checksum_value" TEXT', + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + 'ALTER TABLE "file_nodes" ADD COLUMN "integrity_status" TEXT NOT NULL DEFAULT \'unknown\'', + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + 'ALTER TABLE "file_nodes" ADD COLUMN "last_verified_at" TEXT', + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + 'ALTER TABLE "file_nodes" ADD COLUMN "verification_failure_code" TEXT', + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + 'ALTER TABLE "file_nodes" ADD COLUMN "integrity_acknowledged_at" TEXT', + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + 'ALTER TABLE "file_nodes" ADD COLUMN "integrity_acknowledged_by" TEXT', + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + 'ALTER TABLE "file_versions" ADD COLUMN "checksum_algorithm" TEXT', + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + 'ALTER TABLE "file_versions" ADD COLUMN "checksum_value" TEXT', + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + 'ALTER TABLE "file_versions" ADD COLUMN "integrity_status" TEXT NOT NULL DEFAULT \'unknown\'', + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + 'ALTER TABLE "file_versions" ADD COLUMN "last_verified_at" TEXT', + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + 'ALTER TABLE "file_versions" ADD COLUMN "verification_failure_code" TEXT', + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + 'ALTER TABLE "file_versions" ADD COLUMN "integrity_acknowledged_at" TEXT', + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + 'ALTER TABLE "file_versions" ADD COLUMN "integrity_acknowledged_by" TEXT', + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + expect.stringContaining( + 'CREATE TABLE IF NOT EXISTS "blob_integrity_tasks"', + ), + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + expect.stringContaining( + 'CREATE TABLE IF NOT EXISTS "blob_integrity_results"', + ), + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + 'ALTER TABLE "blob_integrity_results" ADD COLUMN "acknowledged_at" TEXT', + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + 'ALTER TABLE "blob_integrity_results" ADD COLUMN "acknowledged_by" TEXT', + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + 'CREATE UNIQUE INDEX IF NOT EXISTS "blob_integrity_tasks_lease_key_key" ON "blob_integrity_tasks"("lease_key")', + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + 'CREATE INDEX IF NOT EXISTS "blob_integrity_tasks_status_created_at_idx" ON "blob_integrity_tasks"("status", "created_at")', + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + 'CREATE INDEX IF NOT EXISTS "blob_integrity_tasks_status_lease_expires_at_idx" ON "blob_integrity_tasks"("status", "lease_expires_at")', + ); + expect(client.$executeRawUnsafe).toHaveBeenCalledWith( + 'CREATE UNIQUE INDEX IF NOT EXISTS "blob_integrity_results_task_target_key" ON "blob_integrity_results"("task_id", "target_key")', + ); expect(client.$executeRawUnsafe).toHaveBeenCalledWith( 'CREATE INDEX IF NOT EXISTS "file_nodes_workspace_id_space_scope_idx" ON "file_nodes"("workspace_id", "space_scope")', ); @@ -227,7 +297,24 @@ describe('PrismaService', () => { 'directory_key', 'owner_scope_key', 'name_key', + 'checksum_algorithm', + 'checksum_value', + 'integrity_status', + 'last_verified_at', + 'verification_failure_code', + 'integrity_acknowledged_at', + 'integrity_acknowledged_by', + ], + file_versions: [ + 'checksum_algorithm', + 'checksum_value', + 'integrity_status', + 'last_verified_at', + 'verification_failure_code', + 'integrity_acknowledged_at', + 'integrity_acknowledged_by', ], + blob_integrity_results: ['acknowledged_at', 'acknowledged_by'], upload_sessions: [ 'space_scope', 'requested_file_name', @@ -266,6 +353,11 @@ describe('PrismaService', () => { statement.startsWith('ALTER TABLE "upload_sessions"'), ), ).toBe(false); + expect( + statements.some((statement) => + statement.startsWith('ALTER TABLE "file_versions"'), + ), + ).toBe(false); expect(client.$executeRawUnsafe).toHaveBeenCalledWith( 'CREATE INDEX IF NOT EXISTS "file_nodes_workspace_id_space_scope_idx" ON "file_nodes"("workspace_id", "space_scope")', ); diff --git a/backend/src/database/prisma.service.ts b/backend/src/database/prisma.service.ts index 8acb155..822fbb4 100644 --- a/backend/src/database/prisma.service.ts +++ b/backend/src/database/prisma.service.ts @@ -81,6 +81,8 @@ const copyModels = [ 'shareDownloadIntent', 'auditEvent', 'blobReconcileTask', + 'blobIntegrityTask', + 'blobIntegrityResult', 'setupOperation', ] as const; @@ -294,6 +296,16 @@ export class PrismaService implements OnModuleInit, OnModuleDestroy { .blobReconcileTask as PrismaClient['blobReconcileTask']; } + get blobIntegrityTask(): PrismaClient['blobIntegrityTask'] { + return this.activeClient + .blobIntegrityTask as PrismaClient['blobIntegrityTask']; + } + + get blobIntegrityResult(): PrismaClient['blobIntegrityResult'] { + return this.activeClient + .blobIntegrityResult as PrismaClient['blobIntegrityResult']; + } + get $transaction(): PrismaClient['$transaction'] { return this.activeClient.$transaction.bind( this.activeClient, @@ -431,6 +443,8 @@ export class PrismaService implements OnModuleInit, OnModuleDestroy { 'name_key', "TEXT NOT NULL DEFAULT ''", ); + await this.ensureSqliteFileIntegritySchema(); + await this.ensureSqliteFileNodeParentIntegrity(); await this.ensureSqliteColumn( 'upload_sessions', 'space_scope', @@ -586,6 +600,140 @@ export class PrismaService implements OnModuleInit, OnModuleDestroy { } } + private async ensureSqliteFileIntegritySchema() { + const integrityColumns = [ + ['checksum_algorithm', 'TEXT'], + ['checksum_value', 'TEXT'], + ['integrity_status', "TEXT NOT NULL DEFAULT 'unknown'"], + ['last_verified_at', 'TEXT'], + ['verification_failure_code', 'TEXT'], + ['integrity_acknowledged_at', 'TEXT'], + ['integrity_acknowledged_by', 'TEXT'], + ] as const; + for (const tableName of ['file_nodes', 'file_versions'] as const) { + for (const [columnName, definition] of integrityColumns) { + await this.ensureSqliteColumn(tableName, columnName, definition); + } + } + + const statements = [ + 'CREATE TABLE IF NOT EXISTS "blob_integrity_tasks" ("id" TEXT NOT NULL PRIMARY KEY, "actor_user_id" TEXT, "status" TEXT NOT NULL, "scope" TEXT NOT NULL, "workspace_id" TEXT, "mode" TEXT NOT NULL, "target" JSONB NOT NULL DEFAULT \'{}\', "batch_size" INTEGER NOT NULL, "concurrency" INTEGER NOT NULL, "bandwidth_limit_bytes_per_second" INTEGER, "max_attempts" INTEGER NOT NULL, "progress" JSONB NOT NULL DEFAULT \'{}\', "cursor" JSONB NOT NULL DEFAULT \'{}\', "retry_of_task_id" TEXT, "retry_result_ids" JSONB NOT NULL DEFAULT \'[]\', "lease_key" TEXT, "lease_owner" TEXT, "lease_expires_at" TEXT, "failure_code" TEXT, "failure_message" TEXT, "snapshot_at" TEXT NOT NULL, "target_count" INTEGER, "created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, "started_at" TEXT, "finished_at" TEXT)', + 'CREATE TABLE IF NOT EXISTS "blob_integrity_results" ("id" TEXT NOT NULL PRIMARY KEY, "task_id" TEXT NOT NULL, "source_result_id" TEXT, "workspace_id" TEXT NOT NULL, "node_id" TEXT, "version_id" TEXT, "target_key" TEXT NOT NULL, "object_key" TEXT NOT NULL, "status" TEXT NOT NULL, "expected_hash" TEXT, "actual_hash" TEXT, "expected_size_bytes" INTEGER, "size_bytes" INTEGER, "bytes_read" INTEGER NOT NULL DEFAULT 0, "attempts" INTEGER NOT NULL DEFAULT 1, "error_code" TEXT, "error_message" TEXT, "checked_at" TEXT NOT NULL, "acknowledged_at" TEXT, "acknowledged_by" TEXT, CONSTRAINT "blob_integrity_results_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "blob_integrity_tasks" ("id") ON DELETE CASCADE ON UPDATE CASCADE)', + 'CREATE UNIQUE INDEX IF NOT EXISTS "blob_integrity_tasks_lease_key_key" ON "blob_integrity_tasks"("lease_key")', + 'CREATE INDEX IF NOT EXISTS "blob_integrity_tasks_created_at_idx" ON "blob_integrity_tasks"("created_at")', + 'CREATE INDEX IF NOT EXISTS "blob_integrity_tasks_status_created_at_idx" ON "blob_integrity_tasks"("status", "created_at")', + 'CREATE INDEX IF NOT EXISTS "blob_integrity_tasks_status_lease_expires_at_idx" ON "blob_integrity_tasks"("status", "lease_expires_at")', + 'CREATE INDEX IF NOT EXISTS "blob_integrity_tasks_workspace_created_at_idx" ON "blob_integrity_tasks"("workspace_id", "created_at")', + 'CREATE INDEX IF NOT EXISTS "blob_integrity_tasks_retry_of_task_id_idx" ON "blob_integrity_tasks"("retry_of_task_id")', + 'CREATE UNIQUE INDEX IF NOT EXISTS "blob_integrity_results_task_target_key" ON "blob_integrity_results"("task_id", "target_key")', + 'CREATE INDEX IF NOT EXISTS "blob_integrity_results_task_status_checked_at_idx" ON "blob_integrity_results"("task_id", "status", "checked_at")', + 'CREATE INDEX IF NOT EXISTS "blob_integrity_results_workspace_node_idx" ON "blob_integrity_results"("workspace_id", "node_id")', + 'CREATE INDEX IF NOT EXISTS "blob_integrity_results_source_result_id_idx" ON "blob_integrity_results"("source_result_id")', + ]; + for (const statement of statements) { + await this.activeClient.$executeRawUnsafe(statement); + } + await this.ensureSqliteColumn( + 'blob_integrity_tasks', + 'target_count', + 'INTEGER', + ); + await this.ensureSqliteColumn('blob_integrity_tasks', 'active_key', 'TEXT'); + await this.activeClient.$executeRawUnsafe( + 'CREATE UNIQUE INDEX IF NOT EXISTS "blob_integrity_tasks_active_key_key" ON "blob_integrity_tasks"("active_key")', + ); + await this.ensureSqliteColumn( + 'blob_integrity_results', + 'acknowledged_at', + 'TEXT', + ); + await this.ensureSqliteColumn( + 'blob_integrity_results', + 'acknowledged_by', + 'TEXT', + ); + } + + private async ensureSqliteFileNodeParentIntegrity() { + const statements = [ + `CREATE TRIGGER IF NOT EXISTS "file_nodes_parent_insert_guard" + BEFORE INSERT ON "file_nodes" + FOR EACH ROW + WHEN NEW."parent_node_id" IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM "file_nodes" AS "parent" + WHERE "parent"."id" = NEW."parent_node_id" + ) + BEGIN + SELECT RAISE(ABORT, 'file_nodes.parent_node_id references a missing node'); + END`, + `CREATE TRIGGER IF NOT EXISTS "file_nodes_parent_update_guard" + BEFORE UPDATE OF "parent_node_id" ON "file_nodes" + FOR EACH ROW + WHEN NEW."parent_node_id" IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM "file_nodes" AS "parent" + WHERE "parent"."id" = NEW."parent_node_id" + ) + BEGIN + SELECT RAISE(ABORT, 'file_nodes.parent_node_id references a missing node'); + END`, + `CREATE TRIGGER IF NOT EXISTS "file_nodes_parent_delete_cascade" + BEFORE DELETE ON "file_nodes" + FOR EACH ROW + BEGIN + DELETE FROM "file_nodes" + WHERE "id" IN ( + WITH RECURSIVE "descendants"("id") AS ( + SELECT "id" FROM "file_nodes" + WHERE "parent_node_id" = OLD."id" + UNION + SELECT "child"."id" + FROM "file_nodes" AS "child" + INNER JOIN "descendants" + ON "child"."parent_node_id" = "descendants"."id" + ) + SELECT "id" FROM "descendants" + WHERE "id" <> OLD."id" + ); + END`, + ]; + for (const statement of statements) { + await this.activeClient.$executeRawUnsafe(statement); + } + + const rows = await this.activeClient.$queryRawUnsafe( + `SELECT "child"."id", "child"."parent_node_id" + FROM "file_nodes" AS "child" + LEFT JOIN "file_nodes" AS "parent" + ON "parent"."id" = "child"."parent_node_id" + WHERE "child"."parent_node_id" IS NOT NULL + AND "parent"."id" IS NULL + ORDER BY "child"."id" + LIMIT 10`, + ); + const orphans = Array.isArray(rows) + ? rows.filter( + (row): row is { id: string; parent_node_id: string } => + typeof row === 'object' && + row !== null && + typeof (row as { id?: unknown }).id === 'string' && + typeof (row as { parent_node_id?: unknown }).parent_node_id === + 'string', + ) + : []; + if (orphans.length > 0) { + throw new Error( + `SQLite contains file nodes with missing parents: ${orphans + .map( + ({ id, parent_node_id: parentNodeId }) => + `${id} -> ${parentNodeId}`, + ) + .join(', ')}`, + ); + } + } + private async ensureSqliteColumn( tableName: string, columnName: string, diff --git a/backend/src/main.ts b/backend/src/main.ts index a343bca..47e10e8 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -103,6 +103,7 @@ const unprefixedApiRouteRoots = [ '/shares', '/site', '/storage', + '/admin/storage-integrity', '/system', '/transfers', '/workspaces', diff --git a/backend/src/modules/admin/storage-integrity/storage-integrity.controller.spec.ts b/backend/src/modules/admin/storage-integrity/storage-integrity.controller.spec.ts new file mode 100644 index 0000000..c65c9eb --- /dev/null +++ b/backend/src/modules/admin/storage-integrity/storage-integrity.controller.spec.ts @@ -0,0 +1,163 @@ +import { ForbiddenException } from '@nestjs/common'; +import type { AdminGuardService } from '../../../common/security/admin-guard.service'; +import type { StorageIntegrityTaskService } from '../../storage/storage-integrity-task.service'; +import type { StorageIntegrityAcknowledgementService } from '../../storage/storage-integrity-acknowledgement.service'; +import { StorageIntegrityController } from './storage-integrity.controller'; + +function setup() { + const requirePermission = jest.fn(() => + Promise.resolve({ user: { id: 'admin-1' } }), + ); + const createTask = jest.fn(() => Promise.resolve({ id: 'task-1' })); + const listTasks = jest.fn(() => Promise.resolve({ items: [], total: 0 })); + const listResults = jest.fn(() => Promise.resolve({ items: [], total: 0 })); + const getTask = jest.fn(() => Promise.resolve({ id: 'task-1' })); + const retryTask = jest.fn(() => Promise.resolve({ id: 'task-2' })); + const getSummary = jest.fn(() => Promise.resolve({ counts: {} })); + const listTargets = jest.fn(() => Promise.resolve({ items: [], total: 0 })); + const listTargetVersions = jest.fn(() => Promise.resolve([])); + const acknowledgeResult = jest.fn(() => + Promise.resolve({ id: 'result-1', acknowledgedBy: 'admin-1' }), + ); + const tasks = { + createTask, + getSummary, + getTask, + listResults, + listTargets, + listTargetVersions, + listTasks, + retryTask, + } as unknown as StorageIntegrityTaskService; + return { + controller: new StorageIntegrityController( + tasks, + { + acknowledgeResult, + } as unknown as StorageIntegrityAcknowledgementService, + { + requirePermission, + } as unknown as AdminGuardService, + ), + acknowledgeResult, + createTask, + getSummary, + listResults, + listTasks, + listTargets, + requirePermission, + }; +} + +describe('StorageIntegrityController', () => { + it('requires storage manage and attributes acknowledgement to the session', async () => { + const { acknowledgeResult, controller, requirePermission } = setup(); + + await controller.acknowledgeResult('result-1', 'Bearer admin'); + + expect(requirePermission).toHaveBeenCalledWith( + 'Bearer admin', + 'storage', + 'manage', + ); + expect(acknowledgeResult).toHaveBeenCalledWith('result-1', 'admin-1'); + }); + it('requires storage manage and attributes task creation to the session', async () => { + const { controller, createTask, requirePermission } = setup(); + const dto = { + batchSize: 50, + concurrency: 2, + maxAttempts: 3, + mode: 'verify' as const, + scope: 'all' as const, + }; + + await controller.createTask(dto, 'Bearer admin'); + + expect(requirePermission).toHaveBeenCalledWith( + 'Bearer admin', + 'storage', + 'manage', + ); + expect(createTask).toHaveBeenCalledWith(dto, 'admin-1'); + }); + + it('protects the workspace-scoped target picker with storage manage permission', async () => { + const { controller, listTargets, requirePermission } = setup(); + + await controller.listTargets( + { + limit: 20, + offset: 0, + query: 'report', + workspaceId: ' workspace-a ', + }, + 'Bearer admin', + ); + + expect(requirePermission).toHaveBeenCalledWith( + 'Bearer admin', + 'storage', + 'manage', + ); + expect(listTargets).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: ' workspace-a ' }), + ); + }); + + it('does not expose task history when permission validation fails', async () => { + const { controller, listTasks, requirePermission } = setup(); + requirePermission.mockRejectedValueOnce(new ForbiddenException()); + + await expect( + controller.listTasks( + { limit: 50, offset: 0, scope: 'all' }, + 'Bearer member', + ), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(listTasks).not.toHaveBeenCalled(); + }); + + it('maps the all result status to an unfiltered repository query', async () => { + const { controller, listResults } = setup(); + + await controller.listResults( + 'task-1', + { limit: 100, offset: 20, status: 'all' }, + 'Bearer admin', + ); + + expect(listResults).toHaveBeenCalledWith({ + limit: 100, + offset: 20, + status: undefined, + taskId: 'task-1', + }); + }); + + it('passes an explicit workspace scope to summary and history', async () => { + const { controller, getSummary, listTasks } = setup(); + + await controller.getSummary( + { scope: 'workspace', workspaceId: 'workspace-a' }, + 'Bearer admin', + ); + await controller.listTasks( + { + limit: 25, + offset: 0, + scope: 'workspace', + workspaceId: 'workspace-a', + }, + 'Bearer admin', + ); + + expect(getSummary).toHaveBeenCalledWith('workspace', 'workspace-a'); + expect(listTasks).toHaveBeenCalledWith( + expect.objectContaining({ + scope: 'workspace', + workspaceId: 'workspace-a', + }), + ); + }); +}); diff --git a/backend/src/modules/admin/storage-integrity/storage-integrity.controller.ts b/backend/src/modules/admin/storage-integrity/storage-integrity.controller.ts new file mode 100644 index 0000000..ac16d08 --- /dev/null +++ b/backend/src/modules/admin/storage-integrity/storage-integrity.controller.ts @@ -0,0 +1,133 @@ +import { + Body, + Controller, + Get, + Headers, + Param, + Post, + Query, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AdminGuardService } from '../../../common/security/admin-guard.service'; +import { + CreateStorageIntegrityTaskDto, + ListStorageIntegrityResultsQueryDto, + ListStorageIntegrityTasksQueryDto, + ListStorageIntegrityTargetsQueryDto, + RetryStorageIntegrityTaskDto, + StorageIntegritySummaryQueryDto, + StorageIntegrityTargetVersionsQueryDto, +} from '../../storage/storage-integrity-task.dto'; +import { StorageIntegrityTaskService } from '../../storage/storage-integrity-task.service'; +import { StorageIntegrityAcknowledgementService } from '../../storage/storage-integrity-acknowledgement.service'; + +@ApiTags('admin-storage-integrity') +@Controller('admin/storage-integrity') +export class StorageIntegrityController { + constructor( + private readonly tasks: StorageIntegrityTaskService, + private readonly acknowledgements: StorageIntegrityAcknowledgementService, + private readonly adminGuard: AdminGuardService, + ) {} + + @Post('results/:resultId/acknowledge') + async acknowledgeResult( + @Param('resultId') resultId: string, + @Headers('authorization') authorization?: string, + ) { + const session = await this.adminGuard.requirePermission( + authorization, + 'storage', + 'manage', + ); + return this.acknowledgements.acknowledgeResult(resultId, session.user.id); + } + + @Post('tasks') + async createTask( + @Body() dto: CreateStorageIntegrityTaskDto, + @Headers('authorization') authorization?: string, + ) { + const session = await this.adminGuard.requirePermission( + authorization, + 'storage', + 'manage', + ); + return this.tasks.createTask(dto, session.user.id); + } + + @Get('tasks') + async listTasks( + @Query() query: ListStorageIntegrityTasksQueryDto, + @Headers('authorization') authorization?: string, + ) { + await this.adminGuard.requirePermission(authorization, 'storage', 'manage'); + return this.tasks.listTasks(query); + } + + @Get('tasks/:taskId') + async getTask( + @Param('taskId') taskId: string, + @Headers('authorization') authorization?: string, + ) { + await this.adminGuard.requirePermission(authorization, 'storage', 'manage'); + return this.tasks.getTask(taskId); + } + + @Get('tasks/:taskId/results') + async listResults( + @Param('taskId') taskId: string, + @Query() query: ListStorageIntegrityResultsQueryDto, + @Headers('authorization') authorization?: string, + ) { + await this.adminGuard.requirePermission(authorization, 'storage', 'manage'); + return this.tasks.listResults({ + limit: query.limit, + offset: query.offset, + status: query.status === 'all' ? undefined : query.status, + taskId, + }); + } + + @Post('tasks/:taskId/retry') + async retryTask( + @Param('taskId') taskId: string, + @Body() dto: RetryStorageIntegrityTaskDto, + @Headers('authorization') authorization?: string, + ) { + const session = await this.adminGuard.requirePermission( + authorization, + 'storage', + 'manage', + ); + return this.tasks.retryTask(taskId, dto.resultIds, session.user.id); + } + + @Get('summary') + async getSummary( + @Query() query: StorageIntegritySummaryQueryDto, + @Headers('authorization') authorization?: string, + ) { + await this.adminGuard.requirePermission(authorization, 'storage', 'manage'); + return this.tasks.getSummary(query.scope, query.workspaceId); + } + + @Get('targets') + async listTargets( + @Query() query: ListStorageIntegrityTargetsQueryDto, + @Headers('authorization') authorization?: string, + ) { + await this.adminGuard.requirePermission(authorization, 'storage', 'manage'); + return this.tasks.listTargets(query); + } + + @Get('targets/:nodeId/versions') + async listTargetVersions( + @Param('nodeId') nodeId: string, + @Query() query: StorageIntegrityTargetVersionsQueryDto, + @Headers('authorization') authorization?: string, + ) { + await this.adminGuard.requirePermission(authorization, 'storage', 'manage'); + return this.tasks.listTargetVersions(nodeId, query.workspaceId); + } +} diff --git a/backend/src/modules/admin/storage-integrity/storage-integrity.module.ts b/backend/src/modules/admin/storage-integrity/storage-integrity.module.ts new file mode 100644 index 0000000..500b571 --- /dev/null +++ b/backend/src/modules/admin/storage-integrity/storage-integrity.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AuthCoreModule } from '../../auth/core/auth-core.module'; +import { StorageModule } from '../../storage/storage.module'; +import { StorageIntegrityController } from './storage-integrity.controller'; + +@Module({ + imports: [AuthCoreModule, StorageModule], + controllers: [StorageIntegrityController], +}) +export class StorageIntegrityModule {} diff --git a/backend/src/modules/files/file-download-preview.service.spec.ts b/backend/src/modules/files/file-download-preview.service.spec.ts index 4e789ef..057c47a 100644 --- a/backend/src/modules/files/file-download-preview.service.spec.ts +++ b/backend/src/modules/files/file-download-preview.service.spec.ts @@ -230,6 +230,13 @@ describe('FileDownloadPreviewService', () => { const versions = await service.listFileVersions('roadmap'); expect(versions).toHaveLength(1); expect(versions[0]).not.toHaveProperty('objectKey'); + expect(versions[0]).not.toHaveProperty('checksumValue'); + expect(versions[0]).not.toHaveProperty('verificationFailureCode'); + expect(versions[0]).toMatchObject({ + checksumAlgorithm: 'sha256', + integrityStatus: 'verified', + lastVerifiedAt: new Date(1).toISOString(), + }); const intent = await service.createVersionDownloadIntent( 'roadmap', diff --git a/backend/src/modules/files/file-integrity.spec.ts b/backend/src/modules/files/file-integrity.spec.ts new file mode 100644 index 0000000..f45c665 --- /dev/null +++ b/backend/src/modules/files/file-integrity.spec.ts @@ -0,0 +1,43 @@ +import { + needsFileIntegrityVerification, + normalizeFileIntegrityFailureCode, + normalizeFileIntegrityStatus, +} from './file-integrity'; + +describe('file integrity metadata normalization', () => { + it('fails closed when persisted metadata contains unknown values', () => { + expect(normalizeFileIntegrityStatus('database-garbage')).toBe('unknown'); + expect(normalizeFileIntegrityFailureCode('internal-secret')).toBeNull(); + }); + + it('preserves supported integrity values', () => { + expect(normalizeFileIntegrityStatus('mismatch')).toBe('mismatch'); + expect(normalizeFileIntegrityFailureCode('checksum-mismatch')).toBe( + 'checksum-mismatch', + ); + }); + + it('requires verification for copied or archived content without a settled checksum', () => { + expect( + needsFileIntegrityVerification({ + checksumAlgorithm: null, + checksumValue: null, + integrityStatus: 'pending', + }), + ).toBe(true); + expect( + needsFileIntegrityVerification({ + checksumAlgorithm: 'sha256', + checksumValue: 'a'.repeat(64), + integrityStatus: 'verified', + }), + ).toBe(false); + expect( + needsFileIntegrityVerification({ + checksumAlgorithm: null, + checksumValue: 'a'.repeat(64), + integrityStatus: 'verified', + }), + ).toBe(true); + }); +}); diff --git a/backend/src/modules/files/file-integrity.ts b/backend/src/modules/files/file-integrity.ts new file mode 100644 index 0000000..2b56472 --- /dev/null +++ b/backend/src/modules/files/file-integrity.ts @@ -0,0 +1,54 @@ +import type { + FileIntegrityFailureCode, + FileIntegrityStatus, +} from './file-nodes.dto'; + +const statuses = new Set([ + 'unknown', + 'pending', + 'verified', + 'mismatch', + 'failed', +]); + +const failureCodes = new Set([ + 'missing-object', + 'size-mismatch', + 'checksum-mismatch', + 'verification-failed', +]); + +export function normalizeFileIntegrityStatus( + value: string, +): FileIntegrityStatus { + return statuses.has(value as FileIntegrityStatus) + ? (value as FileIntegrityStatus) + : 'unknown'; +} + +export function normalizeFileIntegrityFailureCode( + value: string | null, +): FileIntegrityFailureCode | null { + return value && failureCodes.has(value as FileIntegrityFailureCode) + ? (value as FileIntegrityFailureCode) + : null; +} + +export function needsFileIntegrityVerification(input: { + checksumAlgorithm: string | null; + checksumValue: string | null; + integrityStatus: string; +}) { + const algorithm = input.checksumAlgorithm + ?.trim() + .toLowerCase() + .replace('-', ''); + const hasValidBaseline = + algorithm === 'sha256' && + /^[a-f0-9]{64}$/i.test(input.checksumValue?.trim() ?? ''); + return ( + !hasValidBaseline || + input.integrityStatus === 'unknown' || + input.integrityStatus === 'pending' + ); +} diff --git a/backend/src/modules/files/file-node-hierarchy-write.spec.ts b/backend/src/modules/files/file-node-hierarchy-write.spec.ts new file mode 100644 index 0000000..7cd4259 --- /dev/null +++ b/backend/src/modules/files/file-node-hierarchy-write.spec.ts @@ -0,0 +1,79 @@ +import type { PrismaService } from '../../database/prisma.service'; +import type { FileNode, Prisma } from '../../generated/prisma/client'; +import { lockAndValidateActiveFileNodeParent } from './file-node-hierarchy-write'; + +function folder( + id: string, + parentNodeId: string | null, + archivedAt: Date | null = null, +): FileNode { + return { + id, + workspaceId: 'workspace-a', + spaceScope: 'workspace', + parentNodeId, + directoryKey: parentNodeId ?? '', + ownerScopeKey: '', + name: id, + nameKey: `active:${id}`, + kind: 'folder', + mimeType: 'inode/directory', + sizeBytes: null, + objectKey: null, + checksumAlgorithm: null, + checksumValue: null, + integrityStatus: 'unknown', + lastVerifiedAt: null, + verificationFailureCode: null, + integrityAcknowledgedAt: null, + integrityAcknowledgedBy: null, + ownerName: 'User', + ownerUserId: null, + starred: false, + archivedAt, + archivedBy: archivedAt ? 'admin-1' : null, + originalParentNodeId: null, + originalPath: null, + createdAt: new Date(0), + updatedAt: new Date(0), + }; +} + +describe('file node hierarchy write locks', () => { + it('rejects an active parent whose ancestor was archived concurrently', async () => { + const parent = folder('parent', 'archived-root'); + const archivedRoot = folder('archived-root', null, new Date(1)); + const rows = new Map([ + [parent.id, parent], + [archivedRoot.id, archivedRoot], + ]); + const queryRaw = jest.fn((statement: { values?: unknown[] }) => + Promise.resolve( + (statement.values ?? []) + .filter((value): value is string => typeof value === 'string') + .map((id) => ({ id })), + ), + ); + const findUnique = jest.fn(({ where }: { where: { id: string } }) => + Promise.resolve(rows.get(where.id) ?? null), + ); + const tx = { + $queryRaw: queryRaw, + fileNode: { + findUnique, + }, + } as unknown as Prisma.TransactionClient; + + await expect( + lockAndValidateActiveFileNodeParent( + { isSqlite: () => false } as unknown as PrismaService, + tx, + parent.id, + { spaceScope: 'workspace', workspaceId: 'workspace-a' }, + ), + ).resolves.toBe(false); + + expect(queryRaw).toHaveBeenCalledTimes(2); + expect(findUnique).toHaveBeenCalledTimes(2); + }); +}); diff --git a/backend/src/modules/files/file-node-hierarchy-write.ts b/backend/src/modules/files/file-node-hierarchy-write.ts new file mode 100644 index 0000000..74c9acc --- /dev/null +++ b/backend/src/modules/files/file-node-hierarchy-write.ts @@ -0,0 +1,166 @@ +import { BadRequestException } from '@nestjs/common'; +import type { PrismaService } from '../../database/prisma.service'; +import { Prisma, type FileNode } from '../../generated/prisma/client'; + +export async function lockFileNodeRows( + prisma: PrismaService, + tx: Prisma.TransactionClient, + nodeIds: string[], +) { + const orderedIds = [...new Set(nodeIds)].sort(); + if (orderedIds.length === 0) return true; + if (prisma.isSqlite()) { + const locked = await tx.$executeRaw(Prisma.sql` + UPDATE file_nodes SET id = id + WHERE id IN (${Prisma.join(orderedIds)}) + `); + return locked === orderedIds.length; + } + const locked = await tx.$queryRaw>(Prisma.sql` + SELECT id FROM file_nodes + WHERE id IN (${Prisma.join(orderedIds)}) + ORDER BY id + FOR UPDATE + `); + return locked.length === orderedIds.length; +} + +export async function lockAndValidateActiveFileNodeParent( + prisma: PrismaService, + tx: Prisma.TransactionClient, + parentNodeId: string | null, + expected: { + forbiddenAncestorId?: string; + spaceScope: string; + workspaceId: string; + }, +) { + if (!parentNodeId) return true; + const visited = new Set(); + let ancestorId: string | null = parentNodeId; + while (ancestorId) { + if (ancestorId === expected.forbiddenAncestorId) { + throw new BadRequestException( + 'A folder cannot be used inside its own child folder', + ); + } + if (visited.has(ancestorId)) { + throw new BadRequestException('Parent folder hierarchy contains a cycle'); + } + visited.add(ancestorId); + if (!(await lockFileNodeRows(prisma, tx, [ancestorId]))) return false; + const ancestor: FileNode | null = await tx.fileNode.findUnique({ + where: { id: ancestorId }, + }); + if (!ancestor || ancestor.archivedAt) return false; + if (ancestor.id === parentNodeId) { + if (ancestor.kind !== 'folder') { + throw new BadRequestException('Parent node must be a folder'); + } + if (ancestor.workspaceId !== expected.workspaceId) { + throw new BadRequestException( + 'Parent folder belongs to another workspace', + ); + } + if (ancestor.spaceScope !== expected.spaceScope) { + throw new BadRequestException('Parent folder belongs to another space'); + } + } + ancestorId = ancestor.parentNodeId; + } + return true; +} + +export async function collectLockedActiveFileTree( + prisma: PrismaService, + tx: Prisma.TransactionClient, + rootId: string, +) { + if (!(await lockFileNodeRows(prisma, tx, [rootId]))) return null; + const root = await tx.fileNode.findFirst({ + where: { archivedAt: null, id: rootId }, + }); + if (!root) return null; + + const rows = [root]; + const visited = new Set([root.id]); + let parentIds = [root.id]; + while (parentIds.length > 0) { + const children = await tx.fileNode.findMany({ + where: { archivedAt: null, parentNodeId: { in: parentIds } }, + orderBy: [{ parentNodeId: 'asc' }, { name: 'asc' }], + }); + if (children.length === 0) break; + if (children.some((child) => visited.has(child.id))) return null; + const childIds = children.map((child) => child.id); + if (!(await lockFileNodeRows(prisma, tx, childIds))) return null; + const currentChildren = await tx.fileNode.findMany({ + where: { archivedAt: null, id: { in: childIds } }, + orderBy: [{ parentNodeId: 'asc' }, { name: 'asc' }], + }); + if ( + currentChildren.length !== childIds.length || + currentChildren.some( + (child) => + !child.parentNodeId || !parentIds.includes(child.parentNodeId), + ) + ) { + return null; + } + currentChildren.forEach((child) => visited.add(child.id)); + rows.push(...currentChildren); + parentIds = childIds; + } + return rows; +} + +export async function lockAndValidateFileNodeMove( + prisma: PrismaService, + tx: Prisma.TransactionClient, + nodeId: string, + parentNodeId: string | null, +): Promise { + const initiallyLocked = [nodeId, ...(parentNodeId ? [parentNodeId] : [])]; + if (!(await lockFileNodeRows(prisma, tx, initiallyLocked))) return null; + const source = await tx.fileNode.findUnique({ where: { id: nodeId } }); + if (!source || source.archivedAt) return null; + if (!parentNodeId) return source; + + const lockedIds = new Set(initiallyLocked); + const visited = new Set(); + let ancestorId: string | null = parentNodeId; + while (ancestorId) { + if (ancestorId === nodeId) { + throw new BadRequestException( + 'A folder cannot be moved into its child folder', + ); + } + if (visited.has(ancestorId)) { + throw new BadRequestException('Parent folder hierarchy contains a cycle'); + } + visited.add(ancestorId); + if (!lockedIds.has(ancestorId)) { + if (!(await lockFileNodeRows(prisma, tx, [ancestorId]))) return null; + lockedIds.add(ancestorId); + } + const ancestor: FileNode | null = await tx.fileNode.findUnique({ + where: { id: ancestorId }, + }); + if (!ancestor || ancestor.archivedAt) return null; + if (ancestor.id === parentNodeId) { + if (ancestor.kind !== 'folder') { + throw new BadRequestException('Parent node must be a folder'); + } + if (ancestor.workspaceId !== source.workspaceId) { + throw new BadRequestException( + 'Parent folder belongs to another workspace', + ); + } + if (ancestor.spaceScope !== source.spaceScope) { + throw new BadRequestException('Parent folder belongs to another space'); + } + } + ancestorId = ancestor.parentNodeId; + } + return source; +} diff --git a/backend/src/modules/files/file-node-public-response.spec.ts b/backend/src/modules/files/file-node-public-response.spec.ts index df8f6c3..ff7c31e 100644 --- a/backend/src/modules/files/file-node-public-response.spec.ts +++ b/backend/src/modules/files/file-node-public-response.spec.ts @@ -12,6 +12,11 @@ function createNode(objectKey: string | null): FileNodeResponse { mimeType: 'text/plain', sizeBytes: 16, objectKey, + checksumAlgorithm: 'sha256', + checksumValue: 'a'.repeat(64), + integrityStatus: 'mismatch', + lastVerifiedAt: new Date(1).toISOString(), + verificationFailureCode: 'checksum-mismatch', owner: 'Mina', ownerUserId: 'user-1', starred: false, @@ -39,6 +44,13 @@ describe('toPublicFileNode', () => { ); expect(publicNode).not.toHaveProperty('objectKey'); + expect(publicNode).not.toHaveProperty('checksumValue'); + expect(publicNode).not.toHaveProperty('verificationFailureCode'); + expect(publicNode).toMatchObject({ + checksumAlgorithm: 'sha256', + integrityStatus: 'mismatch', + lastVerifiedAt: new Date(1).toISOString(), + }); expect(publicNode.hasContent).toBe(true); }); diff --git a/backend/src/modules/files/file-node-public-response.ts b/backend/src/modules/files/file-node-public-response.ts index 48e9073..32c2d4c 100644 --- a/backend/src/modules/files/file-node-public-response.ts +++ b/backend/src/modules/files/file-node-public-response.ts @@ -2,14 +2,17 @@ import type { FileNodeResponse } from './file-nodes.dto'; export type PublicFileNodeResponse< T extends FileNodeResponse = FileNodeResponse, -> = Omit & { +> = Omit & { hasContent: boolean; }; export function toPublicFileNode( node: T, ): PublicFileNodeResponse { - const { objectKey, ...publicNode } = node; + const { objectKey, checksumValue, verificationFailureCode, ...publicNode } = + node; + void checksumValue; + void verificationFailureCode; return { ...publicNode, hasContent: Boolean(objectKey), diff --git a/backend/src/modules/files/file-node-versions.repository.spec.ts b/backend/src/modules/files/file-node-versions.repository.spec.ts new file mode 100644 index 0000000..1171fa4 --- /dev/null +++ b/backend/src/modules/files/file-node-versions.repository.spec.ts @@ -0,0 +1,954 @@ +import type { PrismaService } from '../../database/prisma.service'; +import { + FileNodeVersionsRepository, + isSqliteBusyPrismaError, +} from './file-node-versions.repository'; + +function treeNode(id: string, parentNodeId: string | null) { + return { + id, + workspaceId: 'workspace-a', + parentNodeId, + name: id, + kind: 'folder', + mimeType: 'inode/directory', + sizeBytes: null, + objectKey: null, + checksumAlgorithm: null, + checksumValue: null, + integrityStatus: 'unknown', + lastVerifiedAt: null, + verificationFailureCode: null, + integrityAcknowledgedAt: null, + integrityAcknowledgedBy: null, + ownerName: 'User', + ownerUserId: null, + spaceScope: 'workspace', + starred: false, + archivedAt: null, + archivedBy: null, + originalParentNodeId: null, + originalPath: null, + createdAt: new Date(0), + updatedAt: new Date(0), + }; +} + +function treeQueryRaw(rows: ReturnType[]) { + return jest.fn((statement: { sql?: string; values?: unknown[] }) => { + if (statement.sql?.toLowerCase().includes('with recursive')) { + return Promise.resolve(rows.slice(1)); + } + return Promise.resolve( + (statement.values ?? []) + .filter((value): value is string => typeof value === 'string') + .map((id) => ({ id })), + ); + }); +} + +describe('FileNodeVersionsRepository integrity metadata', () => { + it('does not prune an unacknowledged anomalous version', async () => { + const old = new Date('2025-01-01T00:00:00.000Z'); + const protectedVersion = { + id: 'version-protected', + objectKey: 'objects/protected', + createdAt: old, + integrityStatus: 'mismatch', + integrityAcknowledgedAt: null, + }; + const acknowledgedVersion = { + id: 'version-acknowledged', + objectKey: 'objects/acknowledged', + createdAt: old, + integrityStatus: 'failed', + integrityAcknowledgedAt: new Date('2026-08-14T00:00:00.000Z'), + }; + const deleteMany = jest.fn(() => Promise.resolve({ count: 1 })); + const findMany = jest + .fn() + .mockResolvedValueOnce([protectedVersion, acknowledgedVersion]) + .mockResolvedValueOnce([protectedVersion]); + const prisma = { + $queryRaw: jest.fn(() => Promise.resolve([])), + fileNode: { + findMany: jest.fn(() => Promise.resolve([])), + }, + filePolicySetting: { + upsert: jest.fn(() => + Promise.resolve({ + settingKey: 'global', + trashRetentionDays: 30, + versionRetentionCount: 0, + versionRetentionDays: 0, + updatedAt: new Date(), + }), + ), + }, + fileVersion: { + deleteMany, + findMany, + }, + } as unknown as PrismaService; + const repository = new FileNodeVersionsRepository(prisma); + + await expect(repository.pruneVersions('node-1')).resolves.toEqual([ + 'objects/acknowledged', + ]); + expect(deleteMany).toHaveBeenCalledWith({ + where: { + id: 'version-acknowledged', + objectKey: 'objects/acknowledged', + OR: [ + { integrityStatus: { notIn: ['mismatch', 'failed'] } }, + { integrityAcknowledgedAt: { not: null } }, + ], + }, + }); + }); + + it('does not release an object when a newer anomaly prevents version deletion', async () => { + const deleteMany = jest.fn(() => Promise.resolve({ count: 0 })); + const repository = new FileNodeVersionsRepository({ + fileVersion: { + findMany: jest.fn(() => + Promise.resolve([ + { + id: 'version-racing', + objectKey: 'objects/racing', + createdAt: new Date(0), + integrityStatus: 'verified', + integrityAcknowledgedAt: null, + }, + ]), + ), + deleteMany, + }, + } as unknown as PrismaService); + jest.spyOn(repository, 'getPolicy').mockResolvedValue({ + versionRetentionDays: 0, + versionRetentionCount: 0, + } as never); + + await expect(repository.pruneVersions('node-1')).resolves.toEqual([]); + expect(deleteMany).toHaveBeenCalledWith({ + where: { + id: 'version-racing', + objectKey: 'objects/racing', + OR: [ + { integrityStatus: { notIn: ['mismatch', 'failed'] } }, + { integrityAcknowledgedAt: { not: null } }, + ], + }, + }); + }); + + it('does not release an old restored version still referenced by the current node', async () => { + const restoredObjectKey = 'objects/restored-old-version'; + const deleteMany = jest.fn(() => Promise.resolve({ count: 1 })); + const repository = new FileNodeVersionsRepository({ + $queryRaw: jest.fn(() => + Promise.resolve([{ objectKey: restoredObjectKey }]), + ), + fileNode: { + findMany: jest.fn(() => + Promise.resolve([{ objectKey: restoredObjectKey }]), + ), + }, + fileVersion: { + deleteMany, + findMany: jest + .fn() + .mockResolvedValueOnce([ + { + id: 'version-restored-old', + objectKey: restoredObjectKey, + createdAt: new Date(0), + integrityStatus: 'verified', + integrityAcknowledgedAt: null, + }, + ]) + .mockResolvedValueOnce([]), + }, + } as unknown as PrismaService); + jest.spyOn(repository, 'getPolicy').mockResolvedValue({ + versionRetentionDays: 0, + versionRetentionCount: 0, + } as never); + + await expect(repository.pruneVersions('node-1')).resolves.toEqual([]); + expect(deleteMany).toHaveBeenCalledTimes(1); + }); + + it('checks node and version references in one database snapshot', async () => { + const queryRaw = jest.fn(() => + Promise.resolve([{ objectKey: 'objects/still-referenced' }]), + ); + const repository = new FileNodeVersionsRepository({ + $queryRaw: queryRaw, + } as unknown as PrismaService); + + await expect( + repository.filterUnreferencedObjectKeys([ + 'objects/still-referenced', + 'objects/unreferenced', + ]), + ).resolves.toEqual(['objects/unreferenced']); + + expect(queryRaw).toHaveBeenCalledTimes(1); + const statement = (queryRaw.mock.calls[0]?.[0] as { sql?: string }).sql; + expect(statement).toContain('FROM file_nodes'); + expect(statement).toContain('UNION'); + expect(statement).toContain('FROM file_versions'); + }); + + it('deduplicates recursive descendants so legacy cycles cannot recurse forever', async () => { + const queryRaw = jest.fn(() => Promise.resolve([])); + const repository = new FileNodeVersionsRepository({ + $queryRaw: queryRaw, + } as unknown as PrismaService); + + await repository.collectDescendantRows('root-node'); + + const statement = queryRaw.mock.calls[0]?.[0] as { + sql?: string; + strings?: string[]; + }; + const sql = statement.sql ?? statement.strings?.join('?') ?? ''; + expect(sql).toContain('union\n'); + expect(sql).not.toContain('union all'); + expect(sql).toContain('where id <>'); + }); + + it('retries archive after a concurrent child changes the locked tree', async () => { + const root = treeNode('root', null); + const child = treeNode('child', root.id); + const concurrentChild = treeNode('concurrent-child', root.id); + const firstRows = [root, child]; + const secondRows = [root, child, concurrentChild]; + const firstUpdates = jest.fn(); + const secondUpdates = jest.fn( + ({ + data, + where, + }: { + data: Record; + where: { id: string }; + }) => + Promise.resolve({ + ...(secondRows.find((row) => row.id === where.id) ?? root), + ...data, + }), + ); + const createTransaction = ( + rows: ReturnType[], + outsideChildCount: number, + update: jest.Mock, + ) => ({ + $queryRaw: treeQueryRaw(rows), + fileNode: { + count: jest.fn(() => Promise.resolve(outsideChildCount)), + findMany: jest.fn(() => Promise.resolve(rows)), + findUnique: jest.fn(() => Promise.resolve(root)), + update, + }, + }); + const transactions = [ + createTransaction(firstRows, 1, firstUpdates), + createTransaction(secondRows, 0, secondUpdates), + ]; + const transaction = jest.fn( + (operation: (tx: (typeof transactions)[number]) => Promise) => + operation(transactions.shift() ?? transactions[0]), + ); + const repository = new FileNodeVersionsRepository({ + $transaction: transaction, + isSqlite: () => false, + } as unknown as PrismaService); + + await expect( + repository.archiveTree(root.id, 'admin-1'), + ).resolves.toMatchObject({ + id: root.id, + archivedBy: 'admin-1', + }); + + expect(transaction).toHaveBeenCalledTimes(2); + expect(transaction).toHaveBeenNthCalledWith(1, expect.any(Function), { + isolationLevel: 'Serializable', + }); + expect(firstUpdates).not.toHaveBeenCalled(); + expect(secondUpdates).toHaveBeenCalledTimes(3); + }); + + it('keeps an already-active nested node in its current parent when restored again', async () => { + const active = treeNode('active-child', 'parent-folder'); + const update = jest.fn(); + const tx = { + $queryRaw: treeQueryRaw([active]), + fileNode: { + count: jest.fn(() => Promise.resolve(0)), + findMany: jest.fn(() => Promise.resolve([active])), + findUnique: jest.fn(() => Promise.resolve(active)), + update, + }, + }; + const transaction = jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), + ); + const repository = new FileNodeVersionsRepository({ + $transaction: transaction, + isSqlite: () => false, + } as unknown as PrismaService); + + await expect(repository.restoreTree(active.id)).resolves.toBe(active); + + expect(update).not.toHaveBeenCalled(); + expect(transaction).toHaveBeenCalledWith(expect.any(Function), { + isolationLevel: 'Serializable', + }); + }); + + it('skips automatic trash deletion when a tree contains an unacknowledged anomaly', async () => { + const root = { + id: 'root-1', + archivedAt: new Date('2025-01-01T00:00:00.000Z'), + integrityStatus: 'verified', + integrityAcknowledgedAt: null, + }; + const child = { + id: 'child-1', + archivedAt: root.archivedAt, + integrityStatus: 'failed', + integrityAcknowledgedAt: null, + }; + const deleteMany = jest.fn(); + const prisma = { + $queryRaw: jest.fn(() => Promise.resolve([])), + fileNode: { + deleteMany, + findMany: jest.fn(() => Promise.resolve([root])), + findUnique: jest.fn(() => Promise.resolve(root)), + }, + fileVersion: { + findMany: jest.fn(() => Promise.resolve([])), + }, + } as unknown as PrismaService; + const repository = new FileNodeVersionsRepository(prisma); + jest + .spyOn(repository, 'collectDescendantRows') + .mockResolvedValue([child] as never); + + await expect( + repository.cleanupTrash(new Date('2026-01-01T00:00:00.000Z')), + ).resolves.toEqual({ + nodes: [], + objectKeysToDelete: [], + versions: [], + }); + expect(deleteMany).not.toHaveBeenCalled(); + }); + + it('also protects a trash tree with an unacknowledged anomalous version', async () => { + const root = { + id: 'root-version', + archivedAt: new Date('2025-01-01T00:00:00.000Z'), + integrityStatus: 'verified', + integrityAcknowledgedAt: null, + }; + const deleteMany = jest.fn(); + const prisma = { + fileNode: { + deleteMany, + findMany: jest.fn(() => Promise.resolve([root])), + findUnique: jest.fn(() => Promise.resolve(root)), + }, + fileVersion: { + findMany: jest.fn(() => + Promise.resolve([ + { + id: 'version-protected-trash', + nodeId: root.id, + versionNumber: 1, + objectKey: 'objects/protected-trash', + sizeBytes: 12n, + mimeType: 'application/octet-stream', + checksumAlgorithm: 'sha256', + checksumValue: 'a'.repeat(64), + integrityStatus: 'mismatch', + integrityAcknowledgedAt: null, + integrityAcknowledgedBy: null, + lastVerifiedAt: new Date('2025-01-01T00:00:00.000Z'), + verificationFailureCode: 'checksum-mismatch', + uploadedBy: 'User', + remark: '', + createdAt: new Date('2025-01-01T00:00:00.000Z'), + }, + ]), + ), + }, + } as unknown as PrismaService; + const repository = new FileNodeVersionsRepository(prisma); + jest.spyOn(repository, 'collectDescendantRows').mockResolvedValue([]); + + await expect( + repository.cleanupTrash(new Date('2026-01-01T00:00:00.000Z')), + ).resolves.toEqual({ + nodes: [], + objectKeysToDelete: [], + versions: [], + }); + expect(deleteMany).not.toHaveBeenCalled(); + }); + + it('preserves a trash tree when verification reports an anomaly after the initial read', async () => { + const root = { + id: 'root-racing', + objectKey: 'objects/racing', + archivedAt: new Date(0), + integrityStatus: 'verified', + integrityAcknowledgedAt: null, + }; + const deleteMany = jest.fn(); + const tx = { + $executeRaw: jest.fn(() => Promise.resolve(1)), + fileNode: { + findMany: jest.fn(() => + Promise.resolve([{ ...root, integrityStatus: 'mismatch' }]), + ), + count: jest.fn(() => Promise.resolve(0)), + deleteMany, + }, + fileVersion: { findMany: jest.fn(() => Promise.resolve([])) }, + }; + const repository = new FileNodeVersionsRepository({ + fileNode: { findMany: jest.fn(() => Promise.resolve([root])) }, + $transaction: (callback: (client: typeof tx) => unknown) => callback(tx), + isSqlite: () => true, + } as unknown as PrismaService); + jest.spyOn(repository, 'listTreeForDeletion').mockResolvedValue({ + nodes: [root], + versions: [], + hasUnacknowledgedIntegrityAnomaly: false, + } as never); + + await expect(repository.cleanupTrash(new Date())).resolves.toEqual({ + nodes: [], + objectKeysToDelete: [], + versions: [], + }); + expect(tx.$executeRaw).toHaveBeenCalledTimes(1); + expect(deleteMany).not.toHaveBeenCalled(); + }); + + it('allows automatic trash deletion after the anomaly is acknowledged', async () => { + const root = { + id: 'root-acknowledged', + objectKey: 'objects/acknowledged', + archivedAt: new Date('2025-01-01T00:00:00.000Z'), + integrityStatus: 'mismatch', + integrityAcknowledgedAt: new Date('2025-02-01T00:00:00.000Z'), + }; + const deleteMany = jest.fn(() => Promise.resolve({ count: 1 })); + const rootFindMany = jest + .fn() + .mockResolvedValueOnce([root]) + .mockResolvedValueOnce([]); + const prisma = { + $queryRaw: jest.fn(() => Promise.resolve([])), + fileNode: { + deleteMany, + findMany: rootFindMany, + findUnique: jest.fn(() => Promise.resolve(root)), + }, + fileVersion: { + findMany: jest.fn(() => Promise.resolve([])), + }, + isSqlite: () => true, + } as unknown as PrismaService; + const repository = new FileNodeVersionsRepository(prisma); + Object.assign(prisma, { + $transaction: (callback: (tx: unknown) => unknown) => + callback({ + $executeRaw: jest.fn(() => Promise.resolve(1)), + fileNode: { + deleteMany, + findMany: jest.fn(() => Promise.resolve([root])), + count: jest.fn(() => Promise.resolve(0)), + }, + fileVersion: { + findMany: jest.fn(() => Promise.resolve([])), + }, + }), + }); + jest.spyOn(repository, 'collectDescendantRows').mockResolvedValue([]); + + await expect( + repository.cleanupTrash(new Date('2026-01-01T00:00:00.000Z')), + ).resolves.toMatchObject({ + nodes: [root], + objectKeysToDelete: ['objects/acknowledged'], + versions: [], + }); + expect(deleteMany).toHaveBeenCalledWith({ + where: { id: { in: ['root-acknowledged'] } }, + }); + }); + + it('does not release an object key still referenced by a copied node', async () => { + const objectKey = 'objects/shared-copy'; + const archivedCopy = { + id: 'copy-archived', + parentNodeId: null, + objectKey, + archivedAt: new Date(0), + integrityStatus: 'verified', + integrityAcknowledgedAt: null, + }; + const activeCopy = { objectKey }; + const deleteMany = jest.fn(() => Promise.resolve({ count: 1 })); + const prisma = { + $queryRaw: jest.fn(() => Promise.resolve([{ objectKey }])), + fileNode: { + findMany: jest + .fn() + .mockResolvedValueOnce([archivedCopy]) + .mockResolvedValueOnce([activeCopy]), + findUnique: jest.fn(() => Promise.resolve(archivedCopy)), + }, + fileVersion: { + findMany: jest.fn(() => Promise.resolve([])), + }, + isSqlite: () => true, + } as unknown as PrismaService; + const repository = new FileNodeVersionsRepository(prisma); + Object.assign(prisma, { + $transaction: ( + callback: (tx: { + $executeRaw: jest.Mock; + fileNode: { + count: jest.Mock; + deleteMany: jest.Mock; + findMany: jest.Mock; + }; + fileVersion: { findMany: jest.Mock }; + }) => unknown, + ) => + callback({ + $executeRaw: jest.fn(() => Promise.resolve(1)), + fileNode: { + count: jest.fn(() => Promise.resolve(0)), + deleteMany, + findMany: jest.fn(() => Promise.resolve([archivedCopy])), + }, + fileVersion: { + findMany: jest.fn(() => Promise.resolve([])), + }, + }), + }); + jest.spyOn(repository, 'collectDescendantRows').mockResolvedValue([]); + + await expect(repository.cleanupTrash(new Date())).resolves.toMatchObject({ + nodes: [archivedCopy], + objectKeysToDelete: [], + versions: [], + }); + expect(deleteMany).toHaveBeenCalledTimes(1); + }); + + it('uses ordered PostgreSQL row locks and Serializable isolation for tree cleanup', async () => { + const root = { + id: 'root-postgres', + parentNodeId: null, + objectKey: 'objects/postgres', + archivedAt: new Date(0), + integrityStatus: 'verified', + integrityAcknowledgedAt: null, + }; + const tx = { + $queryRaw: jest.fn(() => Promise.resolve([])), + fileNode: { + count: jest.fn(() => Promise.resolve(0)), + deleteMany: jest.fn(() => Promise.resolve({ count: 1 })), + findMany: jest.fn(() => Promise.resolve([root])), + }, + fileVersion: { findMany: jest.fn(() => Promise.resolve([])) }, + }; + const transaction = jest.fn( + (callback: (client: typeof tx) => Promise) => callback(tx), + ); + const repository = new FileNodeVersionsRepository({ + $queryRaw: jest.fn(() => Promise.resolve([])), + $transaction: transaction, + fileNode: { + findMany: jest + .fn() + .mockResolvedValueOnce([root]) + .mockResolvedValueOnce([]), + }, + fileVersion: { findMany: jest.fn(() => Promise.resolve([])) }, + isSqlite: () => false, + } as unknown as PrismaService); + jest.spyOn(repository, 'listTreeForDeletion').mockResolvedValue({ + nodes: [root], + versions: [], + hasUnacknowledgedIntegrityAnomaly: false, + } as never); + + await repository.cleanupTrash(new Date()); + + expect(transaction).toHaveBeenCalledWith(expect.any(Function), { + isolationLevel: 'Serializable', + }); + expect(tx.$queryRaw).toHaveBeenCalledTimes(2); + const lockStatements = tx.$queryRaw.mock.calls.map( + (call) => (call[0] as { sql?: string }).sql ?? '', + ); + expect(lockStatements[0]).toContain('FROM file_nodes'); + expect(lockStatements[0]).toContain('FOR UPDATE'); + expect(lockStatements[1]).toContain('FROM file_versions'); + expect(lockStatements[1]).toContain('FOR UPDATE'); + }); + + it('accepts a cascaded tree deletion even when the direct delete count is smaller', async () => { + const root = { + id: 'cascade-root', + parentNodeId: null, + objectKey: 'objects/cascade-root', + archivedAt: new Date(0), + integrityStatus: 'verified', + integrityAcknowledgedAt: null, + }; + const child = { + ...root, + id: 'cascade-child', + objectKey: 'objects/cascade-child', + parentNodeId: root.id, + }; + const count = jest.fn(() => Promise.resolve(0)); + const tx = { + $queryRaw: jest.fn(() => Promise.resolve([])), + fileNode: { + count, + deleteMany: jest.fn(() => Promise.resolve({ count: 1 })), + findMany: jest.fn(() => Promise.resolve([root, child])), + }, + fileVersion: { findMany: jest.fn(() => Promise.resolve([])) }, + }; + const repository = new FileNodeVersionsRepository({ + $queryRaw: jest.fn(() => Promise.resolve([])), + $transaction: (callback: (client: typeof tx) => Promise) => + callback(tx), + fileNode: { findMany: jest.fn(() => Promise.resolve([])) }, + fileVersion: { findMany: jest.fn(() => Promise.resolve([])) }, + isSqlite: () => false, + } as unknown as PrismaService); + jest.spyOn(repository, 'listTreeForDeletion').mockResolvedValue({ + nodes: [root, child], + versions: [], + hasUnacknowledgedIntegrityAnomaly: false, + } as never); + + await expect(repository.deleteTree(root.id)).resolves.toMatchObject({ + nodes: [root, child], + objectKeysToDelete: [root.objectKey, child.objectKey], + }); + + expect(tx.fileNode.deleteMany).toHaveBeenCalledTimes(1); + expect(count).toHaveBeenCalledTimes(2); + }); + + it('retries a Serializable tree cleanup conflict', async () => { + const root = { + id: 'root-retry', + parentNodeId: null, + objectKey: 'objects/retry', + archivedAt: new Date(0), + integrityStatus: 'verified', + integrityAcknowledgedAt: null, + }; + const tx = { + $executeRaw: jest.fn(() => Promise.resolve(1)), + fileNode: { + count: jest.fn(() => Promise.resolve(0)), + deleteMany: jest.fn(() => Promise.resolve({ count: 1 })), + findMany: jest.fn(() => Promise.resolve([root])), + }, + fileVersion: { findMany: jest.fn(() => Promise.resolve([])) }, + }; + const transaction = jest + .fn() + .mockRejectedValueOnce( + Object.assign(new Error('conflict'), { code: 'P2034' }), + ) + .mockImplementation((callback: (client: typeof tx) => Promise) => + callback(tx), + ); + const repository = new FileNodeVersionsRepository({ + $queryRaw: jest.fn(() => Promise.resolve([])), + $transaction: transaction, + fileNode: { + findMany: jest + .fn() + .mockResolvedValueOnce([root]) + .mockResolvedValueOnce([]), + }, + fileVersion: { findMany: jest.fn(() => Promise.resolve([])) }, + isSqlite: () => true, + } as unknown as PrismaService); + jest.spyOn(repository, 'listTreeForDeletion').mockResolvedValue({ + nodes: [root], + versions: [], + hasUnacknowledgedIntegrityAnomaly: false, + } as never); + + await expect(repository.cleanupTrash(new Date())).resolves.toMatchObject({ + objectKeysToDelete: ['objects/retry'], + }); + expect(transaction).toHaveBeenCalledTimes(2); + }); + + it('archives the current metadata and restores the selected version metadata atomically', async () => { + const current = { + id: 'node-1', + workspaceId: 'workspace-default', + spaceScope: 'workspace', + parentNodeId: null, + directoryKey: '', + ownerScopeKey: '', + name: 'report.txt', + nameKey: 'active:report.txt', + kind: 'doc', + mimeType: 'text/plain', + sizeBytes: 12n, + objectKey: 'objects/current', + checksumAlgorithm: 'sha256', + checksumValue: 'a'.repeat(64), + integrityStatus: 'verified', + lastVerifiedAt: new Date(1), + verificationFailureCode: null, + ownerName: 'User', + ownerUserId: 'user-1', + starred: false, + archivedAt: null, + archivedBy: null, + originalParentNodeId: null, + originalPath: null, + createdAt: new Date(0), + updatedAt: new Date(0), + }; + const selectedVersion = { + id: 'version-1', + nodeId: current.id, + versionNumber: 1, + objectKey: 'objects/version-1', + sizeBytes: 11n, + mimeType: 'text/markdown', + checksumAlgorithm: 'sha256', + checksumValue: 'b'.repeat(64), + integrityStatus: 'mismatch', + lastVerifiedAt: new Date(2), + verificationFailureCode: 'checksum-mismatch', + uploadedBy: 'User', + remark: 'old', + createdAt: new Date(0), + }; + const create = jest.fn(({ data }: { data: Record }) => + Promise.resolve(data), + ); + const update = jest.fn(({ data }: { data: Record }) => + Promise.resolve({ ...current, ...data }), + ); + const tx = { + $queryRaw: jest.fn(() => Promise.resolve([])), + fileNode: { + findUnique: jest.fn(() => Promise.resolve(current)), + update, + }, + fileVersion: { + aggregate: jest.fn(() => + Promise.resolve({ _max: { versionNumber: 1 } }), + ), + create, + findFirst: jest.fn(() => Promise.resolve(selectedVersion)), + }, + }; + const prisma = { + $transaction: jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), + ), + isSqlite: () => false, + } as unknown as PrismaService; + const repository = new FileNodeVersionsRepository(prisma); + const enqueueObjectVerification = jest.fn(() => + Promise.resolve('task-test'), + ); + const integrityTasks = { + enqueueObjectVerification, + kickQueued: jest.fn(), + }; + + await repository.restoreVersion( + current.id, + selectedVersion.id, + 'User', + integrityTasks as never, + ); + + expect(create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + objectKey: 'objects/current', + checksumValue: 'a'.repeat(64), + integrityStatus: 'verified', + lastVerifiedAt: new Date(1), + }) as unknown, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: current.id }, + data: expect.objectContaining({ + objectKey: 'objects/version-1', + checksumAlgorithm: 'sha256', + checksumValue: 'b'.repeat(64), + integrityStatus: 'mismatch', + lastVerifiedAt: new Date(2), + verificationFailureCode: 'checksum-mismatch', + }) as unknown, + }); + }); + + it('pins pending current, historical, and restored targets during a restore', async () => { + const current = { + id: 'node-pending', + workspaceId: 'workspace-default', + spaceScope: 'workspace', + parentNodeId: null, + directoryKey: '', + ownerScopeKey: '', + name: 'report.txt', + nameKey: 'active:report.txt', + kind: 'doc', + mimeType: 'text/plain', + sizeBytes: 12n, + objectKey: 'objects/current-pending', + checksumAlgorithm: null, + checksumValue: null, + integrityStatus: 'pending', + lastVerifiedAt: null, + verificationFailureCode: null, + ownerName: 'User', + ownerUserId: 'user-1', + starred: false, + archivedAt: null, + archivedBy: null, + originalParentNodeId: null, + originalPath: null, + createdAt: new Date(0), + updatedAt: new Date(0), + }; + const selectedVersion = { + id: 'version-pending', + nodeId: current.id, + versionNumber: 1, + objectKey: 'objects/version-pending', + sizeBytes: 11n, + mimeType: 'text/markdown', + checksumAlgorithm: null, + checksumValue: null, + integrityStatus: 'pending', + lastVerifiedAt: null, + verificationFailureCode: null, + uploadedBy: 'User', + remark: 'old', + createdAt: new Date(0), + }; + const create = jest.fn(({ data }: { data: Record }) => + Promise.resolve(data), + ); + const tx = { + $queryRaw: jest.fn(() => Promise.resolve([])), + fileNode: { + findUnique: jest.fn(() => Promise.resolve(current)), + update: jest.fn(({ data }: { data: Record }) => + Promise.resolve({ ...current, ...data }), + ), + }, + fileVersion: { + aggregate: jest.fn(() => + Promise.resolve({ _max: { versionNumber: 1 } }), + ), + create, + findFirst: jest.fn(() => Promise.resolve(selectedVersion)), + }, + }; + const prisma = { + $transaction: jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), + ), + isSqlite: () => false, + } as unknown as PrismaService; + const repository = new FileNodeVersionsRepository(prisma); + const enqueueObjectVerification = jest.fn(() => + Promise.resolve('task-test'), + ); + + await repository.restoreVersion( + current.id, + selectedVersion.id, + 'Admin', + { + enqueueObjectVerification, + } as never, + 'admin-1', + ); + + expect(enqueueObjectVerification).toHaveBeenNthCalledWith( + 1, + { + actorUserId: 'admin-1', + nodeId: current.id, + objectKey: current.objectKey, + versionId: expect.any(String) as unknown, + workspaceId: current.workspaceId, + }, + tx, + ); + expect(enqueueObjectVerification).toHaveBeenNthCalledWith( + 2, + { + actorUserId: 'admin-1', + nodeId: current.id, + objectKey: selectedVersion.objectKey, + versionId: selectedVersion.id, + workspaceId: current.workspaceId, + }, + tx, + ); + expect(enqueueObjectVerification).toHaveBeenNthCalledWith( + 3, + { + actorUserId: 'admin-1', + nodeId: current.id, + objectKey: selectedVersion.objectKey, + workspaceId: current.workspaceId, + }, + tx, + ); + }); + + it('recognizes only SQLite lock contention variants as retryable raw-query errors', () => { + expect(isSqliteBusyPrismaError({ code: 'P1008' })).toBe(true); + expect( + isSqliteBusyPrismaError({ + code: 'P2010', + meta: { code: '5', message: 'database is locked' }, + }), + ).toBe(true); + expect( + isSqliteBusyPrismaError({ + code: 'P2010', + meta: { code: '19', message: 'constraint failed' }, + }), + ).toBe(false); + expect(isSqliteBusyPrismaError({ code: 'P2002' })).toBe(false); + }); +}); diff --git a/backend/src/modules/files/file-node-versions.repository.ts b/backend/src/modules/files/file-node-versions.repository.ts index ed4f93d..ac429af 100644 --- a/backend/src/modules/files/file-node-versions.repository.ts +++ b/backend/src/modules/files/file-node-versions.repository.ts @@ -18,8 +18,53 @@ import { getFileNameConflictKey, normalizeFileName, } from '../../common/security/file-name-policy'; +import { retryPrismaSerializableTransaction } from '../../common/database/serializable-transaction-retry'; +import { + needsFileIntegrityVerification, + normalizeFileIntegrityFailureCode, + normalizeFileIntegrityStatus, +} from './file-integrity'; +import type { StorageIntegrityTaskService } from '../storage/storage-integrity-task.service'; +import { + lockAndValidateActiveFileNodeParent, + lockFileNodeRows, +} from './file-node-hierarchy-write'; const filePolicySettingsKey = 'global'; +const objectReferenceLookupBatchSize = 500; + +class FileTreeChangedDuringWriteError extends Error {} + +export function isSqliteBusyPrismaError(error: unknown) { + if (typeof error !== 'object' || error === null || !('code' in error)) { + return false; + } + const code = String(error.code).toUpperCase(); + if (code === 'P1008' || code === 'SQLITE_BUSY' || code === 'SQLITE_LOCKED') { + return true; + } + if (code !== 'P2010' || !('meta' in error)) return false; + const meta = error.meta; + if (typeof meta !== 'object' || meta === null) return false; + return Object.entries(meta).some(([key, value]) => { + if (typeof value !== 'string' && typeof value !== 'number') return false; + const normalized = String(value).toUpperCase(); + if (key === 'code' && (normalized === '5' || normalized === '6')) { + return true; + } + return /SQLITE_(BUSY|LOCKED)|DATABASE( TABLE)? IS LOCKED/.test(normalized); + }); +} + +function hasUnacknowledgedIntegrityAnomaly(input: { + integrityAcknowledgedAt: Date | null; + integrityStatus: string; +}) { + return ( + input.integrityAcknowledgedAt === null && + (input.integrityStatus === 'mismatch' || input.integrityStatus === 'failed') + ); +} export type StoredFileVersionResponse = FileVersionResponse & { objectKey: string; @@ -32,108 +77,143 @@ export class FileNodeVersionsRepository { constructor(private readonly prisma: PrismaService) {} async archiveTree(id: string, actor?: string) { - const source = await this.prisma.fileNode.findUnique({ where: { id } }); - if (!source) return null; - const rows = [source, ...(await this.collectDescendantRows(source.id))]; - const paths = await this.buildPathsForRows(rows); - const now = new Date(); - - const archiveOperations = rows.map((row) => { - const storageKeys = createFileNodeStorageKeys({ - archived: true, - id: row.id, - name: row.name, - ownerUserId: row.ownerUserId, - parentNodeId: row.parentNodeId, - spaceScope: row.spaceScope, - }); - - return this.prisma.fileNode.update({ - where: { id: row.id }, - data: { - ...storageKeys, - archivedAt: row.archivedAt ?? now, - archivedBy: actor?.trim() || row.archivedBy || 'workspace', - originalParentNodeId: row.originalParentNodeId ?? row.parentNodeId, - originalPath: row.originalPath ?? paths.get(row.id) ?? row.name, - updatedAt: now, - }, - }); - }); - - await this.prisma.$transaction(archiveOperations); - return this.prisma.fileNode.findUnique({ where: { id } }); + return retryPrismaSerializableTransaction( + () => + this.prisma.$transaction( + async (tx) => { + const rows = await this.collectLockedTree(tx, id); + if (!rows) return null; + const paths = await this.buildPathsForRows(rows, tx); + const now = new Date(); + let archivedRoot: FileNode | null = null; + for (const row of rows) { + const storageKeys = createFileNodeStorageKeys({ + archived: true, + id: row.id, + name: row.name, + ownerUserId: row.ownerUserId, + parentNodeId: row.parentNodeId, + spaceScope: row.spaceScope, + }); + const archived = await tx.fileNode.update({ + where: { id: row.id }, + data: { + ...storageKeys, + archivedAt: row.archivedAt ?? now, + archivedBy: actor?.trim() || row.archivedBy || 'workspace', + originalParentNodeId: + row.originalParentNodeId ?? row.parentNodeId, + originalPath: + row.originalPath ?? paths.get(row.id) ?? row.name, + updatedAt: now, + }, + }); + if (row.id === id) archivedRoot = archived; + } + return archivedRoot; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ), + this.treeTransactionRetryOptions(), + ); } async restoreTree( id: string, options: { parentNodeId?: string | null; name?: string } = {}, ) { - const source = await this.prisma.fileNode.findUnique({ where: { id } }); - if (!source) return null; - const rows = [source, ...(await this.collectDescendantRows(source.id))]; - const restoreArchivedAt = source.archivedAt?.getTime(); - const rowsToRestore = rows.filter( - (row) => - row.id === source.id || - (restoreArchivedAt !== undefined && - row.archivedAt?.getTime() === restoreArchivedAt), - ); - const ids = new Set(rowsToRestore.map((row) => row.id)); - const targetParentNodeId = - options.parentNodeId !== undefined - ? options.parentNodeId - : source.originalParentNodeId; - const targetName = await this.resolveRestoreName({ - desiredName: options.name?.trim() || source.name, - excludeIds: ids, - ownerUserId: source.ownerUserId, - parentNodeId: targetParentNodeId ?? null, - spaceScope: source.spaceScope as FileNodeSpaceScope, - workspaceId: source.workspaceId, - }); - const now = new Date(); - - const restoreOperations = rowsToRestore.map((row) => { - const restoredName = row.id === source.id ? targetName : row.name; - const restoredParentNodeId = - row.id === source.id ? (targetParentNodeId ?? null) : row.parentNodeId; - const storageKeys = createFileNodeStorageKeys({ - archived: false, - id: row.id, - name: restoredName, - ownerUserId: row.ownerUserId, - parentNodeId: restoredParentNodeId, - spaceScope: row.spaceScope, - }); - - return this.prisma.fileNode.update({ - where: { id: row.id }, - data: { - ...(row.id === source.id - ? { + return retryPrismaSerializableTransaction( + () => + this.prisma.$transaction( + async (tx) => { + const rows = await this.collectLockedTree(tx, id); + const source = rows?.[0]; + if (!source) return null; + if (!source.archivedAt) return source; + const restoreArchivedAt = source.archivedAt?.getTime(); + const rowsToRestore = rows.filter( + (row) => + row.id === source.id || + (restoreArchivedAt !== undefined && + row.archivedAt?.getTime() === restoreArchivedAt), + ); + const ids = new Set(rowsToRestore.map((row) => row.id)); + const targetParentNodeId = + options.parentNodeId !== undefined + ? options.parentNodeId + : source.originalParentNodeId; + const validParent = await lockAndValidateActiveFileNodeParent( + this.prisma, + tx, + targetParentNodeId ?? null, + { + forbiddenAncestorId: source.id, + spaceScope: source.spaceScope, + workspaceId: source.workspaceId, + }, + ); + if (!validParent) { + throw new FileTreeChangedDuringWriteError(); + } + const targetName = await this.resolveRestoreName( + { + desiredName: options.name?.trim() || source.name, + excludeIds: ids, + ownerUserId: source.ownerUserId, + parentNodeId: targetParentNodeId ?? null, + spaceScope: source.spaceScope as FileNodeSpaceScope, + workspaceId: source.workspaceId, + }, + tx, + ); + const now = new Date(); + let restoredRoot: FileNode | null = null; + for (const row of rowsToRestore) { + const restoredName = row.id === source.id ? targetName : row.name; + const restoredParentNodeId = + row.id === source.id + ? (targetParentNodeId ?? null) + : row.parentNodeId; + const storageKeys = createFileNodeStorageKeys({ + archived: false, + id: row.id, name: restoredName, + ownerUserId: row.ownerUserId, parentNodeId: restoredParentNodeId, - } - : {}), - ...storageKeys, - archivedAt: null, - archivedBy: null, - originalParentNodeId: null, - originalPath: null, - updatedAt: now, - }, - }); - }); - - await this.prisma.$transaction(restoreOperations); - return this.prisma.fileNode.findUnique({ where: { id } }); + spaceScope: row.spaceScope, + }); + const restored = await tx.fileNode.update({ + where: { id: row.id }, + data: { + ...(row.id === source.id + ? { + name: restoredName, + parentNodeId: restoredParentNodeId, + } + : {}), + ...storageKeys, + archivedAt: null, + archivedBy: null, + originalParentNodeId: null, + originalPath: null, + updatedAt: now, + }, + }); + if (row.id === id) restoredRoot = restored; + } + return restoredRoot; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ), + this.treeTransactionRetryOptions(), + ); } async listTreeForDeletion(id: string) { const source = await this.prisma.fileNode.findUnique({ where: { id } }); if (!source) { return { + hasUnacknowledgedIntegrityAnomaly: false, nodes: [] as FileNode[], versions: [] as StoredFileVersionResponse[], }; @@ -145,18 +225,32 @@ export class FileNodeVersionsRepository { orderBy: [{ nodeId: 'asc' }, { versionNumber: 'asc' }], }); return { + hasUnacknowledgedIntegrityAnomaly: + rows.some(hasUnacknowledgedIntegrityAnomaly) || + versions.some(hasUnacknowledgedIntegrityAnomaly), nodes: rows, versions: versions.map((row) => this.mapVersionRow(row)), }; } async deleteTree(id: string) { - const deletion = await this.listTreeForDeletion(id); - if (deletion.nodes.length === 0) return deletion; - await this.prisma.fileNode.deleteMany({ - where: { id: { in: deletion.nodes.map((node) => node.id) } }, - }); - return deletion; + const deletion = await retryPrismaSerializableTransaction(async () => { + const tree = await this.listTreeForDeletion(id); + if (tree.nodes.length === 0) return tree; + const deleted = await this.prisma.$transaction( + (tx) => this.deleteRevalidatedTree(tx, tree), + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ); + if (!deleted) throw new FileTreeChangedDuringWriteError(); + return deleted; + }, this.treeTransactionRetryOptions()); + return { + ...deletion, + objectKeysToDelete: await this.filterUnreferencedObjectKeys([ + ...deletion.nodes.map((node) => node.objectKey), + ...deletion.versions.map((version) => version.objectKey), + ]), + }; } async cleanupTrash(cutoff: Date) { @@ -173,17 +267,35 @@ export class FileNodeVersionsRepository { if (visited.has(root.id)) continue; const tree = await this.listTreeForDeletion(root.id); tree.nodes.forEach((node) => visited.add(node.id)); - await this.prisma.fileNode.deleteMany({ - where: { id: { in: tree.nodes.map((node) => node.id) } }, - }); - deletedNodes.push(...tree.nodes); - deletedVersions.push(...tree.versions); + if (tree.nodes.length === 0 || tree.hasUnacknowledgedIntegrityAnomaly) + continue; + const deleted = await retryPrismaSerializableTransaction( + () => + this.prisma.$transaction( + (tx) => this.deleteRevalidatedTree(tx, tree, cutoff), + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ), + this.treeTransactionRetryOptions(), + ); + if (!deleted) continue; + deletedNodes.push(...deleted.nodes); + deletedVersions.push(...deleted.versions); } - return { nodes: deletedNodes, versions: deletedVersions }; + return { + nodes: deletedNodes, + objectKeysToDelete: await this.filterUnreferencedObjectKeys([ + ...deletedNodes.map((node) => node.objectKey), + ...deletedVersions.map((version) => version.objectKey), + ]), + versions: deletedVersions, + }; } - async collectDescendantRows(parentId: string) { - return this.prisma.$queryRaw` + async collectDescendantRows( + parentId: string, + client: Pick = this.prisma, + ) { + return client.$queryRaw(Prisma.sql` with recursive descendants as ( select id, @@ -194,6 +306,13 @@ export class FileNodeVersionsRepository { mime_type as "mimeType", size_bytes as "sizeBytes", object_key as "objectKey", + checksum_algorithm as "checksumAlgorithm", + checksum_value as "checksumValue", + integrity_status as "integrityStatus", + last_verified_at as "lastVerifiedAt", + verification_failure_code as "verificationFailureCode", + integrity_acknowledged_at as "integrityAcknowledgedAt", + integrity_acknowledged_by as "integrityAcknowledgedBy", owner_name as "ownerName", owner_user_id as "ownerUserId", space_scope as "spaceScope", @@ -203,11 +322,10 @@ export class FileNodeVersionsRepository { original_parent_node_id as "originalParentNodeId", original_path as "originalPath", created_at as "createdAt", - updated_at as "updatedAt", - 1 as depth + updated_at as "updatedAt" from file_nodes where parent_node_id = ${parentId} - union all + union select child.id, child.workspace_id as "workspaceId", @@ -217,6 +335,13 @@ export class FileNodeVersionsRepository { child.mime_type as "mimeType", child.size_bytes as "sizeBytes", child.object_key as "objectKey", + child.checksum_algorithm as "checksumAlgorithm", + child.checksum_value as "checksumValue", + child.integrity_status as "integrityStatus", + child.last_verified_at as "lastVerifiedAt", + child.verification_failure_code as "verificationFailureCode", + child.integrity_acknowledged_at as "integrityAcknowledgedAt", + child.integrity_acknowledged_by as "integrityAcknowledgedBy", child.owner_name as "ownerName", child.owner_user_id as "ownerUserId", child.space_scope as "spaceScope", @@ -226,8 +351,7 @@ export class FileNodeVersionsRepository { child.original_parent_node_id as "originalParentNodeId", child.original_path as "originalPath", child.created_at as "createdAt", - child.updated_at as "updatedAt", - descendants.depth + 1 as depth + child.updated_at as "updatedAt" from file_nodes child inner join descendants on child.parent_node_id = descendants.id ) @@ -240,6 +364,13 @@ export class FileNodeVersionsRepository { "mimeType", "sizeBytes", "objectKey", + "checksumAlgorithm", + "checksumValue", + "integrityStatus", + "lastVerifiedAt", + "verificationFailureCode", + "integrityAcknowledgedAt", + "integrityAcknowledgedBy", "ownerName", "ownerUserId", "spaceScope", @@ -251,11 +382,15 @@ export class FileNodeVersionsRepository { "createdAt", "updatedAt" from descendants - order by depth asc, name asc - `; + where id <> ${parentId} + order by name asc, id asc + `); } - async buildPathsForRows(rows: FileNodePathRow[]) { + async buildPathsForRows( + rows: FileNodePathRow[], + client: Pick = this.prisma, + ) { const rowById = new Map(rows.map((row) => [row.id, row])); let pendingParentIds = new Set( rows @@ -265,7 +400,7 @@ export class FileNodeVersionsRepository { ); while (pendingParentIds.size > 0) { - const parentRows = await this.prisma.fileNode.findMany({ + const parentRows = await client.fileNode.findMany({ where: { id: { in: [...pendingParentIds] } }, select: { id: true, name: true, parentNodeId: true }, }); @@ -351,28 +486,85 @@ export class FileNodeVersionsRepository { return row ? this.mapVersionRow(row) : null; } - async restoreVersion(nodeId: string, versionId: string, actor?: string) { - return this.prisma.$transaction(async (tx) => { - const node = await tx.fileNode.findUnique({ where: { id: nodeId } }); - if (!node?.objectKey) return null; - const version = await tx.fileVersion.findFirst({ - where: { id: versionId, nodeId }, - }); - if (!version) return null; - await this.createVersionForNode(tx, node, { - remark: `Restored version ${version.versionNumber}`, - uploadedBy: actor ?? node.ownerName, - }); - return tx.fileNode.update({ - where: { id: nodeId }, - data: { - mimeType: version.mimeType, - objectKey: version.objectKey, - sizeBytes: version.sizeBytes, - updatedAt: new Date(), - }, - }); - }); + async restoreVersion( + nodeId: string, + versionId: string, + actor: string | undefined, + integrityTasks: StorageIntegrityTaskService, + actorUserId?: string | null, + ) { + return retryPrismaSerializableTransaction( + () => + this.prisma.$transaction(async (tx) => { + await this.lockRestoreTarget(tx, nodeId, versionId); + const node = await tx.fileNode.findUnique({ where: { id: nodeId } }); + if (!node?.objectKey) return null; + const version = await tx.fileVersion.findFirst({ + where: { id: versionId, nodeId }, + }); + if (!version) return null; + const archivedVersion = await this.createVersionForNode(tx, node, { + remark: `Restored version ${version.versionNumber}`, + uploadedBy: actor ?? node.ownerName, + }); + if ( + archivedVersion && + needsFileIntegrityVerification(archivedVersion) + ) { + await integrityTasks.enqueueObjectVerification( + { + actorUserId: actorUserId ?? null, + nodeId, + objectKey: archivedVersion.objectKey, + versionId: archivedVersion.id, + workspaceId: node.workspaceId, + }, + tx, + ); + } + if (needsFileIntegrityVerification(version)) { + await integrityTasks.enqueueObjectVerification( + { + actorUserId: actorUserId ?? null, + nodeId, + objectKey: version.objectKey, + versionId: version.id, + workspaceId: node.workspaceId, + }, + tx, + ); + } + const updated = await tx.fileNode.update({ + where: { id: nodeId }, + data: { + mimeType: version.mimeType, + objectKey: version.objectKey, + sizeBytes: version.sizeBytes, + checksumAlgorithm: version.checksumAlgorithm, + checksumValue: version.checksumValue, + integrityStatus: version.integrityStatus, + lastVerifiedAt: version.lastVerifiedAt, + verificationFailureCode: version.verificationFailureCode, + integrityAcknowledgedAt: version.integrityAcknowledgedAt, + integrityAcknowledgedBy: version.integrityAcknowledgedBy, + updatedAt: new Date(), + }, + }); + if (needsFileIntegrityVerification(version)) { + await integrityTasks.enqueueObjectVerification( + { + actorUserId: actorUserId ?? null, + nodeId, + objectKey: version.objectKey, + workspaceId: node.workspaceId, + }, + tx, + ); + } + return updated; + }), + this.treeTransactionRetryOptions(), + ); } async createVersionForNode( @@ -390,6 +582,13 @@ export class FileNodeVersionsRepository { objectKey: node.objectKey, sizeBytes: node.sizeBytes, mimeType: node.mimeType, + checksumAlgorithm: node.checksumAlgorithm, + checksumValue: node.checksumValue, + integrityStatus: node.integrityStatus, + lastVerifiedAt: node.lastVerifiedAt, + verificationFailureCode: node.verificationFailureCode, + integrityAcknowledgedAt: node.integrityAcknowledgedAt, + integrityAcknowledgedBy: node.integrityAcknowledgedBy, uploadedBy: options.uploadedBy, remark: options.remark, }, @@ -423,24 +622,240 @@ export class FileNodeVersionsRepository { .map((version) => version.id), ); const versionsToDelete = versions.filter( - (version) => !keepIds.has(version.id), + (version) => + !keepIds.has(version.id) && !hasUnacknowledgedIntegrityAnomaly(version), ); - if (versionsToDelete.length > 0) { - await this.prisma.fileVersion.deleteMany({ - where: { id: { in: versionsToDelete.map((version) => version.id) } }, + const deletedObjectKeys: string[] = []; + for (const version of versionsToDelete) { + const deleted = await this.prisma.fileVersion.deleteMany({ + where: { + id: version.id, + objectKey: version.objectKey, + OR: [ + { integrityStatus: { notIn: ['mismatch', 'failed'] } }, + { integrityAcknowledgedAt: { not: null } }, + ], + }, }); + if (deleted.count === 1) deletedObjectKeys.push(version.objectKey); + } + return this.filterUnreferencedObjectKeys(deletedObjectKeys); + } + + async filterUnreferencedObjectKeys(objectKeys: Array) { + const candidates = [ + ...new Set(objectKeys.filter((key): key is string => Boolean(key))), + ]; + if (candidates.length === 0) return []; + + const referenced = new Set(); + for ( + let offset = 0; + offset < candidates.length; + offset += objectReferenceLookupBatchSize + ) { + const batch = candidates.slice( + offset, + offset + objectReferenceLookupBatchSize, + ); + const rows = await this.prisma.$queryRaw>( + Prisma.sql` + SELECT object_key AS "objectKey" + FROM file_nodes + WHERE object_key IN (${Prisma.join(batch)}) + UNION + SELECT object_key AS "objectKey" + FROM file_versions + WHERE object_key IN (${Prisma.join(batch)}) + `, + ); + rows.forEach((row) => referenced.add(row.objectKey)); + } + return candidates.filter((objectKey) => !referenced.has(objectKey)); + } + + private async collectLockedTree( + tx: Prisma.TransactionClient, + rootId: string, + ) { + if (!(await lockFileNodeRows(this.prisma, tx, [rootId]))) return null; + const root = await tx.fileNode.findUnique({ where: { id: rootId } }); + if (!root) return null; + const snapshot = [root, ...(await this.collectDescendantRows(root.id, tx))]; + const nodeIds = [...new Set(snapshot.map((node) => node.id))]; + if (nodeIds.length !== snapshot.length) { + throw new FileTreeChangedDuringWriteError(); + } + if (!(await lockFileNodeRows(this.prisma, tx, nodeIds))) { + throw new FileTreeChangedDuringWriteError(); + } + const [currentNodes, outsideChildCount] = await Promise.all([ + tx.fileNode.findMany({ where: { id: { in: nodeIds } } }), + tx.fileNode.count({ + where: { + id: { notIn: nodeIds }, + parentNodeId: { in: nodeIds }, + }, + }), + ]); + const snapshotParentById = new Map( + snapshot.map((node) => [node.id, node.parentNodeId]), + ); + if ( + currentNodes.length !== nodeIds.length || + outsideChildCount > 0 || + currentNodes.some( + (node) => snapshotParentById.get(node.id) !== node.parentNodeId, + ) + ) { + throw new FileTreeChangedDuringWriteError(); + } + const currentById = new Map(currentNodes.map((node) => [node.id, node])); + return nodeIds.flatMap((nodeId) => { + const node = currentById.get(nodeId); + return node ? [node] : []; + }); + } + + private async deleteRevalidatedTree( + tx: Prisma.TransactionClient, + tree: { nodes: FileNode[] }, + cutoff?: Date, + ) { + const nodeIds = tree.nodes.map((node) => node.id); + await this.lockFileTreeRows(tx, nodeIds); + + const [nodes, versions, outsideChildCount] = await Promise.all([ + tx.fileNode.findMany({ where: { id: { in: nodeIds } } }), + tx.fileVersion.findMany({ + where: { nodeId: { in: nodeIds } }, + orderBy: [{ nodeId: 'asc' }, { versionNumber: 'asc' }], + }), + tx.fileNode.count({ + where: { + parentNodeId: { in: nodeIds }, + id: { notIn: nodeIds }, + }, + }), + ]); + const previousParentById = new Map( + tree.nodes.map((node) => [node.id, node.parentNodeId]), + ); + if ( + nodes.length !== nodeIds.length || + outsideChildCount > 0 || + nodes.some( + (node) => previousParentById.get(node.id) !== node.parentNodeId, + ) || + (cutoff !== undefined && + (nodes.some( + (node) => + hasUnacknowledgedIntegrityAnomaly(node) || + !node.archivedAt || + node.archivedAt >= cutoff, + ) || + versions.some(hasUnacknowledgedIntegrityAnomaly))) + ) { + return null; + } + + await tx.fileNode.deleteMany({ + where: { id: { in: nodeIds } }, + }); + const remainingNodeCount = await tx.fileNode.count({ + where: { id: { in: nodeIds } }, + }); + if (remainingNodeCount > 0) { + throw new FileTreeChangedDuringWriteError(); } - return versionsToDelete.map((version) => version.objectKey); + + const nodeById = new Map(nodes.map((node) => [node.id, node])); + const orderedNodes = tree.nodes + .map((node) => nodeById.get(node.id)) + .filter((node): node is FileNode => node !== undefined); + const mappedVersions = versions.map((version) => + this.mapVersionRow(version), + ); + return { + hasUnacknowledgedIntegrityAnomaly: + orderedNodes.some(hasUnacknowledgedIntegrityAnomaly) || + versions.some(hasUnacknowledgedIntegrityAnomaly), + nodes: orderedNodes, + versions: mappedVersions, + }; } - private async resolveRestoreName(input: { - desiredName: string; - excludeIds: Set; - ownerUserId: string | null; - parentNodeId: string | null; - spaceScope: FileNodeSpaceScope; - workspaceId: string; - }) { + private async lockFileTreeRows( + tx: Prisma.TransactionClient, + nodeIds: string[], + ) { + const orderedNodeIds = [...new Set(nodeIds)].sort(); + if (this.prisma.isSqlite()) { + await tx.$executeRaw(Prisma.sql` + UPDATE file_nodes SET id = id + WHERE id IN (${Prisma.join(orderedNodeIds)}) + `); + return; + } + await tx.$queryRaw(Prisma.sql` + SELECT id FROM file_nodes + WHERE id IN (${Prisma.join(orderedNodeIds)}) + ORDER BY id + FOR UPDATE + `); + await tx.$queryRaw(Prisma.sql` + SELECT id FROM file_versions + WHERE node_id IN (${Prisma.join(orderedNodeIds)}) + ORDER BY id + FOR UPDATE + `); + } + + private async lockRestoreTarget( + tx: Prisma.TransactionClient, + nodeId: string, + versionId: string, + ) { + if (this.prisma.isSqlite()) { + await tx.$executeRaw(Prisma.sql` + UPDATE file_nodes SET id = id WHERE id = ${nodeId} + `); + await tx.$executeRaw(Prisma.sql` + UPDATE file_versions SET id = id + WHERE id = ${versionId} AND node_id = ${nodeId} + `); + return; + } + await tx.$queryRaw(Prisma.sql` + SELECT id FROM file_nodes WHERE id = ${nodeId} FOR UPDATE + `); + await tx.$queryRaw(Prisma.sql` + SELECT id FROM file_versions + WHERE id = ${versionId} AND node_id = ${nodeId} + FOR UPDATE + `); + } + + private treeTransactionRetryOptions() { + const sqlite = this.prisma.isSqlite(); + return { + isRetryableError: (error: unknown) => + error instanceof FileTreeChangedDuringWriteError || + (sqlite && isSqliteBusyPrismaError(error)), + }; + } + + private async resolveRestoreName( + input: { + desiredName: string; + excludeIds: Set; + ownerUserId: string | null; + parentNodeId: string | null; + spaceScope: FileNodeSpaceScope; + workspaceId: string; + }, + client: Pick = this.prisma, + ) { const desiredName = normalizeFileName(input.desiredName); const storageKeys = createFileNodeStorageKeys({ archived: false, @@ -450,7 +865,7 @@ export class FileNodeVersionsRepository { parentNodeId: input.parentNodeId, spaceScope: input.spaceScope, }); - const siblings = await this.prisma.fileNode.findMany({ + const siblings = await client.fileNode.findMany({ where: { archivedAt: null, directoryKey: storageKeys.directoryKey, @@ -483,6 +898,15 @@ export class FileNodeVersionsRepository { objectKey: row.objectKey, sizeBytes: Number(row.sizeBytes), mimeType: row.mimeType, + checksumAlgorithm: row.checksumAlgorithm, + checksumValue: row.checksumValue, + integrityStatus: normalizeFileIntegrityStatus(row.integrityStatus), + lastVerifiedAt: row.lastVerifiedAt + ? row.lastVerifiedAt.toISOString() + : null, + verificationFailureCode: normalizeFileIntegrityFailureCode( + row.verificationFailureCode, + ), uploadedBy: row.uploadedBy, remark: row.remark, createdAt: row.createdAt.toISOString(), diff --git a/backend/src/modules/files/file-nodes.dto.ts b/backend/src/modules/files/file-nodes.dto.ts index 026ac92..b0a5842 100644 --- a/backend/src/modules/files/file-nodes.dto.ts +++ b/backend/src/modules/files/file-nodes.dto.ts @@ -67,6 +67,18 @@ export const uploadConflictStrategies: UploadConflictStrategy[] = [ ]; export const uploadResumeKeyMaxLength = 128; +export type FileIntegrityStatus = + | 'unknown' + | 'pending' + | 'verified' + | 'mismatch' + | 'failed'; +export type FileIntegrityFailureCode = + | 'missing-object' + | 'size-mismatch' + | 'checksum-mismatch' + | 'verification-failed'; + export class CreateUploadIntentDto { @IsString() @IsNotEmpty() @@ -402,6 +414,11 @@ export type FileNodeResponse = { mimeType: string; sizeBytes: number | null; objectKey: string | null; + checksumAlgorithm: string | null; + checksumValue: string | null; + integrityStatus: FileIntegrityStatus; + lastVerifiedAt: string | null; + verificationFailureCode: FileIntegrityFailureCode | null; owner: string; ownerUserId: string | null; starred: boolean; @@ -465,6 +482,11 @@ export type FileVersionResponse = { versionNumber: number; sizeBytes: number; mimeType: string; + checksumAlgorithm: string | null; + checksumValue: string | null; + integrityStatus: FileIntegrityStatus; + lastVerifiedAt: string | null; + verificationFailureCode: FileIntegrityFailureCode | null; uploadedBy: string; remark: string; createdAt: string; diff --git a/backend/src/modules/files/file-nodes.repository.spec-helpers.ts b/backend/src/modules/files/file-nodes.repository.spec-helpers.ts index 12f3802..6217090 100644 --- a/backend/src/modules/files/file-nodes.repository.spec-helpers.ts +++ b/backend/src/modules/files/file-nodes.repository.spec-helpers.ts @@ -5,6 +5,7 @@ import { FileNodeVersionsRepository } from './file-node-versions.repository'; import { FileNodesRepository } from './file-nodes.repository'; import { FilePreviewArtifactsRepository } from './file-preview-artifacts.repository'; import { FileStorageUsageRepository } from './file-storage-usage.repository'; +import { StorageIntegrityTaskService } from '../storage/storage-integrity-task.service'; export const downloadIntentTestSecret = 'download-intent-test-secret'; @@ -72,24 +73,94 @@ export function createPreviewArtifactsRepository(prisma: unknown) { } export function createFileNodeVersionsRepository(prisma: unknown) { - return new FileNodeVersionsRepository(prisma as PrismaService); + return new FileNodeVersionsRepository(addPrismaTestDefaults(prisma)); } export function createFileStorageUsageRepository(prisma: unknown) { return new FileStorageUsageRepository(prisma as PrismaService); } -export function createFileNodesRepository(prisma: unknown) { - const prismaService = prisma as PrismaService; +export function createFileNodesRepository( + prisma: unknown, + integrityTasks: Pick< + StorageIntegrityTaskService, + 'enqueueObjectVerification' | 'kickQueued' + > = { + enqueueObjectVerification: jest.fn(() => Promise.resolve('task-test')), + kickQueued: jest.fn(), + }, +) { + const prismaService = addPrismaTestDefaults(prisma); return new FileNodesRepository( prismaService, new FileDownloadIntentsRepository(prismaService, createConfig()), new FileNodeVersionsRepository(prismaService), new FilePreviewArtifactsRepository(prismaService), new FileStorageUsageRepository(prismaService), + integrityTasks as StorageIntegrityTaskService, ); } +function addPrismaTestDefaults(prisma: unknown) { + const client = prisma as Record & { + $queryRaw?: jest.Mock; + $transaction?: (...args: unknown[]) => unknown; + isSqlite?: () => boolean; + }; + client.isSqlite ??= () => false; + addTransactionClientDefaults(client); + const transaction = client.$transaction; + client.$transaction = transaction + ? (operation: unknown, ...options: unknown[]) => + transaction( + typeof operation === 'function' + ? (tx: unknown) => + (operation as (client: unknown) => unknown)( + addTransactionClientDefaults(tx), + ) + : operation, + ...options, + ) + : jest.fn((operation: unknown) => + typeof operation === 'function' + ? (operation as (tx: unknown) => unknown)(client) + : Promise.all(operation as Promise[]), + ); + return client as unknown as PrismaService; +} + +function addTransactionClientDefaults(client: unknown) { + const transactionClient = client as Record & { + $executeRaw?: jest.Mock; + $queryRaw?: jest.Mock; + fileNode?: Record & { count?: jest.Mock }; + }; + transactionClient.$queryRaw ??= jest.fn((query: unknown) => + Promise.resolve( + queryValues(query) + .filter((value): value is string => typeof value === 'string') + .map((id) => ({ id })), + ), + ); + transactionClient.$executeRaw ??= jest.fn((query: unknown) => + Promise.resolve( + queryValues(query).filter((value) => typeof value === 'string').length, + ), + ); + if (transactionClient.fileNode) { + transactionClient.fileNode.count ??= jest.fn(() => Promise.resolve(0)); + } + return transactionClient; +} + +function queryValues(query: unknown) { + if (typeof query !== 'object' || query === null || !('values' in query)) { + return [] as unknown[]; + } + const values = (query as { values?: unknown }).values; + return Array.isArray(values) ? (values as unknown[]) : []; +} + export function storedIntent( overrides: Partial<{ actorUserId: string | null; diff --git a/backend/src/modules/files/file-nodes.repository.spec.ts b/backend/src/modules/files/file-nodes.repository.spec.ts index 504585e..7ebbee6 100644 --- a/backend/src/modules/files/file-nodes.repository.spec.ts +++ b/backend/src/modules/files/file-nodes.repository.spec.ts @@ -1,5 +1,4 @@ -import { ConflictException } from '@nestjs/common'; -import { serializableTransactionMaxAttempts } from '../../common/database/serializable-transaction-retry'; +import { BadRequestException, ConflictException } from '@nestjs/common'; import { createFileNodesRepository as createRepository } from './file-nodes.repository.spec-helpers'; function storedNode(id: string) { @@ -16,6 +15,11 @@ function storedNode(id: string) { mimeType: 'text/plain', sizeBytes: 1n, objectKey: `objects/${id}`, + checksumAlgorithm: 'sha256', + checksumValue: 'a'.repeat(64), + integrityStatus: 'verified', + lastVerifiedAt: new Date(1), + verificationFailureCode: null, ownerName: 'Workspace User', ownerUserId: null, starred: false, @@ -28,6 +32,18 @@ function storedNode(id: string) { }; } +function rootOnlyQueryRaw(rootId: string) { + return jest.fn((statement: unknown) => { + const sql = + typeof statement === 'object' && statement !== null && 'sql' in statement + ? String(statement.sql) + : ''; + return Promise.resolve( + sql.includes('SELECT id FROM file_nodes') ? [{ id: rootId }] : [], + ); + }); +} + describe('FileNodesRepository', () => { it('looks up unique ids in sequential batches and preserves first input order', async () => { const ids = Array.from({ length: 1001 }, (_, index) => `node-${index}`); @@ -119,16 +135,22 @@ describe('FileNodesRepository', () => { stored = { ...stored, ...data }; return Promise.resolve(stored); }); - const repository = createRepository({ + const tx = { + $queryRaw: rootOnlyQueryRaw(stored.id), fileNode: { + count: jest.fn(() => Promise.resolve(0)), findUnique: jest.fn(() => Promise.resolve(stored)), - findMany: jest.fn(() => Promise.resolve([{ name }])), + findMany: jest.fn((input: { select?: { name: boolean } }) => + Promise.resolve(input.select ? [{ name }] : [stored]), + ), update, }, - $queryRaw: jest.fn(() => Promise.resolve([])), - $transaction: jest.fn((operations: Promise[]) => - Promise.all(operations), + }; + const repository = createRepository({ + $transaction: jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), ), + isSqlite: () => false, }); const restored = await repository.restoreTree(stored.id); @@ -169,38 +191,38 @@ describe('FileNodesRepository', () => { createdAt: new Date(0), updatedAt: new Date(0), }; - const findMany = jest.fn( - (input: { - select: { name: boolean }; - where: Record; - }) => { - void input; - return Promise.resolve([]); - }, + const findMany = jest.fn((input: { select?: { name: boolean } }) => + Promise.resolve(input.select ? [] : [stored]), ); - const repository = createRepository({ + const tx = { + $queryRaw: rootOnlyQueryRaw(stored.id), fileNode: { + count: jest.fn(() => Promise.resolve(0)), findMany, findUnique: jest.fn(() => Promise.resolve(stored)), update: jest.fn(({ data }: { data: Record }) => Promise.resolve({ ...stored, ...data }), ), }, - $queryRaw: jest.fn(() => Promise.resolve([])), - $transaction: jest.fn((operations: Promise[]) => - Promise.all(operations), + }; + const repository = createRepository({ + $transaction: jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), ), + isSqlite: () => false, }); await repository.restoreTree(stored.id); - expect(findMany.mock.calls[0]?.[0]).toMatchObject({ - where: { - ownerScopeKey: 'user-1', - spaceScope: 'personal', - }, - select: { name: true }, - }); + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + ownerScopeKey: 'user-1', + spaceScope: 'personal', + }) as unknown, + select: { name: true }, + }), + ); }); it('persists canonical active name keys when creating a folder', async () => { @@ -212,7 +234,20 @@ describe('FileNodesRepository', () => { originalPath: null, }), ); - const repository = createRepository({ fileNode: { create } }); + const repository = createRepository({ + fileNode: { + create, + findUnique: jest.fn(() => + Promise.resolve({ + ...storedNode('folder-1'), + kind: 'folder', + objectKey: null, + sizeBytes: null, + spaceScope: 'personal', + }), + ), + }, + }); await repository.createFolder({ workspaceId: 'workspace-default', @@ -316,6 +351,66 @@ describe('FileNodesRepository', () => { }); }); + it('revalidates locked ancestors so concurrent moves cannot create a cycle', async () => { + const folders = new Map( + ['folder-a', 'folder-b'].map((id) => [ + id, + { + ...storedNode(id), + kind: 'folder', + mimeType: 'inode/directory', + name: id, + objectKey: null, + sizeBytes: null, + }, + ]), + ); + const tx = { + $queryRaw: jest.fn(() => + Promise.resolve([{ id: 'folder-a' }, { id: 'folder-b' }]), + ), + fileNode: { + findUnique: jest.fn(({ where }: { where: { id: string } }) => + Promise.resolve(folders.get(where.id) ?? null), + ), + update: jest.fn( + ({ + data, + where, + }: { + data: Record; + where: { id: string }; + }) => { + const current = folders.get(where.id); + if (!current) throw new Error('missing test node'); + const updated = { ...current, ...data }; + folders.set(where.id, updated); + return Promise.resolve(updated); + }, + ), + }, + }; + const repository = createRepository({ + $transaction: jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), + ), + isSqlite: () => false, + }); + + await expect( + repository.move('folder-a', 'folder-b'), + ).resolves.toMatchObject({ + id: 'folder-a', + parentNodeId: 'folder-b', + }); + await expect( + repository.move('folder-b', 'folder-a'), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(folders.get('folder-b')?.parentNodeId).toBeNull(); + expect(tx.$queryRaw).toHaveBeenCalledTimes(2); + }); + it('persists canonical name keys when copying a node', async () => { const create = jest.fn(({ data }: { data: Record }) => Promise.resolve({ @@ -325,12 +420,6 @@ describe('FileNodesRepository', () => { originalPath: null, }), ); - const repository = createRepository({ - fileNode: { - create, - findMany: jest.fn(() => Promise.resolve([])), - }, - }); const source = { id: 'source-node', workspaceId: 'workspace-default', @@ -341,6 +430,11 @@ describe('FileNodesRepository', () => { mimeType: 'text/plain', sizeBytes: 32, objectKey: 'object-1', + checksumAlgorithm: 'sha256', + checksumValue: 'b'.repeat(64), + integrityStatus: 'verified', + lastVerifiedAt: new Date(1).toISOString(), + verificationFailureCode: null, owner: 'User 1', ownerUserId: 'user-1', starred: false, @@ -358,681 +452,472 @@ describe('FileNodesRepository', () => { createdAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(), } as const; - - await repository.copyTree(source, { - name: 'Report copy.txt', - parentNodeId: 'folder-2', - }); - - expect(create.mock.calls[0]?.[0]).toMatchObject({ - data: { - directoryKey: 'folder-2', - nameKey: 'active:report copy.txt', - ownerScopeKey: 'user-1', - }, - }); - }); - - it('releases the active name key when archiving a node', async () => { - const stored = { - id: 'node-1', - workspaceId: 'workspace-default', - spaceScope: 'personal', - parentNodeId: 'folder-1', - directoryKey: 'folder-1', - ownerScopeKey: 'user-1', - name: 'Report.txt', - nameKey: 'active:report.txt', - kind: 'doc', - mimeType: 'text/plain', - sizeBytes: BigInt(32), - objectKey: 'object-1', - ownerName: 'User 1', - ownerUserId: 'user-1', - starred: false, - archivedAt: null as Date | null, - archivedBy: null as string | null, - originalParentNodeId: null as string | null, - originalPath: null as string | null, - createdAt: new Date(0), - updatedAt: new Date(0), + const sourceRow = { + ...storedNode(source.id), + checksumValue: source.checksumValue, + lastVerifiedAt: new Date(source.lastVerifiedAt), + name: source.name, + objectKey: source.objectKey, + ownerName: source.owner, + ownerUserId: source.ownerUserId, + sizeBytes: BigInt(source.sizeBytes), + spaceScope: source.spaceScope, }; - const update = jest.fn(({ data }: { data: Partial }) => { - Object.assign(stored, data); - return Promise.resolve(stored); - }); - const repository = createRepository({ - fileNode: { - findMany: jest.fn(() => Promise.resolve([])), - findUnique: jest.fn(() => Promise.resolve(stored)), - update, - }, - $queryRaw: jest.fn(() => Promise.resolve([])), - $transaction: jest.fn((operations: Promise[]) => - Promise.all(operations), - ), - }); - - await repository.archiveTree(stored.id, 'user-1'); - - expect(update.mock.calls[0]?.[0]).toMatchObject({ - where: { id: stored.id }, - data: { - directoryKey: 'folder-1', - nameKey: `archived:${stored.id}`, - ownerScopeKey: 'user-1', - }, - }); - }); - - it('persists canonical name keys when completing a new upload', async () => { - const create = jest.fn(({ data }: { data: Record }) => - Promise.resolve({ - ...data, - archivedBy: null, - originalParentNodeId: null, - originalPath: null, - }), - ); const tx = { + $queryRaw: jest.fn(() => Promise.resolve([{ id: source.id }])), fileNode: { create, + findFirst: jest.fn(() => Promise.resolve(sourceRow)), + findUnique: jest.fn(() => + Promise.resolve({ + ...storedNode('folder-2'), + kind: 'folder', + objectKey: null, + sizeBytes: null, + spaceScope: 'personal', + }), + ), + findMany: jest.fn(() => Promise.resolve([])), }, }; const repository = createRepository({ $transaction: jest.fn( (operation: (client: typeof tx) => Promise) => operation(tx), ), + isSqlite: () => false, }); - await repository.completeUpload({ - fileName: 'Resume.pdf', - mimeType: 'application/pdf', - objectKey: 'objects/original/v2/2026/07/test.blob', - ownerUserId: 'user-1', - parentNodeId: 'folder-1', - sizeBytes: 32, - spaceScope: 'personal', - workspaceId: 'workspace-default', + await repository.copyTree(source, { + name: 'Report copy.txt', + parentNodeId: 'folder-2', }); expect(create.mock.calls[0]?.[0]).toMatchObject({ data: { - directoryKey: 'folder-1', - nameKey: 'active:resume.pdf', + checksumAlgorithm: 'sha256', + checksumValue: 'b'.repeat(64), + directoryKey: 'folder-2', + integrityStatus: 'verified', + lastVerifiedAt: new Date(1), + nameKey: 'active:report copy.txt', ownerScopeKey: 'user-1', + verificationFailureCode: null, }, }); }); - it('persists the file node and completion claim in one transaction', async () => { - const updateMany = jest.fn(() => Promise.resolve({ count: 1 })); + it('atomically queues verification for a copy whose source is still pending', async () => { + const source = { + ...storedNode('source-node'), + ownerUserId: 'owner-1', + ownerScopeKey: 'owner-1', + spaceScope: 'personal', + sizeBytes: 32, + checksumAlgorithm: null, + checksumValue: null, + integrityStatus: 'pending', + lastVerifiedAt: null, + verificationFailureCode: null, + }; const create = jest.fn(({ data }: { data: Record }) => - Promise.resolve({ - ...data, - archivedBy: null, - originalParentNodeId: null, - originalPath: null, - }), + Promise.resolve({ ...source, ...data }), ); const tx = { - fileNode: { create }, - uploadSession: { updateMany }, + $queryRaw: jest.fn(() => Promise.resolve([{ id: source.id }])), + fileNode: { + create, + findFirst: jest.fn(() => Promise.resolve(source)), + findMany: jest.fn(() => Promise.resolve([])), + }, }; - const repository = createRepository({ - $transaction: jest.fn( - (operation: (client: typeof tx) => Promise) => operation(tx), - ), - }); - - const completed = await repository.completeUpload( + const enqueueObjectVerification = jest.fn(() => + Promise.resolve('task-test'), + ); + const kickQueued = jest.fn(); + const repository = createRepository( { - fileName: 'Atomic.pdf', - mimeType: 'application/pdf', - objectKey: 'objects/original/v2/2026/07/atomic.blob', - sizeBytes: 32, - workspaceId: 'workspace-default', + $transaction: jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), + ), + isSqlite: () => false, }, + { enqueueObjectVerification, kickQueued }, + ); + + const copied = await repository.copyTree( { - sessionId: 'upload-session-1', - completionToken: 'completion-token', + ...source, + sizeBytes: Number(source.sizeBytes), + owner: source.ownerName, + previewCapability: { + downloadOnly: false, + previewType: 'text', + reason: null, + renderMode: 'text', + supported: true, + }, + createdAt: source.createdAt.toISOString(), + updatedAt: source.updatedAt.toISOString(), }, + { actorUserId: 'admin-1', parentNodeId: null }, ); - expect(updateMany).toHaveBeenCalledWith({ - where: { - id: 'upload-session-1', - status: 'running', - completionToken: 'completion-token', - OR: [ - { expiresAt: null }, - { expiresAt: { gt: expect.any(Date) as unknown } }, - ], - }, - data: { - nodeId: completed.node.id, - fileName: 'Atomic.pdf', - completionStartedAt: expect.any(Date) as unknown, - updatedAt: expect.any(Date) as unknown, + expect(enqueueObjectVerification).toHaveBeenCalledWith( + { + actorUserId: 'admin-1', + nodeId: copied?.id, + objectKey: source.objectKey, + workspaceId: source.workspaceId, }, - }); + tx, + ); + expect(kickQueued).toHaveBeenCalledTimes(1); }); - it('rejects a file-node write when its completion claim was superseded', async () => { + it('does not reuse an object key when the copy source disappeared before its row lock', async () => { + const source = storedNode('source-deleted'); + const create = jest.fn(); const tx = { + $queryRaw: jest.fn(() => Promise.resolve([])), fileNode: { - create: jest.fn(({ data }: { data: Record }) => - Promise.resolve({ - ...data, - archivedBy: null, - originalParentNodeId: null, - originalPath: null, - }), - ), - }, - uploadSession: { - updateMany: jest.fn(() => Promise.resolve({ count: 0 })), + create, + findFirst: jest.fn(), + findMany: jest.fn(), }, }; const repository = createRepository({ $transaction: jest.fn( (operation: (client: typeof tx) => Promise) => operation(tx), ), + isSqlite: () => false, }); await expect( - repository.completeUpload( - { - fileName: 'Atomic.pdf', - objectKey: 'objects/original/v2/2026/07/atomic.blob', - sizeBytes: 32, - workspaceId: 'workspace-default', - }, + repository.copyTree( { - sessionId: 'upload-session-1', - completionToken: 'stale-token', + ...source, + owner: source.ownerName, + previewCapability: { + downloadOnly: false, + previewType: 'text', + reason: null, + renderMode: 'text', + supported: true, + }, + sizeBytes: Number(source.sizeBytes), + createdAt: source.createdAt.toISOString(), + updatedAt: source.updatedAt.toISOString(), }, + { parentNodeId: null }, ), - ).rejects.toBeInstanceOf(ConflictException); - }); - - it('rejects an upload target that changed after the upload session started', async () => { - const create = jest.fn(); - const update = jest.fn(); - const findUnique = jest.fn((input: { where: Record }) => { - void input; - return Promise.resolve(null); - }); - const tx = { - fileNode: { - create, - findUnique, - update, - }, - }; - const repository = createRepository({ - $transaction: jest.fn( - (operation: (client: typeof tx) => Promise) => operation(tx), - ), - }); + ).resolves.toBeNull(); - await expect( - repository.completeUpload({ - conflictStrategy: 'overwrite', - conflictTargetNodeId: 'node-1', - fileName: 'Report.pdf', - mimeType: 'application/pdf', - objectKey: 'objects/original/v2/2026/07/test.blob', - parentNodeId: 'folder-1', - sizeBytes: 32, - spaceScope: 'workspace', - workspaceId: 'workspace-default', - }), - ).rejects.toBeInstanceOf(ConflictException); - expect(findUnique.mock.calls[0]?.[0]).toEqual({ - where: { id: 'node-1' }, - }); + expect(tx.$queryRaw).toHaveBeenCalledTimes(1); + expect(tx.fileNode.findFirst).not.toHaveBeenCalled(); expect(create).not.toHaveBeenCalled(); - expect(update).not.toHaveBeenCalled(); }); - it('does not overwrite a concurrent same-name insert without a target', async () => { - const databaseError = Object.assign(new Error('Unique constraint failed'), { - code: 'P2002', - meta: { target: 'file_nodes_scope_directory_name_key' }, - }); - const existing = { - archivedAt: null, - id: 'concurrent-node', - name: 'Report.pdf', - objectKey: 'existing-object', - ownerName: 'Workspace User', - ownerUserId: null, - parentNodeId: null, - spaceScope: 'workspace', - workspaceId: 'workspace-default', - }; - const create = jest.fn(() => Promise.reject(databaseError)); - const update = jest.fn(() => Promise.resolve(existing)); + it('archives the complete integrity metadata and marks replacement content pending', async () => { + const existing = storedNode('node-1'); + const createVersion = jest.fn( + ({ data }: { data: Record }) => Promise.resolve(data), + ); + const update = jest.fn(({ data }: { data: Record }) => + Promise.resolve({ ...existing, ...data }), + ); const tx = { + $queryRaw: jest.fn(() => Promise.resolve([{ id: existing.id }])), fileNode: { - create, + findUnique: jest.fn(() => Promise.resolve(existing)), update, }, + fileVersion: { + aggregate: jest.fn(() => + Promise.resolve({ _max: { versionNumber: null } }), + ), + create: createVersion, + }, }; - const repository = createRepository({ - $transaction: jest.fn( - (operation: (client: typeof tx) => Promise) => operation(tx), - ), - }); - - await expect( - repository.completeUpload({ - conflictStrategy: 'overwrite', - fileName: 'Report.pdf', - objectKey: 'new-object', - sizeBytes: 32, - workspaceId: 'workspace-default', - }), - ).rejects.toBeInstanceOf(ConflictException); - expect(update).not.toHaveBeenCalled(); - }); - - it.each(['overwrite', 'version'] as const)( - 'does not rebind a deleted %s target to a concurrent same-name node', - async (conflictStrategy) => { - const concurrentNode = { - ...storedNode('node-b'), - name: 'Report.pdf', - nameKey: 'active:report.pdf', - objectKey: 'object-b', - }; - const update = jest.fn(); - const tx = { - fileNode: { - create: jest.fn(), - findUnique: jest.fn(() => Promise.resolve(null)), - update, - }, - fileVersion: { - aggregate: jest.fn(), - create: jest.fn(), - }, - }; - const repository = createRepository({ - fileNode: { - findMany: jest.fn(() => Promise.resolve([concurrentNode])), - }, + const enqueueObjectVerification = jest.fn(() => + Promise.resolve('task-test'), + ); + const kickQueued = jest.fn(); + const repository = createRepository( + { $transaction: jest.fn( (operation: (client: typeof tx) => Promise) => operation(tx), ), - }); - - await expect( - repository.completeUpload({ - conflictStrategy, - conflictTargetNodeId: 'node-a', - conflictTargetObjectKey: 'object-a', - fileName: 'Report.pdf', - objectKey: 'new-object', - requestedFileName: 'Report.pdf', - sizeBytes: 32, - workspaceId: 'workspace-default', - }), - ).rejects.toMatchObject({ - response: { code: 'UPLOAD_CONFLICT_TARGET_CHANGED' }, - }); - expect(update).not.toHaveBeenCalled(); - expect(tx.fileVersion.create).not.toHaveBeenCalled(); - }, - ); - - it('rejects overwrite when the pinned target object changed', async () => { - const target = { - ...storedNode('node-a'), - name: 'Report.pdf', - nameKey: 'active:report.pdf', - objectKey: 'newer-object', - }; - const update = jest.fn(); - const tx = { - fileNode: { - findUnique: jest.fn(() => Promise.resolve(target)), - update, + isSqlite: () => false, }, - }; - const repository = createRepository({ - $transaction: jest.fn( - (operation: (client: typeof tx) => Promise) => operation(tx), - ), - }); + { enqueueObjectVerification, kickQueued }, + ); - await expect( - repository.completeUpload({ - conflictStrategy: 'overwrite', - conflictTargetNodeId: target.id, - conflictTargetObjectKey: 'intent-object', - fileName: target.name, - objectKey: 'uploaded-object', - sizeBytes: 32, - workspaceId: target.workspaceId, - }), - ).rejects.toMatchObject({ - response: { code: 'UPLOAD_CONFLICT_TARGET_CHANGED' }, - }); - expect(update).not.toHaveBeenCalled(); + await repository.replaceContentObject({ + id: existing.id, + mimeType: 'text/plain', + objectKey: 'objects/replacement', + sizeBytes: 18, + }); + + expect(createVersion).toHaveBeenCalledWith({ + data: expect.objectContaining({ + checksumAlgorithm: 'sha256', + checksumValue: 'a'.repeat(64), + integrityStatus: 'verified', + lastVerifiedAt: new Date(1), + verificationFailureCode: null, + }) as unknown, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: existing.id }, + data: expect.objectContaining({ + checksumAlgorithm: null, + checksumValue: null, + integrityStatus: 'pending', + lastVerifiedAt: null, + objectKey: 'objects/replacement', + verificationFailureCode: null, + }) as unknown, + }); + expect(enqueueObjectVerification).toHaveBeenCalledWith( + { + actorUserId: null, + nodeId: existing.id, + objectKey: 'objects/replacement', + workspaceId: 'workspace-default', + }, + tx, + ); + expect(kickQueued).toHaveBeenCalledTimes(1); }); - it('returns the exact object displaced by overwrite', async () => { - const target = { - ...storedNode('node-a'), - name: 'Report.pdf', - nameKey: 'active:report.pdf', - objectKey: 'intent-object', + it('locks and retries content replacement before archiving a concurrent upload object', async () => { + const original = storedNode('node-racing'); + const uploaded = { + ...original, + objectKey: 'objects/concurrent-upload', + sizeBytes: 12n, + updatedAt: new Date(2), }; - const tx = { + const createVersion = jest.fn( + ({ data }: { data: Record }) => Promise.resolve(data), + ); + const firstUpdate = jest.fn(() => + Promise.reject( + Object.assign(new Error('write conflict'), { code: 'P2034' }), + ), + ); + const secondUpdate = jest.fn( + ({ data }: { data: Record }) => + Promise.resolve({ ...uploaded, ...data }), + ); + const firstTx = { + $queryRaw: jest.fn(() => Promise.resolve([{ id: original.id }])), fileNode: { - findUnique: jest.fn(() => Promise.resolve(target)), - update: jest.fn(({ data }: { data: Record }) => - Promise.resolve({ ...target, ...data }), + findUnique: jest.fn(() => Promise.resolve(original)), + update: firstUpdate, + }, + fileVersion: { + aggregate: jest.fn(() => + Promise.resolve({ _max: { versionNumber: null } }), ), + create: createVersion, }, }; - const repository = createRepository({ - $transaction: jest.fn( - (operation: (client: typeof tx) => Promise) => operation(tx), - ), - }); - - const completed = await repository.completeUpload({ - conflictStrategy: 'overwrite', - conflictTargetNodeId: target.id, - conflictTargetObjectKey: target.objectKey ?? undefined, - fileName: target.name, - objectKey: 'uploaded-object', - sizeBytes: 32, - workspaceId: target.workspaceId, - }); - - expect(completed.displacedObjectKey).toBe('intent-object'); - expect(completed.node.objectKey).toBe('uploaded-object'); - }); - - it('recomputes a rename from the requested name after a name collision', async () => { - const nameConflict = Object.assign(new Error('Unique constraint failed'), { - code: 'P2002', - meta: { target: 'file_nodes_scope_directory_name_key' }, - }); - const findMany = jest - .fn() - .mockResolvedValueOnce([{ name: 'Report.pdf' }]) - .mockResolvedValueOnce([ - { name: 'Report.pdf' }, - { name: 'Report (2).pdf' }, - ]); - const create = jest - .fn< - Promise>, - [ - { - data: Record & { name: string }; - }, - ] - >() - .mockRejectedValueOnce(nameConflict) - .mockImplementationOnce(({ data }: { data: Record }) => - Promise.resolve({ - ...data, - archivedBy: null, - originalParentNodeId: null, - originalPath: null, - }), - ); - const updateMany = jest.fn(() => Promise.resolve({ count: 1 })); - const tx = { - fileNode: { create, findMany }, - uploadSession: { updateMany }, + const secondTx = { + $queryRaw: jest.fn(() => Promise.resolve([{ id: uploaded.id }])), + fileNode: { + findUnique: jest.fn(() => Promise.resolve(uploaded)), + update: secondUpdate, + }, + fileVersion: { + aggregate: jest.fn(() => + Promise.resolve({ _max: { versionNumber: null } }), + ), + create: createVersion, + }, }; + const transactions = [firstTx, secondTx]; const transaction = jest.fn( - (operation: (client: typeof tx) => Promise) => operation(tx), + (operation: (client: typeof firstTx) => Promise) => + operation(transactions.shift() ?? secondTx), ); - const repository = createRepository({ $transaction: transaction }); + const repository = createRepository({ + $transaction: transaction, + isSqlite: () => false, + }); - const completed = await repository.completeUpload( - { - conflictStrategy: 'rename', - fileName: 'Report (2).pdf', - objectKey: 'uploaded-object', - parentNodeId: 'folder-1', - requestedFileName: 'Report.pdf', - sizeBytes: 32, - workspaceId: 'workspace-default', - }, - { - completionToken: 'completion-token', - sessionId: 'upload-session-1', - }, - ); + await expect( + repository.replaceContentObject({ + id: original.id, + mimeType: 'text/plain', + objectKey: 'objects/text-edit', + sizeBytes: 18, + }), + ).resolves.toMatchObject({ objectKey: 'objects/text-edit' }); expect(transaction).toHaveBeenCalledTimes(2); - expect(transaction.mock.calls[0]?.[1]).toEqual({ + expect(transaction).toHaveBeenNthCalledWith(1, expect.any(Function), { isolationLevel: 'Serializable', }); - expect(findMany).toHaveBeenCalledWith( + expect(transaction).toHaveBeenNthCalledWith(2, expect.any(Function), { + isolationLevel: 'Serializable', + }); + expect(firstTx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan( + firstTx.fileNode.findUnique.mock.invocationCallOrder[0], + ); + expect(secondTx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan( + secondTx.fileNode.findUnique.mock.invocationCallOrder[0], + ); + expect(createVersion).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ - where: expect.objectContaining({ - directoryKey: 'folder-1', - parentNodeId: 'folder-1', + data: expect.objectContaining({ + objectKey: uploaded.objectKey, + sizeBytes: uploaded.sizeBytes, }) as unknown, }), ); - expect(create.mock.calls.map(([input]) => input.data.name)).toEqual([ - 'Report (2).pdf', - 'Report (3).pdf', - ]); - expect(completed.node.name).toBe('Report (3).pdf'); - expect(updateMany).toHaveBeenCalledWith( + expect(secondUpdate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ - fileName: 'Report (3).pdf', - nodeId: completed.node.id, + objectKey: 'objects/text-edit', }) as unknown, }), ); }); - it('retries a serializable rename transaction conflict', async () => { - const transactionConflict = Object.assign( - new Error('write conflict or deadlock'), - { code: 'P2034' }, + it('pins verification for a pending object when content replacement archives it', async () => { + const existing = { + ...storedNode('node-pending'), + checksumAlgorithm: null, + checksumValue: null, + integrityStatus: 'pending', + lastVerifiedAt: null, + }; + const createVersion = jest.fn( + ({ data }: { data: Record }) => Promise.resolve(data), ); const tx = { + $queryRaw: jest.fn(() => Promise.resolve([{ id: existing.id }])), fileNode: { - findMany: jest.fn(() => Promise.resolve([])), - create: jest.fn(({ data }: { data: Record }) => - Promise.resolve({ - ...data, - archivedBy: null, - originalParentNodeId: null, - originalPath: null, - }), + findUnique: jest.fn(() => Promise.resolve(existing)), + update: jest.fn(({ data }: { data: Record }) => + Promise.resolve({ ...existing, ...data }), ), }, - }; - const transaction = jest - .fn() - .mockRejectedValueOnce(transactionConflict) - .mockImplementationOnce( - (operation: (client: typeof tx) => Promise) => operation(tx), - ); - const repository = createRepository({ $transaction: transaction }); - - await expect( - repository.completeUpload({ - conflictStrategy: 'rename', - fileName: 'Report.pdf', - objectKey: 'uploaded-object', - requestedFileName: 'Report.pdf', - sizeBytes: 32, - workspaceId: 'workspace-default', - }), - ).resolves.toMatchObject({ node: { name: 'Report.pdf' } }); - expect(transaction).toHaveBeenCalledTimes(2); - }); - - it('retries a version-number collision using the latest target object', async () => { - const versionConflict = Object.assign( - new Error('Unique constraint failed'), - { - code: 'P2002', - meta: { target: ['nodeId', 'versionNumber'] }, - }, - ); - const firstTarget = { - ...storedNode('node-a'), - name: 'Report.pdf', - nameKey: 'active:report.pdf', - objectKey: 'first-object', - }; - const secondTarget = { - ...firstTarget, - objectKey: 'concurrently-uploaded-object', - }; - const findUnique = jest - .fn() - .mockResolvedValueOnce(firstTarget) - .mockResolvedValueOnce(secondTarget); - const createVersion = jest - .fn< - Promise, - [ - { - data: Record & { objectKey: string }; - }, - ] - >() - .mockRejectedValueOnce(versionConflict) - .mockResolvedValueOnce({ id: 'version-2' }); - const update = jest.fn(({ data }: { data: Record }) => - Promise.resolve({ ...secondTarget, ...data }), - ); - const tx = { - fileNode: { findUnique, update }, fileVersion: { aggregate: jest.fn(() => - Promise.resolve({ _max: { versionNumber: 1 } }), + Promise.resolve({ _max: { versionNumber: null } }), ), create: createVersion, }, }; - const transaction = jest.fn( - (operation: (client: typeof tx) => Promise) => operation(tx), + const enqueueObjectVerification = jest.fn(() => + Promise.resolve('task-test'), + ); + const kickQueued = jest.fn(); + const repository = createRepository( + { + $transaction: jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), + ), + isSqlite: () => false, + }, + { enqueueObjectVerification, kickQueued }, ); - const repository = createRepository({ $transaction: transaction }); - const completed = await repository.completeUpload({ - conflictStrategy: 'version', - conflictTargetNodeId: firstTarget.id, - conflictTargetObjectKey: firstTarget.objectKey ?? undefined, - fileName: firstTarget.name, - objectKey: 'final-upload-object', - requestedFileName: firstTarget.name, - sizeBytes: 32, - workspaceId: firstTarget.workspaceId, + await repository.replaceContentObject({ + id: existing.id, + mimeType: 'text/plain', + objectKey: 'objects/replacement', + sizeBytes: 18, }); - expect(transaction).toHaveBeenCalledTimes(2); - expect( - createVersion.mock.calls.map(([input]) => input.data.objectKey), - ).toEqual(['first-object', 'concurrently-uploaded-object']); - expect(update).toHaveBeenCalledTimes(1); - expect(completed.node.objectKey).toBe('final-upload-object'); - expect(completed.displacedObjectKey).toBeNull(); - }); - - it('maps an exhausted version-number collision to a stable conflict', async () => { - const versionConflict = Object.assign( - new Error('Unique constraint failed'), + expect(createVersion).toHaveBeenCalledWith({ + data: expect.objectContaining({ + integrityStatus: 'pending', + objectKey: existing.objectKey, + }) as unknown, + }); + expect(enqueueObjectVerification).toHaveBeenNthCalledWith( + 1, { - code: 'P2002', - meta: { target: ['nodeId', 'versionNumber'] }, + actorUserId: null, + nodeId: existing.id, + objectKey: existing.objectKey, + versionId: expect.any(String) as unknown, + workspaceId: existing.workspaceId, }, + tx, ); - const target = { - ...storedNode('node-a'), - name: 'Report.pdf', - nameKey: 'active:report.pdf', - objectKey: 'current-object', - }; - const createVersion = jest.fn(() => Promise.reject(versionConflict)); - const tx = { - fileNode: { - findUnique: jest.fn(() => Promise.resolve(target)), - update: jest.fn(), - }, - fileVersion: { - aggregate: jest.fn(() => - Promise.resolve({ _max: { versionNumber: 1 } }), - ), - create: createVersion, - }, - }; - const repository = createRepository({ - $transaction: jest.fn( - (operation: (client: typeof tx) => Promise) => operation(tx), - ), - }); - - await expect( - repository.completeUpload({ - conflictStrategy: 'version', - conflictTargetNodeId: target.id, - fileName: target.name, - objectKey: 'final-upload-object', - requestedFileName: target.name, - sizeBytes: 32, - workspaceId: target.workspaceId, - }), - ).rejects.toMatchObject({ - response: { - code: 'UPLOAD_VERSION_CONFLICT', - message: 'File version changed while the upload was being completed', + expect(enqueueObjectVerification).toHaveBeenNthCalledWith( + 2, + { + actorUserId: null, + nodeId: existing.id, + objectKey: 'objects/replacement', + workspaceId: existing.workspaceId, }, - }); - expect(createVersion).toHaveBeenCalledTimes( - serializableTransactionMaxAttempts, + tx, ); - expect(tx.fileNode.update).not.toHaveBeenCalled(); + expect(kickQueued).toHaveBeenCalledTimes(1); }); - it('maps only skip name races to the structured skipped result', async () => { - const nameConflict = Object.assign(new Error('Unique constraint failed'), { - code: 'P2002', - meta: { target: 'file_nodes_scope_directory_name_key' }, + it('releases the active name key when archiving a node', async () => { + const stored = { + id: 'node-1', + workspaceId: 'workspace-default', + spaceScope: 'personal', + parentNodeId: 'folder-1', + directoryKey: 'folder-1', + ownerScopeKey: 'user-1', + name: 'Report.txt', + nameKey: 'active:report.txt', + kind: 'doc', + mimeType: 'text/plain', + sizeBytes: BigInt(32), + objectKey: 'object-1', + ownerName: 'User 1', + ownerUserId: 'user-1', + starred: false, + archivedAt: null as Date | null, + archivedBy: null as string | null, + originalParentNodeId: null as string | null, + originalPath: null as string | null, + createdAt: new Date(0), + updatedAt: new Date(0), + }; + const update = jest.fn(({ data }: { data: Partial }) => { + Object.assign(stored, data); + return Promise.resolve(stored); }); const tx = { + $queryRaw: rootOnlyQueryRaw(stored.id), fileNode: { - create: jest.fn(() => Promise.reject(nameConflict)), + count: jest.fn(() => Promise.resolve(0)), + findMany: jest.fn((input: { select?: Record }) => + Promise.resolve(input.select ? [] : [stored]), + ), + findUnique: jest.fn(() => Promise.resolve(stored)), + update, }, }; const repository = createRepository({ $transaction: jest.fn( (operation: (client: typeof tx) => Promise) => operation(tx), ), + isSqlite: () => false, }); - await expect( - repository.completeUpload({ - conflictStrategy: 'skip', - fileName: 'Report.pdf', - objectKey: 'uploaded-object', - sizeBytes: 32, - workspaceId: 'workspace-default', - }), - ).rejects.toMatchObject({ - response: { - code: 'UPLOAD_CONFLICT_SKIPPED', - message: 'File upload skipped because a same-name item exists', + await repository.archiveTree(stored.id, 'user-1'); + + expect(update.mock.calls[0]?.[0]).toMatchObject({ + where: { id: stored.id }, + data: { + directoryKey: 'folder-1', + nameKey: `archived:${stored.id}`, + ownerScopeKey: 'user-1', }, }); }); diff --git a/backend/src/modules/files/file-nodes.repository.ts b/backend/src/modules/files/file-nodes.repository.ts index 24b3158..eb16381 100644 --- a/backend/src/modules/files/file-nodes.repository.ts +++ b/backend/src/modules/files/file-nodes.repository.ts @@ -17,7 +17,10 @@ import { import { resolveFilePreviewCapability } from './file-preview-policy'; import { createFileNodeStorageKeys } from '../../common/security/file-name-policy'; import { FileDownloadIntentsRepository } from './file-download-intents.repository'; -import { FileNodeVersionsRepository } from './file-node-versions.repository'; +import { + FileNodeVersionsRepository, + isSqliteBusyPrismaError, +} from './file-node-versions.repository'; import { FilePreviewArtifactsRepository } from './file-preview-artifacts.repository'; import { FileStorageUsageRepository, @@ -27,6 +30,19 @@ import { completeFileNodeUploadWrite, isFileNodeNameConstraintError, } from './file-upload-completion-write'; +import { + needsFileIntegrityVerification, + normalizeFileIntegrityFailureCode, + normalizeFileIntegrityStatus, +} from './file-integrity'; +import { StorageIntegrityTaskService } from '../storage/storage-integrity-task.service'; +import { retryPrismaSerializableTransaction } from '../../common/database/serializable-transaction-retry'; +import { + collectLockedActiveFileTree, + lockAndValidateActiveFileNodeParent, + lockAndValidateFileNodeMove, + lockFileNodeRows, +} from './file-node-hierarchy-write'; export type { StoredFileVersionResponse } from './file-node-versions.repository'; @@ -67,6 +83,7 @@ export class FileNodesRepository { private readonly versionsRepository: FileNodeVersionsRepository, private readonly previewArtifactsRepository: FilePreviewArtifactsRepository, private readonly storageUsageRepository: FileStorageUsageRepository, + private readonly integrityTasks: StorageIntegrityTaskService, ) {} async list( @@ -148,26 +165,47 @@ export class FileNodesRepository { spaceScope, }); const row = await this.executeFileNodeWrite(() => - this.prisma.fileNode.create({ - data: { - id, - workspaceId: dto.workspaceId, - spaceScope, - parentNodeId: dto.parentNodeId ?? null, - ...storageKeys, - name: dto.name, - kind: 'folder', - mimeType: 'inode/directory', - sizeBytes: null, - objectKey: null, - ownerName: dto.owner ?? '', - ownerUserId: dto.ownerUserId ?? null, - starred: false, - archivedAt: null, - createdAt: now, - updatedAt: now, + retryPrismaSerializableTransaction( + () => + this.prisma.$transaction( + async (tx) => { + const validParent = await lockAndValidateActiveFileNodeParent( + this.prisma, + tx, + dto.parentNodeId ?? null, + { spaceScope, workspaceId: dto.workspaceId }, + ); + if (!validParent) { + throw new ConflictException('Parent folder changed'); + } + return tx.fileNode.create({ + data: { + id, + workspaceId: dto.workspaceId, + spaceScope, + parentNodeId: dto.parentNodeId ?? null, + ...storageKeys, + name: dto.name, + kind: 'folder', + mimeType: 'inode/directory', + sizeBytes: null, + objectKey: null, + ownerName: dto.owner ?? '', + ownerUserId: dto.ownerUserId ?? null, + starred: false, + archivedAt: null, + createdAt: now, + updatedAt: now, + }, + }); + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ), + { + isRetryableError: (error) => + this.prisma.isSqlite() && isSqliteBusyPrismaError(error), }, - }), + ), ); return this.mapRow(row); } @@ -177,7 +215,41 @@ export class FileNodesRepository { } async move(id: string, parentNodeId: string | null) { - return this.updateNodeIdentity(id, { parentNodeId }); + const row = await this.executeFileNodeWrite(() => + retryPrismaSerializableTransaction( + () => + this.prisma.$transaction(async (tx) => { + const existing = await lockAndValidateFileNodeMove( + this.prisma, + tx, + id, + parentNodeId, + ); + if (!existing) return null; + const storageKeys = createFileNodeStorageKeys({ + archived: false, + id: existing.id, + name: existing.name, + ownerUserId: existing.ownerUserId, + parentNodeId, + spaceScope: existing.spaceScope, + }); + return tx.fileNode.update({ + where: { id }, + data: { + parentNodeId, + ...storageKeys, + updatedAt: new Date(), + }, + }); + }), + { + isRetryableError: (error) => + this.prisma.isSqlite() && isSqliteBusyPrismaError(error), + }, + ), + ); + return row ? this.mapRow(row) : null; } async updateSize(id: string, sizeBytes: number) { @@ -193,90 +265,197 @@ export class FileNodesRepository { sizeBytes: number; mimeType: string; uploadedBy?: string; + actorUserId?: string | null; }) { - const row = await this.prisma.$transaction(async (tx) => { - const existing = await tx.fileNode.findUnique({ - where: { id: input.id }, - }); - if (!existing?.objectKey) return null; - await this.versionsRepository.createVersionForNode(tx, existing, { - remark: 'Replaced by content edit', - uploadedBy: input.uploadedBy ?? existing.ownerName, - }); - return tx.fileNode.update({ - where: { id: input.id }, - data: { - objectKey: input.objectKey, - sizeBytes: BigInt(input.sizeBytes), - mimeType: input.mimeType, - kind: this.getKind(existing.name, input.mimeType), - updatedAt: new Date(), - }, - }); - }); + const row = await retryPrismaSerializableTransaction( + () => + this.prisma.$transaction( + async (tx) => { + const locked = await lockFileNodeRows(this.prisma, tx, [input.id]); + if (!locked) return null; + const existing = await tx.fileNode.findUnique({ + where: { id: input.id }, + }); + if (!existing?.objectKey || existing.archivedAt) return null; + const archivedVersion = + await this.versionsRepository.createVersionForNode(tx, existing, { + remark: 'Replaced by content edit', + uploadedBy: input.uploadedBy ?? existing.ownerName, + }); + if ( + archivedVersion && + needsFileIntegrityVerification(archivedVersion) + ) { + await this.integrityTasks.enqueueObjectVerification( + { + actorUserId: input.actorUserId ?? null, + nodeId: existing.id, + objectKey: archivedVersion.objectKey, + versionId: archivedVersion.id, + workspaceId: existing.workspaceId, + }, + tx, + ); + } + const updated = await tx.fileNode.update({ + where: { id: input.id }, + data: { + objectKey: input.objectKey, + sizeBytes: BigInt(input.sizeBytes), + mimeType: input.mimeType, + checksumAlgorithm: null, + checksumValue: null, + integrityStatus: 'pending', + integrityAcknowledgedAt: null, + integrityAcknowledgedBy: null, + lastVerifiedAt: null, + verificationFailureCode: null, + kind: this.getKind(existing.name, input.mimeType), + updatedAt: new Date(), + }, + }); + await this.integrityTasks.enqueueObjectVerification( + { + actorUserId: input.actorUserId ?? null, + nodeId: updated.id, + objectKey: input.objectKey, + workspaceId: updated.workspaceId, + }, + tx, + ); + return updated; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ), + { + isRetryableError: (error) => + this.prisma.isSqlite() && isSqliteBusyPrismaError(error), + }, + ); + if (row) this.integrityTasks.kickQueued(); return row ? this.mapRow(row) : null; } async copyTree( source: FileNodeResponse, - options: { name?: string; parentNodeId: string | null }, + options: { + actorUserId?: string | null; + name?: string; + parentNodeId: string | null; + }, ) { - const descendants = await this.collectDescendants(source.id); - const rows = [source, ...descendants]; - const idMap = new Map(); - rows.forEach((row) => { - idMap.set(row.id, `node_${randomBytes(12).toString('base64url')}`); - }); + const result = await this.executeFileNodeWrite(() => + retryPrismaSerializableTransaction( + () => + this.prisma.$transaction(async (tx) => { + const rows = await collectLockedActiveFileTree( + this.prisma, + tx, + source.id, + ); + if (!rows) return { copied: null, queuedVerification: false }; + const root = rows[0]; + if (!root) return { copied: null, queuedVerification: false }; + const validParent = await lockAndValidateActiveFileNodeParent( + this.prisma, + tx, + options.parentNodeId, + { + forbiddenAncestorId: root.id, + spaceScope: root.spaceScope, + workspaceId: root.workspaceId, + }, + ); + if (!validParent) { + throw new ConflictException('Parent folder changed'); + } - const now = new Date(); - const copiedNodes: FileNodeResponse[] = []; - for (const row of rows) { - const copiedId = idMap.get(row.id); - if (!copiedId) continue; - const copiedParent = - row.id === source.id - ? options.parentNodeId - : row.parentNodeId - ? (idMap.get(row.parentNodeId) ?? null) - : null; - const copiedName = - row.id === source.id ? options.name?.trim() || row.name : row.name; - const storageKeys = createFileNodeStorageKeys({ - archived: false, - id: copiedId, - name: copiedName, - ownerUserId: row.ownerUserId, - parentNodeId: copiedParent, - spaceScope: row.spaceScope, - }); - const copied = await this.executeFileNodeWrite(() => - this.prisma.fileNode.create({ - data: { - id: copiedId, - workspaceId: row.workspaceId, - spaceScope: row.spaceScope, - parentNodeId: copiedParent, - ...storageKeys, - name: copiedName, - kind: row.kind, - mimeType: row.mimeType, - sizeBytes: - row.sizeBytes === null || row.sizeBytes === undefined - ? null - : BigInt(row.sizeBytes), - objectKey: row.objectKey, - ownerName: row.owner, - ownerUserId: row.ownerUserId, - starred: false, - archivedAt: null, - createdAt: now, - updatedAt: now, - }, - }), - ); - copiedNodes.push(this.mapRow(copied)); - } - return copiedNodes[0] ?? null; + const idMap = new Map(); + rows.forEach((row) => { + idMap.set( + row.id, + `node_${randomBytes(12).toString('base64url')}`, + ); + }); + const now = new Date(); + let copied: FileNode | null = null; + let queuedVerification = false; + for (const row of rows) { + const copiedId = idMap.get(row.id); + if (!copiedId) continue; + const copiedParent = + row.id === source.id + ? options.parentNodeId + : row.parentNodeId + ? (idMap.get(row.parentNodeId) ?? null) + : null; + const copiedName = + row.id === source.id + ? options.name?.trim() || row.name + : row.name; + const storageKeys = createFileNodeStorageKeys({ + archived: false, + id: copiedId, + name: copiedName, + ownerUserId: row.ownerUserId, + parentNodeId: copiedParent, + spaceScope: row.spaceScope, + }); + const created = await tx.fileNode.create({ + data: { + id: copiedId, + workspaceId: row.workspaceId, + spaceScope: row.spaceScope, + parentNodeId: copiedParent, + ...storageKeys, + name: copiedName, + kind: row.kind, + mimeType: row.mimeType, + sizeBytes: row.sizeBytes, + objectKey: row.objectKey, + checksumAlgorithm: row.checksumAlgorithm, + checksumValue: row.checksumValue, + integrityStatus: row.integrityStatus, + integrityAcknowledgedAt: null, + integrityAcknowledgedBy: null, + lastVerifiedAt: row.lastVerifiedAt, + verificationFailureCode: row.verificationFailureCode, + ownerName: row.ownerName, + ownerUserId: row.ownerUserId, + starred: false, + archivedAt: null, + createdAt: now, + updatedAt: now, + }, + }); + copied ??= created; + if ( + row.kind !== 'folder' && + row.objectKey && + needsFileIntegrityVerification(row) + ) { + await this.integrityTasks.enqueueObjectVerification( + { + actorUserId: options.actorUserId ?? null, + nodeId: created.id, + objectKey: row.objectKey, + workspaceId: created.workspaceId, + }, + tx, + ); + queuedVerification = true; + } + } + return { copied, queuedVerification }; + }), + { + isRetryableError: (error) => + this.prisma.isSqlite() && isSqliteBusyPrismaError(error), + }, + ), + ); + if (result.queuedVerification) this.integrityTasks.kickQueued(); + return result.copied ? this.mapRow(result.copied) : null; } async updateState( @@ -327,6 +506,7 @@ export class FileNodesRepository { const deletion = await this.versionsRepository.deleteTree(id); return { nodes: deletion.nodes.map((node) => this.mapRow(node)), + objectKeysToDelete: deletion.objectKeysToDelete, versions: deletion.versions, }; } @@ -335,6 +515,7 @@ export class FileNodesRepository { const deletion = await this.versionsRepository.cleanupTrash(cutoff); return { nodes: deletion.nodes.map((node) => this.mapRow(node)), + objectKeysToDelete: deletion.objectKeysToDelete, versions: deletion.versions, }; } @@ -347,12 +528,20 @@ export class FileNodesRepository { return this.versionsRepository.findVersion(nodeId, versionId); } - async restoreVersion(nodeId: string, versionId: string, actor?: string) { + async restoreVersion( + nodeId: string, + versionId: string, + actor?: string, + actorUserId?: string | null, + ) { const row = await this.versionsRepository.restoreVersion( nodeId, versionId, actor, + this.integrityTasks, + actorUserId, ); + if (row) this.integrityTasks.kickQueued(); return row ? this.mapRow(row) : null; } @@ -413,41 +602,26 @@ export class FileNodesRepository { const completed = await completeFileNodeUploadWrite( this.prisma, this.versionsRepository, + this.integrityTasks, (fileName, mimeType) => this.getKind(fileName, mimeType), dto, completionClaim, ); + this.integrityTasks.kickQueued(); return { displacedObjectKey: completed.displacedObjectKey, node: this.mapRow(completed.fileNode), }; } - private async collectDescendants(parentId: string) { - const collected: FileNodeResponse[] = []; - const visit = async (id: string) => { - const rows = await this.prisma.fileNode.findMany({ - where: { - archivedAt: null, - parentNodeId: id, - }, - orderBy: { name: 'asc' }, - }); - for (const row of rows) { - const node = this.mapRow(row); - collected.push(node); - await visit(node.id); - } - }; - - await visit(parentId); - return collected; - } - pruneVersions(nodeId: string) { return this.versionsRepository.pruneVersions(nodeId); } + filterUnreferencedObjectKeys(objectKeys: Array) { + return this.versionsRepository.filterUnreferencedObjectKeys(objectKeys); + } + async recordAudit( action: FileAuditAction, target: string, @@ -729,33 +903,61 @@ export class FileNodesRepository { id: string, input: { name?: string; parentNodeId?: string | null }, ): Promise { - const existing = await this.prisma.fileNode.findUnique({ where: { id } }); - if (!existing) return null; - const name = input.name ?? existing.name; - const parentNodeId = - input.parentNodeId !== undefined - ? input.parentNodeId - : existing.parentNodeId; - const storageKeys = createFileNodeStorageKeys({ - archived: Boolean(existing.archivedAt), - id: existing.id, - name, - ownerUserId: existing.ownerUserId, - parentNodeId, - spaceScope: existing.spaceScope, - }); const row = await this.executeFileNodeWrite(() => - this.prisma.fileNode.update({ - where: { id }, - data: { - ...(input.name !== undefined ? { name } : {}), - ...(input.parentNodeId !== undefined ? { parentNodeId } : {}), - ...storageKeys, - updatedAt: new Date(), + retryPrismaSerializableTransaction( + () => + this.prisma.$transaction( + async (tx) => { + if (!(await lockFileNodeRows(this.prisma, tx, [id]))) return null; + const existing = await tx.fileNode.findUnique({ where: { id } }); + if (!existing || existing.archivedAt) return null; + const name = input.name ?? existing.name; + const parentNodeId = + input.parentNodeId !== undefined + ? input.parentNodeId + : existing.parentNodeId; + if (input.parentNodeId !== undefined) { + const validParent = await lockAndValidateActiveFileNodeParent( + this.prisma, + tx, + parentNodeId, + { + forbiddenAncestorId: existing.id, + spaceScope: existing.spaceScope, + workspaceId: existing.workspaceId, + }, + ); + if (!validParent) { + throw new ConflictException('Parent folder changed'); + } + } + const storageKeys = createFileNodeStorageKeys({ + archived: false, + id: existing.id, + name, + ownerUserId: existing.ownerUserId, + parentNodeId, + spaceScope: existing.spaceScope, + }); + return tx.fileNode.update({ + where: { id }, + data: { + ...(input.name !== undefined ? { name } : {}), + ...(input.parentNodeId !== undefined ? { parentNodeId } : {}), + ...storageKeys, + updatedAt: new Date(), + }, + }); + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ), + { + isRetryableError: (error) => + this.prisma.isSqlite() && isSqliteBusyPrismaError(error), }, - }), + ), ); - return this.mapRow(row); + return row ? this.mapRow(row) : null; } private async executeFileNodeWrite(write: () => Promise) { @@ -785,6 +987,15 @@ export class FileNodesRepository { ? null : Number(row.sizeBytes), objectKey: row.objectKey, + checksumAlgorithm: row.checksumAlgorithm, + checksumValue: row.checksumValue, + integrityStatus: normalizeFileIntegrityStatus(row.integrityStatus), + lastVerifiedAt: row.lastVerifiedAt + ? row.lastVerifiedAt.toISOString() + : null, + verificationFailureCode: normalizeFileIntegrityFailureCode( + row.verificationFailureCode, + ), owner: row.ownerName, ownerUserId: row.ownerUserId, starred: row.starred, diff --git a/backend/src/modules/files/file-nodes.repository.upload.spec.ts b/backend/src/modules/files/file-nodes.repository.upload.spec.ts new file mode 100644 index 0000000..3324a4c --- /dev/null +++ b/backend/src/modules/files/file-nodes.repository.upload.spec.ts @@ -0,0 +1,768 @@ +import { ConflictException } from '@nestjs/common'; +import { serializableTransactionMaxAttempts } from '../../common/database/serializable-transaction-retry'; +import { createFileNodesRepository as createRepository } from './file-nodes.repository.spec-helpers'; + +function storedNode(id: string) { + return { + id, + workspaceId: 'workspace-default', + spaceScope: 'workspace', + parentNodeId: null, + directoryKey: '', + ownerScopeKey: '', + name: `${id}.txt`, + nameKey: `active:${id}.txt`, + kind: 'doc', + mimeType: 'text/plain', + sizeBytes: 1n, + objectKey: `objects/${id}`, + checksumAlgorithm: 'sha256', + checksumValue: 'a'.repeat(64), + integrityStatus: 'verified', + lastVerifiedAt: new Date(1), + verificationFailureCode: null, + ownerName: 'Workspace User', + ownerUserId: null, + starred: false, + archivedAt: null, + archivedBy: null, + originalParentNodeId: null, + originalPath: null, + createdAt: new Date(0), + updatedAt: new Date(0), + }; +} + +describe('FileNodesRepository upload completion', () => { + it('persists canonical name keys when completing a new upload', async () => { + const create = jest.fn(({ data }: { data: Record }) => + Promise.resolve({ + ...data, + archivedBy: null, + originalParentNodeId: null, + originalPath: null, + }), + ); + const tx = { + fileNode: { + create, + findUnique: jest.fn(() => + Promise.resolve({ + ...storedNode('folder-1'), + kind: 'folder', + objectKey: null, + sizeBytes: null, + spaceScope: 'personal', + }), + ), + }, + }; + const enqueueObjectVerification = jest.fn(() => + Promise.resolve('task-test'), + ); + const kickQueued = jest.fn(); + const repository = createRepository( + { + $transaction: jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), + ), + }, + { enqueueObjectVerification, kickQueued }, + ); + + await repository.completeUpload({ + fileName: 'Resume.pdf', + mimeType: 'application/pdf', + objectKey: 'objects/original/v2/2026/07/test.blob', + ownerUserId: 'user-1', + parentNodeId: 'folder-1', + sizeBytes: 32, + spaceScope: 'personal', + workspaceId: 'workspace-default', + }); + + expect(create.mock.calls[0]?.[0]).toMatchObject({ + data: { + checksumAlgorithm: null, + checksumValue: null, + directoryKey: 'folder-1', + integrityStatus: 'pending', + lastVerifiedAt: null, + nameKey: 'active:resume.pdf', + ownerScopeKey: 'user-1', + verificationFailureCode: null, + }, + }); + expect(enqueueObjectVerification).toHaveBeenCalledWith( + { + actorUserId: 'user-1', + nodeId: expect.any(String) as unknown, + objectKey: 'objects/original/v2/2026/07/test.blob', + workspaceId: 'workspace-default', + }, + tx, + ); + expect(kickQueued).toHaveBeenCalledTimes(1); + }); + + it('pins verification for a pending object archived by a version upload', async () => { + const target = { + ...storedNode('node-pending-upload'), + checksumAlgorithm: null, + checksumValue: null, + integrityStatus: 'pending', + lastVerifiedAt: null, + }; + const createVersion = jest.fn( + ({ data }: { data: Record }) => Promise.resolve(data), + ); + const tx = { + fileNode: { + findUnique: jest.fn(() => Promise.resolve(target)), + update: jest.fn(({ data }: { data: Record }) => + Promise.resolve({ ...target, ...data }), + ), + }, + fileVersion: { + aggregate: jest.fn(() => + Promise.resolve({ _max: { versionNumber: null } }), + ), + create: createVersion, + }, + }; + const enqueueObjectVerification = jest.fn(() => + Promise.resolve('task-test'), + ); + const kickQueued = jest.fn(); + const repository = createRepository( + { + $transaction: jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), + ), + }, + { enqueueObjectVerification, kickQueued }, + ); + + await repository.completeUpload({ + conflictStrategy: 'version', + conflictTargetNodeId: target.id, + fileName: target.name, + objectKey: 'objects/uploaded-replacement', + requestedFileName: target.name, + sizeBytes: 32, + workspaceId: target.workspaceId, + }); + + expect(enqueueObjectVerification).toHaveBeenNthCalledWith( + 1, + { + actorUserId: null, + nodeId: target.id, + objectKey: target.objectKey, + versionId: expect.any(String) as unknown, + workspaceId: target.workspaceId, + }, + tx, + ); + expect(enqueueObjectVerification).toHaveBeenNthCalledWith( + 2, + { + actorUserId: null, + nodeId: target.id, + objectKey: 'objects/uploaded-replacement', + workspaceId: target.workspaceId, + }, + tx, + ); + expect(kickQueued).toHaveBeenCalledTimes(1); + }); + + it('persists the file node and completion claim in one transaction', async () => { + const updateMany = jest.fn(() => Promise.resolve({ count: 1 })); + const create = jest.fn(({ data }: { data: Record }) => + Promise.resolve({ + ...data, + archivedBy: null, + originalParentNodeId: null, + originalPath: null, + }), + ); + const tx = { + fileNode: { create }, + uploadSession: { updateMany }, + }; + const repository = createRepository({ + $transaction: jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), + ), + }); + + const completed = await repository.completeUpload( + { + fileName: 'Atomic.pdf', + mimeType: 'application/pdf', + objectKey: 'objects/original/v2/2026/07/atomic.blob', + sizeBytes: 32, + workspaceId: 'workspace-default', + }, + { + sessionId: 'upload-session-1', + completionToken: 'completion-token', + }, + ); + + expect(updateMany).toHaveBeenCalledWith({ + where: { + id: 'upload-session-1', + status: 'running', + completionToken: 'completion-token', + OR: [ + { expiresAt: null }, + { expiresAt: { gt: expect.any(Date) as unknown } }, + ], + }, + data: { + nodeId: completed.node.id, + fileName: 'Atomic.pdf', + completionStartedAt: expect.any(Date) as unknown, + updatedAt: expect.any(Date) as unknown, + }, + }); + }); + + it('rejects a file-node write when its completion claim was superseded', async () => { + const tx = { + fileNode: { + create: jest.fn(({ data }: { data: Record }) => + Promise.resolve({ + ...data, + archivedBy: null, + originalParentNodeId: null, + originalPath: null, + }), + ), + }, + uploadSession: { + updateMany: jest.fn(() => Promise.resolve({ count: 0 })), + }, + }; + const repository = createRepository({ + $transaction: jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), + ), + }); + + await expect( + repository.completeUpload( + { + fileName: 'Atomic.pdf', + objectKey: 'objects/original/v2/2026/07/atomic.blob', + sizeBytes: 32, + workspaceId: 'workspace-default', + }, + { + sessionId: 'upload-session-1', + completionToken: 'stale-token', + }, + ), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('rejects an upload target that changed after the upload session started', async () => { + const create = jest.fn(); + const update = jest.fn(); + const findUnique = jest.fn((input: { where: Record }) => { + return Promise.resolve( + input.where.id === 'folder-1' + ? { + ...storedNode('folder-1'), + kind: 'folder', + objectKey: null, + sizeBytes: null, + } + : null, + ); + }); + const tx = { + fileNode: { + create, + findUnique, + update, + }, + }; + const repository = createRepository({ + $transaction: jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), + ), + }); + + await expect( + repository.completeUpload({ + conflictStrategy: 'overwrite', + conflictTargetNodeId: 'node-1', + fileName: 'Report.pdf', + mimeType: 'application/pdf', + objectKey: 'objects/original/v2/2026/07/test.blob', + parentNodeId: 'folder-1', + sizeBytes: 32, + spaceScope: 'workspace', + workspaceId: 'workspace-default', + }), + ).rejects.toBeInstanceOf(ConflictException); + expect(findUnique).toHaveBeenCalledWith({ where: { id: 'node-1' } }); + expect(create).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + it('does not overwrite a concurrent same-name insert without a target', async () => { + const databaseError = Object.assign(new Error('Unique constraint failed'), { + code: 'P2002', + meta: { target: 'file_nodes_scope_directory_name_key' }, + }); + const existing = { + archivedAt: null, + id: 'concurrent-node', + name: 'Report.pdf', + objectKey: 'existing-object', + ownerName: 'Workspace User', + ownerUserId: null, + parentNodeId: null, + spaceScope: 'workspace', + workspaceId: 'workspace-default', + }; + const create = jest.fn(() => Promise.reject(databaseError)); + const update = jest.fn(() => Promise.resolve(existing)); + const tx = { + fileNode: { + create, + update, + }, + }; + const repository = createRepository({ + $transaction: jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), + ), + }); + + await expect( + repository.completeUpload({ + conflictStrategy: 'overwrite', + fileName: 'Report.pdf', + objectKey: 'new-object', + sizeBytes: 32, + workspaceId: 'workspace-default', + }), + ).rejects.toBeInstanceOf(ConflictException); + expect(update).not.toHaveBeenCalled(); + }); + + it.each(['overwrite', 'version'] as const)( + 'does not rebind a deleted %s target to a concurrent same-name node', + async (conflictStrategy) => { + const concurrentNode = { + ...storedNode('node-b'), + name: 'Report.pdf', + nameKey: 'active:report.pdf', + objectKey: 'object-b', + }; + const update = jest.fn(); + const tx = { + fileNode: { + create: jest.fn(), + findUnique: jest.fn(() => Promise.resolve(null)), + update, + }, + fileVersion: { + aggregate: jest.fn(), + create: jest.fn(), + }, + }; + const repository = createRepository({ + fileNode: { + findMany: jest.fn(() => Promise.resolve([concurrentNode])), + }, + $transaction: jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), + ), + }); + + await expect( + repository.completeUpload({ + conflictStrategy, + conflictTargetNodeId: 'node-a', + conflictTargetObjectKey: 'object-a', + fileName: 'Report.pdf', + objectKey: 'new-object', + requestedFileName: 'Report.pdf', + sizeBytes: 32, + workspaceId: 'workspace-default', + }), + ).rejects.toMatchObject({ + response: { code: 'UPLOAD_CONFLICT_TARGET_CHANGED' }, + }); + expect(update).not.toHaveBeenCalled(); + expect(tx.fileVersion.create).not.toHaveBeenCalled(); + }, + ); + + it('rejects overwrite when the pinned target object changed', async () => { + const target = { + ...storedNode('node-a'), + name: 'Report.pdf', + nameKey: 'active:report.pdf', + objectKey: 'newer-object', + }; + const update = jest.fn(); + const tx = { + fileNode: { + findUnique: jest.fn(() => Promise.resolve(target)), + update, + }, + }; + const repository = createRepository({ + $transaction: jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), + ), + }); + + await expect( + repository.completeUpload({ + conflictStrategy: 'overwrite', + conflictTargetNodeId: target.id, + conflictTargetObjectKey: 'intent-object', + fileName: target.name, + objectKey: 'uploaded-object', + sizeBytes: 32, + workspaceId: target.workspaceId, + }), + ).rejects.toMatchObject({ + response: { code: 'UPLOAD_CONFLICT_TARGET_CHANGED' }, + }); + expect(update).not.toHaveBeenCalled(); + }); + + it('returns the exact object displaced by overwrite', async () => { + const target = { + ...storedNode('node-a'), + name: 'Report.pdf', + nameKey: 'active:report.pdf', + objectKey: 'intent-object', + }; + const tx = { + fileNode: { + findUnique: jest.fn(() => Promise.resolve(target)), + update: jest.fn(({ data }: { data: Record }) => + Promise.resolve({ ...target, ...data }), + ), + }, + }; + const repository = createRepository({ + $transaction: jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), + ), + }); + + const completed = await repository.completeUpload({ + conflictStrategy: 'overwrite', + conflictTargetNodeId: target.id, + conflictTargetObjectKey: target.objectKey ?? undefined, + fileName: target.name, + objectKey: 'uploaded-object', + sizeBytes: 32, + workspaceId: target.workspaceId, + }); + + expect(completed.displacedObjectKey).toBe('intent-object'); + expect(completed.node.objectKey).toBe('uploaded-object'); + }); + + it('recomputes a rename from the requested name after a name collision', async () => { + const nameConflict = Object.assign(new Error('Unique constraint failed'), { + code: 'P2002', + meta: { target: 'file_nodes_scope_directory_name_key' }, + }); + const findMany = jest + .fn() + .mockResolvedValueOnce([{ name: 'Report.pdf' }]) + .mockResolvedValueOnce([ + { name: 'Report.pdf' }, + { name: 'Report (2).pdf' }, + ]); + const create = jest + .fn< + Promise>, + [ + { + data: Record & { name: string }; + }, + ] + >() + .mockRejectedValueOnce(nameConflict) + .mockImplementationOnce(({ data }: { data: Record }) => + Promise.resolve({ + ...data, + archivedBy: null, + originalParentNodeId: null, + originalPath: null, + }), + ); + const updateMany = jest.fn(() => Promise.resolve({ count: 1 })); + const tx = { + fileNode: { + create, + findMany, + findUnique: jest.fn(() => + Promise.resolve({ + ...storedNode('folder-1'), + kind: 'folder', + objectKey: null, + sizeBytes: null, + }), + ), + }, + uploadSession: { updateMany }, + }; + const transaction = jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), + ); + const repository = createRepository({ $transaction: transaction }); + + const completed = await repository.completeUpload( + { + conflictStrategy: 'rename', + fileName: 'Report (2).pdf', + objectKey: 'uploaded-object', + parentNodeId: 'folder-1', + requestedFileName: 'Report.pdf', + sizeBytes: 32, + workspaceId: 'workspace-default', + }, + { + completionToken: 'completion-token', + sessionId: 'upload-session-1', + }, + ); + + expect(transaction).toHaveBeenCalledTimes(2); + expect(transaction.mock.calls[0]?.[1]).toEqual({ + isolationLevel: 'Serializable', + }); + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + directoryKey: 'folder-1', + parentNodeId: 'folder-1', + }) as unknown, + }), + ); + expect(create.mock.calls.map(([input]) => input.data.name)).toEqual([ + 'Report (2).pdf', + 'Report (3).pdf', + ]); + expect(completed.node.name).toBe('Report (3).pdf'); + expect(updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + fileName: 'Report (3).pdf', + nodeId: completed.node.id, + }) as unknown, + }), + ); + }); + + it('retries a serializable rename transaction conflict', async () => { + const transactionConflict = Object.assign( + new Error('write conflict or deadlock'), + { code: 'P2034' }, + ); + const tx = { + fileNode: { + findMany: jest.fn(() => Promise.resolve([])), + create: jest.fn(({ data }: { data: Record }) => + Promise.resolve({ + ...data, + archivedBy: null, + originalParentNodeId: null, + originalPath: null, + }), + ), + }, + }; + const transaction = jest + .fn() + .mockRejectedValueOnce(transactionConflict) + .mockImplementationOnce( + (operation: (client: typeof tx) => Promise) => operation(tx), + ); + const repository = createRepository({ $transaction: transaction }); + + await expect( + repository.completeUpload({ + conflictStrategy: 'rename', + fileName: 'Report.pdf', + objectKey: 'uploaded-object', + requestedFileName: 'Report.pdf', + sizeBytes: 32, + workspaceId: 'workspace-default', + }), + ).resolves.toMatchObject({ node: { name: 'Report.pdf' } }); + expect(transaction).toHaveBeenCalledTimes(2); + }); + + it('retries a version-number collision using the latest target object', async () => { + const versionConflict = Object.assign( + new Error('Unique constraint failed'), + { + code: 'P2002', + meta: { target: ['nodeId', 'versionNumber'] }, + }, + ); + const firstTarget = { + ...storedNode('node-a'), + name: 'Report.pdf', + nameKey: 'active:report.pdf', + objectKey: 'first-object', + }; + const secondTarget = { + ...firstTarget, + objectKey: 'concurrently-uploaded-object', + }; + const findUnique = jest + .fn() + .mockResolvedValueOnce(firstTarget) + .mockResolvedValueOnce(secondTarget); + const createVersion = jest + .fn< + Promise, + [ + { + data: Record & { objectKey: string }; + }, + ] + >() + .mockRejectedValueOnce(versionConflict) + .mockResolvedValueOnce({ id: 'version-2' }); + const update = jest.fn(({ data }: { data: Record }) => + Promise.resolve({ ...secondTarget, ...data }), + ); + const tx = { + fileNode: { findUnique, update }, + fileVersion: { + aggregate: jest.fn(() => + Promise.resolve({ _max: { versionNumber: 1 } }), + ), + create: createVersion, + }, + }; + const transaction = jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), + ); + const repository = createRepository({ $transaction: transaction }); + + const completed = await repository.completeUpload({ + conflictStrategy: 'version', + conflictTargetNodeId: firstTarget.id, + conflictTargetObjectKey: firstTarget.objectKey ?? undefined, + fileName: firstTarget.name, + objectKey: 'final-upload-object', + requestedFileName: firstTarget.name, + sizeBytes: 32, + workspaceId: firstTarget.workspaceId, + }); + + expect(transaction).toHaveBeenCalledTimes(2); + expect( + createVersion.mock.calls.map(([input]) => input.data.objectKey), + ).toEqual(['first-object', 'concurrently-uploaded-object']); + expect(update).toHaveBeenCalledTimes(1); + expect(completed.node.objectKey).toBe('final-upload-object'); + expect(completed.displacedObjectKey).toBeNull(); + }); + + it('maps an exhausted version-number collision to a stable conflict', async () => { + const versionConflict = Object.assign( + new Error('Unique constraint failed'), + { + code: 'P2002', + meta: { target: ['nodeId', 'versionNumber'] }, + }, + ); + const target = { + ...storedNode('node-a'), + name: 'Report.pdf', + nameKey: 'active:report.pdf', + objectKey: 'current-object', + }; + const createVersion = jest.fn(() => Promise.reject(versionConflict)); + const tx = { + fileNode: { + findUnique: jest.fn(() => Promise.resolve(target)), + update: jest.fn(), + }, + fileVersion: { + aggregate: jest.fn(() => + Promise.resolve({ _max: { versionNumber: 1 } }), + ), + create: createVersion, + }, + }; + const repository = createRepository({ + $transaction: jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), + ), + }); + + await expect( + repository.completeUpload({ + conflictStrategy: 'version', + conflictTargetNodeId: target.id, + fileName: target.name, + objectKey: 'final-upload-object', + requestedFileName: target.name, + sizeBytes: 32, + workspaceId: target.workspaceId, + }), + ).rejects.toMatchObject({ + response: { + code: 'UPLOAD_VERSION_CONFLICT', + message: 'File version changed while the upload was being completed', + }, + }); + expect(createVersion).toHaveBeenCalledTimes( + serializableTransactionMaxAttempts, + ); + expect(tx.fileNode.update).not.toHaveBeenCalled(); + }); + + it('maps only skip name races to the structured skipped result', async () => { + const nameConflict = Object.assign(new Error('Unique constraint failed'), { + code: 'P2002', + meta: { target: 'file_nodes_scope_directory_name_key' }, + }); + const tx = { + fileNode: { + create: jest.fn(() => Promise.reject(nameConflict)), + }, + }; + const repository = createRepository({ + $transaction: jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), + ), + }); + + await expect( + repository.completeUpload({ + conflictStrategy: 'skip', + fileName: 'Report.pdf', + objectKey: 'uploaded-object', + sizeBytes: 32, + workspaceId: 'workspace-default', + }), + ).rejects.toMatchObject({ + response: { + code: 'UPLOAD_CONFLICT_SKIPPED', + message: 'File upload skipped because a same-name item exists', + }, + }); + }); +}); diff --git a/backend/src/modules/files/file-nodes.service.spec.ts b/backend/src/modules/files/file-nodes.service.spec.ts index 1b4c23a..2dde9d3 100644 --- a/backend/src/modules/files/file-nodes.service.spec.ts +++ b/backend/src/modules/files/file-nodes.service.spec.ts @@ -10,9 +10,11 @@ describe('FileNodesService facade', () => { let nodes: FileNodesServiceTestHarness['nodes']; let repository: FileNodesServiceTestHarness['repository']; let service: FileNodesServiceTestHarness['service']; + let storage: FileNodesServiceTestHarness['storage']; beforeEach(() => { - ({ nodes, repository, service } = createFileNodesServiceTestHarness()); + ({ nodes, repository, service, storage } = + createFileNodesServiceTestHarness()); }); it('lists file nodes from the repository', async () => { @@ -21,6 +23,42 @@ describe('FileNodesService facade', () => { expect(nodes.some((node) => node.id === 'roadmap')).toBe(true); }); + it('does not make an archived listing wait for automatic trash cleanup', async () => { + let finishCleanup!: (value: { + nodes: never[]; + objectKeysToDelete: never[]; + versions: never[]; + }) => void; + const cleanupPending = new Promise<{ + nodes: never[]; + objectKeysToDelete: never[]; + versions: never[]; + }>((resolve) => { + finishCleanup = resolve; + }); + const cleanupTrash = jest.fn(() => cleanupPending); + Object.assign(repository, { + cleanupTrash, + getPolicy: jest.fn(() => + Promise.resolve({ + trashRetentionDays: 30, + versionRetentionCount: 20, + versionRetentionDays: 180, + updatedAt: new Date(0).toISOString(), + }), + ), + }); + + await expect( + service.listFileNodes('workspace-default', null, { state: 'archived' }), + ).resolves.toBeDefined(); + await Promise.resolve(); + + expect(cleanupTrash).toHaveBeenCalledTimes(1); + finishCleanup({ nodes: [], objectKeysToDelete: [], versions: [] }); + await new Promise((resolve) => setImmediate(resolve)); + }); + it('treats a null personal owner as an explicit list and usage filter', async () => { nodes.push( createNode({ @@ -132,4 +170,25 @@ describe('FileNodesService facade', () => { 'File is too large to edit as text', ); }); + + it('removes a newly written text object when its metadata transaction fails', async () => { + const writeObjectText = jest.fn(() => Promise.resolve()); + Object.assign(storage, { writeObjectText }); + const transactionError = new Error('concurrent file update'); + Object.assign(repository, { + replaceContentObject: jest.fn(() => Promise.reject(transactionError)), + }); + + await expect( + service.updateFileNodeContent( + 'personal-a', + { content: 'updated content' }, + { actorRole: 'member', actorUserId: 'user-a' }, + ), + ).rejects.toBe(transactionError); + + const objectKey = writeObjectText.mock.calls[0]?.[0]; + expect(objectKey).toEqual(expect.any(String)); + expect(storage.deleteObject).toHaveBeenCalledWith(objectKey); + }); }); diff --git a/backend/src/modules/files/file-nodes.service.ts b/backend/src/modules/files/file-nodes.service.ts index c6b5da1..e041c69 100644 --- a/backend/src/modules/files/file-nodes.service.ts +++ b/backend/src/modules/files/file-nodes.service.ts @@ -3,6 +3,7 @@ import { ConflictException, ForbiddenException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import type { Readable } from 'stream'; @@ -59,7 +60,9 @@ type FileAuditOptions = FileAccessOptions & { @Injectable() export class FileNodesService { + private readonly logger = new Logger(FileNodesService.name); private lastTrashCleanupAt = 0; + private trashCleanupInFlight: Promise | null = null; constructor( private readonly fileNodesRepository: FileNodesRepository, @@ -80,7 +83,7 @@ export class FileNodesService { const state = this.normalizeListState(options.state); const spaceScope = this.normalizeSpaceScope(options.spaceScope); if (state !== 'active') { - await this.cleanupExpiredTrashIfDue(); + this.cleanupExpiredTrashIfDue(); } return this.fileNodesRepository.list(workspaceId, parentNodeId, state, { ownerUserId: spaceScope === 'personal' ? options.ownerUserId : undefined, @@ -345,6 +348,7 @@ export class FileNodesService { name = this.createAvailableSiblingFileName(proposedName, siblings); } const node = await this.fileNodesRepository.copyTree(source, { + actorUserId: options.actorUserId, name, parentNodeId, }); @@ -400,14 +404,24 @@ export class FileNodesService { dto.content, node.mimeType, ); - const updated = await this.fileNodesRepository.replaceContentObject({ - id, - objectKey, - sizeBytes: Buffer.byteLength(dto.content, 'utf8'), - mimeType: node.mimeType, - uploadedBy: node.owner, - }); - if (!updated) throw new NotFoundException('File node not found'); + let updated: FileNodeResponse | null; + try { + updated = await this.fileNodesRepository.replaceContentObject({ + id, + objectKey, + sizeBytes: Buffer.byteLength(dto.content, 'utf8'), + mimeType: node.mimeType, + uploadedBy: node.owner, + actorUserId: options.actorUserId, + }); + } catch (error) { + await this.deleteStoredObjects([objectKey]); + throw error; + } + if (!updated) { + await this.deleteStoredObjects([objectKey]); + throw new NotFoundException('File node not found'); + } await this.deleteStoredObjects( await this.fileNodesRepository.pruneVersions(updated.id), ); @@ -493,10 +507,7 @@ export class FileNodesService { if (deletion.nodes.length === 0) { throw new NotFoundException('File node not found'); } - await this.deleteStoredObjects([ - ...deletion.nodes.map((node) => node.objectKey), - ...deletion.versions.map((version) => version.objectKey), - ]); + await this.deleteStoredObjects(deletion.objectKeysToDelete); const root = deletion.nodes[0]; await this.fileNodesRepository.recordAudit('file.permanently_deleted', id, { metadata: { @@ -618,10 +629,14 @@ export class FileNodesService { async listFileVersions(nodeId: string, access: FileAccessOptions = {}) { await this.requireActiveNode(nodeId, access); const versions = await this.fileNodesRepository.listVersions(nodeId); - return versions.map(({ objectKey, ...version }) => { - void objectKey; - return version; - }); + return versions.map( + ({ objectKey, checksumValue, verificationFailureCode, ...version }) => { + void objectKey; + void checksumValue; + void verificationFailureCode; + return version; + }, + ); } createVersionDownloadIntent( @@ -660,6 +675,7 @@ export class FileNodesService { nodeId, versionId, options.actor, + options.actorUserId, ); if (!node) throw new NotFoundException('File version not found'); await this.deleteStoredObjects( @@ -699,10 +715,7 @@ export class FileNodesService { Date.now() - policy.trashRetentionDays * 24 * 60 * 60 * 1000, ); const deleted = await this.fileNodesRepository.cleanupTrash(cutoff); - await this.deleteStoredObjects([ - ...deleted.nodes.map((node) => node.objectKey), - ...deleted.versions.map((version) => version.objectKey), - ]); + await this.deleteStoredObjects(deleted.objectKeysToDelete); if (options.forceAudit || deleted.nodes.length > 0) { await this.fileNodesRepository.recordAudit( 'file.trash_cleaned', @@ -724,22 +737,32 @@ export class FileNodesService { }; } - private async cleanupExpiredTrashIfDue() { + private cleanupExpiredTrashIfDue() { const now = Date.now(); - if (now - this.lastTrashCleanupAt < trashCleanupThrottleMs) return; - this.lastTrashCleanupAt = now; - try { - await this.cleanupExpiredTrash(); - } catch (error) { - this.lastTrashCleanupAt = 0; - throw error; + if ( + this.trashCleanupInFlight || + now - this.lastTrashCleanupAt < trashCleanupThrottleMs + ) { + return; } + this.lastTrashCleanupAt = now; + const cleanup = this.cleanupExpiredTrash() + .then(() => undefined) + .catch(() => { + this.lastTrashCleanupAt = 0; + this.logger.warn('Automatic trash cleanup failed; retry scheduled'); + }) + .finally(() => { + if (this.trashCleanupInFlight === cleanup) { + this.trashCleanupInFlight = null; + } + }); + this.trashCleanupInFlight = cleanup; } private async deleteStoredObjects(objectKeys: Array) { - const uniqueObjectKeys = [ - ...new Set(objectKeys.filter((key): key is string => Boolean(key))), - ]; + const uniqueObjectKeys = + await this.fileNodesRepository.filterUnreferencedObjectKeys(objectKeys); await Promise.all( uniqueObjectKeys.map((objectKey) => this.storageService.deleteObject(objectKey).catch(() => undefined), @@ -908,12 +931,19 @@ export class FileNodesService { if (parent.kind !== 'folder') { throw new BadRequestException('Parent node must be a folder'); } + const visitedParentIds = new Set([parent.id]); while (sourceNodeId && parent.parentNodeId) { if (parent.parentNodeId === sourceNodeId) { throw new BadRequestException( 'A folder cannot be moved into its child folder', ); } + if (visitedParentIds.has(parent.parentNodeId)) { + throw new BadRequestException( + 'Parent folder hierarchy contains a cycle', + ); + } + visitedParentIds.add(parent.parentNodeId); parent = await this.requireActiveNode(parent.parentNodeId, access); } } diff --git a/backend/src/modules/files/file-upload-completion-write.spec.ts b/backend/src/modules/files/file-upload-completion-write.spec.ts new file mode 100644 index 0000000..f1c6f2e --- /dev/null +++ b/backend/src/modules/files/file-upload-completion-write.spec.ts @@ -0,0 +1,50 @@ +import type { PrismaService } from '../../database/prisma.service'; +import { completeFileNodeUploadWrite } from './file-upload-completion-write'; + +describe('completeFileNodeUploadWrite hierarchy integrity', () => { + it('does not attach a completed upload to an archived parent', async () => { + const create = jest.fn(); + const tx = { + $queryRaw: jest.fn(() => Promise.resolve([{ id: 'parent' }])), + fileNode: { + create, + findUnique: jest.fn(() => + Promise.resolve({ + id: 'parent', + workspaceId: 'workspace-a', + spaceScope: 'workspace', + parentNodeId: null, + kind: 'folder', + archivedAt: new Date(1), + }), + ), + }, + }; + const prisma = { + $transaction: jest.fn( + (operation: (client: typeof tx) => Promise) => operation(tx), + ), + isSqlite: () => false, + } as unknown as PrismaService; + + await expect( + completeFileNodeUploadWrite( + prisma, + {} as never, + {} as never, + () => 'doc', + { + fileName: 'report.txt', + objectKey: 'objects/report', + parentNodeId: 'parent', + sizeBytes: 12, + workspaceId: 'workspace-a', + }, + ), + ).rejects.toMatchObject({ + response: { code: 'UPLOAD_PARENT_CHANGED' }, + }); + + expect(create).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/modules/files/file-upload-completion-write.ts b/backend/src/modules/files/file-upload-completion-write.ts index 544954e..5dbe25b 100644 --- a/backend/src/modules/files/file-upload-completion-write.ts +++ b/backend/src/modules/files/file-upload-completion-write.ts @@ -16,6 +16,12 @@ import type { UploadConflictStrategy, } from './file-nodes.dto'; import { FileNodeVersionsRepository } from './file-node-versions.repository'; +import type { StorageIntegrityTaskService } from '../storage/storage-integrity-task.service'; +import { needsFileIntegrityVerification } from './file-integrity'; +import { + lockAndValidateActiveFileNodeParent, + lockFileNodeRows, +} from './file-node-hierarchy-write'; type CompleteUploadWriteDto = CompleteUploadDto & { conflictStrategy?: UploadConflictStrategy; @@ -33,6 +39,7 @@ type CompletionClaim = { export async function completeFileNodeUploadWrite( prisma: PrismaService, versionsRepository: FileNodeVersionsRepository, + integrityTasks: StorageIntegrityTaskService, getKind: (fileName: string, mimeType?: string) => FileNodeKind, dto: CompleteUploadWriteDto, completionClaim?: CompletionClaim, @@ -58,6 +65,23 @@ export async function completeFileNodeUploadWrite( prisma.$transaction( async (tx) => { const now = new Date(); + if ( + dto.conflictTargetNodeId && + !(await lockFileNodeRows(prisma, tx, [dto.conflictTargetNodeId])) + ) { + throw createUploadConflictTargetChangedException(); + } + const validParent = await lockAndValidateActiveFileNodeParent( + prisma, + tx, + parentNodeId, + { + forbiddenAncestorId: dto.conflictTargetNodeId, + spaceScope, + workspaceId: dto.workspaceId, + }, + ); + if (!validParent) throw createUploadParentChangedException(); const existing = dto.conflictTargetNodeId ? await tx.fileNode.findUnique({ where: { id: dto.conflictTargetNodeId }, @@ -95,10 +119,26 @@ export async function completeFileNodeUploadWrite( throw createUploadConflictTargetChangedException(); } if (conflictStrategy === 'version') { - await versionsRepository.createVersionForNode(tx, existing, { - remark: 'Replaced by upload', - uploadedBy: dto.owner ?? existing.ownerName, - }); + const archivedVersion = + await versionsRepository.createVersionForNode(tx, existing, { + remark: 'Replaced by upload', + uploadedBy: dto.owner ?? existing.ownerName, + }); + if ( + archivedVersion && + needsFileIntegrityVerification(archivedVersion) + ) { + await integrityTasks.enqueueObjectVerification( + { + actorUserId: dto.ownerUserId ?? existing.ownerUserId, + nodeId: existing.id, + objectKey: archivedVersion.objectKey, + versionId: archivedVersion.id, + workspaceId: existing.workspaceId, + }, + tx, + ); + } } else { displacedObjectKey = existing.objectKey; } @@ -119,6 +159,13 @@ export async function completeFileNodeUploadWrite( mimeType: dto.mimeType ?? 'application/octet-stream', name: requestedFileName, objectKey: dto.objectKey, + checksumAlgorithm: null, + checksumValue: null, + integrityStatus: 'pending', + integrityAcknowledgedAt: null, + integrityAcknowledgedBy: null, + lastVerifiedAt: null, + verificationFailureCode: null, ownerUserId, ownerName: dto.owner ?? existing.ownerName, sizeBytes: BigInt(dto.sizeBytes), @@ -159,6 +206,13 @@ export async function completeFileNodeUploadWrite( mimeType: dto.mimeType ?? 'application/octet-stream', sizeBytes: BigInt(dto.sizeBytes), objectKey: dto.objectKey, + checksumAlgorithm: null, + checksumValue: null, + integrityStatus: 'pending', + integrityAcknowledgedAt: null, + integrityAcknowledgedBy: null, + lastVerifiedAt: null, + verificationFailureCode: null, ownerUserId, ownerName: dto.owner ?? '', starred: false, @@ -190,6 +244,15 @@ export async function completeFileNodeUploadWrite( ); } } + await integrityTasks.enqueueObjectVerification( + { + actorUserId: dto.ownerUserId ?? fileNode.ownerUserId, + nodeId: fileNode.id, + objectKey: dto.objectKey, + workspaceId: fileNode.workspaceId, + }, + tx, + ); return { displacedObjectKey, fileNode }; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, @@ -341,6 +404,13 @@ function createUploadConflictTargetChangedException() { }); } +function createUploadParentChangedException() { + return new ConflictException({ + code: 'UPLOAD_PARENT_CHANGED', + message: 'Upload parent folder changed while the upload was running', + }); +} + function createUploadConflictSkippedException() { return new ConflictException({ code: 'UPLOAD_CONFLICT_SKIPPED', diff --git a/backend/src/modules/files/file-upload-test-fixtures.helper.ts b/backend/src/modules/files/file-upload-test-fixtures.helper.ts index 9ba916a..eedb7db 100644 --- a/backend/src/modules/files/file-upload-test-fixtures.helper.ts +++ b/backend/src/modules/files/file-upload-test-fixtures.helper.ts @@ -14,29 +14,44 @@ export function createNode( input: Omit< FileNodeResponse, | 'archivedBy' + | 'checksumAlgorithm' + | 'checksumValue' + | 'integrityStatus' + | 'lastVerifiedAt' | 'originalParentNodeId' | 'originalPath' | 'ownerUserId' | 'previewCapability' | 'spaceScope' + | 'verificationFailureCode' > & Partial< Pick< FileNodeResponse, | 'archivedBy' + | 'checksumAlgorithm' + | 'checksumValue' + | 'integrityStatus' + | 'lastVerifiedAt' | 'originalParentNodeId' | 'originalPath' | 'ownerUserId' | 'spaceScope' + | 'verificationFailureCode' > >, ): FileNodeResponse { const node = { archivedBy: null, + checksumAlgorithm: null, + checksumValue: null, + integrityStatus: 'unknown' as const, + lastVerifiedAt: null, originalParentNodeId: null, originalPath: null, ownerUserId: null, spaceScope: 'workspace' as FileNodeSpaceScope, + verificationFailureCode: null, ...input, }; return { diff --git a/backend/src/modules/files/file-upload-test-repository.helper.ts b/backend/src/modules/files/file-upload-test-repository.helper.ts index 12d7c3c..7bc4575 100644 --- a/backend/src/modules/files/file-upload-test-repository.helper.ts +++ b/backend/src/modules/files/file-upload-test-repository.helper.ts @@ -39,6 +39,7 @@ export type FileNodesRepositoryMocks = { commitDownloadIntent: jest.Mock; completeUpload: jest.Mock; failDownloadIntent: jest.Mock; + filterUnreferencedObjectKeys: jest.Mock; pruneVersions: jest.Mock; recordAudit: jest.Mock; }; @@ -159,6 +160,11 @@ export function createFileNodesRepositoryMock(input: { objectKey: 'uploads/workspace-default/root/seed-roadmap-v1.docx', sizeBytes: 1024, mimeType: docxMimeType, + checksumAlgorithm: 'sha256', + checksumValue: 'a'.repeat(64), + integrityStatus: 'verified', + lastVerifiedAt: new Date(1).toISOString(), + verificationFailureCode: null, uploadedBy: 'Workspace User', remark: 'Initial version', createdAt: new Date(0).toISOString(), @@ -175,6 +181,11 @@ export function createFileNodesRepositoryMock(input: { objectKey: 'uploads/workspace-default/root/seed-roadmap-v1.docx', sizeBytes: 1024, mimeType: docxMimeType, + checksumAlgorithm: 'sha256', + checksumValue: 'a'.repeat(64), + integrityStatus: 'verified', + lastVerifiedAt: new Date(1).toISOString(), + verificationFailureCode: null, uploadedBy: 'Workspace User', remark: 'Initial version', createdAt: new Date(0).toISOString(), @@ -530,6 +541,15 @@ export function createFileNodesRepositoryMock(input: { audits.set(action, (audits.get(action) ?? 0) + 1); return Promise.resolve(); }), + filterUnreferencedObjectKeys: jest.fn((objectKeys: Array) => + Promise.resolve( + [ + ...new Set(objectKeys.filter((key): key is string => Boolean(key))), + ].filter( + (objectKey) => !nodes.some((node) => node.objectKey === objectKey), + ), + ), + ), pruneVersions: jest.fn(() => Promise.resolve([])), countAuditEvents: jest.fn((action: string) => Promise.resolve(audits.get(action) ?? 0), diff --git a/backend/src/modules/files/file-upload.service.ts b/backend/src/modules/files/file-upload.service.ts index b311561..1c54607 100644 --- a/backend/src/modules/files/file-upload.service.ts +++ b/backend/src/modules/files/file-upload.service.ts @@ -678,9 +678,8 @@ export class FileUploadService { } private async deleteStoredObjects(objectKeys: Array) { - const uniqueObjectKeys = [ - ...new Set(objectKeys.filter((key): key is string => Boolean(key))), - ]; + const uniqueObjectKeys = + await this.fileNodesRepository.filterUnreferencedObjectKeys(objectKeys); await Promise.all( uniqueObjectKeys.map((objectKey) => this.storageService.deleteObject(objectKey).catch(() => undefined), diff --git a/backend/src/modules/logs/audit-events.ts b/backend/src/modules/logs/audit-events.ts index 825244f..59ffe16 100644 --- a/backend/src/modules/logs/audit-events.ts +++ b/backend/src/modules/logs/audit-events.ts @@ -145,6 +145,13 @@ export const auditedActivityActions = [ 'transfer.expired', 'transfer.deleted', 'system.auth_policy_updated', + 'system.storage_integrity_task_started', + 'system.storage_integrity_task_completed', + 'system.storage_integrity_task_failed', + 'system.storage_integrity_mismatch_detected', + 'system.storage_integrity_failure_detected', + 'system.storage_integrity_result_acknowledged', + 'system.storage_integrity_retry_started', ] as const; export const auditedActivityActionSet = new Set(auditedActivityActions); diff --git a/backend/src/modules/shares/shares.service.spec-harness.ts b/backend/src/modules/shares/shares.service.spec-harness.ts index f403ff0..c0e1362 100644 --- a/backend/src/modules/shares/shares.service.spec-harness.ts +++ b/backend/src/modules/shares/shares.service.spec-harness.ts @@ -716,6 +716,11 @@ function fixtureNode( 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', sizeBytes: 284 * 1024, objectKey: 'seed/workspace-default/roadmap.docx', + checksumAlgorithm: null, + checksumValue: null, + integrityStatus: 'unknown', + lastVerifiedAt: null, + verificationFailureCode: null, owner: 'Mina', ownerUserId: null, spaceScope: 'workspace', diff --git a/backend/src/modules/storage/storage-integrity-acknowledgement.repository.spec.ts b/backend/src/modules/storage/storage-integrity-acknowledgement.repository.spec.ts new file mode 100644 index 0000000..9966f37 --- /dev/null +++ b/backend/src/modules/storage/storage-integrity-acknowledgement.repository.spec.ts @@ -0,0 +1,247 @@ +import type { PrismaService } from '../../database/prisma.service'; +import { StorageIntegrityAcknowledgementRepository } from './storage-integrity-acknowledgement.repository'; + +const checkedAt = new Date('2026-08-14T00:00:00.000Z'); + +type AuditWrite = { + data: { + action: string; + metadata: Record; + nodeId: string | null; + workspaceId: string | null; + }; +}; + +function result(overrides: Record = {}) { + return { + acknowledgedAt: null, + acknowledgedBy: null, + actualHash: 'b'.repeat(64), + attempts: 1, + bytesRead: 12n, + checkedAt, + errorCode: 'checksum-mismatch', + errorMessage: 'sensitive storage detail', + expectedHash: 'a'.repeat(64), + expectedSizeBytes: 12n, + id: 'result-1', + nodeId: 'node-1', + objectKey: 'private/secret-object', + sizeBytes: 12n, + sourceResultId: null, + status: 'mismatch', + targetKey: 'node:node-1', + taskId: 'task-1', + versionId: null, + workspaceId: 'workspace-a', + ...overrides, + }; +} + +function setup(initial = result()) { + const acknowledgedAt = new Date('2026-08-14T01:00:00.000Z'); + const acknowledged = result({ + ...initial, + acknowledgedAt, + acknowledgedBy: 'admin-1', + }); + const findUnique = jest + .fn() + .mockResolvedValueOnce(initial) + .mockResolvedValue(acknowledged); + const transaction = { + auditEvent: { + create: jest.fn((input: AuditWrite) => { + void input; + return Promise.resolve({}); + }), + }, + blobIntegrityResult: { + findUnique, + updateMany: jest.fn(() => Promise.resolve({ count: 1 })), + }, + fileNode: { + updateMany: jest.fn(() => Promise.resolve({ count: 1 })), + }, + fileVersion: { + findFirst: jest.fn(), + updateMany: jest.fn(() => Promise.resolve({ count: 1 })), + }, + }; + const repository = new StorageIntegrityAcknowledgementRepository({ + $transaction: (callback: (tx: typeof transaction) => unknown) => + callback(transaction), + } as unknown as PrismaService); + return { acknowledgedAt, repository, transaction }; +} + +describe('StorageIntegrityAcknowledgementRepository', () => { + it('acknowledges a current node finding and records only safe audit metadata', async () => { + const { acknowledgedAt, repository, transaction } = setup(); + + await expect( + repository.acknowledgeResult('result-1', 'admin-1'), + ).resolves.toMatchObject({ + kind: 'acknowledged', + result: { + acknowledgedAt: acknowledgedAt.toISOString(), + acknowledgedBy: 'admin-1', + id: 'result-1', + }, + }); + expect(transaction.blobIntegrityResult.updateMany).toHaveBeenCalledWith({ + data: { + acknowledgedAt: expect.any(Date) as unknown, + acknowledgedBy: 'admin-1', + }, + where: { + acknowledgedAt: null, + checkedAt, + id: 'result-1', + nodeId: 'node-1', + objectKey: 'private/secret-object', + status: 'mismatch', + versionId: null, + }, + }); + + expect(transaction.fileNode.updateMany).toHaveBeenCalledWith({ + data: { + integrityAcknowledgedAt: expect.any(Date) as unknown, + integrityAcknowledgedBy: 'admin-1', + }, + where: { + id: 'node-1', + integrityStatus: 'mismatch', + lastVerifiedAt: checkedAt, + objectKey: 'private/secret-object', + workspaceId: 'workspace-a', + }, + }); + expect(transaction.fileVersion.updateMany).not.toHaveBeenCalled(); + expect(transaction.auditEvent.create).toHaveBeenCalledTimes(1); + const audit = transaction.auditEvent.create.mock.calls[0]?.[0]?.data; + expect(audit).toMatchObject({ + action: 'system.storage_integrity_result_acknowledged', + nodeId: 'node-1', + workspaceId: 'workspace-a', + metadata: { + actorUserId: 'admin-1', + nodeId: 'node-1', + resultId: 'result-1', + taskId: 'task-1', + status: 'mismatch', + workspaceId: 'workspace-a', + }, + }); + const payload = JSON.stringify(audit); + expect(payload).not.toContain('private/secret-object'); + expect(payload).not.toContain('a'.repeat(64)); + expect(payload).not.toContain('b'.repeat(64)); + expect(payload).not.toContain('sensitive storage detail'); + }); + + it('acknowledges only the current matching version finding', async () => { + const initial = result({ + status: 'failed', + targetKey: 'version:version-1', + versionId: 'version-1', + }); + const { repository, transaction } = setup(initial); + + await repository.acknowledgeResult('result-1', 'admin-1'); + + expect(transaction.fileVersion.updateMany).toHaveBeenCalledWith({ + data: { + integrityAcknowledgedAt: expect.any(Date) as unknown, + integrityAcknowledgedBy: 'admin-1', + }, + where: { + id: 'version-1', + integrityStatus: 'failed', + lastVerifiedAt: checkedAt, + nodeId: 'node-1', + objectKey: 'private/secret-object', + node: { workspaceId: 'workspace-a' }, + }, + }); + expect(transaction.fileNode.updateMany).not.toHaveBeenCalled(); + expect( + transaction.auditEvent.create.mock.calls[0]?.[0]?.data.metadata, + ).toMatchObject({ + nodeId: 'node-1', + resultId: 'result-1', + status: 'failed', + taskId: 'task-1', + versionId: 'version-1', + workspaceId: 'workspace-a', + }); + }); + + it('acknowledges a node finding after its object moved into a version', async () => { + const { repository, transaction } = setup(); + transaction.fileNode.updateMany.mockResolvedValueOnce({ count: 0 }); + transaction.fileVersion.findFirst.mockResolvedValueOnce({ + id: 'version-after-replacement', + }); + + await repository.acknowledgeResult('result-1', 'admin-1'); + + expect(transaction.fileVersion.findFirst).toHaveBeenCalledWith({ + orderBy: { createdAt: 'asc' }, + select: { id: true }, + where: { + integrityStatus: 'mismatch', + lastVerifiedAt: checkedAt, + nodeId: 'node-1', + objectKey: 'private/secret-object', + node: { workspaceId: 'workspace-a' }, + }, + }); + expect(transaction.fileVersion.updateMany).toHaveBeenCalledWith({ + data: { + integrityAcknowledgedAt: expect.any(Date) as unknown, + integrityAcknowledgedBy: 'admin-1', + }, + where: { + id: 'version-after-replacement', + integrityStatus: 'mismatch', + lastVerifiedAt: checkedAt, + nodeId: 'node-1', + objectKey: 'private/secret-object', + }, + }); + }); + + it('returns an existing acknowledgement without writing or auditing again', async () => { + const alreadyAcknowledged = result({ + acknowledgedAt: checkedAt, + acknowledgedBy: 'admin-original', + }); + const { repository, transaction } = setup(alreadyAcknowledged); + + await expect( + repository.acknowledgeResult('result-1', 'admin-1'), + ).resolves.toMatchObject({ + kind: 'already-acknowledged', + result: { + acknowledgedAt: checkedAt.toISOString(), + acknowledgedBy: 'admin-original', + }, + }); + expect(transaction.blobIntegrityResult.updateMany).not.toHaveBeenCalled(); + expect(transaction.fileNode.updateMany).not.toHaveBeenCalled(); + expect(transaction.auditEvent.create).not.toHaveBeenCalled(); + }); + + it('rejects matched results inside the acknowledgement transaction', async () => { + const { repository, transaction } = setup(result({ status: 'matched' })); + + await expect( + repository.acknowledgeResult('result-1', 'admin-1'), + ).resolves.toEqual({ kind: 'invalid-status' }); + expect(transaction.blobIntegrityResult.updateMany).not.toHaveBeenCalled(); + expect(transaction.fileNode.updateMany).not.toHaveBeenCalled(); + expect(transaction.auditEvent.create).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/modules/storage/storage-integrity-acknowledgement.repository.ts b/backend/src/modules/storage/storage-integrity-acknowledgement.repository.ts new file mode 100644 index 0000000..355ba74 --- /dev/null +++ b/backend/src/modules/storage/storage-integrity-acknowledgement.repository.ts @@ -0,0 +1,152 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../database/prisma.service'; +import type { Prisma } from '../../generated/prisma/client'; +import { recordStorageIntegrityAudit } from './storage-integrity-audit'; +import { mapStorageIntegrityResult } from './storage-integrity-result'; + +type AcknowledgementTransaction = Pick< + Prisma.TransactionClient, + 'auditEvent' | 'blobIntegrityResult' | 'fileNode' | 'fileVersion' +>; + +export type StorageIntegrityAcknowledgementOutcome = + | { + kind: 'acknowledged' | 'already-acknowledged'; + result: ReturnType; + } + | { kind: 'invalid-status' }; + +@Injectable() +export class StorageIntegrityAcknowledgementRepository { + constructor(private readonly prisma: PrismaService) {} + + acknowledgeResult( + resultId: string, + actorUserId: string, + ): Promise { + return this.prisma.$transaction((tx) => + this.acknowledgeInTransaction(tx, resultId, actorUserId), + ); + } + + private async acknowledgeInTransaction( + tx: AcknowledgementTransaction, + resultId: string, + actorUserId: string, + ): Promise { + const finding = await tx.blobIntegrityResult.findUnique({ + where: { id: resultId }, + }); + if (!finding) return null; + if (finding.acknowledgedAt) { + return { + kind: 'already-acknowledged', + result: mapStorageIntegrityResult(finding), + }; + } + if (finding.status !== 'mismatch' && finding.status !== 'failed') { + return { kind: 'invalid-status' }; + } + + const acknowledgedAt = new Date(); + const claimed = await tx.blobIntegrityResult.updateMany({ + data: { acknowledgedAt, acknowledgedBy: actorUserId }, + where: { + acknowledgedAt: null, + checkedAt: finding.checkedAt, + id: finding.id, + nodeId: finding.nodeId, + objectKey: finding.objectKey, + status: finding.status, + versionId: finding.versionId, + }, + }); + if (claimed.count !== 1) { + const concurrent = await tx.blobIntegrityResult.findUnique({ + where: { id: finding.id }, + }); + if (!concurrent) return null; + if (concurrent.acknowledgedAt) { + return { + kind: 'already-acknowledged', + result: mapStorageIntegrityResult(concurrent), + }; + } + return { kind: 'invalid-status' }; + } + + const acknowledgement = { + integrityAcknowledgedAt: acknowledgedAt, + integrityAcknowledgedBy: actorUserId, + }; + if (finding.versionId) { + await tx.fileVersion.updateMany({ + data: acknowledgement, + where: { + id: finding.versionId, + integrityStatus: finding.status, + lastVerifiedAt: finding.checkedAt, + nodeId: finding.nodeId ?? undefined, + objectKey: finding.objectKey, + node: { workspaceId: finding.workspaceId }, + }, + }); + } else if (finding.nodeId) { + const node = await tx.fileNode.updateMany({ + data: acknowledgement, + where: { + id: finding.nodeId, + integrityStatus: finding.status, + lastVerifiedAt: finding.checkedAt, + objectKey: finding.objectKey, + workspaceId: finding.workspaceId, + }, + }); + if (node.count === 0) { + const movedVersion = await tx.fileVersion.findFirst({ + orderBy: { createdAt: 'asc' }, + select: { id: true }, + where: { + integrityStatus: finding.status, + lastVerifiedAt: finding.checkedAt, + nodeId: finding.nodeId, + objectKey: finding.objectKey, + node: { workspaceId: finding.workspaceId }, + }, + }); + if (movedVersion) { + await tx.fileVersion.updateMany({ + data: acknowledgement, + where: { + id: movedVersion.id, + integrityStatus: finding.status, + lastVerifiedAt: finding.checkedAt, + nodeId: finding.nodeId, + objectKey: finding.objectKey, + }, + }); + } + } + } + + const updated = await tx.blobIntegrityResult.findUnique({ + where: { id: finding.id }, + }); + if (!updated) throw new Error('Acknowledged integrity result disappeared'); + await recordStorageIntegrityAudit(tx, { + action: 'system.storage_integrity_result_acknowledged', + actorUserId, + nodeId: finding.nodeId, + result: 'success', + resultId: finding.id, + status: finding.status, + taskId: finding.taskId, + versionId: finding.versionId, + workspaceId: finding.workspaceId, + }); + return { + kind: 'acknowledged', + result: mapStorageIntegrityResult(updated), + }; + } +} diff --git a/backend/src/modules/storage/storage-integrity-acknowledgement.service.spec.ts b/backend/src/modules/storage/storage-integrity-acknowledgement.service.spec.ts new file mode 100644 index 0000000..0926a34 --- /dev/null +++ b/backend/src/modules/storage/storage-integrity-acknowledgement.service.spec.ts @@ -0,0 +1,38 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import type { StorageIntegrityAcknowledgementRepository } from './storage-integrity-acknowledgement.repository'; +import { StorageIntegrityAcknowledgementService } from './storage-integrity-acknowledgement.service'; + +describe('StorageIntegrityAcknowledgementService', () => { + function setup(outcome: unknown) { + const acknowledgeResult = jest.fn(() => Promise.resolve(outcome)); + const service = new StorageIntegrityAcknowledgementService({ + acknowledgeResult, + } as unknown as StorageIntegrityAcknowledgementRepository); + return { acknowledgeResult, service }; + } + + it('normalizes ids and returns the acknowledged result', async () => { + const outcome = { + kind: 'acknowledged', + result: { id: 'result-1' }, + }; + const { acknowledgeResult, service } = setup(outcome); + + await expect( + service.acknowledgeResult(' result-1 ', ' admin-1 '), + ).resolves.toEqual({ id: 'result-1' }); + expect(acknowledgeResult).toHaveBeenCalledWith('result-1', 'admin-1'); + }); + + it('fails closed for missing and non-actionable results', async () => { + await expect( + setup(null).service.acknowledgeResult('missing', 'admin-1'), + ).rejects.toBeInstanceOf(NotFoundException); + await expect( + setup({ kind: 'invalid-status' }).service.acknowledgeResult( + 'matched', + 'admin-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/backend/src/modules/storage/storage-integrity-acknowledgement.service.ts b/backend/src/modules/storage/storage-integrity-acknowledgement.service.ts new file mode 100644 index 0000000..bbb0001 --- /dev/null +++ b/backend/src/modules/storage/storage-integrity-acknowledgement.service.ts @@ -0,0 +1,25 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { StorageIntegrityAcknowledgementRepository } from './storage-integrity-acknowledgement.repository'; + +@Injectable() +export class StorageIntegrityAcknowledgementService { + constructor( + private readonly repository: StorageIntegrityAcknowledgementRepository, + ) {} + + async acknowledgeResult(resultId: string, actorUserId: string) { + const outcome = await this.repository.acknowledgeResult( + resultId.trim(), + actorUserId.trim(), + ); + if (!outcome) throw new NotFoundException('完整性巡检结果不存在'); + if (outcome.kind === 'invalid-status') { + throw new BadRequestException('只有异常或失败的完整性结果可以确认'); + } + return outcome.result; + } +} diff --git a/backend/src/modules/storage/storage-integrity-audit.ts b/backend/src/modules/storage/storage-integrity-audit.ts new file mode 100644 index 0000000..eafc7bb --- /dev/null +++ b/backend/src/modules/storage/storage-integrity-audit.ts @@ -0,0 +1,73 @@ +import type { Prisma } from '../../generated/prisma/client'; +import { createAuditEvent } from '../logs/audit-events'; + +export type StorageIntegrityAuditAction = + | 'system.storage_integrity_task_started' + | 'system.storage_integrity_task_completed' + | 'system.storage_integrity_task_failed' + | 'system.storage_integrity_mismatch_detected' + | 'system.storage_integrity_failure_detected' + | 'system.storage_integrity_result_acknowledged' + | 'system.storage_integrity_retry_started'; + +export async function recordStorageIntegrityAudit( + client: Pick, + input: { + action: StorageIntegrityAuditAction; + actorUserId?: string | null; + errorCode?: string | null; + status?: 'mismatch' | 'failed'; + mode?: 'backfill' | 'verify'; + nodeId?: string | null; + progress?: { + bytesRead: number; + failed: number; + matched: number; + mismatches: number; + processed: number; + total: number; + }; + result?: 'failed' | 'success'; + resultId?: string | null; + scope?: 'all' | 'workspace'; + taskId: string; + versionId?: string | null; + workspaceId?: string | null; + }, +) { + const event = createAuditEvent({ + action: input.action, + actor: input.actorUserId ? 'account' : 'system', + metadata: { + ...(input.actorUserId ? { actorUserId: input.actorUserId } : {}), + ...(input.errorCode ? { errorCode: input.errorCode } : {}), + ...(input.status ? { status: input.status } : {}), + ...(input.mode ? { mode: input.mode } : {}), + ...(input.nodeId ? { nodeId: input.nodeId } : {}), + ...(input.progress ? input.progress : {}), + ...(input.result ? { result: input.result } : {}), + ...(input.resultId ? { resultId: input.resultId } : {}), + ...(input.scope ? { scope: input.scope } : {}), + source: 'storage-integrity-task', + taskId: input.taskId, + ...(input.versionId ? { versionId: input.versionId } : {}), + ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), + }, + nodeId: input.nodeId ?? null, + target: input.taskId, + workspaceId: input.workspaceId ?? null, + }); + await client.auditEvent.create({ + data: { + action: event.action, + actor: event.actor, + createdAt: new Date(event.createdAt), + id: event.id, + metadata: event.metadata as Prisma.InputJsonValue, + nodeId: event.nodeId, + shareToken: null, + target: event.target, + workspaceId: event.workspaceId, + }, + }); +} diff --git a/backend/src/modules/storage/storage-integrity-result.ts b/backend/src/modules/storage/storage-integrity-result.ts new file mode 100644 index 0000000..5481d9e --- /dev/null +++ b/backend/src/modules/storage/storage-integrity-result.ts @@ -0,0 +1,36 @@ +import type { BlobIntegrityResult } from '../../generated/prisma/client'; +import type { + StorageIntegrityResultResponse, + StorageIntegrityResultStatus, +} from './storage-integrity-task.dto'; + +export function mapStorageIntegrityResult( + row: BlobIntegrityResult, +): StorageIntegrityResultResponse { + return { + acknowledgedAt: row.acknowledgedAt?.toISOString() ?? null, + acknowledgedBy: row.acknowledgedBy ?? null, + actualHash: row.actualHash, + actualSizeBytes: row.sizeBytes === null ? null : Number(row.sizeBytes), + attempts: row.attempts, + checkedAt: row.checkedAt.toISOString(), + errorCode: row.errorCode, + errorMessage: row.errorMessage, + expectedHash: row.expectedHash, + expectedSizeBytes: + row.expectedSizeBytes === null ? null : Number(row.expectedSizeBytes), + id: row.id, + nodeId: row.nodeId, + objectKey: row.objectKey, + status: normalizeStorageIntegrityResultStatus(row.status), + taskId: row.taskId, + versionId: row.versionId, + workspaceId: row.workspaceId, + }; +} + +function normalizeStorageIntegrityResultStatus( + status: string, +): StorageIntegrityResultStatus { + return status === 'matched' || status === 'mismatch' ? status : 'failed'; +} diff --git a/backend/src/modules/storage/storage-integrity-target.repository.ts b/backend/src/modules/storage/storage-integrity-target.repository.ts new file mode 100644 index 0000000..d93a5d0 --- /dev/null +++ b/backend/src/modules/storage/storage-integrity-target.repository.ts @@ -0,0 +1,387 @@ +import type { PrismaService } from '../../database/prisma.service'; +import type { Prisma } from '../../generated/prisma/client'; +import type { StorageIntegrityResultStatus } from './storage-integrity-task.dto'; +import type { + IntegrityTargetReference, + StorageIntegrityExecutionTask, +} from './storage-integrity-task.types'; + +type TransactionClient = Pick< + Prisma.TransactionClient, + | 'auditEvent' + | 'blobIntegrityResult' + | 'blobIntegrityTask' + | 'fileNode' + | 'fileVersion' +>; + +export class StorageIntegrityTargetRepository { + constructor(private readonly prisma: PrismaService) {} + + async countTargets(task: StorageIntegrityExecutionTask) { + if (task.retryOfTaskId && task.retryResultIds.length > 0) { + return task.retryResultIds.length; + } + if (this.pinnedTargetReference(task)) return 1; + if (task.target?.versionId) { + return this.prisma.fileVersion.count({ + where: this.versionWhere(task), + }); + } + const nodes = await this.prisma.fileNode.count({ + where: this.nodeWhere(task), + }); + if (task.target?.nodeId && nodes > 0) return nodes; + const versions = await this.prisma.fileVersion.count({ + where: this.versionWhere(task), + }); + return nodes + versions; + } + + async listTargetBatch( + task: StorageIntegrityExecutionTask, + cursor: { kind?: 'node' | 'retry' | 'version'; id?: string }, + ): Promise { + if (task.retryOfTaskId && task.retryResultIds.length > 0) { + return this.listRetryTargetBatch(task, cursor); + } + const remaining = task.config.batchSize; + const results: IntegrityTargetReference[] = []; + if (cursor.kind !== 'version' && !task.target?.versionId) { + const nodes = await this.prisma.fileNode.findMany({ + orderBy: { id: 'asc' }, + take: remaining, + where: { + ...this.nodeWhere(task), + ...(cursor.id ? { id: { gt: cursor.id } } : {}), + }, + select: { + checksumAlgorithm: true, + checksumValue: true, + id: true, + objectKey: true, + sizeBytes: true, + workspaceId: true, + }, + }); + results.push( + ...nodes.map((row) => ({ + expectedChecksumAlgorithm: row.checksumAlgorithm, + expectedHash: row.checksumValue, + expectedSizeBytes: Number(row.sizeBytes ?? 0n), + nodeId: row.id, + objectKey: row.objectKey ?? '', + targetKey: `node:${row.id}`, + versionId: null, + workspaceId: row.workspaceId, + })), + ); + if ( + nodes.length === remaining || + (task.target?.nodeId && nodes.length > 0) + ) { + return results; + } + } + const versionCursor = cursor.kind === 'version' ? cursor.id : undefined; + const versions = await this.prisma.fileVersion.findMany({ + orderBy: { id: 'asc' }, + take: remaining - results.length, + where: { + ...this.versionWhere(task), + ...(versionCursor ? { id: { gt: versionCursor } } : {}), + }, + select: { + checksumAlgorithm: true, + checksumValue: true, + id: true, + nodeId: true, + objectKey: true, + sizeBytes: true, + node: { select: { workspaceId: true } }, + }, + }); + results.push( + ...versions.map((row) => ({ + expectedChecksumAlgorithm: row.checksumAlgorithm, + expectedHash: row.checksumValue, + expectedSizeBytes: Number(row.sizeBytes), + nodeId: row.nodeId, + objectKey: row.objectKey, + targetKey: `version:${row.id}`, + versionId: row.id, + workspaceId: row.node.workspaceId, + })), + ); + const pinned = this.pinnedTargetReference(task); + if (results.length === 0 && !cursor.id && pinned) return [pinned]; + return results; + } + + private nodeWhere(task: StorageIntegrityExecutionTask) { + return { + kind: { not: 'folder' }, + objectKey: { not: null }, + sizeBytes: { not: null }, + createdAt: { lte: task.snapshotAt }, + ...(task.workspaceId ? { workspaceId: task.workspaceId } : {}), + ...(task.target?.nodeId ? { id: task.target.nodeId } : {}), + ...(task.targetObjectKey ? { objectKey: task.targetObjectKey } : {}), + ...(task.targetObjectKey + ? { + checksumAlgorithm: task.targetChecksumAlgorithm, + checksumValue: task.targetChecksumValue, + ...(task.targetExpectedSizeBytes === null + ? {} + : { sizeBytes: BigInt(task.targetExpectedSizeBytes) }), + } + : {}), + checksumValue: task.mode === 'backfill' ? null : { not: null }, + }; + } + + private pinnedTargetReference( + task: StorageIntegrityExecutionTask, + ): IntegrityTargetReference | null { + if ( + !task.targetObjectKey || + task.targetExpectedSizeBytes === null || + !task.workspaceId || + !task.target || + (!task.target.nodeId && !task.target.versionId) + ) { + return null; + } + return { + expectedChecksumAlgorithm: task.targetChecksumAlgorithm, + expectedHash: task.targetChecksumValue, + expectedSizeBytes: task.targetExpectedSizeBytes, + nodeId: task.target.nodeId ?? null, + objectKey: task.targetObjectKey, + targetKey: task.target.versionId + ? `version:${task.target.versionId}` + : `node:${task.target.nodeId}`, + versionId: task.target.versionId ?? null, + workspaceId: task.workspaceId, + }; + } + + private versionWhere(task: StorageIntegrityExecutionTask) { + return { + ...(task.targetObjectKey ? {} : { createdAt: { lte: task.snapshotAt } }), + ...(task.workspaceId ? { node: { workspaceId: task.workspaceId } } : {}), + ...(task.target?.versionId ? { id: task.target.versionId } : {}), + ...(task.target?.nodeId ? { nodeId: task.target.nodeId } : {}), + ...(task.targetObjectKey ? { objectKey: task.targetObjectKey } : {}), + ...(task.targetObjectKey + ? { + checksumAlgorithm: task.targetChecksumAlgorithm, + checksumValue: task.targetChecksumValue, + ...(task.targetExpectedSizeBytes === null + ? {} + : { sizeBytes: BigInt(task.targetExpectedSizeBytes) }), + } + : {}), + checksumValue: task.mode === 'backfill' ? null : { not: null }, + }; + } + + async persistIntegrityState( + tx: TransactionClient, + input: { + actualHash: string | null; + checkedAt: Date; + errorCode: string | null; + reference: IntegrityTargetReference; + status: StorageIntegrityResultStatus; + }, + ) { + const data = { + checksumAlgorithm: + input.status === 'matched' && !input.reference.expectedHash + ? 'sha256' + : undefined, + checksumValue: + input.status === 'matched' && !input.reference.expectedHash + ? input.actualHash + : undefined, + integrityStatus: input.status === 'matched' ? 'verified' : input.status, + integrityAcknowledgedAt: null, + integrityAcknowledgedBy: null, + lastVerifiedAt: input.checkedAt, + verificationFailureCode: input.errorCode, + }; + if (input.reference.versionId) { + const result = await tx.fileVersion.updateMany({ + data, + where: { + checksumAlgorithm: input.reference.expectedChecksumAlgorithm, + checksumValue: input.reference.expectedHash, + id: input.reference.versionId, + objectKey: input.reference.objectKey, + sizeBytes: BigInt(input.reference.expectedSizeBytes), + }, + }); + return result.count === 1 ? input.reference : null; + } + if (!input.reference.nodeId) return null; + const result = await tx.fileNode.updateMany({ + data, + where: { + checksumAlgorithm: input.reference.expectedChecksumAlgorithm, + checksumValue: input.reference.expectedHash, + id: input.reference.nodeId, + objectKey: input.reference.objectKey, + sizeBytes: BigInt(input.reference.expectedSizeBytes), + }, + }); + if (result.count === 1) return input.reference; + const version = await tx.fileVersion.findFirst({ + orderBy: { createdAt: 'asc' }, + where: { + checksumAlgorithm: input.reference.expectedChecksumAlgorithm, + checksumValue: input.reference.expectedHash, + nodeId: input.reference.nodeId, + objectKey: input.reference.objectKey, + sizeBytes: BigInt(input.reference.expectedSizeBytes), + node: { workspaceId: input.reference.workspaceId }, + }, + select: { id: true }, + }); + if (!version) return null; + const versionUpdate = await tx.fileVersion.updateMany({ + data, + where: { + checksumAlgorithm: input.reference.expectedChecksumAlgorithm, + checksumValue: input.reference.expectedHash, + id: version.id, + nodeId: input.reference.nodeId, + objectKey: input.reference.objectKey, + sizeBytes: BigInt(input.reference.expectedSizeBytes), + }, + }); + return versionUpdate.count === 1 + ? { + ...input.reference, + targetKey: `version:${version.id}`, + versionId: version.id, + } + : null; + } + + async targetIsCurrent( + client: Pick, + reference: IntegrityTargetReference, + ) { + if (reference.versionId) { + return Boolean( + await client.fileVersion.findFirst({ + where: { + checksumAlgorithm: reference.expectedChecksumAlgorithm, + checksumValue: reference.expectedHash, + id: reference.versionId, + nodeId: reference.nodeId ?? undefined, + objectKey: reference.objectKey, + sizeBytes: BigInt(reference.expectedSizeBytes), + node: { workspaceId: reference.workspaceId }, + }, + select: { id: true }, + }), + ); + } + if (!reference.nodeId) return false; + const node = await client.fileNode.findFirst({ + where: { + checksumAlgorithm: reference.expectedChecksumAlgorithm, + checksumValue: reference.expectedHash, + id: reference.nodeId, + objectKey: reference.objectKey, + sizeBytes: BigInt(reference.expectedSizeBytes), + workspaceId: reference.workspaceId, + }, + select: { id: true }, + }); + if (node) return true; + return Boolean( + await client.fileVersion.findFirst({ + where: { + checksumAlgorithm: reference.expectedChecksumAlgorithm, + checksumValue: reference.expectedHash, + nodeId: reference.nodeId, + objectKey: reference.objectKey, + sizeBytes: BigInt(reference.expectedSizeBytes), + node: { workspaceId: reference.workspaceId }, + }, + select: { id: true }, + }), + ); + } + + async resolveMovedNodeReference( + client: Pick, + reference: IntegrityTargetReference, + ): Promise { + if (reference.versionId || !reference.nodeId) return null; + const node = await client.fileNode.findFirst({ + where: { + checksumAlgorithm: reference.expectedChecksumAlgorithm, + checksumValue: reference.expectedHash, + id: reference.nodeId, + objectKey: reference.objectKey, + sizeBytes: BigInt(reference.expectedSizeBytes), + workspaceId: reference.workspaceId, + }, + select: { id: true }, + }); + if (node) return null; + const version = await client.fileVersion.findFirst({ + orderBy: { createdAt: 'asc' }, + where: { + checksumAlgorithm: reference.expectedChecksumAlgorithm, + checksumValue: reference.expectedHash, + nodeId: reference.nodeId, + objectKey: reference.objectKey, + sizeBytes: BigInt(reference.expectedSizeBytes), + node: { workspaceId: reference.workspaceId }, + }, + select: { id: true }, + }); + return version + ? { + ...reference, + targetKey: `version:${version.id}`, + versionId: version.id, + } + : null; + } + + private async listRetryTargetBatch( + task: StorageIntegrityExecutionTask, + cursor: { kind?: 'node' | 'retry' | 'version'; id?: string }, + ) { + const rows = await this.prisma.blobIntegrityResult.findMany({ + orderBy: { id: 'asc' }, + take: task.config.batchSize, + where: { + id: { + in: task.retryResultIds, + ...(cursor.kind === 'retry' && cursor.id ? { gt: cursor.id } : {}), + }, + taskId: task.retryOfTaskId ?? '', + }, + }); + return rows.map( + (row): IntegrityTargetReference => ({ + expectedChecksumAlgorithm: row.expectedHash ? 'sha256' : null, + expectedHash: row.expectedHash, + expectedSizeBytes: Number(row.expectedSizeBytes ?? 0n), + nodeId: row.nodeId, + objectKey: row.objectKey, + sourceResultId: row.id, + targetKey: row.targetKey, + versionId: row.versionId, + workspaceId: row.workspaceId, + }), + ); + } +} diff --git a/backend/src/modules/storage/storage-integrity-task-runner.service.spec.ts b/backend/src/modules/storage/storage-integrity-task-runner.service.spec.ts new file mode 100644 index 0000000..807cc27 --- /dev/null +++ b/backend/src/modules/storage/storage-integrity-task-runner.service.spec.ts @@ -0,0 +1,411 @@ +import type { PrismaService } from '../../database/prisma.service'; +import type { StorageIntegrityService } from './storage-integrity.service'; +import { + SharedBandwidthLimiter, + StorageIntegrityTaskRunner, +} from './storage-integrity-task-runner.service'; +import type { + IntegrityTargetReference, + StorageIntegrityExecutionTask, + StorageIntegrityTaskRepository, +} from './storage-integrity-task.repository'; + +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/unbound-method */ + +const reference: IntegrityTargetReference = { + expectedChecksumAlgorithm: 'sha256', + expectedHash: 'a'.repeat(64), + expectedSizeBytes: 100, + nodeId: 'node-1', + objectKey: 'objects/private-key', + targetKey: 'node:node-1', + versionId: null, + workspaceId: 'workspace-a', +}; + +const task: StorageIntegrityExecutionTask = { + actorUserId: 'admin-1', + config: { + bandwidthLimitBytesPerSecond: null, + batchSize: 10, + concurrency: 2, + maxAttempts: 3, + }, + createdAt: new Date(1).toISOString(), + failureCode: null, + failureMessage: null, + finishedAt: null, + id: 'inttask-1', + mode: 'verify', + progress: { + bytesRead: 0, + failed: 0, + matched: 0, + mismatches: 0, + processed: 0, + total: 0, + }, + retryOfTaskId: null, + retryResultIds: [], + scope: 'workspace', + snapshotAt: new Date(1), + startedAt: new Date(1).toISOString(), + status: 'running', + target: null, + targetChecksumAlgorithm: null, + targetChecksumValue: null, + targetCount: null, + targetExpectedSizeBytes: null, + targetObjectKey: null, + workspaceId: 'workspace-a', +}; + +function setup(verifyObject: jest.Mock) { + const repository = { + claimNextTask: jest + .fn() + .mockResolvedValueOnce(task) + .mockResolvedValueOnce(null), + completeTask: jest.fn(() => + Promise.resolve({ ...task, status: 'completed' }), + ), + countTargets: jest.fn(() => Promise.resolve(1)), + getProgressFromResults: jest + .fn() + .mockResolvedValueOnce({ ...task.progress, total: 1 }) + .mockResolvedValue({ + bytesRead: 100, + failed: 1, + matched: 0, + mismatches: 0, + processed: 1, + total: 1, + }), + getTaskCursor: jest.fn(() => Promise.resolve({})), + isTargetCurrent: jest.fn(() => Promise.resolve(true)), + initializeTargetCount: jest.fn((_id, _owner, total) => + Promise.resolve(total), + ), + listTargetBatch: jest + .fn() + .mockResolvedValueOnce([reference]) + .mockResolvedValueOnce([]), + renewLease: jest.fn(() => Promise.resolve(true)), + requeueOwnedTask: jest.fn(), + saveProgress: jest.fn(() => Promise.resolve(true)), + saveResult: jest.fn((input) => + Promise.resolve({ + ...input, + actualSizeBytes: null, + checkedAt: new Date().toISOString(), + expectedSizeBytes: 100, + id: 'result-1', + status: input.status, + versionId: null, + }), + ), + } as unknown as StorageIntegrityTaskRepository; + const prisma = { + auditEvent: { create: jest.fn(() => Promise.resolve({})) }, + } as unknown as PrismaService; + const runner = new StorageIntegrityTaskRunner( + repository, + { verifyObject } as unknown as StorageIntegrityService, + prisma, + ); + return { prisma, repository, runner }; +} + +describe('StorageIntegrityTaskRunner', () => { + afterEach(() => jest.useRealTimers()); + + it('shares one bandwidth reservation across concurrent consumers and cleans listeners', async () => { + jest.useFakeTimers(); + jest.setSystemTime(0); + const controller = new AbortController(); + const add = jest.spyOn(controller.signal, 'addEventListener'); + const remove = jest.spyOn(controller.signal, 'removeEventListener'); + const limiter = new SharedBandwidthLimiter(100); + + const waits = [ + limiter.consume(50, controller.signal), + limiter.consume(50, controller.signal), + ]; + await jest.advanceTimersByTimeAsync(999); + expect(remove).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(1); + await Promise.all(waits); + + expect(Date.now()).toBe(1000); + expect(add).toHaveBeenCalledTimes(2); + expect(remove).toHaveBeenCalledTimes(2); + }); + + it('does not retry a deterministic missing-object result', async () => { + const verifyObject = jest.fn(() => + Promise.resolve({ + actualChecksum: null, + actualSizeBytes: null, + checksumAlgorithm: 'sha256' as const, + integrityStatus: 'failed' as const, + lastVerifiedAt: new Date(), + verificationFailureCode: 'missing-object' as const, + }), + ); + const { repository, runner } = setup(verifyObject); + + runner.kick(); + await runner.waitForIdle(); + + expect(verifyObject).toHaveBeenCalledTimes(1); + expect(repository.saveResult).toHaveBeenCalledWith( + expect.objectContaining({ + attempts: 1, + errorCode: 'missing-object', + status: 'failed', + }), + ); + }); + + it('reuses the snapshotted target count when resuming a claimed task', async () => { + const verifyObject = jest.fn(() => + Promise.resolve({ + actualChecksum: 'a'.repeat(64), + actualSizeBytes: 100, + checksumAlgorithm: 'sha256', + integrityStatus: 'verified', + lastVerifiedAt: new Date(), + verificationFailureCode: null, + }), + ); + const { repository, runner } = setup(verifyObject); + (repository.claimNextTask as jest.Mock) + .mockReset() + .mockResolvedValueOnce({ ...task, targetCount: 1 }) + .mockResolvedValueOnce(null); + + runner.kick(); + await runner.waitForIdle(); + + expect(repository.countTargets).not.toHaveBeenCalled(); + expect(repository.initializeTargetCount).not.toHaveBeenCalled(); + }); + + it('retries transient read failures and passes the shared signal and byte hook', async () => { + const verifyObject = jest + .fn() + .mockResolvedValueOnce({ + actualChecksum: null, + actualSizeBytes: null, + checksumAlgorithm: 'sha256', + integrityStatus: 'failed', + lastVerifiedAt: new Date(), + verificationFailureCode: 'verification-failed', + }) + .mockResolvedValueOnce({ + actualChecksum: 'a'.repeat(64), + actualSizeBytes: 100, + checksumAlgorithm: 'sha256', + integrityStatus: 'verified', + lastVerifiedAt: new Date(), + verificationFailureCode: null, + }); + const { repository, runner } = setup(verifyObject); + + runner.kick(); + await runner.waitForIdle(); + + expect(verifyObject).toHaveBeenCalledTimes(2); + expect(verifyObject).toHaveBeenLastCalledWith( + expect.objectContaining({ + onBytes: expect.any(Function), + signal: expect.any(AbortSignal), + }), + ); + expect(repository.saveResult).toHaveBeenCalledWith( + expect.objectContaining({ attempts: 2, status: 'matched' }), + ); + }); + + it('uses abortable equal-jitter backoff before retrying a transient read failure', async () => { + jest.useFakeTimers(); + const random = jest.spyOn(Math, 'random').mockReturnValue(0); + const verifyObject = jest + .fn() + .mockResolvedValueOnce({ + actualChecksum: null, + actualSizeBytes: null, + checksumAlgorithm: 'sha256', + integrityStatus: 'failed', + lastVerifiedAt: new Date(), + verificationFailureCode: 'verification-failed', + }) + .mockResolvedValueOnce({ + actualChecksum: 'a'.repeat(64), + actualSizeBytes: 100, + checksumAlgorithm: 'sha256', + integrityStatus: 'verified', + lastVerifiedAt: new Date(), + verificationFailureCode: null, + }); + const { runner } = setup(verifyObject); + + try { + runner.kick(); + await jest.advanceTimersByTimeAsync(0); + expect(verifyObject).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(49); + expect(verifyObject).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(1); + await runner.waitForIdle(); + expect(verifyObject).toHaveBeenCalledTimes(2); + } finally { + random.mockRestore(); + } + }); + + it('waits for every concurrent worker to settle before completing a failed task', async () => { + const verifyObject = jest.fn(() => + Promise.resolve({ + actualChecksum: 'a'.repeat(64), + actualSizeBytes: 100, + checksumAlgorithm: 'sha256', + integrityStatus: 'verified', + lastVerifiedAt: new Date(), + verificationFailureCode: null, + }), + ); + const { repository, runner } = setup(verifyObject); + const secondReference = { + ...reference, + nodeId: 'node-2', + objectKey: 'objects/second', + targetKey: 'node:node-2', + }; + (repository.listTargetBatch as jest.Mock) + .mockReset() + .mockResolvedValueOnce([reference, secondReference]) + .mockResolvedValueOnce([]); + let markSecondStarted!: () => void; + const secondStarted = new Promise((resolve) => { + markSecondStarted = resolve; + }); + let releaseSecond!: () => void; + const secondFinished = new Promise((resolve) => { + releaseSecond = resolve; + }); + (repository.saveResult as jest.Mock).mockImplementation( + async (input: { reference: IntegrityTargetReference }) => { + if (input.reference.nodeId === 'node-1') { + await secondStarted; + throw new Error('persist failed'); + } + markSecondStarted(); + await secondFinished; + return { id: 'result-2' }; + }, + ); + + runner.kick(); + await secondStarted; + await new Promise((resolve) => setImmediate(resolve)); + + expect(repository.completeTask).not.toHaveBeenCalled(); + + releaseSecond(); + await runner.waitForIdle(); + expect(repository.completeTask).toHaveBeenCalledWith( + task.id, + expect.any(String), + expect.objectContaining({ status: 'failed' }), + ); + expect(repository.getProgressFromResults).toHaveBeenLastCalledWith( + task.id, + 1, + ); + }); + + it('keeps the initialized total when failure progress cannot be reloaded', async () => { + const verifyObject = jest.fn(); + const { repository, runner } = setup(verifyObject); + (repository.countTargets as jest.Mock).mockResolvedValue(3); + (repository.listTargetBatch as jest.Mock).mockReset().mockResolvedValue([]); + (repository.getProgressFromResults as jest.Mock) + .mockReset() + .mockResolvedValueOnce({ ...task.progress, total: 3 }) + .mockRejectedValue(new Error('progress temporarily unavailable')); + + runner.kick(); + await runner.waitForIdle(); + + expect(repository.completeTask).toHaveBeenCalledWith( + task.id, + expect.any(String), + expect.objectContaining({ + progress: expect.objectContaining({ total: 3 }), + status: 'failed', + }), + ); + }); + + it('completes a recovered backfill whose persisted results already reached its target count', async () => { + const verifyObject = jest.fn(); + const { repository, runner } = setup(verifyObject); + const resumedTask: StorageIntegrityExecutionTask = { + ...task, + mode: 'backfill', + progress: { ...task.progress, matched: 1, processed: 1, total: 1 }, + target: { nodeId: reference.nodeId ?? undefined }, + targetChecksumAlgorithm: null, + targetChecksumValue: null, + targetCount: 1, + targetExpectedSizeBytes: reference.expectedSizeBytes, + targetObjectKey: reference.objectKey, + }; + (repository.claimNextTask as jest.Mock) + .mockReset() + .mockResolvedValueOnce(resumedTask) + .mockResolvedValueOnce(null); + (repository.getProgressFromResults as jest.Mock) + .mockReset() + .mockResolvedValue(resumedTask.progress); + + runner.kick(); + await runner.waitForIdle(); + + expect(repository.listTargetBatch).not.toHaveBeenCalled(); + expect(verifyObject).not.toHaveBeenCalled(); + expect(repository.completeTask).toHaveBeenCalledWith( + resumedTask.id, + expect.any(String), + expect.objectContaining({ + progress: resumedTask.progress, + status: 'completed', + }), + ); + }); + + it('fails the task instead of reporting completion when a snapshotted target disappears', async () => { + const verifyObject = jest.fn(); + const { repository, runner } = setup(verifyObject); + (repository.listTargetBatch as jest.Mock).mockReset().mockResolvedValue([]); + (repository.getProgressFromResults as jest.Mock) + .mockReset() + .mockResolvedValue({ ...task.progress, processed: 0, total: 1 }); + + runner.kick(); + await runner.waitForIdle(); + + expect(repository.completeTask).toHaveBeenCalledWith( + 'inttask-1', + expect.any(String), + expect.objectContaining({ + failureCode: 'STORAGE_INTEGRITY_TASK_FAILED', + status: 'failed', + }), + ); + expect(verifyObject).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/modules/storage/storage-integrity-task-runner.service.ts b/backend/src/modules/storage/storage-integrity-task-runner.service.ts new file mode 100644 index 0000000..9238206 --- /dev/null +++ b/backend/src/modules/storage/storage-integrity-task-runner.service.ts @@ -0,0 +1,423 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { randomBytes } from 'crypto'; +import { PrismaService } from '../../database/prisma.service'; +import { recordStorageIntegrityAudit } from './storage-integrity-audit'; +import type { StorageIntegrityFailureCode } from './storage-integrity.service'; +import { StorageIntegrityService } from './storage-integrity.service'; +import { + StorageIntegrityTaskRepository, + type IntegrityTargetReference, + type StorageIntegrityExecutionTask, +} from './storage-integrity-task.repository'; + +const leaseDurationMs = 90_000; +const leaseHeartbeatMs = 20_000; +const scanIntervalMs = 30_000; +const retryBaseDelayMs = 100; +const retryMaxDelayMs = 2_000; + +class LeaseLostError extends Error {} + +export class SharedBandwidthLimiter { + private nextAvailableAt = 0; + + constructor(private readonly bytesPerSecond: number | null) {} + + async consume(bytes: number, signal: AbortSignal) { + if (!this.bytesPerSecond || bytes <= 0) return; + const now = Date.now(); + const availableAt = Math.max(now, this.nextAvailableAt); + const reservationEndsAt = + availableAt + Math.ceil((bytes / this.bytesPerSecond) * 1000); + this.nextAvailableAt = reservationEndsAt; + const waitMs = reservationEndsAt - now; + if (waitMs > 0) await abortableDelay(waitMs, signal); + } +} + +@Injectable() +export class StorageIntegrityTaskRunner + implements OnModuleInit, OnModuleDestroy +{ + private readonly leaseOwner = `integrity_${randomBytes(12).toString('base64url')}`; + private readonly activeControllers = new Set(); + private currentLoop: Promise | null = null; + private destroyed = false; + private requested = false; + private scanTimer: ReturnType | null = null; + + constructor( + private readonly repository: StorageIntegrityTaskRepository, + private readonly integrity: StorageIntegrityService, + private readonly prisma: PrismaService, + ) {} + + onModuleInit() { + this.kick(); + this.scanTimer = setInterval(() => this.kick(), scanIntervalMs); + this.scanTimer.unref(); + } + + async onModuleDestroy() { + this.destroyed = true; + if (this.scanTimer) clearInterval(this.scanTimer); + this.activeControllers.forEach((controller) => controller.abort()); + await this.currentLoop?.catch(() => undefined); + } + + kick() { + if (this.destroyed) return; + this.requested = true; + if (this.currentLoop) return; + this.currentLoop = this.runClaimLoop() + .catch(() => undefined) + .finally(() => { + this.currentLoop = null; + if (this.requested && !this.destroyed) this.kick(); + }); + } + + async waitForIdle() { + while (this.currentLoop) await this.currentLoop; + } + + private async runClaimLoop() { + while (!this.destroyed) { + this.requested = false; + const now = new Date(); + const task = await this.repository.claimNextTask({ + leaseExpiresAt: new Date(now.getTime() + leaseDurationMs), + leaseOwner: this.leaseOwner, + now, + }); + if (task) { + await this.runTask(task); + continue; + } + if (!this.requested) return; + } + } + + private async runTask(task: StorageIntegrityExecutionTask) { + const controller = new AbortController(); + this.activeControllers.add(controller); + let leaseLost = false; + let total = task.targetCount ?? task.progress.total; + const heartbeat = setInterval(() => { + void this.repository + .renewLease( + task.id, + this.leaseOwner, + new Date(Date.now() + leaseDurationMs), + ) + .then((renewed) => { + if (!renewed) { + leaseLost = true; + controller.abort(); + } + }) + .catch(() => { + leaseLost = true; + controller.abort(); + }); + }, leaseHeartbeatMs); + heartbeat.unref(); + try { + if (task.targetCount === null) { + const countedTargets = await this.repository.countTargets(task); + const initializedTotal = await this.repository.initializeTargetCount( + task.id, + this.leaseOwner, + countedTargets, + ); + if (initializedTotal === null) throw new LeaseLostError(); + total = initializedTotal; + } + let cursor = await this.repository.getTaskCursor(task.id); + let progress = await this.repository.getProgressFromResults( + task.id, + total, + ); + const limiter = new SharedBandwidthLimiter( + task.config.bandwidthLimitBytesPerSecond, + ); + while (!this.destroyed && progress.processed < total) { + if (leaseLost) throw new LeaseLostError(); + const references = await this.repository.listTargetBatch(task, cursor); + if (references.length === 0) break; + await this.processBatch(task, references, limiter, controller); + progress = await this.repository.getProgressFromResults(task.id, total); + const last = references.at(-1); + if (!last) break; + const nextCursor: { + id: string; + kind: 'node' | 'retry' | 'version'; + } = { + id: + last.sourceResultId ?? + last.versionId ?? + last.nodeId ?? + last.targetKey, + kind: last.sourceResultId + ? ('retry' as const) + : last.versionId + ? ('version' as const) + : ('node' as const), + }; + cursor = nextCursor; + const saved = await this.repository.saveProgress( + task.id, + this.leaseOwner, + progress, + nextCursor, + ); + if (!saved) throw new LeaseLostError(); + } + if (this.destroyed) throw new Error('runner stopped'); + if (leaseLost) throw new LeaseLostError(); + progress = await this.repository.getProgressFromResults(task.id, total); + if (progress.processed !== total) { + throw new Error('storage integrity target set changed'); + } + const completed = await this.repository.completeTask( + task.id, + this.leaseOwner, + { + actorUserId: task.actorUserId, + mode: task.mode, + progress, + scope: task.scope, + status: 'completed', + workspaceId: task.workspaceId, + }, + ); + if (!completed) throw new LeaseLostError(); + return completed; + } catch (error) { + if (this.destroyed || error instanceof LeaseLostError) { + if (this.destroyed) { + await this.repository + .requeueOwnedTask(task.id, this.leaseOwner) + .catch(() => undefined); + } + return; + } + const failureCode = + error instanceof LeaseLostError + ? 'STORAGE_INTEGRITY_LEASE_LOST' + : 'STORAGE_INTEGRITY_TASK_FAILED'; + const failureMessage = + error instanceof LeaseLostError + ? '完整性巡检任务租约已失效' + : '完整性巡检任务执行失败'; + const progress = await this.repository + .getProgressFromResults(task.id, total) + .catch(() => ({ ...task.progress, total })); + await this.repository + .completeTask(task.id, this.leaseOwner, { + actorUserId: task.actorUserId, + failureCode, + failureMessage, + mode: task.mode, + progress, + scope: task.scope, + status: 'failed', + workspaceId: task.workspaceId, + }) + .catch(() => undefined); + } finally { + clearInterval(heartbeat); + this.activeControllers.delete(controller); + } + } + + private async processBatch( + task: StorageIntegrityExecutionTask, + references: IntegrityTargetReference[], + limiter: SharedBandwidthLimiter, + controller: AbortController, + ) { + let nextIndex = 0; + const worker = async () => { + while (nextIndex < references.length) { + const reference = references[nextIndex++]; + if (!reference) return; + await this.verifyReference(task, reference, limiter, controller.signal); + } + }; + let failed = false; + let firstError: unknown; + const workers = Array.from( + { length: Math.min(task.config.concurrency, references.length) }, + async () => { + try { + await worker(); + } catch (error) { + if (!failed) { + failed = true; + firstError = error; + } + controller.abort(); + } + }, + ); + await Promise.all(workers); + if (failed) throw firstError; + } + + private async verifyReference( + task: StorageIntegrityExecutionTask, + reference: IntegrityTargetReference, + limiter: SharedBandwidthLimiter, + signal: AbortSignal, + ) { + if (!(await this.repository.isTargetCurrent(reference))) { + const saved = await this.repository.saveResult({ + actorUserId: task.actorUserId, + auditScope: task.scope, + actualHash: null, + actualSizeBytes: null, + attempts: 0, + bytesRead: 0, + checkedAt: new Date(), + errorCode: 'target-changed', + errorMessage: '文件内容引用已发生变化', + leaseOwner: this.leaseOwner, + reference, + sourceResultId: reference.sourceResultId, + status: 'failed', + taskId: task.id, + }); + if (!saved) throw new LeaseLostError(); + return; + } + let attempts = 0; + let bytesRead = 0; + let result: + | Awaited> + | undefined; + while (attempts < task.config.maxAttempts) { + attempts += 1; + if (signal.aborted) throw new LeaseLostError(); + if (attempts > 1) { + await recordStorageIntegrityAudit(this.prisma, { + action: 'system.storage_integrity_retry_started', + actorUserId: task.actorUserId, + errorCode: result?.verificationFailureCode ?? 'verification-failed', + nodeId: reference.nodeId, + scope: task.scope, + taskId: task.id, + workspaceId: reference.workspaceId, + }); + } + try { + result = await this.integrity.verifyObject({ + checksumAlgorithm: reference.expectedChecksumAlgorithm, + checksumValue: reference.expectedHash, + expectedSizeBytes: reference.expectedSizeBytes, + objectKey: reference.objectKey, + onBytes: async (bytes) => { + bytesRead += bytes; + await limiter.consume(bytes, signal); + }, + signal, + }); + } catch (error) { + if ( + signal.aborted || + (error instanceof Error && error.name === 'AbortError') + ) { + throw new LeaseLostError(); + } + result = { + actualChecksum: null, + actualSizeBytes: null, + checksumAlgorithm: 'sha256', + integrityStatus: 'failed', + lastVerifiedAt: new Date(), + verificationFailureCode: 'verification-failed', + }; + } + if ( + result.verificationFailureCode !== 'verification-failed' || + attempts >= task.config.maxAttempts + ) { + break; + } + await abortableDelay( + resolveRetryDelayMs(attempts, Math.random()), + signal, + ); + } + if (!result) throw new Error('verification did not produce a result'); + const status = + result.integrityStatus === 'verified' + ? 'matched' + : result.integrityStatus === 'mismatch' + ? 'mismatch' + : 'failed'; + const errorCode = result.verificationFailureCode; + const saved = await this.repository.saveResult({ + actorUserId: task.actorUserId, + auditScope: task.scope, + actualHash: result.actualChecksum, + actualSizeBytes: result.actualSizeBytes, + attempts, + bytesRead, + checkedAt: result.lastVerifiedAt, + errorCode, + errorMessage: errorCode ? safeVerificationMessage(errorCode) : null, + leaseOwner: this.leaseOwner, + reference, + sourceResultId: reference.sourceResultId, + status, + taskId: task.id, + }); + if (!saved) throw new LeaseLostError(); + } +} + +function safeVerificationMessage(code: StorageIntegrityFailureCode) { + const messages: Record = { + 'checksum-mismatch': '内容校验和与记录不一致', + 'missing-object': '存储对象不存在', + 'size-mismatch': '对象大小与记录不一致', + 'verification-failed': '无法读取并校验存储对象', + }; + return messages[code]; +} + +function abortableDelay(milliseconds: number, signal: AbortSignal) { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new LeaseLostError()); + return; + } + const finish = () => { + signal.removeEventListener('abort', abort); + resolve(); + }; + const timer = setTimeout(finish, milliseconds); + const abort = () => { + clearTimeout(timer); + signal.removeEventListener('abort', abort); + reject(new LeaseLostError()); + }; + signal.addEventListener('abort', abort, { once: true }); + }); +} + +function resolveRetryDelayMs(retryNumber: number, randomValue: number) { + const upperBound = Math.min( + retryMaxDelayMs, + retryBaseDelayMs * 2 ** Math.max(0, retryNumber - 1), + ); + const lowerBound = Math.ceil(upperBound / 2); + const normalizedRandom = Math.min( + 1 - Number.EPSILON, + Math.max(0, randomValue), + ); + return ( + lowerBound + Math.floor(normalizedRandom * (upperBound - lowerBound + 1)) + ); +} diff --git a/backend/src/modules/storage/storage-integrity-task.audit.spec.ts b/backend/src/modules/storage/storage-integrity-task.audit.spec.ts new file mode 100644 index 0000000..4171a17 --- /dev/null +++ b/backend/src/modules/storage/storage-integrity-task.audit.spec.ts @@ -0,0 +1,208 @@ +import { PrismaService } from '../../database/prisma.service'; +import { StorageIntegrityTaskRepository } from './storage-integrity-task.repository'; + +const reference = () => ({ + expectedChecksumAlgorithm: 'sha256', + expectedHash: 'a'.repeat(64), + expectedSizeBytes: 12, + nodeId: 'node-1', + objectKey: 'private/secret-object', + targetKey: 'node:node-1', + versionId: null, + workspaceId: 'workspace-a', +}); + +const persistedResult = { + actualHash: null, + attempts: 3, + bytesRead: 0n, + checkedAt: new Date('2026-08-14T00:00:00.000Z'), + errorCode: 'missing-object', + errorMessage: 'sensitive local path', + expectedHash: 'a'.repeat(64), + expectedSizeBytes: 12n, + id: 'result-1', + nodeId: 'node-1', + objectKey: 'private/secret-object', + sizeBytes: null, + sourceResultId: null, + status: 'failed', + targetKey: 'node:node-1', + taskId: 'task-1', + versionId: null, + workspaceId: 'workspace-a', +}; + +const persistedTask = { + actorUserId: 'admin-1', + bandwidthLimitBytesPerSecond: null, + batchSize: 10, + concurrency: 2, + createdAt: new Date('2026-08-14T00:00:00.000Z'), + cursor: {}, + failureCode: null, + failureMessage: null, + finishedAt: new Date('2026-08-14T00:01:00.000Z'), + id: 'task-1', + leaseExpiresAt: null, + leaseKey: null, + leaseOwner: null, + maxAttempts: 3, + mode: 'verify', + progress: { + bytesRead: 12, + failed: 1, + matched: 0, + mismatches: 1, + processed: 2, + total: 2, + }, + retryOfTaskId: null, + retryResultIds: [], + scope: 'workspace', + snapshotAt: new Date('2026-08-14T00:00:00.000Z'), + startedAt: new Date('2026-08-14T00:00:01.000Z'), + status: 'completed', + target: {}, + targetCount: 2, + workspaceId: 'workspace-a', +}; + +describe('StorageIntegrityTaskRepository audit safety', () => { + it('audits a failed finding without exposing object, hash, or raw error details', async () => { + const auditCreate = jest.fn(() => Promise.resolve({})); + const transaction = { + auditEvent: { create: auditCreate }, + blobIntegrityResult: { + create: jest.fn(() => Promise.resolve(persistedResult)), + findFirst: jest.fn(() => Promise.resolve(null)), + }, + blobIntegrityTask: { + updateMany: jest.fn(() => Promise.resolve({ count: 1 })), + }, + fileNode: { + updateMany: jest.fn(() => Promise.resolve({ count: 1 })), + }, + fileVersion: { updateMany: jest.fn() }, + }; + const repository = new StorageIntegrityTaskRepository({ + $transaction: (callback: (tx: typeof transaction) => unknown) => + callback(transaction), + } as unknown as PrismaService); + + await repository.saveResult({ + actorUserId: 'admin-1', + actualHash: null, + actualSizeBytes: null, + attempts: 3, + auditScope: 'workspace', + bytesRead: 0, + checkedAt: persistedResult.checkedAt, + errorCode: 'missing-object', + errorMessage: 'sensitive local path', + leaseOwner: 'worker-a', + reference: reference(), + status: 'failed', + taskId: 'task-1', + }); + + expect(auditCreate).toHaveBeenCalledTimes(1); + const audit = auditCreate.mock.calls[0]?.[0]?.data as unknown as { + action: string; + metadata: Record; + nodeId: string | null; + workspaceId: string | null; + }; + expect(audit).toMatchObject({ + action: 'system.storage_integrity_failure_detected', + nodeId: 'node-1', + workspaceId: 'workspace-a', + }); + expect(audit.metadata).toMatchObject({ + errorCode: 'missing-object', + result: 'failed', + resultId: 'result-1', + taskId: 'task-1', + }); + const payload = JSON.stringify(audit); + expect(payload).not.toContain('private/secret-object'); + expect(payload).not.toContain('a'.repeat(64)); + expect(payload).not.toContain('sensitive local path'); + }); + + it('marks a completed task audit as failed when findings were detected', async () => { + const auditCreate = jest.fn(() => Promise.resolve({})); + const transaction = { + auditEvent: { create: auditCreate }, + blobIntegrityTask: { + findUnique: jest.fn(() => Promise.resolve(persistedTask)), + updateMany: jest.fn(() => Promise.resolve({ count: 1 })), + }, + }; + const repository = new StorageIntegrityTaskRepository({ + $transaction: (callback: (tx: typeof transaction) => unknown) => + callback(transaction), + } as unknown as PrismaService); + + await repository.completeTask('task-1', 'worker-a', { + actorUserId: 'admin-1', + mode: 'verify', + progress: persistedTask.progress, + scope: 'workspace', + status: 'completed', + workspaceId: 'workspace-a', + }); + + expect(auditCreate).toHaveBeenCalledTimes(1); + expect(auditCreate.mock.calls[0]?.[0]?.data).toMatchObject({ + action: 'system.storage_integrity_task_completed', + metadata: { + failed: 1, + matched: 0, + mismatches: 1, + processed: 2, + result: 'failed', + taskId: 'task-1', + total: 2, + }, + }); + }); + + it('publishes the immutable target count in progress before the first batch', async () => { + const findFirst = jest.fn(() => + Promise.resolve({ + progress: { + bytesRead: 4, + failed: 0, + matched: 1, + mismatches: 0, + processed: 1, + total: 0, + }, + }), + ); + const updateMany = jest.fn(() => Promise.resolve({ count: 1 })); + const repository = new StorageIntegrityTaskRepository({ + blobIntegrityTask: { findFirst, updateMany }, + } as unknown as PrismaService); + + await expect( + repository.initializeTargetCount('task-1', 'worker-a', 30), + ).resolves.toBe(30); + expect(updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + progress: { + bytesRead: 4, + failed: 0, + matched: 1, + mismatches: 0, + processed: 1, + total: 30, + }, + targetCount: 30, + }, + }), + ); + }); +}); diff --git a/backend/src/modules/storage/storage-integrity-task.dto.spec.ts b/backend/src/modules/storage/storage-integrity-task.dto.spec.ts new file mode 100644 index 0000000..7e03dc4 --- /dev/null +++ b/backend/src/modules/storage/storage-integrity-task.dto.spec.ts @@ -0,0 +1,31 @@ +import 'reflect-metadata'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { RetryStorageIntegrityTaskDto } from './storage-integrity-task.dto'; + +describe('RetryStorageIntegrityTaskDto', () => { + it('rejects an explicitly empty result selection', async () => { + const dto = plainToInstance(RetryStorageIntegrityTaskDto, { + resultIds: [], + }); + + await expect(validate(dto)).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ property: 'resultIds' }), + ]), + ); + }); + + it('allows an omitted selection or at least one result id', async () => { + await expect( + validate(plainToInstance(RetryStorageIntegrityTaskDto, {})), + ).resolves.toEqual([]); + await expect( + validate( + plainToInstance(RetryStorageIntegrityTaskDto, { + resultIds: ['result-1'], + }), + ), + ).resolves.toEqual([]); + }); +}); diff --git a/backend/src/modules/storage/storage-integrity-task.dto.ts b/backend/src/modules/storage/storage-integrity-task.dto.ts new file mode 100644 index 0000000..f400d79 --- /dev/null +++ b/backend/src/modules/storage/storage-integrity-task.dto.ts @@ -0,0 +1,243 @@ +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsIn, + IsInt, + IsObject, + IsOptional, + IsString, + Max, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; + +export type StorageIntegrityScope = + | { kind: 'all' } + | { kind: 'workspace'; workspaceId: string }; +export type StorageIntegrityTaskStatus = + | 'queued' + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; +export type StorageIntegrityResultStatus = 'matched' | 'mismatch' | 'failed'; + +export class StorageIntegrityTargetDto { + @IsString() + @MaxLength(191) + @IsOptional() + nodeId?: string; + + @IsString() + @MaxLength(191) + @IsOptional() + versionId?: string; +} + +export class CreateStorageIntegrityTaskDto { + @IsIn(['all', 'workspace']) + scope!: 'all' | 'workspace'; + + @IsString() + @MaxLength(191) + @IsOptional() + workspaceId?: string; + + @IsIn(['backfill', 'verify']) + mode!: 'backfill' | 'verify'; + + @IsObject() + @ValidateNested() + @Type(() => StorageIntegrityTargetDto) + @IsOptional() + target?: StorageIntegrityTargetDto; + + @IsInt() + @Min(1) + @Max(500) + batchSize!: number; + + @IsInt() + @Min(1) + @Max(8) + concurrency!: number; + + @IsInt() + @Min(1) + @Max(1_000_000_000) + @IsOptional() + bandwidthLimitBytesPerSecond?: number | null; + + @IsInt() + @Min(1) + @Max(10) + maxAttempts!: number; +} + +export class RetryStorageIntegrityTaskDto { + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(500) + @IsString({ each: true }) + @IsOptional() + resultIds?: string[]; +} + +export class ListStorageIntegrityTasksQueryDto { + @IsIn(['all', 'workspace']) + scope!: 'all' | 'workspace'; + + @IsString() + @MaxLength(191) + @IsOptional() + workspaceId?: string; + + @Type(() => Number) + @IsInt() + @Min(0) + @IsOptional() + offset = 0; + + @Type(() => Number) + @IsInt() + @Min(1) + @Max(200) + @IsOptional() + limit = 50; +} + +export class ListStorageIntegrityResultsQueryDto { + @IsIn(['all', 'matched', 'mismatch', 'failed']) + @IsOptional() + status: 'all' | StorageIntegrityResultStatus = 'all'; + + @Type(() => Number) + @IsInt() + @Min(0) + @IsOptional() + offset = 0; + + @Type(() => Number) + @IsInt() + @Min(1) + @Max(500) + @IsOptional() + limit = 100; +} + +export class StorageIntegritySummaryQueryDto { + @IsIn(['all', 'workspace']) + scope!: 'all' | 'workspace'; + + @IsString() + @MaxLength(191) + @IsOptional() + workspaceId?: string; +} + +export class ListStorageIntegrityTargetsQueryDto { + @IsString() + @MaxLength(191) + workspaceId!: string; + + @IsString() + @MaxLength(200) + @IsOptional() + query?: string; + + @Type(() => Number) + @IsInt() + @Min(0) + @IsOptional() + offset = 0; + + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + @IsOptional() + limit = 20; +} + +export class StorageIntegrityTargetVersionsQueryDto { + @IsString() + @MaxLength(191) + workspaceId!: string; +} + +export type StorageIntegrityTargetResponse = { + id: string; + workspaceId: string; + name: string; + path: string; + sizeBytes: number; + kind: string; + mimeType: string; + integrityStatus: string; + lastVerifiedAt: string | null; + updatedAt: string; +}; + +export type StorageIntegrityTaskResponse = { + id: string; + status: StorageIntegrityTaskStatus; + scope: 'all' | 'workspace'; + workspaceId: string | null; + mode: 'backfill' | 'verify'; + target: { nodeId?: string; versionId?: string } | null; + config: { + batchSize: number; + concurrency: number; + bandwidthLimitBytesPerSecond: number | null; + maxAttempts: number; + }; + progress: { + processed: number; + total: number; + matched: number; + mismatches: number; + failed: number; + bytesRead: number; + }; + createdAt: string; + startedAt: string | null; + finishedAt: string | null; + failureCode: string | null; + failureMessage: string | null; +}; + +export type StorageIntegrityResultResponse = { + id: string; + taskId: string; + acknowledgedAt: string | null; + acknowledgedBy: string | null; + workspaceId: string; + nodeId: string | null; + versionId: string | null; + objectKey: string; + status: StorageIntegrityResultStatus; + expectedHash: string | null; + actualHash: string | null; + expectedSizeBytes: number | null; + actualSizeBytes: number | null; + attempts: number; + errorCode: string | null; + errorMessage: string | null; + checkedAt: string; +}; + +export type StorageIntegritySummaryResponse = { + scope: StorageIntegrityScope; + generatedAt: string; + counts: { + unknown: number; + pending: number; + verified: number; + mismatch: number; + failed: number; + }; + lastVerifiedAt: string | null; +}; diff --git a/backend/src/modules/storage/storage-integrity-task.repository.spec.ts b/backend/src/modules/storage/storage-integrity-task.repository.spec.ts new file mode 100644 index 0000000..4ab8a31 --- /dev/null +++ b/backend/src/modules/storage/storage-integrity-task.repository.spec.ts @@ -0,0 +1,780 @@ +import type { PrismaService } from '../../database/prisma.service'; +import { StorageIntegrityTaskRepository } from './storage-integrity-task.repository'; + +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +const persistedResult = { + actualHash: null, + attempts: 1, + bytesRead: 0n, + checkedAt: new Date(1), + errorCode: 'target-changed', + errorMessage: '文件内容引用已发生变化', + expectedHash: null, + expectedSizeBytes: 12n, + id: 'result-1', + nodeId: 'node-1', + objectKey: 'objects/private', + sizeBytes: null, + sourceResultId: null, + status: 'failed', + targetKey: 'node:node-1', + taskId: 'task-1', + versionId: null, + workspaceId: 'workspace-a', +}; + +function reference() { + return { + expectedChecksumAlgorithm: null, + expectedHash: null, + expectedSizeBytes: 12, + nodeId: 'node-1', + objectKey: 'objects/private', + targetKey: 'node:node-1', + versionId: null, + workspaceId: 'workspace-a', + }; +} + +describe('StorageIntegrityTaskRepository', () => { + it('keeps all-scope history separate from workspace-scoped tasks', async () => { + const findMany = jest.fn(() => Promise.resolve([])); + const count = jest.fn(() => Promise.resolve(0)); + const repository = new StorageIntegrityTaskRepository({ + blobIntegrityTask: { count, findMany }, + } as unknown as PrismaService); + + await repository.listTasks({ limit: 20, offset: 0, scope: 'all' }); + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { scope: 'all' } }), + ); + expect(count).toHaveBeenCalledWith({ where: { scope: 'all' } }); + + await repository.listTasks({ + limit: 20, + offset: 0, + scope: 'workspace', + workspaceId: 'workspace-a', + }); + expect(findMany).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { scope: 'workspace', workspaceId: 'workspace-a' }, + }), + ); + }); + + it('selects non-folder current files by creation snapshot and preserves checksum baseline', async () => { + const findMany = jest.fn(() => + Promise.resolve([ + { + checksumAlgorithm: 'sha256', + checksumValue: 'a'.repeat(64), + id: 'node-1', + objectKey: 'objects/private', + sizeBytes: 12n, + workspaceId: 'workspace-a', + }, + ]), + ); + const repository = new StorageIntegrityTaskRepository({ + fileNode: { findMany }, + } as unknown as PrismaService); + const snapshotAt = new Date('2026-08-13T00:00:00.000Z'); + + const rows = await repository.listTargetBatch( + { + actorUserId: 'admin-1', + config: { + bandwidthLimitBytesPerSecond: null, + batchSize: 1, + concurrency: 1, + maxAttempts: 1, + }, + createdAt: snapshotAt.toISOString(), + failureCode: null, + failureMessage: null, + finishedAt: null, + id: 'task-1', + mode: 'verify', + progress: { + bytesRead: 0, + failed: 0, + matched: 0, + mismatches: 0, + processed: 0, + total: 0, + }, + retryOfTaskId: null, + retryResultIds: [], + scope: 'workspace', + snapshotAt, + startedAt: null, + status: 'running', + target: null, + targetChecksumAlgorithm: null, + targetChecksumValue: null, + targetCount: null, + targetExpectedSizeBytes: null, + targetObjectKey: null, + workspaceId: 'workspace-a', + }, + {}, + ); + + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + checksumValue: { not: null }, + createdAt: { lte: snapshotAt }, + kind: { not: 'folder' }, + sizeBytes: { not: null }, + }), + }), + ); + expect(rows[0]).toMatchObject({ + expectedChecksumAlgorithm: 'sha256', + expectedHash: 'a'.repeat(64), + }); + }); + + it('follows a pinned upload object into its newly-created historical version instead of the replaced node', async () => { + const nodeFindMany = jest.fn(() => Promise.resolve([])); + const versionFindMany = jest.fn(() => + Promise.resolve([ + { + checksumAlgorithm: null, + checksumValue: null, + id: 'version-a', + nodeId: 'node-1', + node: { workspaceId: 'workspace-a' }, + objectKey: 'objects/a', + sizeBytes: 12n, + }, + ]), + ); + const repository = new StorageIntegrityTaskRepository({ + fileNode: { findMany: nodeFindMany }, + fileVersion: { findMany: versionFindMany }, + } as unknown as PrismaService); + const executionTask = { + actorUserId: null, + config: { + bandwidthLimitBytesPerSecond: null, + batchSize: 1, + concurrency: 1, + maxAttempts: 3, + }, + createdAt: new Date(1).toISOString(), + failureCode: null, + failureMessage: null, + finishedAt: null, + id: 'task-a', + mode: 'backfill' as const, + progress: { + bytesRead: 0, + failed: 0, + matched: 0, + mismatches: 0, + processed: 0, + total: 0, + }, + retryOfTaskId: null, + retryResultIds: [], + scope: 'workspace' as const, + snapshotAt: new Date(1), + startedAt: null, + status: 'running' as const, + target: { nodeId: 'node-1' }, + targetChecksumAlgorithm: null, + targetChecksumValue: null, + targetCount: null, + targetExpectedSizeBytes: 12, + targetObjectKey: 'objects/a', + workspaceId: 'workspace-a', + }; + + const rows = await repository.listTargetBatch(executionTask, {}); + + expect(nodeFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ objectKey: 'objects/a' }), + }), + ); + expect(versionFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + nodeId: 'node-1', + objectKey: 'objects/a', + }), + }), + ); + expect(rows).toEqual([ + expect.objectContaining({ + objectKey: 'objects/a', + targetKey: 'version:version-a', + versionId: 'version-a', + }), + ]); + }); + + it('keeps a disappeared pinned target in the task snapshot instead of completing zero of zero', async () => { + const repository = new StorageIntegrityTaskRepository({ + fileNode: { + count: jest.fn(() => Promise.resolve(0)), + findMany: jest.fn(() => Promise.resolve([])), + }, + fileVersion: { + count: jest.fn(() => Promise.resolve(0)), + findMany: jest.fn(() => Promise.resolve([])), + }, + } as unknown as PrismaService); + const executionTask = { + actorUserId: null, + config: { + bandwidthLimitBytesPerSecond: null, + batchSize: 1, + concurrency: 1, + maxAttempts: 3, + }, + createdAt: new Date(1).toISOString(), + failureCode: null, + failureMessage: null, + finishedAt: null, + id: 'task-missing-target', + mode: 'backfill' as const, + progress: { + bytesRead: 0, + failed: 0, + matched: 0, + mismatches: 0, + processed: 0, + total: 0, + }, + retryOfTaskId: null, + retryResultIds: [], + scope: 'workspace' as const, + snapshotAt: new Date(1), + startedAt: null, + status: 'running' as const, + target: { nodeId: 'node-gone' }, + targetChecksumAlgorithm: null, + targetChecksumValue: null, + targetCount: null, + targetExpectedSizeBytes: 12, + targetObjectKey: 'objects/gone', + workspaceId: 'workspace-a', + }; + + await expect(repository.countTargets(executionTask)).resolves.toBe(1); + await expect( + repository.listTargetBatch(executionTask, {}), + ).resolves.toEqual([ + { + expectedChecksumAlgorithm: null, + expectedHash: null, + expectedSizeBytes: 12, + nodeId: 'node-gone', + objectKey: 'objects/gone', + targetKey: 'node:node-gone', + versionId: null, + workspaceId: 'workspace-a', + }, + ]); + await expect( + repository.listTargetBatch(executionTask, { + id: 'node-gone', + kind: 'node', + }), + ).resolves.toEqual([]); + }); + + it('marks a result target-changed when the transactional baseline condition no longer matches', async () => { + const updateMany = jest.fn(() => Promise.resolve({ count: 0 })); + const create = jest.fn(({ data }) => + Promise.resolve({ ...persistedResult, ...data }), + ); + const auditCreate = jest.fn(); + const transaction = { + auditEvent: { create: auditCreate }, + blobIntegrityResult: { + create, + findFirst: jest.fn(() => Promise.resolve(null)), + }, + blobIntegrityTask: { + updateMany: jest.fn(() => Promise.resolve({ count: 1 })), + }, + fileNode: { updateMany }, + fileVersion: { + findFirst: jest.fn(() => Promise.resolve(null)), + updateMany: jest.fn(), + }, + }; + const repository = new StorageIntegrityTaskRepository({ + $transaction: (callback: (tx: typeof transaction) => unknown) => + callback(transaction), + } as unknown as PrismaService); + + const result = await repository.saveResult({ + actualHash: 'b'.repeat(64), + actualSizeBytes: 12, + attempts: 1, + bytesRead: 12, + checkedAt: new Date(1), + errorCode: null, + errorMessage: null, + leaseOwner: 'worker-a', + reference: reference(), + status: 'matched', + taskId: 'task-1', + }); + + expect(updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + checksumAlgorithm: null, + checksumValue: null, + objectKey: 'objects/private', + sizeBytes: 12n, + }), + }), + ); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + actualHash: null, + errorCode: 'target-changed', + status: 'failed', + }), + }), + ); + expect(result).toMatchObject({ + errorCode: 'target-changed', + errorMessage: '文件内容引用已发生变化', + status: 'failed', + }); + expect(auditCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'system.storage_integrity_failure_detected', + metadata: expect.objectContaining({ + errorCode: 'target-changed', + result: 'failed', + }) as unknown, + }) as unknown, + }); + }); + + it('persists mismatch and a redacted audit atomically without replacing its expected hash', async () => { + const updateMany = jest.fn(() => Promise.resolve({ count: 1 })); + const auditCreate = jest.fn(() => Promise.resolve({})); + const create = jest.fn(({ data }) => + Promise.resolve({ + ...persistedResult, + ...data, + actualHash: 'b'.repeat(64), + errorCode: 'checksum-mismatch', + errorMessage: '内容校验和与记录不一致', + status: 'mismatch', + }), + ); + const transaction = { + auditEvent: { create: auditCreate }, + blobIntegrityResult: { + create, + findFirst: jest.fn(() => Promise.resolve(null)), + }, + blobIntegrityTask: { + updateMany: jest.fn(() => Promise.resolve({ count: 1 })), + }, + fileNode: { updateMany }, + fileVersion: { updateMany: jest.fn() }, + }; + const repository = new StorageIntegrityTaskRepository({ + $transaction: (callback: (tx: typeof transaction) => unknown) => + callback(transaction), + } as unknown as PrismaService); + const baseline = reference(); + baseline.expectedChecksumAlgorithm = 'sha256'; + baseline.expectedHash = 'a'.repeat(64); + + await repository.saveResult({ + actorUserId: 'admin-1', + actualHash: 'b'.repeat(64), + actualSizeBytes: 12, + attempts: 1, + auditScope: 'workspace', + bytesRead: 12, + checkedAt: new Date(1), + errorCode: 'checksum-mismatch', + errorMessage: '内容校验和与记录不一致', + leaseOwner: 'worker-a', + reference: baseline, + status: 'mismatch', + taskId: 'task-1', + }); + + expect(updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + checksumValue: undefined, + integrityAcknowledgedAt: null, + integrityAcknowledgedBy: null, + }), + }), + ); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + acknowledgedAt: null, + acknowledgedBy: null, + }) as unknown, + }), + ); + expect(auditCreate).toHaveBeenCalledTimes(1); + const auditPayload = JSON.stringify(auditCreate.mock.calls); + expect(auditPayload).not.toContain('objects/private'); + expect(auditPayload).not.toContain('a'.repeat(64)); + expect(auditPayload).not.toContain('b'.repeat(64)); + }); + + it('keeps an acknowledged first result when lease recovery replays the same target', async () => { + const acknowledgedAt = new Date('2026-08-13T01:00:00.000Z'); + const existing = { + ...persistedResult, + acknowledgedAt, + acknowledgedBy: 'admin-1', + actualHash: 'b'.repeat(64), + errorCode: 'checksum-mismatch', + errorMessage: '内容校验和与记录不一致', + status: 'mismatch', + targetKey: 'version:version-after-replacement', + versionId: 'version-after-replacement', + }; + const create = jest.fn(); + const fileUpdateMany = jest.fn(); + const auditCreate = jest.fn(); + const findFirst = jest.fn( + ({ where }: { where: { targetKey?: string } }) => { + if (where.targetKey === 'node:node-1') return Promise.resolve(null); + if (where.targetKey === 'version:version-after-replacement') { + return Promise.resolve(existing); + } + return Promise.resolve({ id: existing.id }); + }, + ); + const nodeFindFirst = jest.fn(() => Promise.resolve(null)); + const versionFindFirst = jest.fn(() => + Promise.resolve({ id: 'version-after-replacement' }), + ); + const transaction = { + auditEvent: { create: auditCreate }, + blobIntegrityResult: { create, findFirst }, + blobIntegrityTask: { + updateMany: jest.fn(() => Promise.resolve({ count: 1 })), + }, + fileNode: { findFirst: nodeFindFirst, updateMany: fileUpdateMany }, + fileVersion: { findFirst: versionFindFirst, updateMany: jest.fn() }, + }; + const repository = new StorageIntegrityTaskRepository({ + $transaction: (callback: (tx: typeof transaction) => unknown) => + callback(transaction), + } as unknown as PrismaService); + + const result = await repository.saveResult({ + actorUserId: 'admin-1', + actualHash: 'a'.repeat(64), + actualSizeBytes: 12, + attempts: 1, + auditScope: 'workspace', + bytesRead: 12, + checkedAt: new Date('2026-08-13T01:05:00.000Z'), + errorCode: null, + errorMessage: null, + leaseOwner: 'recovery-worker', + reference: reference(), + status: 'matched', + taskId: 'task-1', + }); + + expect(findFirst).toHaveBeenNthCalledWith(1, { + where: { + taskId: 'task-1', + targetKey: 'node:node-1', + }, + }); + expect(findFirst).toHaveBeenNthCalledWith(2, { + select: { id: true }, + where: { + expectedHash: null, + expectedSizeBytes: 12n, + nodeId: 'node-1', + objectKey: 'objects/private', + taskId: 'task-1', + versionId: { not: null }, + workspaceId: 'workspace-a', + }, + }); + expect(nodeFindFirst).toHaveBeenCalledTimes(1); + expect(versionFindFirst).toHaveBeenCalledTimes(1); + expect(findFirst).toHaveBeenNthCalledWith(3, { + where: { + taskId: 'task-1', + targetKey: 'version:version-after-replacement', + }, + }); + expect(result).toMatchObject({ + acknowledgedAt: acknowledgedAt.toISOString(), + acknowledgedBy: 'admin-1', + status: 'mismatch', + }); + expect(fileUpdateMany).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect(auditCreate).not.toHaveBeenCalled(); + }); + + it('records current nodes separately from historical versions sharing the same snapshot', async () => { + const historicalResult = { + ...persistedResult, + id: 'result-version', + targetKey: 'version:version-existing', + versionId: 'version-existing', + }; + const findFirst = jest.fn( + ({ where }: { where: { targetKey?: string } }) => { + if (where.targetKey) return Promise.resolve(null); + return Promise.resolve({ id: historicalResult.id }); + }, + ); + const create = jest.fn(({ data }) => + Promise.resolve({ ...persistedResult, ...data }), + ); + const nodeFindFirst = jest.fn(() => Promise.resolve({ id: 'node-1' })); + const versionFindFirst = jest.fn(); + const fileUpdateMany = jest.fn(() => Promise.resolve({ count: 1 })); + const transaction = { + auditEvent: { create: jest.fn() }, + blobIntegrityResult: { create, findFirst }, + blobIntegrityTask: { + updateMany: jest.fn(() => Promise.resolve({ count: 1 })), + }, + fileNode: { findFirst: nodeFindFirst, updateMany: fileUpdateMany }, + fileVersion: { findFirst: versionFindFirst, updateMany: jest.fn() }, + }; + const repository = new StorageIntegrityTaskRepository({ + $transaction: (callback: (tx: typeof transaction) => unknown) => + callback(transaction), + } as unknown as PrismaService); + + const result = await repository.saveResult({ + actualHash: 'a'.repeat(64), + actualSizeBytes: 12, + attempts: 1, + bytesRead: 12, + checkedAt: new Date('2026-08-13T01:05:00.000Z'), + errorCode: null, + errorMessage: null, + leaseOwner: 'worker-a', + reference: reference(), + status: 'matched', + taskId: 'task-1', + }); + + expect(result).toMatchObject({ versionId: null }); + expect(nodeFindFirst).toHaveBeenCalledTimes(1); + expect(versionFindFirst).not.toHaveBeenCalled(); + expect(fileUpdateMany).toHaveBeenCalledTimes(1); + expect(create).toHaveBeenCalledTimes(1); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + targetKey: 'node:node-1', + versionId: null, + }) as unknown, + }), + ); + expect(findFirst).toHaveBeenCalledTimes(2); + }); + + it('fences result and cursor writes after the lease moves to another runner', async () => { + const taskUpdateMany = jest.fn(() => Promise.resolve({ count: 0 })); + const fileUpdateMany = jest.fn(); + const create = jest.fn(); + const auditCreate = jest.fn(); + const transaction = { + auditEvent: { create: auditCreate }, + blobIntegrityResult: { create, findFirst: jest.fn() }, + blobIntegrityTask: { updateMany: taskUpdateMany }, + fileNode: { updateMany: fileUpdateMany }, + fileVersion: { updateMany: jest.fn() }, + }; + const repository = new StorageIntegrityTaskRepository({ + $transaction: (callback: (tx: typeof transaction) => unknown) => + callback(transaction), + blobIntegrityTask: { updateMany: taskUpdateMany }, + } as unknown as PrismaService); + + await expect( + repository.saveResult({ + actualHash: 'a'.repeat(64), + actualSizeBytes: 12, + attempts: 1, + bytesRead: 12, + checkedAt: new Date(), + errorCode: null, + errorMessage: null, + leaseOwner: 'stale-worker', + reference: reference(), + status: 'matched', + taskId: 'task-1', + }), + ).resolves.toBeNull(); + expect(fileUpdateMany).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect(auditCreate).not.toHaveBeenCalled(); + + await expect( + repository.saveProgress( + 'task-1', + 'stale-worker', + { + bytesRead: 12, + failed: 0, + matched: 1, + mismatches: 0, + processed: 1, + total: 1, + }, + { id: 'node-1', kind: 'node' }, + ), + ).resolves.toBe(false); + expect(taskUpdateMany).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + leaseOwner: 'stale-worker', + status: 'running', + }), + }), + ); + }); + + it('treats a unique lease race as an unsuccessful claim', async () => { + const repository = new StorageIntegrityTaskRepository({ + blobIntegrityTask: { + findFirst: jest.fn(() => Promise.resolve({ id: 'task-1' })), + update: jest.fn(() => + Promise.reject( + Object.assign(new Error('lease conflict'), { code: 'P2002' }), + ), + ), + updateMany: jest.fn(() => Promise.resolve({ count: 0 })), + }, + } as unknown as PrismaService); + + await expect( + repository.claimNextTask({ + leaseExpiresAt: new Date(2), + leaseOwner: 'worker-a', + now: new Date(1), + }), + ).resolves.toBeNull(); + }); + + it('builds the integrity summary with database aggregates for nodes and versions', async () => { + const nodeGroupBy = jest.fn(() => + Promise.resolve([ + { _count: { _all: 2 }, integrityStatus: 'pending' }, + { _count: { _all: 1 }, integrityStatus: 'verified' }, + ]), + ); + const versionGroupBy = jest.fn(() => + Promise.resolve([{ _count: { _all: 3 }, integrityStatus: 'mismatch' }]), + ); + const verifiedAt = new Date('2026-08-14T00:00:00.000Z'); + const repository = new StorageIntegrityTaskRepository({ + fileNode: { + aggregate: jest.fn(() => + Promise.resolve({ _max: { lastVerifiedAt: verifiedAt } }), + ), + groupBy: nodeGroupBy, + }, + fileVersion: { + aggregate: jest.fn(() => + Promise.resolve({ _max: { lastVerifiedAt: null } }), + ), + groupBy: versionGroupBy, + }, + } as unknown as PrismaService); + + const summary = await repository.getSummary('workspace', 'workspace-a'); + + expect(summary).toMatchObject({ + counts: { + failed: 0, + mismatch: 3, + pending: 2, + unknown: 0, + verified: 1, + }, + lastVerifiedAt: verifiedAt.toISOString(), + scope: { kind: 'workspace', workspaceId: 'workspace-a' }, + }); + expect(nodeGroupBy).toHaveBeenCalledWith( + expect.objectContaining({ + by: ['integrityStatus'], + where: expect.objectContaining({ workspaceId: 'workspace-a' }), + }), + ); + expect(versionGroupBy).toHaveBeenCalledWith( + expect.objectContaining({ + where: { node: { workspaceId: 'workspace-a' } }, + }), + ); + }); + + it('returns safe paginated target candidates without object keys or hashes', async () => { + const repository = new StorageIntegrityTaskRepository({ + fileNode: { + count: jest.fn(() => Promise.resolve(1)), + findMany: jest.fn(() => + Promise.resolve([ + { + id: 'node-1', + integrityStatus: 'pending', + kind: 'doc', + lastVerifiedAt: null, + mimeType: 'text/plain', + name: 'report.txt', + originalPath: null, + sizeBytes: 12n, + updatedAt: new Date(1), + workspaceId: 'workspace-a', + }, + ]), + ), + }, + isSqlite: () => false, + } as unknown as PrismaService); + + const page = await repository.listTargets({ + limit: 20, + offset: 0, + query: ' report ', + workspaceId: 'workspace-a', + }); + + expect(page.items[0]).toEqual({ + id: 'node-1', + integrityStatus: 'pending', + kind: 'doc', + lastVerifiedAt: null, + mimeType: 'text/plain', + name: 'report.txt', + path: 'report.txt', + sizeBytes: 12, + updatedAt: new Date(1).toISOString(), + workspaceId: 'workspace-a', + }); + expect(JSON.stringify(page)).not.toContain('objectKey'); + expect(JSON.stringify(page)).not.toContain('checksum'); + }); +}); diff --git a/backend/src/modules/storage/storage-integrity-task.repository.sqlite-lifecycle.spec.ts b/backend/src/modules/storage/storage-integrity-task.repository.sqlite-lifecycle.spec.ts new file mode 100644 index 0000000..24a006d --- /dev/null +++ b/backend/src/modules/storage/storage-integrity-task.repository.sqlite-lifecycle.spec.ts @@ -0,0 +1,310 @@ +import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'; +import { randomUUID } from 'crypto'; +import { existsSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { PrismaService } from '../../database/prisma.service'; +import { PrismaClient } from '../../generated/prisma-sqlite/client'; +import { StorageIntegrityTaskRepository } from './storage-integrity-task.repository'; + +function sqliteClient(databasePath: string) { + return new PrismaClient({ + adapter: new PrismaBetterSqlite3( + { url: databasePath }, + { timestampFormat: 'iso8601' }, + ), + }); +} + +function sqliteService(client: PrismaClient) { + const service = Object.create(PrismaService.prototype) as PrismaService; + Object.assign(service as unknown as Record, { + activeClient: client, + activeSource: { provider: 'sqlite' }, + }); + return service; +} + +describe('StorageIntegrityTaskRepository SQLite lifecycle', () => { + let client: PrismaClient; + let secondClient: PrismaClient; + let databasePath: string; + + beforeEach(async () => { + databasePath = join( + tmpdir(), + `icedr-storage-integrity-lifecycle-${randomUUID()}.sqlite`, + ); + client = sqliteClient(databasePath); + await client.$connect(); + await client.$executeRawUnsafe(` + CREATE TABLE "file_nodes" ( + "id" TEXT NOT NULL PRIMARY KEY, + "workspace_id" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "object_key" TEXT, + "size_bytes" INTEGER, + "created_at" TEXT NOT NULL + ) + `); + await client.$executeRawUnsafe(` + CREATE TABLE "file_versions" ( + "id" TEXT NOT NULL PRIMARY KEY + ) + `); + await client.$executeRawUnsafe(` + CREATE TABLE "audit_events" ( + "id" TEXT NOT NULL PRIMARY KEY, + "action" TEXT NOT NULL, + "actor" TEXT NOT NULL, + "target" TEXT NOT NULL, + "workspace_id" TEXT, + "share_token" TEXT, + "node_id" TEXT, + "metadata" JSONB NOT NULL, + "created_at" TEXT NOT NULL + ) + `); + + const runtimeSchema = sqliteService(client); + await ( + runtimeSchema as unknown as { + ensureSqliteFileIntegritySchema: () => Promise; + } + ).ensureSqliteFileIntegritySchema(); + }); + + afterEach(async () => { + await secondClient?.$disconnect(); + await client.$disconnect(); + for (const suffix of ['', '-shm', '-wal']) { + const path = `${databasePath}${suffix}`; + if (existsSync(path)) rmSync(path, { force: true }); + } + }); + + it('reuses an identical active task and allows a fresh run after completion', async () => { + const repository = new StorageIntegrityTaskRepository( + sqliteService(client), + ); + secondClient = sqliteClient(databasePath); + await secondClient.$connect(); + const competingRepository = new StorageIntegrityTaskRepository( + sqliteService(secondClient), + ); + const input = { + actorUserId: 'admin-1', + batchSize: 10, + concurrency: 1, + maxAttempts: 3, + mode: 'verify' as const, + scope: 'all' as const, + }; + const [first, second] = await Promise.all([ + repository.createOrReuseTask(input), + competingRepository.createOrReuseTask({ + ...input, + actorUserId: 'admin-2', + }), + ]); + expect(first.task.id).toBe(second.task.id); + expect([first.created, second.created].sort()).toEqual([false, true]); + expect(await client.blobIntegrityTask.count()).toBe(1); + const now = new Date(); + await repository.claimNextTask({ + now, + leaseOwner: 'worker', + leaseExpiresAt: new Date(now.getTime() + 60000), + }); + await repository.completeTask(first.task.id, 'worker', { + mode: 'verify', + scope: 'all', + status: 'completed', + progress: { + bytesRead: 0, + failed: 0, + matched: 0, + mismatches: 0, + processed: 0, + total: 0, + }, + }); + const next = await repository.createOrReuseTask(input); + expect(next.created).toBe(true); + expect(next.task.id).not.toBe(first.task.id); + }); + + it('upgrades an old database and executes the full task lifecycle', async () => { + await client.$executeRawUnsafe( + `INSERT INTO "file_nodes" ( + "id", "workspace_id", "kind", "object_key", "size_bytes", + "created_at", "integrity_status" + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + 'node-1', + 'workspace-a', + 'other', + 'objects/node-1', + 12, + '2026-08-13T12:00:00.000Z', + 'pending', + ); + const repository = new StorageIntegrityTaskRepository( + sqliteService(client), + ); + const task = await repository.createTask({ + actorUserId: 'admin-1', + batchSize: 1, + concurrency: 1, + maxAttempts: 3, + mode: 'backfill', + scope: 'workspace', + target: { nodeId: 'node-1' }, + targetExpectedSizeBytes: 12, + targetObjectKey: 'objects/node-1', + workspaceId: 'workspace-a', + }); + const now = new Date(); + const claimed = await repository.claimNextTask({ + leaseExpiresAt: new Date(now.getTime() + 60_000), + leaseOwner: 'worker-a', + now, + }); + + expect(claimed?.id).toBe(task.id); + expect(await repository.countTargets(claimed!)).toBe(1); + const [target] = await repository.listTargetBatch(claimed!, {}); + expect(target).toMatchObject({ + nodeId: 'node-1', + objectKey: 'objects/node-1', + }); + const result = await repository.saveResult({ + actorUserId: 'admin-1', + actualHash: 'a'.repeat(64), + actualSizeBytes: 12, + attempts: 1, + auditScope: 'workspace', + bytesRead: 12, + checkedAt: new Date(), + errorCode: null, + errorMessage: null, + leaseOwner: 'worker-a', + reference: target, + status: 'matched', + taskId: task.id, + }); + expect(result).toMatchObject({ status: 'matched' }); + const progress = { + bytesRead: 12, + failed: 0, + matched: 1, + mismatches: 0, + processed: 1, + total: 1, + }; + await expect( + repository.completeTask(task.id, 'worker-a', { + actorUserId: 'admin-1', + mode: 'backfill', + progress, + scope: 'workspace', + status: 'completed', + workspaceId: 'workspace-a', + }), + ).resolves.toMatchObject({ progress, status: 'completed' }); + const [node] = await client.$queryRawUnsafe< + Array<{ + checksum_algorithm: string | null; + checksum_value: string | null; + integrity_status: string; + }> + >( + 'SELECT "checksum_algorithm", "checksum_value", "integrity_status" FROM "file_nodes" WHERE "id" = ?', + 'node-1', + ); + expect(node).toMatchObject({ + checksum_algorithm: 'sha256', + checksum_value: 'a'.repeat(64), + integrity_status: 'verified', + }); + }); + + it('allows only one owner to claim a queued task across two clients', async () => { + const firstRepository = new StorageIntegrityTaskRepository( + sqliteService(client), + ); + await firstRepository.createTask({ + actorUserId: 'admin-1', + batchSize: 10, + concurrency: 1, + maxAttempts: 3, + mode: 'verify', + scope: 'all', + }); + secondClient = sqliteClient(databasePath); + await secondClient.$connect(); + const secondRepository = new StorageIntegrityTaskRepository( + sqliteService(secondClient), + ); + const now = new Date(); + const claims = await Promise.all([ + firstRepository.claimNextTask({ + leaseExpiresAt: new Date(now.getTime() + 60_000), + leaseOwner: 'worker-a', + now, + }), + secondRepository.claimNextTask({ + leaseExpiresAt: new Date(now.getTime() + 60_000), + leaseOwner: 'worker-b', + now, + }), + ]); + + expect(claims.filter(Boolean)).toHaveLength(1); + expect(claims.filter(Boolean)[0]?.leaseOwner).toBeUndefined(); + const persisted = await client.blobIntegrityTask.findFirst(); + expect(persisted?.leaseOwner).toMatch(/^worker-[ab]$/); + }); + + it('atomically reclaims an expired lease before queued work', async () => { + const repository = new StorageIntegrityTaskRepository( + sqliteService(client), + ); + const expired = await repository.createTask({ + actorUserId: 'admin-1', + batchSize: 10, + concurrency: 1, + maxAttempts: 3, + mode: 'verify', + scope: 'all', + }); + await client.blobIntegrityTask.update({ + data: { + leaseExpiresAt: new Date('2026-08-13T11:59:00.000Z'), + leaseKey: 'storage-integrity', + leaseOwner: 'dead-worker', + startedAt: new Date('2026-08-13T11:58:00.000Z'), + status: 'running', + }, + where: { id: expired.id }, + }); + const queued = await repository.createTask({ + actorUserId: 'admin-1', + batchSize: 20, + concurrency: 1, + maxAttempts: 3, + mode: 'verify', + scope: 'all', + }); + + const claimed = await repository.claimNextTask({ + leaseExpiresAt: new Date('2026-08-13T12:01:00.000Z'), + leaseOwner: 'replacement-worker', + now: new Date('2026-08-13T12:00:00.000Z'), + }); + + expect(claimed?.id).toBe(expired.id); + expect( + await client.blobIntegrityTask.findUnique({ where: { id: queued.id } }), + ).toMatchObject({ leaseOwner: null, status: 'queued' }); + }); +}); diff --git a/backend/src/modules/storage/storage-integrity-task.repository.sqlite.spec.ts b/backend/src/modules/storage/storage-integrity-task.repository.sqlite.spec.ts new file mode 100644 index 0000000..5e19600 --- /dev/null +++ b/backend/src/modules/storage/storage-integrity-task.repository.sqlite.spec.ts @@ -0,0 +1,122 @@ +import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'; +import { randomUUID } from 'crypto'; +import { existsSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import type { PrismaService } from '../../database/prisma.service'; +import { PrismaClient } from '../../generated/prisma-sqlite/client'; +import { StorageIntegrityTaskRepository } from './storage-integrity-task.repository'; + +describe('StorageIntegrityTaskRepository SQLite target search', () => { + let client: PrismaClient; + let databasePath: string; + + beforeEach(async () => { + databasePath = join( + tmpdir(), + `icedr-storage-integrity-${randomUUID()}.sqlite`, + ); + client = new PrismaClient({ + adapter: new PrismaBetterSqlite3( + { url: databasePath }, + { timestampFormat: 'iso8601' }, + ), + }); + await client.$connect(); + await client.$executeRawUnsafe(` + CREATE TABLE "file_nodes" ( + "id" TEXT NOT NULL PRIMARY KEY, + "workspace_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "mime_type" TEXT NOT NULL, + "size_bytes" INTEGER, + "object_key" TEXT, + "integrity_status" TEXT NOT NULL DEFAULT 'unknown', + "last_verified_at" TEXT, + "archived_at" TEXT, + "original_path" TEXT, + "updated_at" TEXT NOT NULL + ) + `); + await client.$executeRawUnsafe( + `INSERT INTO "file_nodes" ( + "id", "workspace_id", "name", "kind", "mime_type", "size_bytes", + "object_key", "integrity_status", "updated_at" + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + 'node-archive', + 'workspace-a', + 'archive.bin', + 'other', + 'application/octet-stream', + 7, + 'objects/archive.bin', + 'unknown', + '2026-08-13T12:00:00.000Z', + ); + }); + + afterEach(async () => { + await client.$disconnect(); + for (const suffix of ['', '-shm', '-wal']) { + const path = `${databasePath}${suffix}`; + if (existsSync(path)) rmSync(path, { force: true }); + } + }); + + it('searches target names case-insensitively through the SQLite client', async () => { + const repository = new StorageIntegrityTaskRepository({ + fileNode: client.fileNode, + isSqlite: () => true, + } as unknown as PrismaService); + + await expect( + repository.listTargets({ + limit: 20, + offset: 0, + query: 'ARCHIVE', + workspaceId: 'workspace-a', + }), + ).resolves.toMatchObject({ + items: [ + { + id: 'node-archive', + name: 'archive.bin', + workspaceId: 'workspace-a', + }, + ], + total: 1, + }); + }); + + it('does not offer files without a trustworthy expected size as targets', async () => { + await client.$executeRawUnsafe( + `INSERT INTO "file_nodes" ( + "id", "workspace_id", "name", "kind", "mime_type", "size_bytes", + "object_key", "integrity_status", "updated_at" + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + 'node-missing-size', + 'workspace-a', + 'unknown-size.bin', + 'other', + 'application/octet-stream', + null, + 'objects/unknown-size.bin', + 'unknown', + '2026-08-13T13:00:00.000Z', + ); + const repository = new StorageIntegrityTaskRepository({ + fileNode: client.fileNode, + isSqlite: () => true, + } as unknown as PrismaService); + + const page = await repository.listTargets({ + limit: 20, + offset: 0, + workspaceId: 'workspace-a', + }); + + expect(page.items.map((item) => item.id)).toEqual(['node-archive']); + expect(page.total).toBe(1); + }); +}); diff --git a/backend/src/modules/storage/storage-integrity-task.repository.ts b/backend/src/modules/storage/storage-integrity-task.repository.ts new file mode 100644 index 0000000..3917edc --- /dev/null +++ b/backend/src/modules/storage/storage-integrity-task.repository.ts @@ -0,0 +1,967 @@ +import { createHash, randomBytes } from 'crypto'; +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../database/prisma.service'; +import { Prisma, type BlobIntegrityTask } from '../../generated/prisma/client'; +import type { + CreateStorageIntegrityTaskDto, + StorageIntegrityResultStatus, + StorageIntegritySummaryResponse, + StorageIntegrityTaskResponse, +} from './storage-integrity-task.dto'; +import { recordStorageIntegrityAudit } from './storage-integrity-audit'; +import { mapStorageIntegrityResult } from './storage-integrity-result'; +import type { + IntegrityTargetReference, + StorageIntegrityExecutionTask, +} from './storage-integrity-task.types'; +export type { + IntegrityTargetReference, + StorageIntegrityExecutionTask, +} from './storage-integrity-task.types'; +import { StorageIntegrityTargetRepository } from './storage-integrity-target.repository'; + +type CreateStorageIntegrityTaskInput = CreateStorageIntegrityTaskDto & { + actorUserId: string | null; + retryOfTaskId?: string | null; + retryResultIds?: string[]; + snapshotAt?: Date; + targetChecksumAlgorithm?: string | null; + targetChecksumValue?: string | null; + targetExpectedSizeBytes?: number | null; + targetObjectKey?: string | null; +}; + +type IntegrityTaskPersistenceClient = Pick< + Prisma.TransactionClient, + 'blobIntegrityTask' +>; + +const emptyProgress = () => ({ + bytesRead: 0, + failed: 0, + matched: 0, + mismatches: 0, + processed: 0, + total: 0, +}); + +@Injectable() +export class StorageIntegrityTaskRepository { + private readonly targets: StorageIntegrityTargetRepository; + + constructor(private readonly prisma: PrismaService) { + this.targets = new StorageIntegrityTargetRepository(prisma); + } + + async workspaceExists(workspaceId: string) { + return Boolean( + await this.prisma.workspace.findUnique({ + where: { id: workspaceId }, + select: { id: true }, + }), + ); + } + + createTask( + input: CreateStorageIntegrityTaskInput, + client: IntegrityTaskPersistenceClient = this.prisma, + ) { + return this.createOrReuseTask(input, client).then(({ task }) => task); + } + + async createOrReuseTask( + input: CreateStorageIntegrityTaskInput, + client: IntegrityTaskPersistenceClient = this.prisma, + ): Promise<{ created: boolean; task: StorageIntegrityTaskResponse }> { + const now = new Date(); + const id = this.newId('inttask'); + const activeKey = createHash('sha256') + .update( + JSON.stringify([ + input.scope, + input.workspaceId ?? null, + input.mode, + input.target?.nodeId ?? null, + input.target?.versionId ?? null, + input.targetObjectKey ?? null, + input.targetChecksumAlgorithm ?? null, + input.targetChecksumValue ?? null, + input.targetExpectedSizeBytes ?? null, + input.batchSize, + input.concurrency, + input.maxAttempts, + input.bandwidthLimitBytesPerSecond ?? null, + input.retryOfTaskId ?? null, + [...(input.retryResultIds ?? [])].sort(), + input.snapshotAt?.toISOString() ?? null, + ]), + ) + .digest('hex'); + const row = await client.blobIntegrityTask.upsert({ + where: { activeKey }, + update: { activeKey }, + create: { + activeKey, + actorUserId: input.actorUserId, + bandwidthLimitBytesPerSecond: + input.bandwidthLimitBytesPerSecond == null + ? null + : BigInt(Math.trunc(input.bandwidthLimitBytesPerSecond)), + batchSize: input.batchSize, + concurrency: input.concurrency, + createdAt: now, + cursor: {}, + id, + maxAttempts: input.maxAttempts, + mode: input.mode, + progress: emptyProgress(), + retryOfTaskId: input.retryOfTaskId ?? null, + retryResultIds: input.retryResultIds ?? [], + scope: input.scope, + snapshotAt: input.snapshotAt ?? now, + status: 'queued', + target: input.target + ? { + ...(input.target.nodeId ? { nodeId: input.target.nodeId } : {}), + ...(input.target.versionId + ? { versionId: input.target.versionId } + : {}), + ...(input.targetObjectKey + ? { objectKey: input.targetObjectKey } + : {}), + ...(input.targetExpectedSizeBytes !== undefined + ? { expectedSizeBytes: input.targetExpectedSizeBytes } + : {}), + ...(input.targetChecksumAlgorithm !== undefined + ? { checksumAlgorithm: input.targetChecksumAlgorithm } + : {}), + ...(input.targetChecksumValue !== undefined + ? { checksumValue: input.targetChecksumValue } + : {}), + } + : {}, + workspaceId: input.workspaceId ?? null, + }, + }); + return { created: row.id === id, task: this.mapTask(row) }; + } + + async getTask(id: string) { + const row = await this.prisma.blobIntegrityTask.findUnique({ + where: { id }, + }); + return row ? this.mapTask(row) : null; + } + + async getExecutionTask(id: string) { + const row = await this.prisma.blobIntegrityTask.findUnique({ + where: { id }, + }); + return row ? this.mapExecutionTask(row) : null; + } + + async listTasks(input: { + limit: number; + offset: number; + scope: 'all' | 'workspace'; + workspaceId?: string; + }) { + const where = + input.scope === 'workspace' + ? { scope: 'workspace', workspaceId: input.workspaceId } + : { scope: 'all' }; + const [rows, total] = await Promise.all([ + this.prisma.blobIntegrityTask.findMany({ + orderBy: { createdAt: 'desc' }, + skip: input.offset, + take: input.limit, + where, + }), + this.prisma.blobIntegrityTask.count({ where }), + ]); + return { + items: rows.map((row) => this.mapTask(row)), + limit: input.limit, + offset: input.offset, + total, + }; + } + + async listResults(input: { + limit: number; + offset: number; + status?: StorageIntegrityResultStatus; + taskId: string; + }) { + const where = { + taskId: input.taskId, + ...(input.status ? { status: input.status } : {}), + }; + const [rows, total] = await Promise.all([ + this.prisma.blobIntegrityResult.findMany({ + orderBy: [{ checkedAt: 'desc' }, { id: 'desc' }], + skip: input.offset, + take: input.limit, + where, + }), + this.prisma.blobIntegrityResult.count({ where }), + ]); + return { + items: rows.map(mapStorageIntegrityResult), + limit: input.limit, + offset: input.offset, + total, + }; + } + + async getRetryableResults(taskId: string, resultIds?: string[]) { + return this.prisma.blobIntegrityResult.findMany({ + orderBy: { id: 'asc' }, + where: { + taskId, + status: { in: ['mismatch', 'failed'] }, + ...(resultIds?.length ? { id: { in: resultIds } } : {}), + }, + }); + } + + async claimNextTask(input: { + leaseExpiresAt: Date; + leaseOwner: string; + now: Date; + }) { + if (this.prisma.isSqlite?.()) { + return this.claimNextSqliteTask(input); + } + await this.recoverExpiredLease(input.now); + const candidate = await this.prisma.blobIntegrityTask.findFirst({ + orderBy: { createdAt: 'asc' }, + where: { status: 'queued' }, + select: { id: true, startedAt: true }, + }); + if (!candidate) return null; + try { + const row = await this.prisma.blobIntegrityTask.update({ + data: { + leaseExpiresAt: input.leaseExpiresAt, + leaseKey: 'storage-integrity', + leaseOwner: input.leaseOwner, + startedAt: candidate.startedAt ?? input.now, + status: 'running', + }, + where: { id: candidate.id, status: 'queued' }, + }); + return this.mapExecutionTask(row); + } catch (error) { + if (this.isClaimConflict(error)) return null; + throw error; + } + } + + private async claimNextSqliteTask(input: { + leaseExpiresAt: Date; + leaseOwner: string; + now: Date; + }) { + try { + const rows = await this.prisma.$queryRawUnsafe>( + ` + UPDATE "blob_integrity_tasks" + SET + "lease_expires_at" = ?, + "lease_key" = 'storage-integrity', + "lease_owner" = ?, + "started_at" = COALESCE("started_at", ?), + "status" = 'running' + WHERE "id" = ( + SELECT "candidate"."id" + FROM "blob_integrity_tasks" AS "candidate" + WHERE ( + "candidate"."status" = 'queued' + OR ( + "candidate"."status" = 'running' + AND "candidate"."lease_expires_at" IS NOT NULL + AND "candidate"."lease_expires_at" <= ? + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM "blob_integrity_tasks" AS "active" + WHERE "active"."status" = 'running' + AND "active"."lease_key" = 'storage-integrity' + AND ( + "active"."lease_expires_at" IS NULL + OR "active"."lease_expires_at" > ? + ) + ) + ORDER BY + CASE + WHEN "candidate"."status" = 'running' + AND "candidate"."lease_key" = 'storage-integrity' THEN 0 + WHEN "candidate"."status" = 'running' THEN 1 + ELSE 2 + END, + "candidate"."created_at" ASC, + "candidate"."id" ASC + LIMIT 1 + ) + AND ( + "status" = 'queued' + OR ( + "status" = 'running' + AND "lease_expires_at" IS NOT NULL + AND "lease_expires_at" <= ? + ) + ) + RETURNING "id" + `, + input.leaseExpiresAt.toISOString(), + input.leaseOwner, + input.now.toISOString(), + input.now.toISOString(), + input.now.toISOString(), + input.now.toISOString(), + ); + const claimedId = rows[0]?.id; + if (!claimedId) return null; + const row = await this.prisma.blobIntegrityTask.findUnique({ + where: { id: claimedId }, + }); + return row ? this.mapExecutionTask(row) : null; + } catch (error) { + if (this.isClaimConflict(error)) return null; + throw error; + } + } + + async recoverExpiredLease(now: Date) { + await this.prisma.blobIntegrityTask.updateMany({ + data: { + leaseExpiresAt: null, + leaseKey: null, + leaseOwner: null, + status: 'queued', + }, + where: { + leaseExpiresAt: { lte: now }, + status: 'running', + }, + }); + } + + async renewLease(id: string, leaseOwner: string, leaseExpiresAt: Date) { + const result = await this.prisma.blobIntegrityTask.updateMany({ + data: { leaseExpiresAt }, + where: { id, leaseOwner, status: 'running' }, + }); + return result.count === 1; + } + + async initializeTargetCount( + id: string, + leaseOwner: string, + targetCount: number, + ) { + const now = new Date(); + const task = await this.prisma.blobIntegrityTask.findFirst({ + select: { progress: true }, + where: { + id, + leaseExpiresAt: { gt: now }, + leaseOwner, + status: 'running', + targetCount: null, + }, + }); + if (!task) return null; + const result = await this.prisma.blobIntegrityTask.updateMany({ + data: { + progress: { ...this.parseProgress(task.progress), total: targetCount }, + targetCount, + }, + where: { + id, + leaseExpiresAt: { gt: now }, + leaseOwner, + status: 'running', + targetCount: null, + }, + }); + return result.count === 1 ? targetCount : null; + } + + countTargets(task: StorageIntegrityExecutionTask) { + return this.targets.countTargets(task); + } + + listTargetBatch( + task: StorageIntegrityExecutionTask, + cursor: { kind?: 'node' | 'retry' | 'version'; id?: string }, + ) { + return this.targets.listTargetBatch(task, cursor); + } + + getTaskCursor( + id: string, + ): Promise<{ kind?: 'node' | 'retry' | 'version'; id?: string }> { + return this.prisma.blobIntegrityTask + .findUnique({ where: { id }, select: { cursor: true } }) + .then((row) => this.parseCursor(row?.cursor)); + } + + async getProgressFromResults(taskId: string, total: number) { + const [matched, mismatches, failed, bytes] = await Promise.all([ + this.prisma.blobIntegrityResult.count({ + where: { status: 'matched', taskId }, + }), + this.prisma.blobIntegrityResult.count({ + where: { status: 'mismatch', taskId }, + }), + this.prisma.blobIntegrityResult.count({ + where: { status: 'failed', taskId }, + }), + this.prisma.blobIntegrityResult.aggregate({ + _sum: { bytesRead: true }, + where: { taskId }, + }), + ]); + return { + bytesRead: Number(bytes._sum.bytesRead ?? 0n), + failed, + matched, + mismatches, + processed: matched + mismatches + failed, + total, + }; + } + + async saveResult(input: { + actorUserId?: string | null; + auditScope?: 'all' | 'workspace'; + actualHash: string | null; + actualSizeBytes: number | null; + bytesRead: number; + attempts: number; + checkedAt: Date; + errorCode: string | null; + errorMessage: string | null; + leaseOwner: string; + reference: IntegrityTargetReference; + sourceResultId?: string | null; + status: StorageIntegrityResultStatus; + taskId: string; + }) { + const resultId = this.newId('intres'); + return this.prisma.$transaction(async (tx) => { + const ownership = await tx.blobIntegrityTask.updateMany({ + data: { leaseOwner: input.leaseOwner }, + where: { + id: input.taskId, + leaseExpiresAt: { gt: new Date() }, + leaseOwner: input.leaseOwner, + status: 'running', + }, + }); + if (ownership.count !== 1) return null; + const existing = await tx.blobIntegrityResult.findFirst({ + where: { + taskId: input.taskId, + targetKey: input.reference.targetKey, + }, + }); + if (existing) return mapStorageIntegrityResult(existing); + if (!input.reference.versionId && input.reference.nodeId) { + const possibleMovedResult = await tx.blobIntegrityResult.findFirst({ + select: { id: true }, + where: { + expectedHash: input.reference.expectedHash, + expectedSizeBytes: BigInt(input.reference.expectedSizeBytes), + nodeId: input.reference.nodeId, + objectKey: input.reference.objectKey, + taskId: input.taskId, + versionId: { not: null }, + workspaceId: input.reference.workspaceId, + }, + }); + if (possibleMovedResult) { + const movedReference = await this.targets.resolveMovedNodeReference( + tx, + input.reference, + ); + if (movedReference) { + const movedResult = await tx.blobIntegrityResult.findFirst({ + where: { + taskId: input.taskId, + targetKey: movedReference.targetKey, + }, + }); + if (movedResult) return mapStorageIntegrityResult(movedResult); + } + } + } + const persistedReference = await this.targets.persistIntegrityState( + tx, + input, + ); + const effective = persistedReference + ? { ...input, reference: persistedReference } + : { + ...input, + actualHash: null, + actualSizeBytes: null, + errorCode: 'target-changed', + errorMessage: '文件内容引用已发生变化', + status: 'failed' as const, + }; + const row = await tx.blobIntegrityResult.create({ + data: { + acknowledgedAt: null, + acknowledgedBy: null, + actualHash: effective.actualHash, + attempts: effective.attempts, + bytesRead: BigInt(effective.bytesRead), + checkedAt: effective.checkedAt, + errorCode: effective.errorCode, + errorMessage: effective.errorMessage, + expectedHash: effective.reference.expectedHash, + expectedSizeBytes: BigInt(effective.reference.expectedSizeBytes), + id: resultId, + nodeId: effective.reference.nodeId, + objectKey: effective.reference.objectKey, + sizeBytes: + effective.actualSizeBytes === null + ? null + : BigInt(effective.actualSizeBytes), + sourceResultId: input.sourceResultId ?? null, + status: effective.status, + targetKey: effective.reference.targetKey, + taskId: input.taskId, + versionId: effective.reference.versionId, + workspaceId: effective.reference.workspaceId, + }, + }); + if (effective.status !== 'matched') { + await recordStorageIntegrityAudit(tx, { + action: + effective.status === 'mismatch' + ? 'system.storage_integrity_mismatch_detected' + : 'system.storage_integrity_failure_detected', + actorUserId: input.actorUserId, + errorCode: effective.errorCode, + nodeId: effective.reference.nodeId, + result: 'failed', + resultId: row.id, + scope: input.auditScope, + status: effective.status, + taskId: input.taskId, + versionId: effective.reference.versionId, + workspaceId: effective.reference.workspaceId, + }); + } + return mapStorageIntegrityResult(row); + }); + } + + isTargetCurrent(reference: IntegrityTargetReference) { + return this.targets.targetIsCurrent(this.prisma, reference); + } + + async saveProgress( + taskId: string, + leaseOwner: string, + progress: StorageIntegrityTaskResponse['progress'], + cursor: { kind: 'node' | 'retry' | 'version'; id: string }, + ) { + const result = await this.prisma.blobIntegrityTask.updateMany({ + data: { cursor, progress }, + where: { + id: taskId, + leaseExpiresAt: { gt: new Date() }, + leaseOwner, + status: 'running', + }, + }); + return result.count === 1; + } + + async completeTask( + taskId: string, + leaseOwner: string, + input: { + actorUserId?: string | null; + failureCode?: string | null; + failureMessage?: string | null; + mode: 'backfill' | 'verify'; + progress: StorageIntegrityTaskResponse['progress']; + scope: 'all' | 'workspace'; + status: 'completed' | 'failed'; + workspaceId?: string | null; + }, + ) { + return this.prisma.$transaction(async (tx) => { + const result = await tx.blobIntegrityTask.updateMany({ + data: { + failureCode: input.failureCode ?? null, + activeKey: null, + failureMessage: input.failureMessage ?? null, + finishedAt: new Date(), + leaseExpiresAt: null, + leaseKey: null, + leaseOwner: null, + progress: input.progress, + status: input.status, + }, + where: { + id: taskId, + leaseExpiresAt: { gt: new Date() }, + leaseOwner, + status: 'running', + }, + }); + if (result.count !== 1) return null; + const row = await tx.blobIntegrityTask.findUnique({ + where: { id: taskId }, + }); + if (!row) return null; + await recordStorageIntegrityAudit(tx, { + action: + input.status === 'completed' + ? 'system.storage_integrity_task_completed' + : 'system.storage_integrity_task_failed', + actorUserId: input.actorUserId, + errorCode: input.failureCode, + mode: input.mode, + progress: input.progress, + result: + input.status === 'completed' && + input.progress.mismatches === 0 && + input.progress.failed === 0 + ? 'success' + : 'failed', + scope: input.scope, + taskId, + workspaceId: input.workspaceId, + }); + return this.mapTask(row); + }); + } + + async requeueOwnedTask(taskId: string, leaseOwner: string) { + await this.prisma.blobIntegrityTask.updateMany({ + data: { + leaseExpiresAt: null, + leaseKey: null, + leaseOwner: null, + status: 'queued', + }, + where: { id: taskId, leaseOwner, status: 'running' }, + }); + } + + async getSummary( + scope: 'all' | 'workspace', + workspaceId?: string, + ): Promise { + const nodeWhere = { + kind: { not: 'folder' }, + objectKey: { not: null }, + sizeBytes: { not: null }, + ...(scope === 'workspace' ? { workspaceId } : {}), + } as const; + const versionWhere = scope === 'workspace' ? { node: { workspaceId } } : {}; + const [nodeGroups, versionGroups, nodeLatest, versionLatest] = + await Promise.all([ + this.prisma.fileNode.groupBy({ + _count: { _all: true }, + by: ['integrityStatus'], + where: nodeWhere, + }), + this.prisma.fileVersion.groupBy({ + _count: { _all: true }, + by: ['integrityStatus'], + where: versionWhere, + }), + this.prisma.fileNode.aggregate({ + _max: { lastVerifiedAt: true }, + where: nodeWhere, + }), + this.prisma.fileVersion.aggregate({ + _max: { lastVerifiedAt: true }, + where: versionWhere, + }), + ]); + const counts = { + failed: 0, + mismatch: 0, + pending: 0, + unknown: 0, + verified: 0, + }; + for (const row of [...nodeGroups, ...versionGroups]) { + counts[this.normalizeIntegrityStatus(row.integrityStatus)] += + row._count._all; + } + const latest = [ + nodeLatest._max.lastVerifiedAt, + versionLatest._max.lastVerifiedAt, + ].filter((value): value is Date => value instanceof Date); + const lastVerifiedAt = latest.sort( + (left, right) => right.getTime() - left.getTime(), + )[0]; + return { + counts, + generatedAt: new Date().toISOString(), + lastVerifiedAt: lastVerifiedAt?.toISOString() ?? null, + scope: + scope === 'workspace' + ? { kind: 'workspace', workspaceId: workspaceId ?? '' } + : { kind: 'all' }, + }; + } + + async listTargets(input: { + limit: number; + offset: number; + query?: string; + workspaceId: string; + }) { + const query = input.query?.trim(); + const nameFilter = query + ? this.prisma.isSqlite() + ? { contains: query } + : { contains: query, mode: 'insensitive' as const } + : undefined; + const where = { + archivedAt: null, + kind: { not: 'folder' }, + objectKey: { not: null }, + sizeBytes: { not: null }, + workspaceId: input.workspaceId, + ...(query + ? { + OR: [{ name: nameFilter! }, { id: query }], + } + : {}), + }; + const [rows, total] = await Promise.all([ + this.prisma.fileNode.findMany({ + orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }], + select: { + id: true, + integrityStatus: true, + kind: true, + lastVerifiedAt: true, + mimeType: true, + name: true, + originalPath: true, + sizeBytes: true, + updatedAt: true, + workspaceId: true, + }, + skip: input.offset, + take: input.limit, + where, + }), + this.prisma.fileNode.count({ where }), + ]); + return { + items: rows.map((row) => ({ + id: row.id, + integrityStatus: this.normalizeIntegrityStatus(row.integrityStatus), + kind: row.kind, + lastVerifiedAt: row.lastVerifiedAt?.toISOString() ?? null, + mimeType: row.mimeType, + name: row.name, + path: row.originalPath ?? row.name, + sizeBytes: Number(row.sizeBytes ?? 0n), + updatedAt: row.updatedAt.toISOString(), + workspaceId: row.workspaceId, + })), + limit: input.limit, + offset: input.offset, + total, + }; + } + + async listTargetVersions(nodeId: string, workspaceId: string) { + const node = await this.prisma.fileNode.findFirst({ + where: { id: nodeId, workspaceId }, + select: { id: true }, + }); + if (!node) return null; + const rows = await this.prisma.fileVersion.findMany({ + orderBy: { versionNumber: 'desc' }, + where: { nodeId }, + select: { + createdAt: true, + id: true, + integrityStatus: true, + lastVerifiedAt: true, + mimeType: true, + nodeId: true, + remark: true, + sizeBytes: true, + uploadedBy: true, + versionNumber: true, + }, + }); + return rows.map((row) => ({ + ...row, + createdAt: row.createdAt.toISOString(), + integrityStatus: this.normalizeIntegrityStatus(row.integrityStatus), + lastVerifiedAt: row.lastVerifiedAt?.toISOString() ?? null, + sizeBytes: Number(row.sizeBytes), + })); + } + + private mapTask(row: BlobIntegrityTask): StorageIntegrityTaskResponse { + return { + config: { + bandwidthLimitBytesPerSecond: + row.bandwidthLimitBytesPerSecond === null + ? null + : Number(row.bandwidthLimitBytesPerSecond), + batchSize: row.batchSize, + concurrency: row.concurrency, + maxAttempts: row.maxAttempts, + }, + createdAt: row.createdAt.toISOString(), + failureCode: row.failureCode, + failureMessage: row.failureMessage, + finishedAt: row.finishedAt?.toISOString() ?? null, + id: row.id, + mode: this.normalizeMode(row.mode), + progress: this.parseProgress(row.progress), + scope: this.normalizeScope(row.scope), + startedAt: row.startedAt?.toISOString() ?? null, + status: this.normalizeTaskStatus(row.status), + target: this.parseTarget(row.target), + workspaceId: row.workspaceId, + }; + } + + private mapExecutionTask( + row: BlobIntegrityTask, + ): StorageIntegrityExecutionTask { + return { + ...this.mapTask(row), + actorUserId: row.actorUserId, + retryOfTaskId: row.retryOfTaskId, + retryResultIds: this.parseStringArray(row.retryResultIds), + snapshotAt: row.snapshotAt, + targetChecksumAlgorithm: this.parseTargetNullableString( + row.target, + 'checksumAlgorithm', + ), + targetChecksumValue: this.parseTargetNullableString( + row.target, + 'checksumValue', + ), + targetCount: row.targetCount, + targetExpectedSizeBytes: this.parseTargetExpectedSize(row.target), + targetObjectKey: this.parseTargetObjectKey(row.target), + }; + } + + private parseProgress(value: Prisma.JsonValue) { + const record = this.asRecord(value); + const fallback = emptyProgress(); + return Object.fromEntries( + Object.keys(fallback).map((key) => [key, this.number(record[key])]), + ) as StorageIntegrityTaskResponse['progress']; + } + + private parseTarget(value: Prisma.JsonValue) { + const record = this.asRecord(value); + const nodeId = + typeof record.nodeId === 'string' ? record.nodeId : undefined; + const versionId = + typeof record.versionId === 'string' ? record.versionId : undefined; + return nodeId || versionId ? { nodeId, versionId } : null; + } + + private parseTargetObjectKey(value: Prisma.JsonValue) { + const objectKey = this.asRecord(value).objectKey; + return typeof objectKey === 'string' && objectKey ? objectKey : null; + } + + private parseTargetExpectedSize(value: Prisma.JsonValue) { + const size = this.asRecord(value).expectedSizeBytes; + return typeof size === 'number' && Number.isSafeInteger(size) && size >= 0 + ? size + : null; + } + + private parseTargetNullableString( + value: Prisma.JsonValue, + key: 'checksumAlgorithm' | 'checksumValue', + ) { + const field = this.asRecord(value)[key]; + return typeof field === 'string' ? field : null; + } + + private parseCursor(value: Prisma.JsonValue | null | undefined): { + kind?: 'node' | 'retry' | 'version'; + id?: string; + } { + const record = this.asRecord(value); + const kind = record.kind; + const id = record.id; + return (kind === 'node' || kind === 'retry' || kind === 'version') && + typeof id === 'string' + ? { id, kind: kind } + : {}; + } + + private parseStringArray(value: Prisma.JsonValue) { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; + } + + private asRecord(value: Prisma.JsonValue | null | undefined) { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; + } + + private number(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) ? value : 0; + } + + private normalizeTaskStatus( + status: string, + ): StorageIntegrityTaskResponse['status'] { + return ['queued', 'running', 'completed', 'failed', 'cancelled'].includes( + status, + ) + ? (status as StorageIntegrityTaskResponse['status']) + : 'failed'; + } + + private normalizeMode(mode: string) { + if (mode === 'backfill' || mode === 'verify') return mode; + throw new Error('Invalid persisted storage integrity task mode'); + } + + private normalizeScope(scope: string) { + if (scope === 'all' || scope === 'workspace') return scope; + throw new Error('Invalid persisted storage integrity task scope'); + } + + private normalizeIntegrityStatus(status: string) { + return ['pending', 'verified', 'mismatch', 'failed'].includes(status) + ? (status as 'pending' | 'verified' | 'mismatch' | 'failed') + : 'unknown'; + } + + private isClaimConflict(error: unknown) { + if (!error || typeof error !== 'object') return false; + const code = (error as { code?: unknown }).code; + return code === 'P2002' || code === 'P2025'; + } + + private newId(prefix: string) { + return `${prefix}_${randomBytes(12).toString('base64url')}`; + } +} diff --git a/backend/src/modules/storage/storage-integrity-task.service.spec.ts b/backend/src/modules/storage/storage-integrity-task.service.spec.ts new file mode 100644 index 0000000..f23ea85 --- /dev/null +++ b/backend/src/modules/storage/storage-integrity-task.service.spec.ts @@ -0,0 +1,415 @@ +import { BadRequestException } from '@nestjs/common'; +import type { PrismaService } from '../../database/prisma.service'; +import { StorageIntegrityTaskService } from './storage-integrity-task.service'; +import type { StorageIntegrityTaskRepository } from './storage-integrity-task.repository'; +import type { StorageIntegrityTaskRunner } from './storage-integrity-task-runner.service'; + +/* eslint-disable @typescript-eslint/unbound-method */ + +const task = { + config: { + bandwidthLimitBytesPerSecond: null, + batchSize: 25, + concurrency: 2, + maxAttempts: 3, + }, + createdAt: new Date(1).toISOString(), + failureCode: null, + failureMessage: null, + finishedAt: null, + id: 'inttask-new', + mode: 'verify' as const, + progress: { + bytesRead: 0, + failed: 0, + matched: 0, + mismatches: 0, + processed: 0, + total: 0, + }, + scope: 'all' as const, + startedAt: null, + status: 'queued' as const, + target: null, + workspaceId: null, +}; + +function setup() { + const transaction = { + auditEvent: { create: jest.fn(() => Promise.resolve({})) }, + blobIntegrityTask: {}, + fileNode: { + findFirst: jest.fn(() => + Promise.resolve({ + checksumAlgorithm: null, + checksumValue: null, + objectKey: 'private/object-key', + sizeBytes: 16n, + }), + ), + }, + fileVersion: { findFirst: jest.fn() }, + }; + const prisma = { + $transaction: jest.fn((callback: (tx: typeof transaction) => unknown) => + Promise.resolve(callback(transaction)), + ), + fileNode: { findFirst: jest.fn() }, + fileVersion: { findFirst: jest.fn() }, + } as unknown as PrismaService; + const repository = { + createOrReuseTask: jest.fn(() => Promise.resolve({ created: true, task })), + getRetryableResults: jest.fn(), + getExecutionTask: jest.fn(), + getTask: jest.fn(), + getSummary: jest.fn(), + listResults: jest.fn(), + listTasks: jest.fn(), + workspaceExists: jest.fn(() => Promise.resolve(true)), + } as unknown as StorageIntegrityTaskRepository; + const runner = { kick: jest.fn() } as unknown as StorageIntegrityTaskRunner; + return { + repository, + runner, + service: new StorageIntegrityTaskService(repository, runner, prisma), + transaction, + }; +} + +describe('StorageIntegrityTaskService', () => { + it('durably creates a queued task and audit before triggering async execution', async () => { + const { repository, runner, service, transaction } = setup(); + + await expect( + service.createTask( + { + batchSize: 25, + concurrency: 2, + maxAttempts: 3, + mode: 'verify', + scope: 'all', + }, + 'admin-1', + ), + ).resolves.toEqual(task); + + expect(repository.createOrReuseTask).toHaveBeenCalledWith( + expect.objectContaining({ actorUserId: 'admin-1', scope: 'all' }), + transaction, + ); + expect(transaction.auditEvent.create).toHaveBeenCalledTimes(1); + expect(runner.kick).toHaveBeenCalledTimes(1); + }); + + it('does not record another start audit when an active task is reused', async () => { + const { repository, runner, service, transaction } = setup(); + (repository.createOrReuseTask as jest.Mock).mockResolvedValueOnce({ + created: false, + task, + }); + + await expect( + service.createTask( + { + batchSize: 25, + concurrency: 2, + maxAttempts: 3, + mode: 'verify', + scope: 'all', + }, + 'admin-2', + ), + ).resolves.toEqual(task); + + expect(transaction.auditEvent.create).not.toHaveBeenCalled(); + expect(runner.kick).toHaveBeenCalledTimes(1); + }); + + it('fails closed for an absent workspace or a target outside that workspace', async () => { + const { repository, service } = setup(); + (repository.workspaceExists as jest.Mock).mockResolvedValueOnce(false); + + await expect( + service.createTask( + { + batchSize: 10, + concurrency: 1, + maxAttempts: 1, + mode: 'backfill', + scope: 'workspace', + workspaceId: 'workspace-missing', + }, + 'admin-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + expect(repository.createOrReuseTask).not.toHaveBeenCalled(); + + (repository.workspaceExists as jest.Mock).mockResolvedValueOnce(true); + await expect( + service.createTask( + { + batchSize: 10, + concurrency: 1, + maxAttempts: 1, + mode: 'verify', + scope: 'workspace', + target: { nodeId: 'node-outside' }, + workspaceId: 'workspace-a', + }, + 'admin-1', + ), + ).rejects.toThrow('目标不属于指定工作区'); + expect(repository.createOrReuseTask).not.toHaveBeenCalled(); + }); + + it('rejects duplicate, foreign, or non-retryable result selections', async () => { + const { repository, service } = setup(); + (repository.getTask as jest.Mock).mockResolvedValue({ + ...task, + status: 'failed', + }); + + await expect( + service.retryTask('inttask-old', ['result-a', 'result-a'], 'admin-1'), + ).rejects.toThrow('resultIds 必须非空且不能重复'); + + (repository.getRetryableResults as jest.Mock).mockResolvedValue([ + { id: 'result-a' }, + ]); + await expect( + service.retryTask( + 'inttask-old', + ['result-a', 'result-foreign'], + 'admin-1', + ), + ).rejects.toThrow('所选结果不可重试或不属于该任务'); + expect(repository.createOrReuseTask).not.toHaveBeenCalled(); + }); + + it('does not kick a task persisted inside a caller transaction before commit', async () => { + const { repository, runner, service, transaction } = setup(); + + await service.enqueueObjectVerification( + { + nodeId: 'node-1', + objectKey: 'private/object-key', + workspaceId: 'workspace-a', + }, + transaction as never, + ); + + expect(runner.kick).not.toHaveBeenCalled(); + expect(repository.createOrReuseTask).toHaveBeenCalledWith( + expect.objectContaining({ + targetObjectKey: 'private/object-key', + }), + transaction, + ); + expect(transaction.auditEvent.create).toHaveBeenCalledTimes(1); + expect( + JSON.stringify(transaction.auditEvent.create.mock.calls), + ).not.toContain('private/object-key'); + }); + + it('revalidates an existing checksum baseline instead of enqueueing an empty backfill', async () => { + const { repository, service, transaction } = setup(); + transaction.fileNode.findFirst.mockResolvedValueOnce({ + checksumAlgorithm: 'sha256', + checksumValue: 'a'.repeat(64), + objectKey: 'private/object-key', + sizeBytes: 16n, + }); + + await service.enqueueObjectVerification( + { + nodeId: 'node-1', + objectKey: 'private/object-key', + workspaceId: 'workspace-a', + }, + transaction as never, + ); + + expect(repository.createOrReuseTask).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'verify', + targetChecksumAlgorithm: 'sha256', + targetChecksumValue: 'a'.repeat(64), + }), + transaction, + ); + }); + + it('creates a new linked task when retrying every retryable result', async () => { + const { repository, runner, service } = setup(); + (repository.getTask as jest.Mock).mockResolvedValue({ + ...task, + id: 'inttask-old', + status: 'completed', + }); + (repository.getRetryableResults as jest.Mock).mockResolvedValue([ + { id: 'result-a' }, + { id: 'result-b' }, + ]); + + const retried = await service.retryTask( + 'inttask-old', + undefined, + 'admin-1', + ); + + expect(retried.id).toBe('inttask-new'); + expect(repository.createOrReuseTask).toHaveBeenCalledWith( + expect.objectContaining({ + retryOfTaskId: 'inttask-old', + retryResultIds: ['result-a', 'result-b'], + }), + expect.any(Object), + ); + expect(runner.kick).toHaveBeenCalledTimes(1); + }); + + it('retries the original snapshot when a failed task has unprocessed targets', async () => { + const { repository, runner, service } = setup(); + const snapshotAt = new Date('2026-08-13T12:00:00.000Z'); + (repository.getTask as jest.Mock).mockResolvedValue({ + ...task, + id: 'inttask-failed', + status: 'failed', + }); + (repository.getExecutionTask as jest.Mock).mockResolvedValue({ + ...task, + actorUserId: 'admin-old', + id: 'inttask-failed', + retryOfTaskId: null, + retryResultIds: [], + snapshotAt, + status: 'failed', + targetChecksumAlgorithm: 'sha256', + targetChecksumValue: 'a'.repeat(64), + targetCount: 10, + targetExpectedSizeBytes: 12, + targetObjectKey: 'objects/pinned', + }); + + const retried = await service.retryTask( + 'inttask-failed', + undefined, + 'admin-new', + ); + + expect(retried.id).toBe('inttask-new'); + expect(repository.getRetryableResults).not.toHaveBeenCalled(); + expect(repository.createOrReuseTask).toHaveBeenCalledWith( + expect.objectContaining({ + actorUserId: 'admin-new', + retryOfTaskId: 'inttask-failed', + retryResultIds: [], + snapshotAt, + targetChecksumAlgorithm: 'sha256', + targetChecksumValue: 'a'.repeat(64), + targetExpectedSizeBytes: 12, + targetObjectKey: 'objects/pinned', + }), + expect.any(Object), + ); + expect(runner.kick).toHaveBeenCalledTimes(1); + }); + + it('preserves a selective target set when retrying a failed retry task', async () => { + const { repository, service } = setup(); + const snapshotAt = new Date('2026-08-13T12:00:00.000Z'); + (repository.getTask as jest.Mock).mockResolvedValue({ + ...task, + id: 'inttask-selective-failed', + status: 'failed', + }); + (repository.getExecutionTask as jest.Mock).mockResolvedValue({ + ...task, + actorUserId: 'admin-old', + id: 'inttask-selective-failed', + retryOfTaskId: 'inttask-original', + retryResultIds: ['result-a', 'result-b'], + snapshotAt, + status: 'failed', + targetChecksumAlgorithm: null, + targetChecksumValue: null, + targetCount: 2, + targetExpectedSizeBytes: null, + targetObjectKey: null, + }); + + await service.retryTask('inttask-selective-failed', undefined, 'admin-new'); + + expect(repository.getRetryableResults).not.toHaveBeenCalled(); + expect(repository.createOrReuseTask).toHaveBeenCalledWith( + expect.objectContaining({ + retryOfTaskId: 'inttask-original', + retryResultIds: ['result-a', 'result-b'], + snapshotAt, + }), + expect.any(Object), + ); + }); + + it('rejects a selected verify target without a checksum instead of completing an empty task', async () => { + const { repository, service } = setup(); + (repository.workspaceExists as jest.Mock).mockResolvedValue(true); + const prisma = (service as unknown as { prisma: PrismaService }).prisma; + (prisma.fileNode.findFirst as jest.Mock).mockResolvedValue({ + checksumAlgorithm: null, + checksumValue: null, + id: 'node-1', + objectKey: 'objects/a', + sizeBytes: 12n, + }); + + await expect( + service.createTask( + { + batchSize: 1, + concurrency: 1, + maxAttempts: 1, + mode: 'verify', + scope: 'workspace', + target: { nodeId: 'node-1' }, + workspaceId: 'workspace-a', + }, + 'admin-1', + ), + ).rejects.toThrow('目标尚无校验和,请先执行回填'); + expect(repository.createOrReuseTask).not.toHaveBeenCalled(); + }); + + it('canonicalizes a version-only target with its owning node', async () => { + const { repository, service } = setup(); + const prisma = (service as unknown as { prisma: PrismaService }).prisma; + (prisma.fileVersion.findFirst as jest.Mock).mockResolvedValue({ + checksumAlgorithm: 'sha256', + checksumValue: 'a'.repeat(64), + id: 'version-1', + nodeId: 'node-1', + objectKey: 'objects/a', + sizeBytes: 12n, + }); + + await service.createTask( + { + batchSize: 1, + concurrency: 1, + maxAttempts: 1, + mode: 'verify', + scope: 'workspace', + target: { versionId: 'version-1' }, + workspaceId: 'workspace-a', + }, + 'admin-1', + ); + + expect(repository.createOrReuseTask).toHaveBeenCalledWith( + expect.objectContaining({ + target: { nodeId: 'node-1', versionId: 'version-1' }, + }), + expect.any(Object), + ); + }); +}); diff --git a/backend/src/modules/storage/storage-integrity-task.service.ts b/backend/src/modules/storage/storage-integrity-task.service.ts new file mode 100644 index 0000000..41d9171 --- /dev/null +++ b/backend/src/modules/storage/storage-integrity-task.service.ts @@ -0,0 +1,412 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../../database/prisma.service'; +import type { Prisma } from '../../generated/prisma/client'; +import { recordStorageIntegrityAudit } from './storage-integrity-audit'; +import { + CreateStorageIntegrityTaskDto, + type StorageIntegrityResultStatus, +} from './storage-integrity-task.dto'; +import { StorageIntegrityTaskRepository } from './storage-integrity-task.repository'; +import { StorageIntegrityTaskRunner } from './storage-integrity-task-runner.service'; + +type EnqueueObjectVerificationInput = { + actorUserId?: string | null; + nodeId: string; + objectKey: string; + versionId?: string | null; + workspaceId: string; +}; + +type IntegrityTaskTransaction = Pick< + Prisma.TransactionClient, + 'auditEvent' | 'blobIntegrityTask' | 'fileNode' | 'fileVersion' +>; + +@Injectable() +export class StorageIntegrityTaskService { + constructor( + private readonly repository: StorageIntegrityTaskRepository, + private readonly runner: StorageIntegrityTaskRunner, + private readonly prisma: PrismaService, + ) {} + + async createTask(input: CreateStorageIntegrityTaskDto, actorUserId: string) { + const normalized = await this.validateCreateInput(input); + const task = await this.prisma.$transaction(async (tx) => { + const persisted = await this.repository.createOrReuseTask( + { ...normalized, actorUserId }, + tx, + ); + if (persisted.created) { + await recordStorageIntegrityAudit(tx, { + action: 'system.storage_integrity_task_started', + actorUserId, + mode: persisted.task.mode, + result: 'success', + scope: persisted.task.scope, + taskId: persisted.task.id, + workspaceId: persisted.task.workspaceId, + }); + } + return persisted.task; + }); + this.runner.kick(); + return task; + } + + async enqueueObjectVerification( + input: EnqueueObjectVerificationInput, + transaction?: IntegrityTaskTransaction, + ) { + const dto: CreateStorageIntegrityTaskDto = { + batchSize: 1, + concurrency: 1, + maxAttempts: 3, + mode: 'backfill', + scope: 'workspace', + target: { + nodeId: input.nodeId, + ...(input.versionId ? { versionId: input.versionId } : {}), + }, + workspaceId: input.workspaceId, + }; + const persist = async (client: IntegrityTaskTransaction) => { + const target = input.versionId + ? await client.fileVersion.findFirst({ + where: { + id: input.versionId, + nodeId: input.nodeId, + objectKey: input.objectKey, + node: { workspaceId: input.workspaceId }, + }, + select: { + checksumAlgorithm: true, + checksumValue: true, + objectKey: true, + sizeBytes: true, + }, + }) + : await client.fileNode.findFirst({ + where: { + id: input.nodeId, + objectKey: input.objectKey, + workspaceId: input.workspaceId, + }, + select: { + checksumAlgorithm: true, + checksumValue: true, + objectKey: true, + sizeBytes: true, + }, + }); + if (!target?.objectKey || target.sizeBytes === null) { + throw new BadRequestException('无法为当前文件内容建立完整性任务'); + } + const expectedSizeBytes = Number(target.sizeBytes); + if (!Number.isSafeInteger(expectedSizeBytes) || expectedSizeBytes < 0) { + throw new BadRequestException('文件大小超出完整性任务支持范围'); + } + const mode = + target.checksumAlgorithm?.trim() || target.checksumValue?.trim() + ? ('verify' as const) + : ('backfill' as const); + const persisted = await this.repository.createOrReuseTask( + { + ...dto, + mode, + actorUserId: input.actorUserId ?? null, + targetChecksumAlgorithm: target.checksumAlgorithm, + targetChecksumValue: target.checksumValue, + targetExpectedSizeBytes: expectedSizeBytes, + targetObjectKey: input.objectKey, + }, + client, + ); + if (persisted.created) { + await recordStorageIntegrityAudit(client, { + action: 'system.storage_integrity_task_started', + actorUserId: input.actorUserId, + mode, + result: 'success', + scope: 'workspace', + taskId: persisted.task.id, + workspaceId: input.workspaceId, + }); + } + return persisted.task.id; + }; + if (transaction) return persist(transaction); + const id = await this.prisma.$transaction((tx) => persist(tx)); + this.runner.kick(); + return id; + } + + kickQueued() { + this.runner.kick(); + } + + listTasks(input: { + limit: number; + offset: number; + scope: 'all' | 'workspace'; + workspaceId?: string; + }) { + return this.validateScope(input.scope, input.workspaceId).then( + (workspaceId) => this.repository.listTasks({ ...input, workspaceId }), + ); + } + + async getTask(taskId: string) { + return this.requireTask(taskId); + } + + async listResults(input: { + limit: number; + offset: number; + status?: StorageIntegrityResultStatus; + taskId: string; + }) { + await this.requireTask(input.taskId); + return this.repository.listResults(input); + } + + async retryTask( + taskId: string, + resultIds: string[] | undefined, + actorUserId: string, + ) { + const source = await this.requireTask(taskId); + if (source.status === 'queued' || source.status === 'running') { + throw new BadRequestException('运行中的任务不能重试'); + } + const requestedIds = resultIds + ? [...new Set(resultIds.map((id) => id.trim()).filter(Boolean))] + : undefined; + if (resultIds && requestedIds?.length !== resultIds.length) { + throw new BadRequestException('resultIds 必须非空且不能重复'); + } + const retryWholeFailedTask = + source.status === 'failed' && requestedIds === undefined; + const executionSource = retryWholeFailedTask + ? await this.repository.getExecutionTask(taskId) + : null; + if (retryWholeFailedTask && !executionSource) { + throw new NotFoundException('完整性巡检任务不存在'); + } + if ( + executionSource?.retryResultIds.length && + !executionSource.retryOfTaskId + ) { + throw new BadRequestException('完整性巡检任务的重试来源无效'); + } + const results = retryWholeFailedTask + ? [] + : await this.repository.getRetryableResults(taskId, requestedIds); + if ( + !retryWholeFailedTask && + (results.length === 0 || + (requestedIds && results.length !== requestedIds.length)) + ) { + throw new BadRequestException('所选结果不可重试或不属于该任务'); + } + const replaySelectedResults = Boolean( + retryWholeFailedTask && executionSource?.retryResultIds.length, + ); + const task = await this.prisma.$transaction(async (tx) => { + const persisted = await this.repository.createOrReuseTask( + { + actorUserId, + bandwidthLimitBytesPerSecond: + source.config.bandwidthLimitBytesPerSecond, + batchSize: source.config.batchSize, + concurrency: source.config.concurrency, + maxAttempts: source.config.maxAttempts, + mode: source.mode, + retryOfTaskId: replaySelectedResults + ? executionSource!.retryOfTaskId + : source.id, + retryResultIds: replaySelectedResults + ? executionSource!.retryResultIds + : results.map((result) => result.id), + scope: source.scope, + ...(executionSource + ? { + snapshotAt: executionSource.snapshotAt, + targetChecksumAlgorithm: + executionSource.targetChecksumAlgorithm, + targetChecksumValue: executionSource.targetChecksumValue, + targetExpectedSizeBytes: + executionSource.targetExpectedSizeBytes, + targetObjectKey: executionSource.targetObjectKey, + } + : {}), + target: source.target ?? undefined, + workspaceId: source.workspaceId ?? undefined, + }, + tx, + ); + if (persisted.created) { + await recordStorageIntegrityAudit(tx, { + action: 'system.storage_integrity_retry_started', + actorUserId, + mode: persisted.task.mode, + result: 'success', + scope: persisted.task.scope, + taskId: persisted.task.id, + workspaceId: persisted.task.workspaceId, + }); + } + return persisted.task; + }); + this.runner.kick(); + return task; + } + + async getSummary(scope: 'all' | 'workspace', workspaceId?: string) { + const normalizedWorkspaceId = await this.validateScope(scope, workspaceId); + return this.repository.getSummary(scope, normalizedWorkspaceId); + } + + async listTargets(input: { + limit: number; + offset: number; + query?: string; + workspaceId: string; + }) { + const workspaceId = await this.validateScope( + 'workspace', + input.workspaceId, + ); + return this.repository.listTargets({ ...input, workspaceId: workspaceId! }); + } + + async listTargetVersions(nodeId: string, workspaceId: string) { + const normalizedWorkspaceId = await this.validateScope( + 'workspace', + workspaceId, + ); + const versions = await this.repository.listTargetVersions( + nodeId.trim(), + normalizedWorkspaceId!, + ); + if (!versions) throw new NotFoundException('完整性巡检目标不存在'); + return versions; + } + + private async validateCreateInput(input: CreateStorageIntegrityTaskDto) { + const workspaceId = input.workspaceId?.trim() || undefined; + await this.validateScope(input.scope, workspaceId); + const nodeId = input.target?.nodeId?.trim() || undefined; + const versionId = input.target?.versionId?.trim() || undefined; + if (input.target && !nodeId && !versionId) { + throw new BadRequestException('target 必须包含 nodeId 或 versionId'); + } + if ((nodeId || versionId) && input.scope !== 'workspace') { + throw new BadRequestException('单目标巡检必须使用 workspace 范围'); + } + if (nodeId || versionId) { + const valid = versionId + ? await this.prisma.fileVersion.findFirst({ + where: { + id: versionId, + ...(nodeId ? { nodeId } : {}), + node: { workspaceId }, + }, + select: { + checksumAlgorithm: true, + checksumValue: true, + id: true, + nodeId: true, + objectKey: true, + sizeBytes: true, + }, + }) + : await this.prisma.fileNode.findFirst({ + where: { id: nodeId, workspaceId }, + select: { + checksumAlgorithm: true, + checksumValue: true, + id: true, + objectKey: true, + sizeBytes: true, + }, + }); + if (!valid) throw new BadRequestException('目标不属于指定工作区'); + if (!valid.objectKey) { + throw new BadRequestException('目标没有可校验的存储对象'); + } + if (valid.sizeBytes === null) { + throw new BadRequestException('目标缺少有效的文件大小'); + } + const targetExpectedSizeBytes = Number(valid.sizeBytes); + if ( + !Number.isSafeInteger(targetExpectedSizeBytes) || + targetExpectedSizeBytes < 0 + ) { + throw new BadRequestException('文件大小超出完整性任务支持范围'); + } + if (input.mode === 'verify' && !valid.checksumValue) { + throw new BadRequestException('目标尚无校验和,请先执行回填'); + } + if (input.mode === 'backfill' && valid.checksumValue) { + throw new BadRequestException('目标已有校验和,请使用重新验证模式'); + } + const canonicalNodeId = + versionId && 'nodeId' in valid && typeof valid.nodeId === 'string' + ? valid.nodeId + : nodeId; + return { + ...input, + bandwidthLimitBytesPerSecond: + input.bandwidthLimitBytesPerSecond == null + ? null + : Math.trunc(input.bandwidthLimitBytesPerSecond), + target: { nodeId: canonicalNodeId, versionId }, + targetChecksumAlgorithm: valid.checksumAlgorithm, + targetChecksumValue: valid.checksumValue, + targetExpectedSizeBytes, + targetObjectKey: valid.objectKey, + workspaceId, + }; + } + return { + ...input, + bandwidthLimitBytesPerSecond: + input.bandwidthLimitBytesPerSecond == null + ? null + : Math.trunc(input.bandwidthLimitBytesPerSecond), + target: nodeId || versionId ? { nodeId, versionId } : undefined, + workspaceId, + }; + } + + private async validateScope( + scope: 'all' | 'workspace', + workspaceId?: string, + ) { + const normalizedWorkspaceId = workspaceId?.trim(); + if (scope === 'all') { + if (normalizedWorkspaceId) { + throw new BadRequestException('all 范围不能指定 workspaceId'); + } + return undefined; + } + if (!normalizedWorkspaceId) { + throw new BadRequestException('workspace 范围必须指定 workspaceId'); + } + if (!(await this.repository.workspaceExists(normalizedWorkspaceId))) { + throw new BadRequestException('指定的工作区不存在'); + } + return normalizedWorkspaceId; + } + + private async requireTask(taskId: string) { + const task = await this.repository.getTask(taskId.trim()); + if (!task) throw new NotFoundException('完整性巡检任务不存在'); + return task; + } +} diff --git a/backend/src/modules/storage/storage-integrity-task.types.ts b/backend/src/modules/storage/storage-integrity-task.types.ts new file mode 100644 index 0000000..cc18288 --- /dev/null +++ b/backend/src/modules/storage/storage-integrity-task.types.ts @@ -0,0 +1,25 @@ +import type { StorageIntegrityTaskResponse } from './storage-integrity-task.dto'; + +export type IntegrityTargetReference = { + expectedChecksumAlgorithm: string | null; + expectedHash: string | null; + expectedSizeBytes: number; + nodeId: string | null; + objectKey: string; + sourceResultId?: string | null; + targetKey: string; + versionId: string | null; + workspaceId: string; +}; + +export type StorageIntegrityExecutionTask = StorageIntegrityTaskResponse & { + actorUserId: string | null; + retryOfTaskId: string | null; + retryResultIds: string[]; + snapshotAt: Date; + targetChecksumAlgorithm: string | null; + targetChecksumValue: string | null; + targetCount: number | null; + targetExpectedSizeBytes: number | null; + targetObjectKey: string | null; +}; diff --git a/backend/src/modules/storage/storage-integrity.minio.spec.ts b/backend/src/modules/storage/storage-integrity.minio.spec.ts new file mode 100644 index 0000000..c7c5167 --- /dev/null +++ b/backend/src/modules/storage/storage-integrity.minio.spec.ts @@ -0,0 +1,155 @@ +import { + AbortMultipartUploadCommand, + CompleteMultipartUploadCommand, + CreateBucketCommand, + CreateMultipartUploadCommand, + DeleteBucketCommand, + DeleteObjectCommand, + HeadObjectCommand, + S3Client, + UploadPartCommand, +} from '@aws-sdk/client-s3'; +import { createHash, randomBytes } from 'crypto'; +import { createStorageTestContext } from './storage-settings-usage.spec-helper'; +import { StorageIntegrityService } from './storage-integrity.service'; + +const describeMinio = + process.env.STORAGE_INTEGRITY_MINIO_TEST === '1' ? describe : describe.skip; + +async function createBucketWithRetry(client: S3Client, bucket: string) { + let lastError: unknown; + for (let attempt = 1; attempt <= 30; attempt += 1) { + try { + await client.send(new CreateBucketCommand({ Bucket: bucket })); + return; + } catch (error) { + lastError = error; + if (attempt < 30) { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + } + throw lastError; +} + +describeMinio('StorageIntegrityService with MinIO', () => { + jest.setTimeout(90_000); + + it('streams and verifies a multipart object while ignoring its ETag', async () => { + const endpoint = + process.env.STORAGE_INTEGRITY_MINIO_ENDPOINT ?? 'http://127.0.0.1:9000'; + const region = process.env.STORAGE_INTEGRITY_MINIO_REGION ?? 'us-east-1'; + const accessKeyId = + process.env.STORAGE_INTEGRITY_MINIO_ACCESS_KEY ?? 'minioadmin'; + const secretAccessKey = + process.env.STORAGE_INTEGRITY_MINIO_SECRET_KEY ?? 'minioadmin'; + const bucket = `icedr-integrity-${randomBytes(8).toString('hex')}`; + const objectKey = 'multipart/integrity.bin'; + const firstPart = Buffer.alloc(5 * 1024 * 1024, 0x61); + const secondPart = Buffer.from('minio-final-part'); + const content = Buffer.concat([firstPart, secondPart]); + const client = new S3Client({ + endpoint, + forcePathStyle: true, + region, + credentials: { accessKeyId, secretAccessKey }, + }); + let uploadId: string | undefined; + + await createBucketWithRetry(client, bucket); + try { + const created = await client.send( + new CreateMultipartUploadCommand({ Bucket: bucket, Key: objectKey }), + ); + uploadId = created.UploadId; + if (!uploadId) throw new Error('MinIO did not create a multipart upload'); + const uploadedParts = []; + for (const [index, body] of [firstPart, secondPart].entries()) { + const uploaded = await client.send( + new UploadPartCommand({ + Body: body, + Bucket: bucket, + Key: objectKey, + PartNumber: index + 1, + UploadId: uploadId, + }), + ); + uploadedParts.push({ ETag: uploaded.ETag, PartNumber: index + 1 }); + } + await client.send( + new CompleteMultipartUploadCommand({ + Bucket: bucket, + Key: objectKey, + MultipartUpload: { Parts: uploadedParts }, + UploadId: uploadId, + }), + ); + uploadId = undefined; + + const values: Record = { + 'storage.accessKeyId': accessKeyId, + 'storage.bucket': bucket, + 'storage.endpoint': endpoint, + 'storage.forcePathStyle': true, + 'storage.region': region, + 'storage.secretAccessKey': secretAccessKey, + }; + const { objectStorage } = createStorageTestContext(values); + jest + .spyOn( + objectStorage as unknown as { createClient: () => S3Client }, + 'createClient', + ) + .mockReturnValue(client); + const expectedHash = createHash('sha256').update(content).digest('hex'); + const head = await client.send( + new HeadObjectCommand({ Bucket: bucket, Key: objectKey }), + ); + expect(head.ETag?.replaceAll('"', '')).not.toBe(expectedHash); + + await expect( + new StorageIntegrityService(objectStorage).verifyObject({ + objectKey, + expectedSizeBytes: content.length, + }), + ).resolves.toMatchObject({ + actualChecksum: expectedHash, + actualSizeBytes: content.length, + integrityStatus: 'verified', + }); + await client.send( + new DeleteObjectCommand({ Bucket: bucket, Key: objectKey }), + ); + await expect( + new StorageIntegrityService(objectStorage).verifyObject({ + objectKey, + expectedSizeBytes: content.length, + }), + ).resolves.toMatchObject({ + actualChecksum: null, + actualSizeBytes: null, + integrityStatus: 'failed', + verificationFailureCode: 'missing-object', + }); + } finally { + if (uploadId) { + await client + .send( + new AbortMultipartUploadCommand({ + Bucket: bucket, + Key: objectKey, + UploadId: uploadId, + }), + ) + .catch(() => undefined); + } + await client + .send(new DeleteObjectCommand({ Bucket: bucket, Key: objectKey })) + .catch(() => undefined); + await client + .send(new DeleteBucketCommand({ Bucket: bucket })) + .catch(() => undefined); + client.destroy(); + } + }); +}); diff --git a/backend/src/modules/storage/storage-integrity.service.spec.ts b/backend/src/modules/storage/storage-integrity.service.spec.ts new file mode 100644 index 0000000..68ca1c2 --- /dev/null +++ b/backend/src/modules/storage/storage-integrity.service.spec.ts @@ -0,0 +1,334 @@ +import { createHash } from 'crypto'; +import { NotFoundException } from '@nestjs/common'; +import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { mkdir, rm, writeFile } from 'fs/promises'; +import { dirname } from 'path'; +import { createStorageTestContext } from './storage-settings-usage.spec-helper'; +import { Readable } from 'stream'; +import type { StorageObjectService } from './storage-object.service'; +import { StorageIntegrityService } from './storage-integrity.service'; + +describe('StorageIntegrityService', () => { + it('streams an object once to calculate its SHA-256 checksum and byte size without trusting ETag', async () => { + const content = Buffer.from('完整性校验内容', 'utf8'); + const openObjectStream = jest.fn().mockResolvedValue({ + acceptRanges: 'bytes', + contentLength: content.length, + contentRange: null, + contentType: 'application/octet-stream', + etag: 'this-is-not-a-checksum', + lastModified: null, + statusCode: 200, + stream: Readable.from([content.subarray(0, 5), content.subarray(5)]), + }); + const service = new StorageIntegrityService({ + openObjectStream, + } as unknown as StorageObjectService); + + await expect( + service.verifyObject({ + objectKey: 'local/workspace/file.bin', + expectedSizeBytes: content.length, + }), + ).resolves.toMatchObject({ + checksumAlgorithm: 'sha256', + actualChecksum: createHash('sha256').update(content).digest('hex'), + actualSizeBytes: content.length, + integrityStatus: 'verified', + verificationFailureCode: null, + lastVerifiedAt: expect.any(Date) as unknown, + }); + expect(openObjectStream).toHaveBeenCalledWith({ + objectKey: 'local/workspace/file.bin', + }); + }); + + it('classifies an object that cannot be opened because it is missing', async () => { + const service = new StorageIntegrityService({ + openObjectStream: jest + .fn() + .mockRejectedValue(new NotFoundException('Stored object not found')), + } as unknown as StorageObjectService); + + await expect( + service.verifyObject({ + objectKey: 'local/workspace/missing.bin', + expectedSizeBytes: 10, + }), + ).resolves.toMatchObject({ + checksumAlgorithm: 'sha256', + actualChecksum: null, + actualSizeBytes: null, + integrityStatus: 'failed', + verificationFailureCode: 'missing-object', + }); + }); + + it('classifies a streamed object whose actual byte size differs from metadata', async () => { + const content = Buffer.from('actual bytes'); + const service = new StorageIntegrityService({ + openObjectStream: jest.fn().mockResolvedValue({ + etag: 'ignored', + stream: Readable.from([content]), + }), + } as unknown as StorageObjectService); + + await expect( + service.verifyObject({ + objectKey: 'remote/file.bin', + expectedSizeBytes: content.length + 1, + }), + ).resolves.toMatchObject({ + actualChecksum: createHash('sha256').update(content).digest('hex'), + actualSizeBytes: content.length, + integrityStatus: 'mismatch', + verificationFailureCode: 'size-mismatch', + }); + }); + + it('classifies a same-sized object whose SHA-256 checksum differs from metadata', async () => { + const content = Buffer.from('same sized object'); + const service = new StorageIntegrityService({ + openObjectStream: jest.fn().mockResolvedValue({ + etag: createHash('sha256').update(content).digest('hex'), + stream: Readable.from([content]), + }), + } as unknown as StorageObjectService); + + await expect( + service.verifyObject({ + objectKey: 'remote/file.bin', + expectedSizeBytes: content.length, + checksumAlgorithm: 'sha256', + checksumValue: '0'.repeat(64), + }), + ).resolves.toMatchObject({ + actualChecksum: createHash('sha256').update(content).digest('hex'), + actualSizeBytes: content.length, + integrityStatus: 'mismatch', + verificationFailureCode: 'checksum-mismatch', + }); + }); + + it('returns a sanitized verification failure and destroys the stream when reading fails', async () => { + const stream = Readable.from( + (async function* () { + await new Promise((resolve) => setImmediate(resolve)); + yield Buffer.from('partial'); + throw new Error('sensitive storage failure'); + })(), + ); + const destroy = jest.spyOn(stream, 'destroy'); + const service = new StorageIntegrityService({ + openObjectStream: jest.fn().mockResolvedValue({ stream }), + } as unknown as StorageObjectService); + + await expect( + service.verifyObject({ + objectKey: 'remote/file.bin', + expectedSizeBytes: 100, + }), + ).resolves.toMatchObject({ + actualChecksum: null, + actualSizeBytes: null, + integrityStatus: 'failed', + verificationFailureCode: 'verification-failed', + }); + expect(destroy).toHaveBeenCalled(); + expect(stream.destroyed).toBe(true); + }); + + it('awaits a shared bandwidth hook for every streamed chunk', async () => { + const consumed: number[] = []; + const content = [Buffer.from('abc'), Buffer.from('de')]; + const service = new StorageIntegrityService({ + openObjectStream: jest.fn().mockResolvedValue({ + stream: Readable.from(content), + }), + } as unknown as StorageObjectService); + + const result = await service.verifyObject({ + objectKey: 'remote/file.bin', + expectedSizeBytes: 5, + onBytes: async (count) => { + await Promise.resolve(); + consumed.push(count); + }, + }); + + expect(consumed).toEqual([3, 2]); + expect(result.integrityStatus).toBe('verified'); + }); + + it('cancels an active verification by destroying its stream', async () => { + const controller = new AbortController(); + const stream = new Readable({ read: () => undefined }); + stream.push(Buffer.from('partial')); + const destroy = jest.spyOn(stream, 'destroy'); + const openObjectStream = jest.fn().mockResolvedValue({ stream }); + const service = new StorageIntegrityService({ + openObjectStream, + } as unknown as StorageObjectService); + + const verification = service.verifyObject({ + objectKey: 'remote/file.bin', + expectedSizeBytes: 100, + signal: controller.signal, + }); + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(); + + await expect(verification).rejects.toMatchObject({ name: 'AbortError' }); + expect(openObjectStream).toHaveBeenCalledWith({ + objectKey: 'remote/file.bin', + signal: controller.signal, + }); + expect(destroy).toHaveBeenCalled(); + expect(stream.destroyed).toBe(true); + }); + + it('records verification time after the object stream has finished', async () => { + jest.useFakeTimers(); + const startedAt = new Date('2026-08-13T10:00:00.000Z'); + const finishedAt = new Date('2026-08-13T10:01:00.000Z'); + jest.setSystemTime(startedAt); + const service = new StorageIntegrityService({ + openObjectStream: jest.fn().mockResolvedValue({ + stream: Readable.from([Buffer.from('content')]), + }), + } as unknown as StorageObjectService); + + try { + const result = await service.verifyObject({ + objectKey: 'remote/file.bin', + expectedSizeBytes: 7, + onBytes: () => { + jest.setSystemTime(finishedAt); + return Promise.resolve(); + }, + }); + + expect(result.lastVerifiedAt).toEqual(finishedAt); + } finally { + jest.useRealTimers(); + } + }); + + it('rejects an unsupported baseline algorithm without reading the object', async () => { + const openObjectStream = jest.fn(); + const service = new StorageIntegrityService({ + openObjectStream, + } as unknown as StorageObjectService); + + await expect( + service.verifyObject({ + objectKey: 'remote/file.bin', + expectedSizeBytes: 10, + checksumAlgorithm: 'md5', + checksumValue: '0'.repeat(32), + }), + ).resolves.toMatchObject({ + actualChecksum: null, + actualSizeBytes: null, + integrityStatus: 'failed', + verificationFailureCode: 'verification-failed', + }); + expect(openObjectStream).not.toHaveBeenCalled(); + }); + + it('verifies a chunked S3 response through the storage adapter without using its multipart ETag', async () => { + const content = Buffer.from('chunked S3 content'); + const { objectStorage } = createStorageTestContext(); + const send = jest.fn((command: GetObjectCommand) => { + expect(command).toBeInstanceOf(GetObjectCommand); + return Promise.resolve({ + Body: Readable.from([content.subarray(0, 4), content.subarray(4)]), + ContentLength: content.length, + ETag: '"multipart-etag-2"', + }); + }); + jest + .spyOn( + objectStorage as unknown as { + createClient: () => { send: typeof send }; + }, + 'createClient', + ) + .mockReturnValue({ send }); + const service = new StorageIntegrityService(objectStorage); + + await expect( + service.verifyObject({ + objectKey: 'workspace/file.bin', + expectedSizeBytes: content.length, + }), + ).resolves.toMatchObject({ + actualChecksum: createHash('sha256').update(content).digest('hex'), + integrityStatus: 'verified', + }); + }); + + it('verifies a real local object stream through the storage adapter', async () => { + const content = Buffer.from('real local content'); + const objectKey = 'local/workspace/integrity/local.bin'; + const filePath = + 'backend/.tmp/storage-service-spec-local-files/workspace/integrity/local.bin'; + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + const { objectStorage } = createStorageTestContext(); + + try { + await expect( + new StorageIntegrityService(objectStorage).verifyObject({ + objectKey, + expectedSizeBytes: content.length, + }), + ).resolves.toMatchObject({ + actualChecksum: createHash('sha256').update(content).digest('hex'), + actualSizeBytes: content.length, + integrityStatus: 'verified', + }); + } finally { + await rm('backend/.tmp/storage-service-spec-local-files', { + force: true, + recursive: true, + }); + } + }); + + it('sanitizes an interrupted S3 response stream through the storage adapter', async () => { + const { objectStorage } = createStorageTestContext(); + const stream = Readable.from( + (async function* () { + await new Promise((resolve) => setImmediate(resolve)); + yield Buffer.from('partial'); + throw new Error('sensitive S3 socket error'); + })(), + ); + const send = jest.fn().mockResolvedValue({ + Body: stream, + ContentLength: 100, + ETag: '"opaque"', + }); + jest + .spyOn( + objectStorage as unknown as { + createClient: () => { send: typeof send }; + }, + 'createClient', + ) + .mockReturnValue({ send }); + + await expect( + new StorageIntegrityService(objectStorage).verifyObject({ + objectKey: 'workspace/file.bin', + expectedSizeBytes: 100, + }), + ).resolves.toMatchObject({ + actualChecksum: null, + integrityStatus: 'failed', + verificationFailureCode: 'verification-failed', + }); + expect(stream.destroyed).toBe(true); + }); +}); diff --git a/backend/src/modules/storage/storage-integrity.service.ts b/backend/src/modules/storage/storage-integrity.service.ts new file mode 100644 index 0000000..044ce41 --- /dev/null +++ b/backend/src/modules/storage/storage-integrity.service.ts @@ -0,0 +1,121 @@ +import { createHash } from 'crypto'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { StorageObjectService } from './storage-object.service'; + +export type StorageIntegrityFailureCode = + | 'missing-object' + | 'size-mismatch' + | 'checksum-mismatch' + | 'verification-failed'; + +export type StorageIntegrityVerificationResult = { + checksumAlgorithm: 'sha256'; + actualChecksum: string | null; + actualSizeBytes: number | null; + integrityStatus: 'verified' | 'mismatch' | 'failed'; + lastVerifiedAt: Date; + verificationFailureCode: StorageIntegrityFailureCode | null; +}; + +@Injectable() +export class StorageIntegrityService { + constructor(private readonly objectStorage: StorageObjectService) {} + + async verifyObject(input: { + objectKey: string; + expectedSizeBytes: number; + checksumAlgorithm?: string | null; + checksumValue?: string | null; + onBytes?: (count: number) => Promise; + signal?: AbortSignal; + }): Promise { + const checksumAlgorithm = input.checksumAlgorithm?.trim() ?? ''; + const expectedChecksum = input.checksumValue?.trim().toLowerCase() ?? ''; + const hasChecksumBaseline = Boolean(checksumAlgorithm || expectedChecksum); + if ( + !Number.isSafeInteger(input.expectedSizeBytes) || + input.expectedSizeBytes < 0 || + (hasChecksumBaseline && + (checksumAlgorithm.toLowerCase().replace('-', '') !== 'sha256' || + !/^[a-f0-9]{64}$/.test(expectedChecksum))) + ) { + return this.failedResult('verification-failed'); + } + input.signal?.throwIfAborted(); + let opened; + try { + opened = await this.objectStorage.openObjectStream({ + objectKey: input.objectKey, + ...(input.signal ? { signal: input.signal } : {}), + }); + } catch (error) { + if (input.signal?.aborted || isAbortError(error)) throw error; + if (error instanceof NotFoundException) { + return this.failedResult('missing-object'); + } + return this.failedResult('verification-failed'); + } + const hash = createHash('sha256'); + let sizeBytes = 0; + const abortStream = () => { + const abortError = new Error('Storage integrity verification aborted'); + abortError.name = 'AbortError'; + opened.stream.destroy(abortError); + }; + input.signal?.addEventListener('abort', abortStream, { once: true }); + try { + input.signal?.throwIfAborted(); + for await (const chunk of opened.stream) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + await input.onBytes?.(bytes.length); + input.signal?.throwIfAborted(); + hash.update(bytes); + sizeBytes += bytes.length; + } + } catch (error) { + if (input.signal?.aborted || isAbortError(error)) throw error; + return this.failedResult('verification-failed'); + } finally { + input.signal?.removeEventListener('abort', abortStream); + if (!opened.stream.destroyed) opened.stream.destroy(); + } + + const checksumValue = hash.digest('hex'); + const sizeMatches = sizeBytes === input.expectedSizeBytes; + const checksumMatches = + !hasChecksumBaseline || checksumValue === expectedChecksum; + const verificationFailureCode = !sizeMatches + ? 'size-mismatch' + : !checksumMatches + ? 'checksum-mismatch' + : null; + return { + checksumAlgorithm: 'sha256', + actualChecksum: checksumValue, + actualSizeBytes: sizeBytes, + integrityStatus: verificationFailureCode ? 'mismatch' : 'verified', + lastVerifiedAt: new Date(), + verificationFailureCode, + }; + } + + private failedResult( + verificationFailureCode: StorageIntegrityFailureCode, + ): StorageIntegrityVerificationResult { + return { + checksumAlgorithm: 'sha256', + actualChecksum: null, + actualSizeBytes: null, + integrityStatus: 'failed', + lastVerifiedAt: new Date(), + verificationFailureCode, + }; + } +} + +function isAbortError(error: unknown) { + return ( + error instanceof Error && + (error.name === 'AbortError' || error.name === 'TimeoutError') + ); +} diff --git a/backend/src/modules/storage/storage-object.service.spec.ts b/backend/src/modules/storage/storage-object.service.spec.ts index 6ccccf0..696311f 100644 --- a/backend/src/modules/storage/storage-object.service.spec.ts +++ b/backend/src/modules/storage/storage-object.service.spec.ts @@ -5,6 +5,7 @@ import { } from '@aws-sdk/client-s3'; import { BadRequestException, + NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; import { promises as fileSystem } from 'fs'; @@ -375,6 +376,41 @@ describe('StorageObjectService', () => { } }); + it.each(['ENOENT', 'ENOTDIR'])( + 'normalizes the local %s error as a missing object', + async (code) => { + const { service } = createStorageTestContext(); + jest + .spyOn(fileSystem, 'open') + .mockRejectedValue( + Object.assign(new Error(`local open failed: ${code}`), { code }), + ); + + await expect( + service.openObjectStream({ + objectKey: 'local/workspace-default/root/missing.bin', + }), + ).rejects.toBeInstanceOf(NotFoundException); + }, + ); + + it.each(['EACCES', 'EMFILE', 'EIO'])( + 'preserves the retryable local %s error', + async (code) => { + const { service } = createStorageTestContext(); + const error = Object.assign(new Error(`local open failed: ${code}`), { + code, + }); + jest.spyOn(fileSystem, 'open').mockRejectedValue(error); + + await expect( + service.openObjectStream({ + objectKey: 'local/workspace-default/root/unavailable.bin', + }), + ).rejects.toBe(error); + }, + ); + it('streams object storage ranges without creating a signed url', async () => { const { objectStorage, service, signer } = createStorageTestContext(); const lastModified = new Date('2026-07-11T00:00:00.000Z'); @@ -429,6 +465,73 @@ describe('StorageObjectService', () => { expect(send.mock.calls[1][0].input.Range).toBe('bytes=2-5'); }); + it('cancels an S3 GetObject request before a stream is returned', async () => { + const { objectStorage, service } = createStorageTestContext(); + const controller = new AbortController(); + let markStarted: (() => void) | undefined; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const send = jest.fn( + (command: GetObjectCommand, options?: { abortSignal?: AbortSignal }) => + new Promise((_resolve, reject) => { + expect(command).toBeInstanceOf(GetObjectCommand); + markStarted?.(); + const signal = options?.abortSignal; + const abortError = new Error('S3 object request aborted'); + abortError.name = 'AbortError'; + if (signal?.aborted) { + reject(abortError); + return; + } + signal?.addEventListener('abort', () => reject(abortError), { + once: true, + }); + }), + ); + jest + .spyOn( + objectStorage as unknown as { + createClient: () => { send: typeof send }; + }, + 'createClient', + ) + .mockReturnValue({ send }); + + const opening = service.openObjectStream({ + objectKey: 'workspace-default/root/cancelled.bin', + signal: controller.signal, + }); + await started; + controller.abort(); + + await expect(opening).rejects.toMatchObject({ name: 'AbortError' }); + expect(send).toHaveBeenCalledWith(expect.any(GetObjectCommand), { + abortSignal: controller.signal, + }); + }); + + it.each([ + [{ name: 'NoSuchKey' }, 'NoSuchKey'], + [{ code: 'NoSuchKey' }, 'NoSuchKey code'], + [{ $metadata: { httpStatusCode: 404 } }, 'HTTP 404'], + ])('normalizes an S3 missing-object %s response', async (error) => { + const { objectStorage, service } = createStorageTestContext(); + const send = jest.fn().mockRejectedValue(error); + jest + .spyOn( + objectStorage as unknown as { + createClient: () => { send: typeof send }; + }, + 'createClient', + ) + .mockReturnValue({ send }); + + await expect( + service.openObjectStream({ objectKey: 'workspace/missing.bin' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + it('rejects object storage ranges before requesting the object body', async () => { const { objectStorage, service } = createStorageTestContext(); const send = jest.fn(() => Promise.resolve({ ContentLength: 10 })); diff --git a/backend/src/modules/storage/storage-object.service.ts b/backend/src/modules/storage/storage-object.service.ts index bd30565..7332ac6 100644 --- a/backend/src/modules/storage/storage-object.service.ts +++ b/backend/src/modules/storage/storage-object.service.ts @@ -29,15 +29,7 @@ import { promises as fileSystem, type Stats, } from 'fs'; -import { - mkdir, - open, - readFile, - readdir, - rm, - stat, - writeFile, -} from 'fs/promises'; +import { mkdir, readFile, readdir, rm, stat, writeFile } from 'fs/promises'; import { dirname, relative, resolve, sep } from 'path'; import { Readable, Transform } from 'stream'; import { pipeline } from 'stream/promises'; @@ -254,7 +246,9 @@ export class StorageObjectService { async openObjectStream(input: { objectKey: string; range?: string; + signal?: AbortSignal; }): Promise { + input.signal?.throwIfAborted(); if ( input.objectKey.startsWith('local/') && !this.isLocalObjectKey(input.objectKey) @@ -269,12 +263,16 @@ export class StorageObjectService { let fileHandle; let fileStat; try { - fileHandle = await open(filePath, 'r'); + fileHandle = await fileSystem.open(filePath, 'r'); fileStat = await fileHandle.stat(); if (!fileStat.isFile()) throw new Error('Not a file'); - } catch { + } catch (error) { await fileHandle?.close().catch(() => undefined); - throw new NotFoundException('Stored object not found'); + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') { + throw new NotFoundException('Stored object not found'); + } + throw error; } let range; try { @@ -619,6 +617,7 @@ export class StorageObjectService { private async openDistributedObjectStream(input: { objectKey: string; range?: string; + signal?: AbortSignal; }): Promise { if (!input.objectKey.trim() || hasControlCharacter(input.objectKey)) { throw new BadRequestException('Object key is invalid'); @@ -633,6 +632,7 @@ export class StorageObjectService { if (input.range?.trim()) { const head = await client.send( new HeadObjectCommand({ Bucket: bucket, Key: input.objectKey }), + { abortSignal: input.signal }, ); totalSize = this.normalizeObjectSize(head.ContentLength); range = resolveObjectByteRange(input.range, totalSize); @@ -643,6 +643,7 @@ export class StorageObjectService { Key: input.objectKey, Range: range ? `bytes=${range.start}-${range.end}` : undefined, }), + { abortSignal: input.signal }, ); const stream = this.toNodeReadable(response.Body); const contentLength = range @@ -663,6 +664,12 @@ export class StorageObjectService { stream, }; } catch (error) { + if ( + input.signal?.aborted || + (error instanceof Error && error.name === 'AbortError') + ) { + throw error; + } if (this.isNotFoundError(error)) { throw new NotFoundException('Stored object not found'); } @@ -862,6 +869,7 @@ export class StorageObjectService { return ( maybeError.code === 'ENOENT' || maybeError.code === 'ENOTDIR' || + maybeError.code === 'NoSuchKey' || maybeError.name === 'NotFound' || maybeError.name === 'NoSuchKey' || maybeError.name === 'NoSuchUpload' || diff --git a/backend/src/modules/storage/storage.module.ts b/backend/src/modules/storage/storage.module.ts index 4f62304..f3f1920 100644 --- a/backend/src/modules/storage/storage.module.ts +++ b/backend/src/modules/storage/storage.module.ts @@ -7,6 +7,12 @@ import { StorageReconcileRepository } from './storage-reconcile.repository'; import { StorageSettingsUsageService } from './storage-settings-usage.service'; import { StorageSettingsRepository } from './storage-settings.repository'; import { StorageService } from './storage.service'; +import { StorageIntegrityService } from './storage-integrity.service'; +import { StorageIntegrityTaskRepository } from './storage-integrity-task.repository'; +import { StorageIntegrityTaskRunner } from './storage-integrity-task-runner.service'; +import { StorageIntegrityTaskService } from './storage-integrity-task.service'; +import { StorageIntegrityAcknowledgementRepository } from './storage-integrity-acknowledgement.repository'; +import { StorageIntegrityAcknowledgementService } from './storage-integrity-acknowledgement.service'; @Module({ imports: [AuthCoreModule], @@ -15,10 +21,21 @@ import { StorageService } from './storage.service'; StorageReconcileRepository, StorageReconcileRunner, StorageObjectService, + StorageIntegrityService, + StorageIntegrityAcknowledgementRepository, + StorageIntegrityAcknowledgementService, + StorageIntegrityTaskRepository, + StorageIntegrityTaskRunner, + StorageIntegrityTaskService, StorageSettingsRepository, StorageSettingsUsageService, StorageService, ], - exports: [StorageService], + exports: [ + StorageIntegrityService, + StorageIntegrityAcknowledgementService, + StorageIntegrityTaskService, + StorageService, + ], }) export class StorageModule {} diff --git a/backend/src/modules/storage/storage.service.ts b/backend/src/modules/storage/storage.service.ts index e741a02..772af01 100644 --- a/backend/src/modules/storage/storage.service.ts +++ b/backend/src/modules/storage/storage.service.ts @@ -124,6 +124,7 @@ export class StorageService { openObjectStream(input: { objectKey: string; range?: string; + signal?: AbortSignal; }): Promise { return this.objectStorage.openObjectStream(input); } diff --git a/database/migrations/20260813120000_storage_object_integrity/migration.sql b/database/migrations/20260813120000_storage_object_integrity/migration.sql new file mode 100644 index 0000000..d99c89c --- /dev/null +++ b/database/migrations/20260813120000_storage_object_integrity/migration.sql @@ -0,0 +1,13 @@ +ALTER TABLE "file_nodes" +ADD COLUMN "checksum_algorithm" TEXT, +ADD COLUMN "checksum_value" TEXT, +ADD COLUMN "integrity_status" TEXT NOT NULL DEFAULT 'unknown', +ADD COLUMN "last_verified_at" TIMESTAMPTZ, +ADD COLUMN "verification_failure_code" TEXT; + +ALTER TABLE "file_versions" +ADD COLUMN "checksum_algorithm" TEXT, +ADD COLUMN "checksum_value" TEXT, +ADD COLUMN "integrity_status" TEXT NOT NULL DEFAULT 'unknown', +ADD COLUMN "last_verified_at" TIMESTAMPTZ, +ADD COLUMN "verification_failure_code" TEXT; diff --git a/database/migrations/20260813123000_blob_integrity_tasks/migration.sql b/database/migrations/20260813123000_blob_integrity_tasks/migration.sql new file mode 100644 index 0000000..ecc3368 --- /dev/null +++ b/database/migrations/20260813123000_blob_integrity_tasks/migration.sql @@ -0,0 +1,99 @@ +ALTER TABLE "file_nodes" +ADD COLUMN "integrity_acknowledged_at" TIMESTAMPTZ, +ADD COLUMN "integrity_acknowledged_by" TEXT; + +ALTER TABLE "file_versions" +ADD COLUMN "integrity_acknowledged_at" TIMESTAMPTZ, +ADD COLUMN "integrity_acknowledged_by" TEXT; + +CREATE TABLE "blob_integrity_tasks" ( + "active_key" TEXT, + "id" TEXT NOT NULL, + "actor_user_id" TEXT, + "status" TEXT NOT NULL, + "scope" TEXT NOT NULL, + "workspace_id" TEXT, + "mode" TEXT NOT NULL, + "target" JSONB NOT NULL DEFAULT '{}'::jsonb, + "batch_size" INTEGER NOT NULL, + "concurrency" INTEGER NOT NULL, + "bandwidth_limit_bytes_per_second" BIGINT, + "max_attempts" INTEGER NOT NULL, + "progress" JSONB NOT NULL DEFAULT '{}'::jsonb, + "cursor" JSONB NOT NULL DEFAULT '{}'::jsonb, + "retry_of_task_id" TEXT, + "retry_result_ids" JSONB NOT NULL DEFAULT '[]'::jsonb, + "lease_key" TEXT, + "lease_owner" TEXT, + "lease_expires_at" TIMESTAMPTZ, + "failure_code" TEXT, + "failure_message" TEXT, + "snapshot_at" TIMESTAMPTZ NOT NULL, + "target_count" INTEGER, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "started_at" TIMESTAMPTZ, + "finished_at" TIMESTAMPTZ, + + CONSTRAINT "blob_integrity_tasks_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "blob_integrity_results" ( + "id" TEXT NOT NULL, + "task_id" TEXT NOT NULL, + "source_result_id" TEXT, + "workspace_id" TEXT NOT NULL, + "node_id" TEXT, + "version_id" TEXT, + "target_key" TEXT NOT NULL, + "object_key" TEXT NOT NULL, + "status" TEXT NOT NULL, + "expected_hash" TEXT, + "actual_hash" TEXT, + "expected_size_bytes" BIGINT, + "size_bytes" BIGINT, + "bytes_read" BIGINT NOT NULL DEFAULT 0, + "attempts" INTEGER NOT NULL DEFAULT 1, + "error_code" TEXT, + "error_message" TEXT, + "checked_at" TIMESTAMPTZ NOT NULL, + "acknowledged_at" TIMESTAMPTZ, + "acknowledged_by" TEXT, + + CONSTRAINT "blob_integrity_results_pkey" PRIMARY KEY ("id"), + CONSTRAINT "blob_integrity_results_task_id_fkey" + FOREIGN KEY ("task_id") REFERENCES "blob_integrity_tasks"("id") + ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX "blob_integrity_tasks_lease_key_key" +ON "blob_integrity_tasks"("lease_key"); + +CREATE UNIQUE INDEX "blob_integrity_tasks_active_key_key" +ON "blob_integrity_tasks"("active_key"); + +CREATE INDEX "blob_integrity_tasks_created_at_idx" +ON "blob_integrity_tasks"("created_at"); + +CREATE INDEX "blob_integrity_tasks_status_created_at_idx" +ON "blob_integrity_tasks"("status", "created_at"); + +CREATE INDEX "blob_integrity_tasks_status_lease_expires_at_idx" +ON "blob_integrity_tasks"("status", "lease_expires_at"); + +CREATE INDEX "blob_integrity_tasks_workspace_created_at_idx" +ON "blob_integrity_tasks"("workspace_id", "created_at"); + +CREATE INDEX "blob_integrity_tasks_retry_of_task_id_idx" +ON "blob_integrity_tasks"("retry_of_task_id"); + +CREATE UNIQUE INDEX "blob_integrity_results_task_target_key" +ON "blob_integrity_results"("task_id", "target_key"); + +CREATE INDEX "blob_integrity_results_task_status_checked_at_idx" +ON "blob_integrity_results"("task_id", "status", "checked_at"); + +CREATE INDEX "blob_integrity_results_workspace_node_idx" +ON "blob_integrity_results"("workspace_id", "node_id"); + +CREATE INDEX "blob_integrity_results_source_result_id_idx" +ON "blob_integrity_results"("source_result_id"); diff --git a/database/migrations/20260813124000_file_node_parent_integrity/migration.sql b/database/migrations/20260813124000_file_node_parent_integrity/migration.sql new file mode 100644 index 0000000..48ac790 --- /dev/null +++ b/database/migrations/20260813124000_file_node_parent_integrity/migration.sql @@ -0,0 +1,8 @@ +ALTER TABLE "file_nodes" +ADD CONSTRAINT "file_nodes_parent_node_id_fkey" +FOREIGN KEY ("parent_node_id") REFERENCES "file_nodes"("id") +ON DELETE CASCADE ON UPDATE CASCADE +NOT VALID; + +ALTER TABLE "file_nodes" +VALIDATE CONSTRAINT "file_nodes_parent_node_id_fkey"; diff --git a/database/schema.prisma b/database/schema.prisma index 58a4d18..001d67a 100644 --- a/database/schema.prisma +++ b/database/schema.prisma @@ -296,6 +296,13 @@ model FileNode { mimeType String @map("mime_type") sizeBytes BigInt? @map("size_bytes") objectKey String? @map("object_key") + checksumAlgorithm String? @map("checksum_algorithm") + checksumValue String? @map("checksum_value") + integrityStatus String @default("unknown") @map("integrity_status") + lastVerifiedAt DateTime? @map("last_verified_at") @db.Timestamptz + verificationFailureCode String? @map("verification_failure_code") + integrityAcknowledgedAt DateTime? @map("integrity_acknowledged_at") @db.Timestamptz + integrityAcknowledgedBy String? @map("integrity_acknowledged_by") ownerName String @map("owner_name") ownerUserId String? @map("owner_user_id") starred Boolean @default(false) @@ -307,6 +314,8 @@ model FileNode { updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz owner User? @relation(fields: [ownerUserId], references: [id], onDelete: SetNull) + parent FileNode? @relation("FileNodeHierarchy", fields: [parentNodeId], references: [id], onDelete: Cascade, onUpdate: Cascade) + children FileNode[] @relation("FileNodeHierarchy") versions FileVersion[] @@index([workspaceId, spaceScope]) @@ -324,6 +333,13 @@ model FileVersion { objectKey String @map("object_key") sizeBytes BigInt @map("size_bytes") mimeType String @map("mime_type") + checksumAlgorithm String? @map("checksum_algorithm") + checksumValue String? @map("checksum_value") + integrityStatus String @default("unknown") @map("integrity_status") + lastVerifiedAt DateTime? @map("last_verified_at") @db.Timestamptz + verificationFailureCode String? @map("verification_failure_code") + integrityAcknowledgedAt DateTime? @map("integrity_acknowledged_at") @db.Timestamptz + integrityAcknowledgedBy String? @map("integrity_acknowledged_by") uploadedBy String @default("") @map("uploaded_by") remark String @default("") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz @@ -622,3 +638,72 @@ model BlobReconcileTask { @@index([startedAt], map: "blob_reconcile_tasks_started_at_idx") @@map("blob_reconcile_tasks") } + +model BlobIntegrityTask { + activeKey String? @unique(map: "blob_integrity_tasks_active_key_key") @map("active_key") + id String @id @map("id") + actorUserId String? @map("actor_user_id") + status String @map("status") + scope String @map("scope") + workspaceId String? @map("workspace_id") + mode String @map("mode") + target Json @default("{}") @map("target") + batchSize Int @map("batch_size") + concurrency Int @map("concurrency") + bandwidthLimitBytesPerSecond BigInt? @map("bandwidth_limit_bytes_per_second") + maxAttempts Int @map("max_attempts") + progress Json @default("{}") @map("progress") + cursor Json @default("{}") @map("cursor") + retryOfTaskId String? @map("retry_of_task_id") + retryResultIds Json @default("[]") @map("retry_result_ids") + leaseKey String? @unique(map: "blob_integrity_tasks_lease_key_key") @map("lease_key") + leaseOwner String? @map("lease_owner") + leaseExpiresAt DateTime? @map("lease_expires_at") @db.Timestamptz + failureCode String? @map("failure_code") + failureMessage String? @map("failure_message") + snapshotAt DateTime @map("snapshot_at") @db.Timestamptz + targetCount Int? @map("target_count") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + startedAt DateTime? @map("started_at") @db.Timestamptz + finishedAt DateTime? @map("finished_at") @db.Timestamptz + + results BlobIntegrityResult[] + + @@index([createdAt], map: "blob_integrity_tasks_created_at_idx") + @@index([status, createdAt], map: "blob_integrity_tasks_status_created_at_idx") + @@index([status, leaseExpiresAt], map: "blob_integrity_tasks_status_lease_expires_at_idx") + @@index([workspaceId, createdAt], map: "blob_integrity_tasks_workspace_created_at_idx") + @@index([retryOfTaskId], map: "blob_integrity_tasks_retry_of_task_id_idx") + @@map("blob_integrity_tasks") +} + +model BlobIntegrityResult { + id String @id @map("id") + taskId String @map("task_id") + sourceResultId String? @map("source_result_id") + workspaceId String @map("workspace_id") + nodeId String? @map("node_id") + versionId String? @map("version_id") + targetKey String @map("target_key") + objectKey String @map("object_key") + status String @map("status") + expectedHash String? @map("expected_hash") + actualHash String? @map("actual_hash") + expectedSizeBytes BigInt? @map("expected_size_bytes") + sizeBytes BigInt? @map("size_bytes") + bytesRead BigInt @default(0) @map("bytes_read") + attempts Int @default(1) @map("attempts") + errorCode String? @map("error_code") + errorMessage String? @map("error_message") + checkedAt DateTime @map("checked_at") @db.Timestamptz + acknowledgedAt DateTime? @map("acknowledged_at") @db.Timestamptz + acknowledgedBy String? @map("acknowledged_by") + + task BlobIntegrityTask @relation(fields: [taskId], references: [id], onDelete: Cascade) + + @@unique([taskId, targetKey], map: "blob_integrity_results_task_target_key") + @@index([taskId, status, checkedAt], map: "blob_integrity_results_task_status_checked_at_idx") + @@index([workspaceId, nodeId], map: "blob_integrity_results_workspace_node_idx") + @@index([sourceResultId], map: "blob_integrity_results_source_result_id_idx") + @@map("blob_integrity_results") +} From 7184872997886f3187658506510939299f4dc398 Mon Sep 17 00:00:00 2001 From: Cloudwhile <86512509+Cloudwhile@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:49:11 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(auth):=20=E4=BF=AE=E6=AD=A3=20OAuth=20?= =?UTF-8?q?=E5=9B=9E=E8=B0=83=E4=B8=8E=E4=BA=A4=E6=8D=A2=E7=A0=81=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E6=B6=88=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/modules/auth/core/auth.dto.ts | 7 ++ .../modules/auth/core/auth.repository.spec.ts | 81 ++++++++++++++++- .../src/modules/auth/core/auth.repository.ts | 31 +++---- .../modules/auth/core/auth.service.spec.ts | 60 +++++++++++++ backend/src/modules/auth/core/auth.service.ts | 17 ++-- frontend/src/lib/drive-api-auth-settings.ts | 3 +- frontend/src/lib/drive-api-types.ts | 7 ++ .../drive/oauth-callback-client.test.tsx | 89 +++++++++++++++++++ .../src/views/drive/oauth-callback-client.tsx | 11 ++- 9 files changed, 278 insertions(+), 28 deletions(-) create mode 100644 frontend/src/views/drive/oauth-callback-client.test.tsx diff --git a/backend/src/modules/auth/core/auth.dto.ts b/backend/src/modules/auth/core/auth.dto.ts index 5c17065..27ee813 100644 --- a/backend/src/modules/auth/core/auth.dto.ts +++ b/backend/src/modules/auth/core/auth.dto.ts @@ -60,6 +60,13 @@ export type OAuthStartResponse = { export type OAuthExchangeResponse = AuthSessionResponse; +export type OAuthFrontendCallbackResponse = + | AuthSessionResponse + | { + flow: 'step-up'; + code: string; + }; + export class UpdateAuthSettingsDto { @IsBoolean() @IsOptional() diff --git a/backend/src/modules/auth/core/auth.repository.spec.ts b/backend/src/modules/auth/core/auth.repository.spec.ts index b214acb..d5cd3e9 100644 --- a/backend/src/modules/auth/core/auth.repository.spec.ts +++ b/backend/src/modules/auth/core/auth.repository.spec.ts @@ -23,6 +23,17 @@ type UserIdentityUpsertArgs = { }; }; +type OAuthExchangeCodeRow = { + codeHash: string; + userId: string; + flow: 'login' | 'step-up'; + sessionTokenHash: string | null; + purpose: string | null; + expiresAt: Date; + usedAt: Date | null; + createdAt: Date; +}; + function createPrismaMock(queryRows: unknown[] = []) { const prismaUser = { id: 'user_1', @@ -53,7 +64,16 @@ function createPrismaMock(queryRows: unknown[] = []) { void args; return Promise.resolve(null); }); + const oauthExchangeCode = { + findUnique: jest.fn, [unknown]>(() => + Promise.resolve(null), + ), + updateMany: jest.fn, [unknown]>(() => + Promise.resolve({ count: 0 }), + ), + }; const tx = { + authOAuthExchangeCode: oauthExchangeCode, user: { findUnique: jest.fn(() => Promise.resolve(prismaUser)), create: userCreate, @@ -67,8 +87,8 @@ function createPrismaMock(queryRows: unknown[] = []) { }, }; return { - $transaction: jest.fn((callback: (tx: typeof tx) => Promise) => - callback(tx), + $transaction: jest.fn( + (callback: (transaction: typeof tx) => Promise) => callback(tx), ), $executeRawUnsafe: jest.fn(() => Promise.resolve(0)), $queryRawUnsafe: jest.fn(() => Promise.resolve(queryRows)), @@ -205,6 +225,63 @@ describe('AuthRepository', () => { ); }); + it('atomically consumes a login OAuth exchange code only once', async () => { + const prisma = createPrismaMock(); + const now = new Date(); + const row: OAuthExchangeCodeRow = { + codeHash: 'exchange-code-hash', + userId: 'user_1', + flow: 'login', + sessionTokenHash: null, + purpose: null, + expiresAt: new Date(now.getTime() + 60_000), + usedAt: null as Date | null, + createdAt: now, + }; + let available = true; + prisma.tx.authOAuthExchangeCode.updateMany.mockImplementation( + async (untypedArgs: unknown) => { + const args = untypedArgs as { data: { usedAt: Date } }; + await Promise.resolve(); + if (!available) return { count: 0 }; + available = false; + row.usedAt = args.data.usedAt; + return { count: 1 }; + }, + ); + prisma.tx.authOAuthExchangeCode.findUnique.mockImplementation(() => + Promise.resolve(row), + ); + const repository = new AuthRepository(prisma as never); + + const results = await Promise.all([ + repository.consumeOAuthLoginExchangeCode('exchange-code-hash'), + repository.consumeOAuthLoginExchangeCode('exchange-code-hash'), + ]); + + expect(results.filter(Boolean)).toHaveLength(1); + const updateArgs = prisma.tx.authOAuthExchangeCode.updateMany.mock + .calls[0]?.[0] as + | { + where: { + codeHash: string; + flow: string; + usedAt: null; + expiresAt: { gt: Date }; + }; + data: { usedAt: Date }; + } + | undefined; + expect(updateArgs?.where).toMatchObject({ + codeHash: 'exchange-code-hash', + flow: 'login', + usedAt: null, + }); + expect(updateArgs?.where.expiresAt.gt).toBeInstanceOf(Date); + expect(updateArgs?.data.usedAt).toBeInstanceOf(Date); + expect(prisma.tx.authOAuthExchangeCode.findUnique).toHaveBeenCalledTimes(1); + }); + it('reports an unused setup administrator email as available', async () => { const prisma = createPrismaMock(); const repository = new AuthRepository(prisma as never); diff --git a/backend/src/modules/auth/core/auth.repository.ts b/backend/src/modules/auth/core/auth.repository.ts index ca68c15..468e2ab 100644 --- a/backend/src/modules/auth/core/auth.repository.ts +++ b/backend/src/modules/auth/core/auth.repository.ts @@ -867,21 +867,22 @@ export class AuthRepository implements OnModuleInit { return this.mapPrismaOAuthExchangeCode(row); } - async findOAuthExchangeCode(codeHash: string) { - const row = await this.prisma.authOAuthExchangeCode.findUnique({ - where: { codeHash }, - }); - return row ? this.mapPrismaOAuthExchangeCode(row) : null; - } - - async markOAuthExchangeCodeUsed(codeHash: string) { - const existing = await this.prisma.authOAuthExchangeCode.findUnique({ - where: { codeHash }, - }); - if (!existing || existing.usedAt) return; - await this.prisma.authOAuthExchangeCode.update({ - where: { codeHash }, - data: { usedAt: new Date() }, + async consumeOAuthLoginExchangeCode(codeHash: string) { + return this.prisma.$transaction(async (tx) => { + const consumed = await tx.authOAuthExchangeCode.updateMany({ + where: { + codeHash, + flow: 'login', + usedAt: null, + expiresAt: { gt: new Date() }, + }, + data: { usedAt: new Date() }, + }); + if (consumed.count !== 1) return null; + const row = await tx.authOAuthExchangeCode.findUnique({ + where: { codeHash }, + }); + return row ? this.mapPrismaOAuthExchangeCode(row) : null; }); } diff --git a/backend/src/modules/auth/core/auth.service.spec.ts b/backend/src/modules/auth/core/auth.service.spec.ts index 389ee28..9e1d45a 100644 --- a/backend/src/modules/auth/core/auth.service.spec.ts +++ b/backend/src/modules/auth/core/auth.service.spec.ts @@ -127,6 +127,8 @@ function createService( findUserByProviderIdentity: jest.fn(), createOAuthUser: jest.fn(), createOAuthExchangeCode: jest.fn(() => Promise.resolve()), + consumeOAuthLoginExchangeCode: jest.fn(), + findUserById: jest.fn(), deletePasskey: jest.fn(() => Promise.resolve()), findSessionByTokenHash: jest.fn(() => Promise.resolve({ @@ -603,6 +605,64 @@ describe('AuthService', () => { expect(createdState.providerSnapshot).not.toHaveProperty('clientSecret'); }); + it('returns the step-up exchange code from frontend callbacks without creating a login session', async () => { + const { repository, service } = createService(); + jest.spyOn(service, 'handleOAuthCallback').mockResolvedValue({ + flow: 'step-up', + code: 'oauth_stepup_code', + user: createUserResponse(), + }); + + await expect( + service.completeFrontendOAuthCallback( + 'https://app.example/callback?state=stored-state&code=oauth-code', + ), + ).resolves.toEqual({ + flow: 'step-up', + code: 'oauth_stepup_code', + }); + + expect(repository.consumeOAuthLoginExchangeCode).not.toHaveBeenCalled(); + expect(repository.createSession).not.toHaveBeenCalled(); + }); + + it('creates only one session when a login exchange code is submitted concurrently', async () => { + const { repository, service } = createService(); + let available = true; + repository.consumeOAuthLoginExchangeCode.mockImplementation( + async (codeHash: string) => { + await Promise.resolve(); + if (!available) return null; + available = false; + return { + codeHash, + userId: 'user_1', + flow: 'login', + sessionTokenHash: null, + purpose: null, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + usedAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + }; + }, + ); + repository.findUserById.mockResolvedValue(createUserResponse()); + + const results = await Promise.allSettled([ + service.exchangeOAuthCode({ code: 'one-time-code' }), + service.exchangeOAuthCode({ code: 'one-time-code' }), + ]); + + expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength( + 1, + ); + expect(results.filter(({ status }) => status === 'rejected')).toHaveLength( + 1, + ); + expect(repository.findUserById).toHaveBeenCalledTimes(1); + expect(repository.createSession).toHaveBeenCalledTimes(1); + }); + it('uses the OAuth provider snapshot stored with the state during callbacks', async () => { const { repository, settingsService, service } = createService(); const providerSnapshot = { diff --git a/backend/src/modules/auth/core/auth.service.ts b/backend/src/modules/auth/core/auth.service.ts index c1a3110..96ba278 100644 --- a/backend/src/modules/auth/core/auth.service.ts +++ b/backend/src/modules/auth/core/auth.service.ts @@ -29,6 +29,7 @@ import { LoginDto, OAuthExchangeDto, OAuthExchangeResponse, + OAuthFrontendCallbackResponse, OAuthStartResponse, PasswordResetConfirmDto, PasswordResetConfirmResponse, @@ -542,8 +543,11 @@ export class AuthService { async completeFrontendOAuthCallback( callbackUrl: string, request?: Request, - ): Promise { + ): Promise { const result = await this.handleOAuthCallback(callbackUrl); + if (result.flow === 'step-up') { + return { flow: result.flow, code: result.code }; + } return this.exchangeOAuthCode({ code: result.code }, request); } @@ -553,18 +557,13 @@ export class AuthService { ): Promise { await this.bootstrapState.requireCompleted(); const codeHash = this.hashToken(dto.code); - const code = await this.authRepository.findOAuthExchangeCode(codeHash); - if ( - !code || - code.flow !== 'login' || - code.usedAt || - new Date(code.expiresAt).getTime() < Date.now() - ) { + const code = + await this.authRepository.consumeOAuthLoginExchangeCode(codeHash); + if (!code) { throw new UnauthorizedException('OAuth exchange code is invalid'); } const user = await this.authRepository.findUserById(code.userId); if (!user) throw new UnauthorizedException('OAuth user is unavailable'); - await this.authRepository.markOAuthExchangeCodeUsed(codeHash); const session = await this.createSession(user); await this.recordAuthAudit('auth.login', session.user, { method: 'oauth', diff --git a/frontend/src/lib/drive-api-auth-settings.ts b/frontend/src/lib/drive-api-auth-settings.ts index d347d22..a1c6819 100644 --- a/frontend/src/lib/drive-api-auth-settings.ts +++ b/frontend/src/lib/drive-api-auth-settings.ts @@ -17,6 +17,7 @@ import type { MailSettings, MailSettingsInput, OAuthConnectionTestResult, + OAuthFrontendCallbackResponse, OAuthProviderListResponse, OAuthSettingsInput, OAuthSettingsResponse, @@ -280,7 +281,7 @@ export function exchangeOAuthCode(input: { code: string }) { } export function completeOAuthCallback(input: { callbackUrl: string }) { - return requestDriveApi("/auth/oauth/callback", { + return requestDriveApi("/auth/oauth/callback", { method: "POST", body: JSON.stringify(input), }, publicRequest); diff --git a/frontend/src/lib/drive-api-types.ts b/frontend/src/lib/drive-api-types.ts index 7acb0ea..0626ae9 100644 --- a/frontend/src/lib/drive-api-types.ts +++ b/frontend/src/lib/drive-api-types.ts @@ -506,6 +506,13 @@ export type AuthSession = { user: AuthUser; }; +export type OAuthFrontendCallbackResponse = + | AuthSession + | { + flow: "step-up"; + code: string; + }; + export type PasswordResetRequestResponse = { configured: boolean; delivery: "email"; diff --git a/frontend/src/views/drive/oauth-callback-client.test.tsx b/frontend/src/views/drive/oauth-callback-client.test.tsx new file mode 100644 index 0000000..7ab10af --- /dev/null +++ b/frontend/src/views/drive/oauth-callback-client.test.tsx @@ -0,0 +1,89 @@ +import type { ReactNode } from "react"; +import { cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { palettes } from "@/features/file/model"; +import { OAuthCallbackRoute } from "./oauth-callback-client"; + +const mocks = vi.hoisted(() => ({ + completeOAuthCallback: vi.fn(), + replace: vi.fn(), + setStoredAuthToken: vi.fn(), +})); + +vi.mock("@/compat/navigation", () => ({ + useRouter: () => ({ replace: mocks.replace }), +})); + +vi.mock("@/i18n/react", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/lib/drive-api", () => ({ + completeOAuthCallback: mocks.completeOAuthCallback, + setStoredAuthToken: mocks.setStoredAuthToken, +})); + +vi.mock("./drive-shell", () => ({ + LocalizedDriveShell: ({ + children, + }: { + children: (value: { palette: typeof palettes.light }) => ReactNode; + }) => children({ palette: palettes.light }), +})); + +vi.mock("./auth-form-primitives", () => ({ + AuthPrimaryButton: ({ children }: { children: ReactNode }) => ( + + ), + AuthStatusNotice: () => null, +})); + +vi.mock("./drive-primitives", () => ({ + LocalIcon: () => null, + Surface: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +describe("OAuthCallbackRoute", () => { + beforeEach(() => { + mocks.completeOAuthCallback.mockReset(); + mocks.replace.mockReset(); + mocks.setStoredAuthToken.mockReset(); + window.history.replaceState(null, "", "/callback?code=provider-code"); + }); + + afterEach(() => cleanup()); + + it("stores a login session before returning to the drive", async () => { + mocks.completeOAuthCallback.mockResolvedValue({ + expiresAt: "2026-09-20T12:00:00.000Z", + token: "session-token", + user: { id: "user-1" }, + }); + + render(); + + await waitFor(() => { + expect(mocks.setStoredAuthToken).toHaveBeenCalledWith("session-token"); + expect(mocks.replace).toHaveBeenCalledWith("/"); + }); + expect(mocks.completeOAuthCallback).toHaveBeenCalledWith({ + callbackUrl: window.location.href, + }); + }); + + it("routes step-up codes to security settings without replacing the session", async () => { + mocks.completeOAuthCallback.mockResolvedValue({ + code: "step-up/code+value", + flow: "step-up", + }); + + render(); + + await waitFor(() => { + expect(mocks.replace).toHaveBeenCalledWith( + "/settings?oauthStepUpCode=step-up%2Fcode%2Bvalue&tab=security", + ); + }); + expect(mocks.setStoredAuthToken).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/views/drive/oauth-callback-client.tsx b/frontend/src/views/drive/oauth-callback-client.tsx index e184f31..3be0c11 100644 --- a/frontend/src/views/drive/oauth-callback-client.tsx +++ b/frontend/src/views/drive/oauth-callback-client.tsx @@ -33,7 +33,16 @@ function OAuthCallbackPage({ const callbackUrl = window.location.href; void completeOAuthCallback({ callbackUrl - }).then(session => { + }).then(result => { + if ("flow" in result) { + const search = new URLSearchParams({ + oauthStepUpCode: result.code, + tab: "security" + }); + router.replace(`/settings?${search.toString()}`); + return; + } + const session = result; setStoredAuthToken(session.token); setStatus({ tone: "success", From 7dec916f1c05dddc5f3b37e8d5aa5a5470c2f78b Mon Sep 17 00:00:00 2001 From: Cloudwhile <86512509+Cloudwhile@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:50:23 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(admin):=20=E5=A2=9E=E5=8A=A0=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E6=80=A7=E9=9D=A2=E6=9D=BF=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=20OAuth=20=E9=85=8D=E7=BD=AE=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 + .../components/admin/admin-scope-selector.css | 4 - .../admin/admin-scope-selector.test.tsx | 27 + .../components/admin/admin-scope-selector.tsx | 8 +- .../admin/admin-storage-integrity-panel.css | 730 ++++++++++++++ .../admin-storage-integrity-panel.test.tsx | 680 +++++++++++++ .../admin/admin-storage-integrity-panel.tsx | 932 ++++++++++++++++++ .../admin/admin-storage-integrity-target.css | 207 ++++ .../admin-storage-integrity-target.test.tsx | 197 ++++ .../admin/admin-storage-integrity-target.tsx | 298 ++++++ .../components/oauth/oauth-admin-parts.tsx | 67 +- .../oauth/oauth-provider-dialog.tsx | 81 +- .../src/features/admin/admin-routes.test.ts | 27 + frontend/src/features/admin/admin-routes.ts | 18 + .../admin/storage-integrity-colors.ts | 55 ++ .../admin/use-storage-integrity.test.tsx | 582 +++++++++++ .../features/admin/use-storage-integrity.ts | 626 ++++++++++++ .../src/i18n/admin-scope-messages.test.ts | 36 + frontend/src/i18n/locales/en_US.tsln | 87 ++ frontend/src/i18n/locales/zh_CN.tsln | 87 ++ frontend/src/lib/drive-api-admin-types.ts | 162 +++ frontend/src/lib/drive-api-admin.test.ts | 382 +++++++ frontend/src/lib/drive-api-admin.ts | 178 ++++ frontend/src/views/drive/admin-app.tsx | 101 +- .../drive/drive-oauth-admin-settings.test.tsx | 58 +- .../drive/drive-oauth-admin-settings.tsx | 240 ++++- .../src/views/drive/styles/admin-oauth.css | 199 ++-- frontend/src/views/drive/styles/admin.css | 83 +- frontend/tests/e2e/admin-oauth-visual.spec.ts | 335 +++++++ frontend/tests/e2e/storage-integrity.spec.ts | 354 +++++++ 30 files changed, 6592 insertions(+), 255 deletions(-) create mode 100644 frontend/src/components/admin/admin-scope-selector.test.tsx create mode 100644 frontend/src/components/admin/admin-storage-integrity-panel.css create mode 100644 frontend/src/components/admin/admin-storage-integrity-panel.test.tsx create mode 100644 frontend/src/components/admin/admin-storage-integrity-panel.tsx create mode 100644 frontend/src/components/admin/admin-storage-integrity-target.css create mode 100644 frontend/src/components/admin/admin-storage-integrity-target.test.tsx create mode 100644 frontend/src/components/admin/admin-storage-integrity-target.tsx create mode 100644 frontend/src/features/admin/storage-integrity-colors.ts create mode 100644 frontend/src/features/admin/use-storage-integrity.test.tsx create mode 100644 frontend/src/features/admin/use-storage-integrity.ts create mode 100644 frontend/tests/e2e/admin-oauth-visual.spec.ts create mode 100644 frontend/tests/e2e/storage-integrity.spec.ts diff --git a/.gitignore b/.gitignore index 6965642..c9f091a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,5 +32,11 @@ skills* data/ designpics/ test*/ +!frontend/tests/ +frontend/tests/* +!frontend/tests/e2e/ +frontend/tests/e2e/* +!frontend/tests/e2e/admin-oauth-visual.spec.ts +!frontend/tests/e2e/storage-integrity.spec.ts frontend/visual-artifacts/ private-docs/ diff --git a/frontend/src/components/admin/admin-scope-selector.css b/frontend/src/components/admin/admin-scope-selector.css index e2c31f6..5421c69 100644 --- a/frontend/src/components/admin/admin-scope-selector.css +++ b/frontend/src/components/admin/admin-scope-selector.css @@ -24,10 +24,6 @@ } @media (max-width: 560px) { - .admin-header-actions { - flex-wrap: wrap; - } - .admin-scope-selector { align-items: stretch; flex: 1 0 100%; diff --git a/frontend/src/components/admin/admin-scope-selector.test.tsx b/frontend/src/components/admin/admin-scope-selector.test.tsx new file mode 100644 index 0000000..2200f0f --- /dev/null +++ b/frontend/src/components/admin/admin-scope-selector.test.tsx @@ -0,0 +1,27 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { palettes } from "@/features/file/model"; +import { AdminScopeSelector } from "./admin-scope-selector"; + +vi.mock("@/i18n/react", () => ({ + useTranslations: () => (key: string) => key, +})); + +afterEach(cleanup); + +describe("AdminScopeSelector", () => { + it("can exclude system scope from sections that only support all/workspace", () => { + render( + , + ); + + const options = screen.getAllByRole("option").map((option) => option.getAttribute("value")); + expect(options).toEqual(["scope:all", "workspace:workspace-1"]); + }); +}); diff --git a/frontend/src/components/admin/admin-scope-selector.tsx b/frontend/src/components/admin/admin-scope-selector.tsx index 7f6fef9..acba3a9 100644 --- a/frontend/src/components/admin/admin-scope-selector.tsx +++ b/frontend/src/components/admin/admin-scope-selector.tsx @@ -10,6 +10,7 @@ import "./admin-scope-selector.css"; export type AdminScopeSelectorProps = { disabled?: boolean; + includeSystem?: boolean; onChange: (scope: AdminScope) => void; palette: Palette; scope: AdminScope; @@ -22,6 +23,7 @@ const workspaceScopePrefix = "workspace:"; export function AdminScopeSelector({ disabled, + includeSystem = true, onChange, palette, scope, @@ -31,13 +33,15 @@ export function AdminScopeSelector({ const options = useMemo( () => [ { label: t("admin.scopeAll"), value: allScopeValue }, - { label: t("admin.scopeSystem"), value: systemScopeValue }, + ...(includeSystem + ? [{ label: t("admin.scopeSystem"), value: systemScopeValue }] + : []), ...workspaces.map((workspace) => ({ label: t("admin.scopeWorkspaceOption", { name: workspace.name }), value: `${workspaceScopePrefix}${workspace.id}`, })), ], - [t, workspaces], + [includeSystem, t, workspaces], ); const requestedValue = scopeToValue(scope); const value = options.some((option) => option.value === requestedValue) diff --git a/frontend/src/components/admin/admin-storage-integrity-panel.css b/frontend/src/components/admin/admin-storage-integrity-panel.css new file mode 100644 index 0000000..c55288f --- /dev/null +++ b/frontend/src/components/admin/admin-storage-integrity-panel.css @@ -0,0 +1,730 @@ +.admin-integrity { + background: var(--drive-ui-surface); + border: 1px solid var(--drive-ui-line); + border-radius: var(--drive-ui-radius); + box-shadow: var(--drive-ui-shadow-tight); + box-sizing: border-box; + color: var(--drive-ui-ink); + min-width: 0; + overflow: hidden; +} + +.admin-integrity-commandbar { + align-items: center; + background: + linear-gradient(90deg, color-mix(in srgb, var(--ui-info) 7%, var(--ui-surface)) 0 1px, transparent 1px 100%), + var(--drive-ui-surface-soft); + background-size: 18px 100%, auto; + border-bottom: 1px solid var(--drive-ui-line); + display: grid; + gap: 18px; + grid-template-columns: minmax(0, 1fr) auto; + min-height: 88px; + padding: 18px 20px; +} + +.admin-integrity-commandbar > div, +.admin-integrity-zone-heading > div { + min-width: 0; +} + +.admin-integrity-commandbar h1, +.admin-integrity-zone-heading h3 { + color: var(--drive-ui-ink); + margin: 0; +} + +.admin-integrity-commandbar h1 { + font-size: 19px; + font-weight: 850; + letter-spacing: -0.01em; + line-height: 25px; +} + +.admin-integrity-commandbar p, +.admin-integrity-zone-heading p { + color: var(--drive-ui-muted); + margin: 0; +} + +.admin-integrity-commandbar p { + font-size: 12px; + font-weight: 650; + line-height: 17px; + margin-top: 3px; +} + +.admin-integrity-kicker { + color: var(--drive-ui-blue-600); + display: block; + font-size: 10px; + font-weight: 840; + letter-spacing: 0.11em; + line-height: 15px; + text-transform: uppercase; +} + +.admin-integrity-summary { + border-bottom: 1px solid var(--drive-ui-line); + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + min-width: 0; +} + +.admin-integrity-summary > div { + border-right: 1px solid var(--drive-ui-line); + display: grid; + gap: 5px; + min-height: 78px; + padding: 13px 16px; +} + +.admin-integrity-summary > div:last-child { + border-right: 0; +} + +.admin-integrity-summary span { + color: var(--drive-ui-muted); + font-size: 11px; + font-weight: 740; + line-height: 15px; +} + +.admin-integrity-summary strong { + color: var(--drive-ui-ink); + font-size: 23px; + font-variant-numeric: tabular-nums; + font-weight: 860; + line-height: 29px; +} + +.admin-integrity-summary [data-tone="info"] strong { + color: var(--drive-ui-info); +} + +.admin-integrity-summary [data-tone="success"] strong { + color: var(--drive-ui-success); +} + +.admin-integrity-summary [data-tone="warning"] strong { + color: var(--drive-ui-warning); +} + +.admin-integrity-summary [data-tone="danger"] strong { + color: var(--drive-ui-danger); +} + +.admin-integrity-timestamp { + align-items: center; + background: var(--drive-ui-surface-soft); + border-bottom: 1px solid var(--drive-ui-line); + color: var(--drive-ui-muted); + display: flex; + font-size: 11px; + font-weight: 680; + gap: 7px; + min-height: 32px; + padding: 0 18px; +} + +.admin-integrity-alert, +.admin-integrity-task-error { + background: color-mix(in srgb, var(--ui-danger) 8%, var(--ui-surface)); + color: var(--drive-ui-danger); +} + +.admin-integrity-alert { + align-items: center; + border-bottom: 1px solid color-mix(in srgb, var(--ui-danger) 24%, var(--drive-ui-line)); + display: flex; + font-size: 12px; + font-weight: 740; + gap: 8px; + min-height: 40px; + padding: 7px 18px; +} + +.admin-integrity-zone { + border-top: 1px solid var(--drive-ui-line); + min-width: 0; + padding: 20px; +} + +.admin-integrity-alert + .admin-integrity-zone, +.admin-integrity-timestamp + .admin-integrity-zone { + border-top: 0; +} + +.admin-integrity-zone-heading { + align-items: start; + display: grid; + gap: 11px; + grid-template-columns: 30px minmax(0, 1fr); + margin-bottom: 16px; + min-width: 0; +} + +.admin-integrity-zone-heading-actions { + grid-template-columns: 30px minmax(0, 1fr) auto; +} + +.admin-integrity-zone-heading > span:first-child { + align-items: center; + background: var(--ui-selection); + border: 1px solid color-mix(in srgb, var(--ui-accent) 24%, var(--drive-ui-line)); + border-radius: 4px; + color: var(--drive-ui-blue-600); + display: inline-flex; + height: 28px; + justify-content: center; + width: 28px; +} + +.admin-integrity-zone-heading h3 { + font-size: 14px; + font-weight: 820; + line-height: 19px; +} + +.admin-integrity-zone-heading p { + font-size: 11px; + font-weight: 650; + line-height: 16px; + margin-top: 2px; +} + +.admin-integrity-form { + display: grid; + gap: 13px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + min-width: 0; +} + +.admin-integrity-form > label { + display: grid; + gap: 6px; + min-width: 0; +} + +.admin-integrity-form > label > span:first-child { + color: var(--drive-ui-muted); + font-size: 11px; + font-weight: 730; + line-height: 15px; +} + +.admin-integrity-submit { + align-items: center; + border-top: 1px dashed var(--drive-ui-line-strong); + display: flex; + gap: 12px; + grid-column: 1 / -1; + justify-content: space-between; + min-height: 48px; + padding-top: 12px; +} + +.admin-integrity-target-unavailable { + align-items: center; + background: color-mix(in srgb, var(--ui-info) 7%, var(--ui-surface)); + border: 1px solid color-mix(in srgb, var(--ui-info) 24%, var(--drive-ui-line)); + color: var(--drive-ui-muted); + display: flex; + font-size: 11px; + font-weight: 700; + gap: 8px; + grid-column: 1 / -1; + min-height: 42px; + padding: 8px 11px; +} + +.admin-integrity-target-unavailable svg { + color: var(--drive-ui-info); + flex: 0 0 auto; +} + +.admin-integrity-confirm { + align-items: center; + color: var(--drive-ui-muted); + cursor: pointer; + display: inline-flex; + font-size: 11px; + font-weight: 700; + gap: 8px; + line-height: 16px; + min-width: 0; +} + +.admin-integrity-confirm input { + accent-color: var(--drive-ui-blue-600); + flex: 0 0 auto; + height: 15px; + width: 15px; +} + +.admin-integrity-scope-note { + color: var(--drive-ui-muted); + font-size: 11px; + font-weight: 700; +} + +.admin-integrity-progress { + display: grid; + gap: 14px; +} + +.admin-integrity-task-line { + align-items: center; + display: grid; + gap: 10px; + grid-template-columns: minmax(120px, 1fr) auto auto auto; + min-width: 0; +} + +.admin-integrity-task-line code, +.admin-integrity-table code, +.admin-integrity-history-list code { + color: var(--drive-ui-ink); + font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace; + font-size: 11px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-integrity-task-line > span, +.admin-integrity-task-line time, +.admin-integrity-result { + border: 1px solid var(--drive-ui-line); + border-radius: 3px; + color: var(--drive-ui-muted); + font-size: 10px; + font-weight: 780; + line-height: 16px; + padding: 1px 6px; + white-space: nowrap; +} + +.admin-integrity-task-line [data-status="running"], +.admin-integrity-task-line [data-status="queued"], +.admin-integrity-result[data-status="mismatch"] { + background: color-mix(in srgb, var(--ui-warning) 8%, var(--ui-surface)); + border-color: color-mix(in srgb, var(--ui-warning) 28%, var(--drive-ui-line)); + color: var(--drive-ui-warning); +} + +.admin-integrity-task-line [data-status="completed"], +.admin-integrity-result[data-status="matched"] { + background: color-mix(in srgb, var(--ui-success) 8%, var(--ui-surface)); + border-color: color-mix(in srgb, var(--ui-success) 28%, var(--drive-ui-line)); + color: var(--drive-ui-success); +} + +.admin-integrity-task-line [data-status="failed"], +.admin-integrity-result[data-status="failed"] { + background: color-mix(in srgb, var(--ui-danger) 8%, var(--ui-surface)); + border-color: color-mix(in srgb, var(--ui-danger) 28%, var(--drive-ui-line)); + color: var(--drive-ui-danger); +} + +.admin-integrity-progress-facts { + border: 1px solid var(--drive-ui-line); + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + min-width: 0; +} + +.admin-integrity-progress-facts > span { + border-right: 1px solid var(--drive-ui-line); + display: grid; + gap: 3px; + min-width: 0; + padding: 9px 11px; +} + +.admin-integrity-progress-facts > span:last-child { + border-right: 0; +} + +.admin-integrity-progress-facts small, +.admin-integrity-table small, +.admin-integrity-history-list small { + color: var(--drive-ui-muted); + font-size: 10px; + font-weight: 680; + line-height: 14px; +} + +.admin-integrity-progress-facts strong { + color: var(--drive-ui-ink); + font-size: 13px; + font-variant-numeric: tabular-nums; + font-weight: 820; + line-height: 18px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-integrity-task-error { + border-left: 3px solid var(--drive-ui-danger); + font-size: 11px; + font-weight: 700; + padding: 9px 11px; +} + +.admin-integrity-empty { + align-items: center; + background: var(--drive-ui-surface-soft); + border: 1px dashed var(--drive-ui-line-strong); + color: var(--drive-ui-muted); + display: flex; + font-size: 12px; + font-weight: 690; + justify-content: center; + min-height: 86px; + padding: 14px; + text-align: center; +} + +.admin-integrity-table-scroll { + border: 1px solid var(--drive-ui-line); + min-width: 0; + overflow-x: auto; +} + +.admin-integrity-table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; +} + +.admin-integrity-table th, +.admin-integrity-table td { + border-bottom: 1px solid var(--drive-ui-line); + box-sizing: border-box; + min-width: 0; + padding: 9px 10px; + text-align: left; + vertical-align: middle; +} + +.admin-integrity-table th { + background: var(--drive-ui-surface-soft); + color: var(--drive-ui-muted); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.03em; + line-height: 15px; + text-transform: uppercase; +} + +.admin-integrity-table tr:last-child td { + border-bottom: 0; +} + +.admin-integrity-table th:nth-child(1) { width: 23%; } +.admin-integrity-table th:nth-child(2) { width: 14%; } +.admin-integrity-table th:nth-child(3), +.admin-integrity-table th:nth-child(4) { width: 18%; } +.admin-integrity-table th:nth-child(5) { width: 19%; } +.admin-integrity-table th:nth-child(6) { width: 8%; } + +.admin-integrity-table td > code, +.admin-integrity-table td > small, +.admin-integrity-table td > time { + display: block; + max-width: 100%; +} + +.admin-integrity-table td > small { + margin-top: 3px; +} + +.admin-integrity-table time { + color: var(--drive-ui-ink); + font-size: 11px; + font-weight: 710; + line-height: 15px; +} + +.admin-integrity-object { + color: var(--drive-ui-ink); + display: block; + font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace; + font-size: 11px; + font-weight: 690; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-integrity-object-references { + display: grid; + gap: 1px; + margin-top: 4px; + min-width: 0; +} + +.admin-integrity-object-references small { + align-items: baseline; + display: grid; + gap: 5px; + grid-template-columns: auto minmax(0, 1fr); + min-width: 0; +} + +.admin-integrity-object-references small > span { + color: var(--drive-ui-muted); + font-size: 9px; + font-weight: 760; +} + +.admin-integrity-result-state { + align-items: start; + display: grid; + gap: 5px; + justify-items: start; +} + +.admin-integrity-acknowledged { + align-items: center; + color: var(--drive-ui-muted); + display: grid; + font-size: 9px; + font-weight: 720; + gap: 2px 4px; + grid-template-columns: 12px minmax(0, 1fr); + line-height: 13px; + min-width: 0; +} + +.admin-integrity-acknowledged svg { + color: var(--drive-ui-success); + grid-row: 1 / span 2; +} + +.admin-integrity-acknowledged time { + color: var(--drive-ui-muted); + font-size: 9px; + line-height: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-integrity-result-actions { + align-items: center; + display: flex; + gap: 3px; + justify-content: flex-end; +} + +.admin-integrity-history-list { + border: 1px solid var(--drive-ui-line); + display: grid; + min-width: 0; +} + +.admin-integrity-history-list > button { + align-items: center; + background: transparent; + border: 0; + border-bottom: 1px solid var(--drive-ui-line); + color: var(--drive-ui-muted); + cursor: pointer; + display: grid; + font: inherit; + gap: 11px; + grid-template-columns: 28px minmax(140px, 1fr) auto auto auto 16px; + min-height: 48px; + min-width: 0; + padding: 7px 10px; + text-align: left; + transition: background 140ms var(--drive-ui-ease); +} + +.admin-integrity-history-list > button:last-child { + border-bottom: 0; +} + +.admin-integrity-history-list > button:hover, +.admin-integrity-history-list > button[data-active="true"] { + background: var(--ui-selection); +} + +.admin-integrity-history-list > button:focus-visible { + outline: 2px solid var(--ui-focus-ring); + outline-offset: -2px; +} + +.admin-integrity-history-list > button > span:nth-child(2) { + display: grid; + min-width: 0; +} + +.admin-integrity-history-list > button > span:nth-child(n + 3), +.admin-integrity-history-list strong { + font-size: 10px; + font-weight: 760; + white-space: nowrap; +} + +.admin-integrity-history-icon { + align-items: center; + background: var(--drive-ui-surface-soft); + border: 1px solid var(--drive-ui-line); + border-radius: 3px; + display: inline-flex; + height: 26px; + justify-content: center; + width: 26px; +} + +.admin-integrity-history-icon[data-status="completed"] { + color: var(--drive-ui-success); +} + +.admin-integrity-history-icon[data-status="failed"] { + color: var(--drive-ui-danger); +} + +.admin-integrity-history-icon[data-status="running"], +.admin-integrity-history-icon[data-status="queued"], +.admin-integrity-history-icon[data-status="mismatch"] { + color: var(--drive-ui-warning); +} + +.admin-integrity-pagination { + align-items: center; + border-top: 1px solid var(--drive-ui-line); + display: flex; + justify-content: space-between; + margin-top: 10px; + min-height: 38px; + padding-top: 8px; +} + +.admin-integrity-pagination > span { + color: var(--drive-ui-muted); + font-size: 10px; + font-variant-numeric: tabular-nums; + font-weight: 700; +} + +.admin-integrity-pagination > div { + display: flex; + gap: 4px; +} + +@media (max-width: 1120px) { + .admin-integrity-form { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .admin-integrity-summary { + grid-template-columns: repeat(5, minmax(110px, 1fr)); + overflow-x: auto; + } + + .admin-integrity-table { + min-width: 920px; + } +} + +@media (max-width: 680px) { + .admin-integrity-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + overflow: visible; + } + + .admin-integrity-summary > div { + border-bottom: 1px solid var(--drive-ui-line); + } + + .admin-integrity-summary > div:nth-child(2n) { + border-right: 0; + } + + .admin-integrity-summary > div:last-child { + border-bottom: 0; + grid-column: 1 / -1; + } + + .admin-integrity-commandbar, + .admin-integrity-zone { + padding-left: 14px; + padding-right: 14px; + } + + .admin-integrity-form { + grid-template-columns: minmax(0, 1fr); + } + + .admin-integrity-submit { + align-items: flex-start; + } + + .admin-integrity-confirm { + max-width: calc(100% - 48px); + } + + .admin-integrity-task-line { + grid-template-columns: minmax(0, 1fr) auto; + } + + .admin-integrity-task-line time { + grid-column: 1 / -1; + justify-self: start; + } + + .admin-integrity-progress-facts { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .admin-integrity-progress-facts > span { + border-bottom: 1px solid var(--drive-ui-line); + } + + .admin-integrity-progress-facts > span:nth-child(2n) { + border-right: 0; + } + + .admin-integrity-progress-facts > span:last-child { + border-bottom: 0; + grid-column: 1 / -1; + } + + .admin-integrity-history-list > button { + grid-template-columns: 28px minmax(0, 1fr) auto 16px; + } + + .admin-integrity-history-list > button > span:nth-child(3), + .admin-integrity-history-list > button > span:nth-child(5) { + display: none; + } +} + +@media (max-width: 420px) { + .admin-integrity-commandbar { + gap: 10px; + } + + .admin-integrity-zone-heading-actions { + grid-template-columns: 30px minmax(0, 1fr); + } + + .admin-integrity-zone-heading-actions > :last-child:not(div) { + grid-column: 2; + justify-self: start; + } + + .admin-integrity-history-list > button { + grid-template-columns: 26px minmax(0, 1fr) 16px; + } + + .admin-integrity-history-list > button > strong { + display: none; + } +} diff --git a/frontend/src/components/admin/admin-storage-integrity-panel.test.tsx b/frontend/src/components/admin/admin-storage-integrity-panel.test.tsx new file mode 100644 index 0000000..0f494c2 --- /dev/null +++ b/frontend/src/components/admin/admin-storage-integrity-panel.test.tsx @@ -0,0 +1,680 @@ +import type { ComponentProps, ReactNode } from "react"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { palettes } from "@/features/file/model"; +import { + acknowledgeStorageIntegrityResult, + createStorageIntegrityTask, + fetchStorageIntegrityResults, + fetchStorageIntegritySummary, + fetchStorageIntegrityTask, + fetchStorageIntegrityTasks, + retryStorageIntegrityTask, + type StorageIntegrityResult, + type StorageIntegrityTask, +} from "@/lib/drive-api"; +import { AdminStorageIntegrityPanel } from "./admin-storage-integrity-panel"; + +vi.mock("@/lib/drive-api", async (importOriginal) => ({ + ...(await importOriginal()), + acknowledgeStorageIntegrityResult: vi.fn(), + createStorageIntegrityTask: vi.fn(), + fetchStorageIntegrityResults: vi.fn(), + fetchStorageIntegritySummary: vi.fn(), + fetchStorageIntegrityTask: vi.fn(), + fetchStorageIntegrityTasks: vi.fn(), + retryStorageIntegrityTask: vi.fn(), +})); + +vi.mock("@/components/ui/app-icon", () => ({ + LocalIcon: ({ name }: { name: string }) => , +})); + +vi.mock("@/components/ui/tool-button", () => ({ + ToolButton: ({ + children, + disabled, + isPending, + label, + onClick, + type, + }: { + children: ReactNode; + disabled?: boolean; + isPending?: boolean; + label: string; + onClick?: () => void; + type?: "button" | "submit"; + }) => ( + + ), +})); + +vi.mock("@/components/ui/app-input", () => ({ + AppInput: ({ palette: _palette, ...props }: Record) => ( + + ), +})); + +vi.mock("@/components/ui/app-select", () => ({ + AppSelect: ({ + options, + palette: _palette, + ...props + }: { + options: Array<{ label: string; value: string }>; + palette: unknown; + }) => ( + + ), +})); + +vi.mock("@/components/ui/progress-meter", () => ({ + ProgressMeter: ({ + ariaLabel, + max, + value, + }: { + ariaLabel: string; + max: number; + value: number; + }) => ( + + ), +})); + +vi.mock("@/i18n/react", () => ({ + useTranslations: () => ( + key: string, + values?: Record, + ) => + Object.entries(values ?? {}).reduce( + (message, [name, value]) => `${message}:${name}=${value}`, + key, + ), +})); + +const summary = { + counts: { failed: 2, mismatch: 3, pending: 4, unknown: 5, verified: 6 }, + generatedAt: "2026-08-13T12:00:00.000Z", + lastVerifiedAt: "2026-08-13T11:00:00.000Z", + scope: { kind: "all" as const }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchStorageIntegrityTasks).mockResolvedValue({ + items: [], + limit: 20, + offset: 0, + total: 0, + }); + vi.mocked(fetchStorageIntegritySummary).mockResolvedValue(summary); + vi.mocked(fetchStorageIntegrityResults).mockResolvedValue({ + items: [], + limit: 25, + offset: 0, + total: 0, + }); +}); + +afterEach(cleanup); + +describe("AdminStorageIntegrityPanel", () => { + it("allows retrying a failed task before any result was saved", async () => { + vi.mocked(fetchStorageIntegrityTask).mockResolvedValue(createTask({ + status: "failed", + progress: { bytesRead: 0, failed: 0, matched: 0, mismatches: 0, processed: 0, total: 0 }, + })); + vi.mocked(retryStorageIntegrityTask).mockResolvedValue( + createTask({ id: "task-retried", status: "queued" }), + ); + const { props } = renderPanel({ requestedTaskId: "task-1" }); + fireEvent.click(await screen.findByRole("button", { name: "storageIntegrity.retryAll" })); + await waitFor(() => expect(retryStorageIntegrityTask).toHaveBeenCalledWith("task-1", {})); + expect(props.onTaskIdChange).toHaveBeenCalledWith("task-retried"); + }); + + it("shows a warning for completed history containing failed reads", async () => { + vi.mocked(fetchStorageIntegrityTasks).mockResolvedValue({ + items: [createTask({ + progress: { bytesRead: 0, failed: 1, matched: 0, mismatches: 0, processed: 1, total: 1 }, + })], + limit: 20, offset: 0, total: 1, + }); + renderPanel(); + const task = await screen.findByRole("button", { name: "storageIntegrity.openTask:task=task-1" }); + expect( + task.querySelector(".admin-integrity-history-icon"), + ).toHaveAttribute("data-status", "failed"); + expect(task.querySelector('[data-icon="exclamation"]')).not.toBeNull(); + expect(task.querySelector('[data-icon="tick"]')).toBeNull(); + expect(task).toHaveAccessibleDescription( + "storageIntegrity.status.completed 1 / 1", + ); + }); + + it("provides the page-level storage integrity heading", () => { + renderPanel(); + + expect( + screen.getByRole("heading", { + level: 1, + name: "storageIntegrity.title", + }), + ).toBeVisible(); + }); + + it("uses the server summary and requires confirmation for an all-scope run", async () => { + const task = createTask({ id: "task-created", status: "queued" }); + vi.mocked(createStorageIntegrityTask).mockResolvedValue(task); + const { props } = renderPanel(); + + await waitFor(() => + expect(fetchStorageIntegritySummary).toHaveBeenCalledWith( + { kind: "all" }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ), + ); + expect(screen.getByTestId("integrity-count-unknown")).toHaveTextContent( + "5", + ); + expect( + screen.getByText("storageIntegrity.targetWorkspaceOnly"), + ).toBeInTheDocument(); + expect( + screen.queryByRole("textbox", { + name: "storageIntegrity.targetSearch", + }), + ).not.toBeInTheDocument(); + const start = screen.getByRole("button", { + name: "storageIntegrity.start", + }); + expect(start).toBeDisabled(); + + fireEvent.click( + screen.getByRole("checkbox", { + name: "storageIntegrity.confirmAll", + }), + ); + fireEvent.click(start); + + await waitFor(() => expect(createStorageIntegrityTask).toHaveBeenCalled()); + expect(createStorageIntegrityTask).toHaveBeenCalledWith( + expect.objectContaining({ mode: "verify", scope: "all" }), + ); + expect(props.onTaskIdChange).toHaveBeenCalledWith("task-created"); + }); + + it("explains a size and hash mismatch without exposing a delete action", async () => { + const task = createTask(); + const finding = createFinding(); + vi.mocked(fetchStorageIntegrityTask).mockResolvedValue(task); + vi.mocked(fetchStorageIntegrityResults).mockResolvedValue({ + items: [finding], + limit: 25, + offset: 0, + total: 1, + }); + vi.mocked(retryStorageIntegrityTask).mockResolvedValue( + createTask({ id: "task-retried", status: "queued" }), + ); + const { props } = renderPanel({ requestedTaskId: "task-1" }); + + const objectKey = await screen.findByText("objects/archive.bin"); + expect(objectKey).toHaveAttribute("title", "objects/archive.bin"); + const resultRow = objectKey.closest("tr"); + expect(resultRow).not.toBeNull(); + expect(within(resultRow!).getByText("storageIntegrity.nodeId")).toBeVisible(); + expect(within(resultRow!).getByText("node-1")).toBeVisible(); + expect(within(resultRow!).getByText("storageIntegrity.versionId")).toBeVisible(); + expect(within(resultRow!).getByText("version-1")).toBeVisible(); + expect(screen.getByText("sha256:expected")).toBeInTheDocument(); + expect(screen.getByText("sha256:actual")).toBeInTheDocument(); + expect(screen.getByTestId("integrity-expected-size")).toHaveTextContent( + "1 KB", + ); + expect(screen.getByTestId("integrity-actual-size")).toHaveTextContent( + "2 KB", + ); + expect( + screen.queryByRole("button", { name: /delete|storageIntegrity\.delete/i }), + ).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByRole("button", { name: "storageIntegrity.retryAll" }), + ); + await waitFor(() => + expect(retryStorageIntegrityTask).toHaveBeenCalledWith("task-1", {}), + ); + expect(props.onTaskIdChange).toHaveBeenCalledWith("task-retried"); + }); + + it("uses readable foregrounds for light-theme result statuses", async () => { + vi.mocked(fetchStorageIntegrityTask).mockResolvedValue(createTask()); + vi.mocked(fetchStorageIntegrityResults).mockResolvedValue({ + items: [ + createFinding({ id: "matched", status: "matched" }), + createFinding({ id: "mismatch", status: "mismatch" }), + createFinding({ id: "failed", status: "failed" }), + ], + limit: 25, + offset: 0, + total: 3, + }); + renderPanel({ requestedTaskId: "task-1" }); + + const cases = [ + ["matched", palettes.light.success], + ["mismatch", palettes.light.warning], + ["failed", palettes.light.danger], + ] as const; + for (const [status, semanticColor] of cases) { + const label = await screen.findByText(`storageIntegrity.result.${status}`, { + selector: ".admin-integrity-result", + }); + expect(label.style.color).not.toBe(""); + expect( + contrastRatio( + parseCssColor(label.style.color), + mixHexColors(semanticColor, palettes.light.surface1, 0.08), + ), + ).toBeGreaterThanOrEqual(4.5); + } + }); + + it("disables finding retries while an integrity operation is pending", async () => { + const task = createTask(); + vi.mocked(fetchStorageIntegrityTask).mockResolvedValue(task); + vi.mocked(fetchStorageIntegrityResults).mockResolvedValue({ + items: [createFinding()], + limit: 25, + offset: 0, + total: 1, + }); + let resolveRetry: ((task: StorageIntegrityTask) => void) | undefined; + vi.mocked(retryStorageIntegrityTask).mockReturnValue( + new Promise((resolve) => { + resolveRetry = resolve; + }), + ); + renderPanel({ requestedTaskId: "task-1" }); + const retry = await screen.findByRole("button", { + name: "storageIntegrity.retryFinding", + }); + + fireEvent.click(retry); + await waitFor(() => expect(retry).toBeDisabled()); + fireEvent.click(retry); + + expect(retryStorageIntegrityTask).toHaveBeenCalledTimes(1); + await act(async () => { + resolveRetry?.(createTask({ id: "task-retried", status: "queued" })); + }); + }); + + it("does not offer finding retries until the current task is terminal", async () => { + vi.mocked(fetchStorageIntegrityTask).mockResolvedValue( + createTask({ + finishedAt: null, + status: "running", + }), + ); + vi.mocked(fetchStorageIntegrityResults).mockResolvedValue({ + items: [createFinding()], + limit: 25, + offset: 0, + total: 1, + }); + + renderPanel({ requestedTaskId: "task-1" }); + + await screen.findByText("objects/archive.bin"); + expect( + screen.queryByRole("button", { + name: "storageIntegrity.retryFinding", + }), + ).not.toBeInTheDocument(); + expect(retryStorageIntegrityTask).not.toHaveBeenCalled(); + }); + + it("acknowledges only unacknowledged anomalies and prevents duplicate actions", async () => { + const finding = createFinding({ objectKey: "objects/mismatch.bin" }); + const acknowledgedFinding = createFinding({ + acknowledgedAt: "2026-08-14T01:00:00.000Z", + acknowledgedBy: "admin-1", + objectKey: "objects/mismatch.bin", + }); + vi.mocked(fetchStorageIntegrityTask).mockResolvedValue(createTask()); + vi.mocked(fetchStorageIntegrityResults) + .mockResolvedValueOnce({ + items: [ + finding, + createFinding({ + acknowledgedAt: "2026-08-13T22:00:00.000Z", + acknowledgedBy: "admin-2", + id: "acknowledged-failed", + objectKey: "objects/acknowledged-failed.bin", + status: "failed", + }), + createFinding({ + id: "unacknowledged-failed", + objectKey: "objects/unacknowledged-failed.bin", + status: "failed", + }), + createFinding({ + id: "matched", + objectKey: "objects/matched.bin", + status: "matched", + }), + ], + limit: 25, + offset: 0, + total: 4, + }) + .mockResolvedValue({ + items: [acknowledgedFinding], + limit: 25, + offset: 0, + total: 1, + }); + let resolveAcknowledgement: + | ((result: StorageIntegrityResult) => void) + | undefined; + vi.mocked(acknowledgeStorageIntegrityResult).mockReturnValue( + new Promise((resolve) => { + resolveAcknowledgement = resolve; + }), + ); + renderPanel({ requestedTaskId: "task-1" }); + + const mismatchRow = ( + await screen.findByText("objects/mismatch.bin") + ).closest("tr"); + expect(mismatchRow).not.toBeNull(); + const acknowledge = within(mismatchRow!).getByRole("button", { + name: "storageIntegrity.acknowledgeFinding", + }); + expect( + screen.getAllByRole("button", { + name: "storageIntegrity.acknowledgeFinding", + }), + ).toHaveLength(2); + expect(acknowledge).not.toHaveTextContent( + "storageIntegrity.acknowledgeFinding", + ); + + fireEvent.click(acknowledge); + await waitFor(() => expect(acknowledge).toBeDisabled()); + fireEvent.click(acknowledge); + expect(acknowledgeStorageIntegrityResult).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveAcknowledgement?.(acknowledgedFinding); + }); + + const resultRow = (await screen.findByText("objects/mismatch.bin")).closest( + "tr", + ); + expect(resultRow).not.toBeNull(); + expect( + within(resultRow!).getByText("storageIntegrity.acknowledged"), + ).toBeVisible(); + expect(within(resultRow!).getByText(/Aug 14, 2026/, { selector: "time" })).toBeVisible(); + expect( + within(resultRow!).queryByRole("button", { + name: "storageIntegrity.acknowledgeFinding", + }), + ).not.toBeInTheDocument(); + }); + + it("surfaces acknowledgement failures through the panel alert", async () => { + vi.mocked(fetchStorageIntegrityTask).mockResolvedValue(createTask()); + vi.mocked(fetchStorageIntegrityResults).mockResolvedValue({ + items: [createFinding()], + limit: 25, + offset: 0, + total: 1, + }); + vi.mocked(acknowledgeStorageIntegrityResult).mockRejectedValue( + new Error("request failed"), + ); + renderPanel({ requestedTaskId: "task-1" }); + + const acknowledge = await screen.findByRole("button", { + name: "storageIntegrity.acknowledgeFinding", + }); + fireEvent.click(acknowledge); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "storageIntegrity.acknowledgeFailed", + ); + expect(acknowledge).toBeEnabled(); + }); + + it("opens a task from paginated history", async () => { + vi.mocked(fetchStorageIntegrityTasks).mockResolvedValue({ + items: [createTask()], + limit: 20, + offset: 0, + total: 1, + }); + const { props } = renderPanel(); + + fireEvent.click( + await screen.findByRole("button", { + name: "storageIntegrity.openTask:task=task-1", + }), + ); + expect(props.onTaskIdChange).toHaveBeenCalledWith("task-1"); + }); + + it("shows a localized safe message for a failed task", async () => { + vi.mocked(fetchStorageIntegrityTask).mockResolvedValue( + createTask({ + failureCode: "STORAGE_INTEGRITY_TASK_FAILED", + failureMessage: "完整性巡检任务执行失败", + status: "failed", + }), + ); + + renderPanel({ requestedTaskId: "task-1" }); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("storageIntegrity.failure.taskFailed"); + expect(alert).not.toHaveTextContent("完整性巡检任务执行失败"); + }); + + it("uses a safe fallback for an unknown failure code", async () => { + vi.mocked(fetchStorageIntegrityTask).mockResolvedValue( + createTask({ + failureCode: "UNRECOGNIZED_FAILURE", + failureMessage: "不应直接展示的后端错误", + status: "failed", + }), + ); + + renderPanel({ requestedTaskId: "task-1" }); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("storageIntegrity.failure.unknown"); + expect(alert).not.toHaveTextContent("不应直接展示的后端错误"); + }); + + it("uses native form constraints instead of silently clamping invalid limits", async () => { + vi.mocked(createStorageIntegrityTask).mockResolvedValue( + createTask({ id: "task-created", status: "queued" }), + ); + renderPanel({ + scope: { kind: "workspace", workspaceId: "workspace-1" }, + }); + const batchSize = screen.getByRole("spinbutton", { + name: "storageIntegrity.batchSize", + }); + const bandwidth = screen.getByRole("spinbutton", { + name: "storageIntegrity.bandwidthMib", + }); + + expect(bandwidth).toHaveAttribute("max", "953"); + + fireEvent.change(batchSize, { target: { value: "0" } }); + expect(batchSize).toBeInvalid(); + fireEvent.click( + screen.getByRole("button", { name: "storageIntegrity.start" }), + ); + + expect(createStorageIntegrityTask).not.toHaveBeenCalled(); + + fireEvent.change(batchSize, { target: { value: "501" } }); + expect(batchSize).toBeInvalid(); + fireEvent.click( + screen.getByRole("button", { name: "storageIntegrity.start" }), + ); + + expect(createStorageIntegrityTask).not.toHaveBeenCalled(); + + fireEvent.change(batchSize, { target: { value: "100" } }); + fireEvent.change(bandwidth, { target: { value: "954" } }); + expect(bandwidth).toBeInvalid(); + fireEvent.click( + screen.getByRole("button", { name: "storageIntegrity.start" }), + ); + + expect(createStorageIntegrityTask).not.toHaveBeenCalled(); + }); +}); + +function renderPanel( + overrides: Partial> = {}, +) { + const props: ComponentProps = { + locale: "en", + onTaskIdChange: vi.fn(), + palette: palettes.light, + requestedTaskId: null, + scope: { kind: "all" }, + timeZone: "UTC", + ...overrides, + }; + render(); + return { props }; +} + +function createTask( + overrides: Partial = {}, +): StorageIntegrityTask { + return { + config: { + bandwidthLimitBytesPerSecond: null, + batchSize: 100, + concurrency: 2, + maxAttempts: 3, + }, + createdAt: "2026-08-13T12:00:00.000Z", + failureCode: null, + failureMessage: null, + finishedAt: "2026-08-13T12:02:00.000Z", + id: "task-1", + mode: "verify", + progress: { + bytesRead: 2_048, + failed: 0, + matched: 8, + mismatches: 2, + processed: 10, + total: 10, + }, + scope: "all", + startedAt: "2026-08-13T12:00:01.000Z", + status: "completed", + target: null, + workspaceId: null, + ...overrides, + }; +} + +function createFinding( + overrides: Partial = {}, +): StorageIntegrityResult { + return { + acknowledgedAt: null, + acknowledgedBy: null, + actualHash: "sha256:actual", + actualSizeBytes: 2_048, + attempts: 2, + checkedAt: "2026-08-13T12:01:00.000Z", + errorCode: null, + errorMessage: null, + expectedHash: "sha256:expected", + expectedSizeBytes: 1_024, + id: "finding-1", + nodeId: "node-1", + objectKey: "objects/archive.bin", + status: "mismatch", + taskId: "task-1", + versionId: "version-1", + workspaceId: "workspace-1", + ...overrides, + }; +} + +function mixHexColors(foreground: string, background: string, weight: number) { + const left = parseCssColor(foreground); + const right = parseCssColor(background); + return left.map((channel, index) => + Math.round(channel * weight + right[index] * (1 - weight)), + ) as [number, number, number]; +} + +function parseCssColor(color: string): [number, number, number] { + if (color.startsWith("#")) { + return [1, 3, 5].map((index) => + Number.parseInt(color.slice(index, index + 2), 16), + ) as [number, number, number]; + } + const channels = color.match(/[\d.]+/g)?.slice(0, 3).map(Number); + if (!channels || channels.length !== 3) throw new Error(`Invalid color: ${color}`); + return channels as [number, number, number]; +} + +function contrastRatio( + foreground: [number, number, number], + background: [number, number, number], +) { + const foregroundLuminance = relativeLuminance(foreground); + const backgroundLuminance = relativeLuminance(background); + const lighter = Math.max(foregroundLuminance, backgroundLuminance); + const darker = Math.min(foregroundLuminance, backgroundLuminance); + return (lighter + 0.05) / (darker + 0.05); +} + +function relativeLuminance(channels: [number, number, number]) { + const [red, green, blue] = channels.map((channel) => { + const normalized = channel / 255; + return normalized <= 0.04045 + ? normalized / 12.92 + : ((normalized + 0.055) / 1.055) ** 2.4; + }); + return red * 0.2126 + green * 0.7152 + blue * 0.0722; +} diff --git a/frontend/src/components/admin/admin-storage-integrity-panel.tsx b/frontend/src/components/admin/admin-storage-integrity-panel.tsx new file mode 100644 index 0000000..12c7a97 --- /dev/null +++ b/frontend/src/components/admin/admin-storage-integrity-panel.tsx @@ -0,0 +1,932 @@ +"use client"; + +import { useMemo, useState, type FormEvent } from "react"; +import { AppInput } from "@/components/ui/app-input"; +import { LocalIcon } from "@/components/ui/app-icon"; +import { AppSelect } from "@/components/ui/app-select"; +import { ProgressMeter } from "@/components/ui/progress-meter"; +import { ToolButton } from "@/components/ui/tool-button"; +import { AdminStorageIntegrityTarget } from "./admin-storage-integrity-target"; +import type { AdminScope } from "@/features/admin/admin-scope"; +import { storageIntegrityStatusTextColor } from "@/features/admin/storage-integrity-colors"; +import { useStorageIntegrity } from "@/features/admin/use-storage-integrity"; +import { + formatFileSize, + getIntlLocale, + type Locale, + type Palette, +} from "@/features/file/model"; +import { useTranslations } from "@/i18n/react"; +import type { + CreateStorageIntegrityTaskInput, + StorageIntegrityResult, + StorageIntegrityResultFilter, + StorageIntegrityTarget, + StorageIntegrityTask, +} from "@/lib/drive-api"; +import "./admin-storage-integrity-panel.css"; + +export function AdminStorageIntegrityPanel({ + locale, + onTaskIdChange, + palette, + requestedTaskId, + scope, + timeZone, +}: { + locale: Locale; + onTaskIdChange: (taskId: string | null) => void; + palette: Palette; + requestedTaskId: string | null; + scope: AdminScope; + timeZone: string; +}) { + const t = useTranslations(); + const controller = useStorageIntegrity({ + onTaskIdChange, + requestedTaskId, + scope, + }); + const [mode, setMode] = useState<"backfill" | "verify">("verify"); + const [batchSize, setBatchSize] = useState("100"); + const [concurrency, setConcurrency] = useState("2"); + const [bandwidthMib, setBandwidthMib] = useState(""); + const [maxAttempts, setMaxAttempts] = useState("3"); + const workspaceId = scope.kind === "workspace" ? scope.workspaceId : null; + const integrityScope = useMemo( + () => + workspaceId + ? ({ kind: "workspace", workspaceId } as const) + : ({ kind: "all" } as const), + [workspaceId], + ); + const integrityScopeKey = workspaceId ? `workspace:${workspaceId}` : "all"; + const [targetState, setTargetState] = useState<{ + scopeKey: string; + target: StorageIntegrityTarget; + }>({ scopeKey: integrityScopeKey, target: {} }); + const target = + targetState.scopeKey === integrityScopeKey + ? targetState.target + : emptyIntegrityTarget; + const [confirmation, setConfirmation] = useState({ + checked: false, + scopeKey: integrityScopeKey, + }); + const confirmAll = + confirmation.scopeKey === integrityScopeKey && confirmation.checked; + const globalRun = integrityScope.kind === "all"; + + const formInput = useMemo( + () => ({ + batchSize: boundedInteger(batchSize, 1, 500, 100), + bandwidthLimitBytesPerSecond: bandwidthMib.trim() + ? boundedInteger(bandwidthMib, 1, maxBandwidthMib, 1) * 1024 * 1024 + : null, + concurrency: boundedInteger(concurrency, 1, 8, 2), + maxAttempts: boundedInteger(maxAttempts, 1, 10, 3), + mode, + scope: integrityScope.kind, + ...(integrityScope.kind === "workspace" + ? { workspaceId: integrityScope.workspaceId } + : {}), + ...(target.nodeId || target.versionId ? { target } : {}), + }), + [ + bandwidthMib, + batchSize, + concurrency, + integrityScope, + maxAttempts, + mode, + target, + ], + ); + + const startDisabled = + controller.operationPending || (globalRun && !confirmAll); + const progressMax = Math.max(controller.task?.progress.total ?? 0, 1); + const progressValue = Math.min( + controller.task?.progress.processed ?? 0, + progressMax, + ); + + const submit = (event: FormEvent) => { + event.preventDefault(); + startTask(); + }; + + const startTask = () => { + if (startDisabled) return; + void controller.start(formInput); + }; + + return ( +
+
+
+ + {t("storageIntegrity.kicker")} + +

{t("storageIntegrity.title")}

+

{t("storageIntegrity.subtitle")}

+
+ + + +
+ +
+ + + + + +
+ +
+ + + {t("storageIntegrity.lastVerifiedAt", { + time: formatTimestamp( + controller.summary?.lastVerifiedAt, + locale, + timeZone, + ), + })} + +
+ + {controller.error ? ( +
+ + {translateControllerError(controller.error, t)} +
+ ) : null} + +
+
+ +
+

{t("storageIntegrity.newTask")}

+

{t("storageIntegrity.newTaskHint")}

+
+
+
+ + + + + + {integrityScope.kind === "workspace" ? ( + + setTargetState({ + scopeKey: integrityScopeKey, + target: nextTarget, + }) + } + palette={palette} + scope={integrityScope} + value={target} + /> + ) : ( +
+ + {t("storageIntegrity.targetWorkspaceOnly")} +
+ )} +
+ {globalRun ? ( + + ) : ( + + {t("storageIntegrity.workspaceRun")} + + )} + + + +
+ +
+ +
+
+ +
+

{t("storageIntegrity.currentTask")}

+

{t("storageIntegrity.currentTaskHint")}

+
+ {controller.task && canRetry(controller.task) ? ( + void controller.retry()} + palette={palette} + visual="surface" + > + + + ) : null} +
+ {controller.loadingTask && !controller.task ? ( +
+ {t("app.loading")} +
+ ) : controller.task ? ( + + ) : ( +
+ {t("storageIntegrity.noCurrentTask")} +
+ )} +
+ +
+
+ +
+

{t("storageIntegrity.findings")}

+

{t("storageIntegrity.findingsHint")}

+
+ + controller.setResultFilter( + event.target.value as StorageIntegrityResultFilter, + ) + } + options={resultFilterOptions.map((value) => ({ + label: t(`storageIntegrity.result.${value}`), + value, + }))} + palette={palette} + value={controller.resultFilter} + /> +
+ void controller.acknowledge(id)} + onRetry={(id) => void controller.retry([id])} + operationPending={controller.operationPending} + palette={palette} + pendingAcknowledgementId={controller.pendingAcknowledgementId} + taskStatus={controller.task?.status ?? null} + timeZone={timeZone} + /> + +
+ +
+
+ +
+

{t("storageIntegrity.history")}

+

{t("storageIntegrity.historyHint")}

+
+
+ + +
+
+ ); +} + +function SummaryMetric({ + count, + label, + loading, + testId, + tone, +}: { + count: number | undefined; + label: string; + loading: boolean; + testId: string; + tone: string; +}) { + return ( +
+ {label} + {loading && count === undefined ? "--" : (count ?? 0)} +
+ ); +} + +function NumberField({ + label, + max, + min, + onChange, + palette, + placeholder, + required, + value, +}: { + label: string; + max: number; + min: number; + onChange: (value: string) => void; + palette: Palette; + placeholder?: string; + required?: boolean; + value: string; +}) { + return ( + + ); +} + +function TaskProgress({ + locale, + palette, + progressMax, + progressValue, + task, + timeZone, +}: { + locale: Locale; + palette: Palette; + progressMax: number; + progressValue: number; + task: StorageIntegrityTask; + timeZone: string; +}) { + const t = useTranslations(); + return ( +
+
+ {task.id} + + {t(`storageIntegrity.status.${task.status}`)} + + {t(`storageIntegrity.mode.${task.mode}`)} + +
+ +
+ + + + + +
+ {task.failureCode || task.failureMessage ? ( +
+ {t(storageIntegrityFailureMessageKey(task.failureCode))} +
+ ) : null} +
+ ); +} + +function ProgressFact({ label, value }: { label: string; value: string }) { + return {label}{value}; +} + +function IntegrityResults({ + items, + loading, + locale, + onAcknowledge, + onRetry, + operationPending, + palette, + pendingAcknowledgementId, + taskStatus, + timeZone, +}: { + items: StorageIntegrityResult[]; + loading: boolean; + locale: Locale; + onAcknowledge: (id: string) => void; + onRetry: (id: string) => void; + operationPending: boolean; + palette: Palette; + pendingAcknowledgementId: string | null; + taskStatus: StorageIntegrityTask["status"] | null; + timeZone: string; +}) { + const t = useTranslations(); + if (loading && items.length === 0) { + return
{t("app.loading")}
; + } + if (items.length === 0) { + return ( +
+ {taskStatus + ? t("storageIntegrity.noFindings") + : t("storageIntegrity.selectTask")} +
+ ); + } + return ( +
+ + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + + ))} + +
{t("storageIntegrity.object")}{t("storageIntegrity.resultLabel")}{t("storageIntegrity.expected")}{t("storageIntegrity.actual")}{t("storageIntegrity.checkedAt")}{t("transfers.actions")}
+ + {item.objectKey} + + + + {t("storageIntegrity.nodeId")} + + {item.nodeId ?? "--"} + + + + {t("storageIntegrity.versionId")} + + {item.versionId ?? "--"} + + + + +
+ + {t(`storageIntegrity.result.${item.status}`)} + + {item.acknowledgedAt ? ( + + + {t("storageIntegrity.acknowledged")} + + + ) : null} +
+
+ {item.expectedHash ?? "--"} + {formatFileSize(item.expectedSizeBytes, locale)} + + {item.actualHash ?? "--"} + {formatFileSize(item.actualSizeBytes, locale)} + + + {t("storageIntegrity.attempts", { count: item.attempts })} + +
+ {canAcknowledge(item) ? ( + onAcknowledge(item.id)} + palette={palette} + tone="success" + visual="surface" + > + + + ) : null} + {item.status === "matched" || + !canRetryFinding(taskStatus) ? null : ( + onRetry(item.id)} + palette={palette} + > + + + )} +
+
+
+ ); +} + +function TaskHistory({ + activeTaskId, + items, + loading, + locale, + onSelect, + timeZone, +}: { + activeTaskId: string | null; + items: StorageIntegrityTask[]; + loading: boolean; + locale: Locale; + onSelect: (taskId: string) => void; + timeZone: string; +}) { + const t = useTranslations(); + if (loading && items.length === 0) { + return
{t("app.loading")}
; + } + if (items.length === 0) { + return
{t("storageIntegrity.noHistory")}
; + } + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + +function Pagination({ + disabled, + label, + limit, + offset, + onOffsetChange, + palette, + total, +}: { + disabled: boolean; + label: string; + limit: number; + offset: number; + onOffsetChange: (offset: number) => void; + palette: Palette; + total: number; +}) { + const t = useTranslations(); + if (total <= limit && offset === 0) return null; + const start = total === 0 ? 0 : offset + 1; + const end = Math.min(offset + limit, total); + return ( +
+ {t("storageIntegrity.pageRange", { end, start, total })} +
+ onOffsetChange(Math.max(0, offset - limit))} + palette={palette} + > + + + = total} + label={t("storageIntegrity.nextPage", { section: label })} + onClick={() => onOffsetChange(offset + limit)} + palette={palette} + > + + +
+
+ ); +} + +const resultFilterOptions: StorageIntegrityResultFilter[] = [ + "all", + "matched", + "mismatch", + "failed", +]; +const maxBandwidthMib = Math.floor(1_000_000_000 / (1024 * 1024)); +const emptyIntegrityTarget: StorageIntegrityTarget = {}; + +function canRetry(task: StorageIntegrityTask) { + return ( + task.status === "failed" || + (task.status === "completed" && + (task.progress.mismatches > 0 || task.progress.failed > 0)) + ); +} + +function canAcknowledge(result: StorageIntegrityResult) { + return ( + !result.acknowledgedAt && + (result.status === "mismatch" || result.status === "failed") + ); +} + +function canRetryFinding(status: StorageIntegrityTask["status"] | null) { + return status !== null && status !== "queued" && status !== "running"; +} + +function taskVisualStatus(task: StorageIntegrityTask) { + if (task.status !== "completed") return task.status; + if (task.progress.failed > 0) return "failed" as const; + if (task.progress.mismatches > 0) return "mismatch" as const; + return task.status; +} + +function taskStatusIcon(task: StorageIntegrityTask) { + if (task.status === "completed") { + return task.progress.mismatches === 0 && task.progress.failed === 0 + ? "tick" as const + : "exclamation" as const; + } + if (task.status === "failed") return "exclamation" as const; + if (task.status === "running" || task.status === "queued") return "time" as const; + return "info" as const; +} + +function storageIntegrityFailureMessageKey(code: string | null | undefined) { + if (code === "STORAGE_INTEGRITY_LEASE_LOST") { + return "storageIntegrity.failure.leaseLost"; + } + if (code === "STORAGE_INTEGRITY_TASK_FAILED") { + return "storageIntegrity.failure.taskFailed"; + } + return "storageIntegrity.failure.unknown"; +} + +function boundedInteger(value: string, min: number, max: number, fallback: number) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(max, Math.max(min, Math.trunc(parsed))); +} + +function formatTimestamp( + value: string | null | undefined, + locale: Locale, + timeZone: string, +) { + if (!value) return "--"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "--"; + return new Intl.DateTimeFormat(getIntlLocale(locale), { + dateStyle: "medium", + timeStyle: "short", + timeZone, + }).format(date); +} + +function translateControllerError( + error: string, + t: (key: string, values?: Record) => string, +) { + if (error === "scope-mismatch") return t("storageIntegrity.scopeMismatch"); + if (error === "retry-reused-task") return t("storageIntegrity.retryReusedTask"); + if (error === "acknowledge-failed") { + return t("storageIntegrity.acknowledgeFailed"); + } + return t("storageIntegrity.loadFailed"); +} diff --git a/frontend/src/components/admin/admin-storage-integrity-target.css b/frontend/src/components/admin/admin-storage-integrity-target.css new file mode 100644 index 0000000..ddcac41 --- /dev/null +++ b/frontend/src/components/admin/admin-storage-integrity-target.css @@ -0,0 +1,207 @@ +.admin-integrity-target { + border: 1px solid var(--drive-ui-line); + display: grid; + gap: 10px; + grid-column: 1 / -1; + min-width: 0; + padding: 12px; +} + +.admin-integrity-target-heading, +.admin-integrity-target-search, +.admin-integrity-target-selection { + align-items: center; + display: grid; + min-width: 0; +} + +.admin-integrity-target-heading { + gap: 10px; + grid-template-columns: minmax(0, 1fr) auto; +} + +.admin-integrity-target-heading > div, +.admin-integrity-target-heading > div > span, +.admin-integrity-target-selection > span:nth-child(2) { + display: grid; + min-width: 0; +} + +.admin-integrity-target-heading strong, +.admin-integrity-target-selection strong { + color: var(--drive-ui-ink); + font-size: 11px; + font-weight: 800; + line-height: 16px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-integrity-target-heading span, +.admin-integrity-target-selection code, +.admin-integrity-target-selection label > span, +.admin-integrity-target-status, +.admin-integrity-target-results small { + color: var(--drive-ui-muted); + font-size: 10px; + font-weight: 680; + line-height: 14px; +} + +.admin-integrity-target-search { + gap: 7px; + grid-template-columns: minmax(0, 1fr) auto; +} + +.admin-integrity-target-status { + min-height: 14px; +} + +.admin-integrity-target-status [role="alert"], +.admin-integrity-target-selection > [role="alert"] { + color: var(--drive-ui-danger); +} + +.admin-integrity-target-results { + border: 1px solid var(--drive-ui-line); + display: grid; + max-height: 190px; + min-width: 0; + overflow-y: auto; +} + +.admin-integrity-target-results > button { + align-items: center; + background: transparent; + border: 0; + border-bottom: 1px solid var(--drive-ui-line); + color: var(--drive-ui-ink); + cursor: pointer; + display: grid; + font: inherit; + gap: 9px; + grid-template-columns: 26px minmax(0, 1fr) auto; + min-height: 42px; + min-width: 0; + padding: 6px 8px; + text-align: left; +} + +.admin-integrity-target-results > button:last-child { + border-bottom: 0; +} + +.admin-integrity-target-results > button:hover, +.admin-integrity-target-results > button[data-selected="true"] { + background: var(--ui-selection); +} + +.admin-integrity-target-results > button:focus-visible { + outline: 2px solid var(--ui-focus-ring); + outline-offset: -2px; +} + +.admin-integrity-target-results > button > span:first-child, +.admin-integrity-target-selected-icon { + align-items: center; + background: var(--drive-ui-surface-soft); + border: 1px solid var(--drive-ui-line); + border-radius: 3px; + color: var(--drive-ui-blue-600); + display: inline-flex; + height: 24px; + justify-content: center; + width: 24px; +} + +.admin-integrity-target-results > button > span:nth-child(2) { + display: grid; + min-width: 0; +} + +.admin-integrity-target-meta { + display: grid; + gap: 2px; + justify-items: end; +} + +.admin-integrity-target-meta [data-status="verified"] { + color: var(--drive-ui-success); +} + +.admin-integrity-target-meta [data-status="mismatch"], +.admin-integrity-target-meta [data-status="pending"] { + color: var(--drive-ui-warning); +} + +.admin-integrity-target-meta [data-status="failed"] { + color: var(--drive-ui-danger); +} + +.admin-integrity-target-results strong, +.admin-integrity-target-results small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-integrity-target-results strong { + font-size: 11px; + font-weight: 760; + line-height: 15px; +} + +.admin-integrity-target-selection { + background: var(--drive-ui-surface-soft); + border-left: 3px solid var(--drive-ui-success); + gap: 9px; + grid-template-columns: 26px minmax(120px, 1fr) minmax(180px, 0.8fr); + padding: 8px 9px; +} + +.admin-integrity-target-selected-icon { + color: var(--drive-ui-success); +} + +.admin-integrity-target-selection code { + font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-integrity-target-selection label { + display: grid; + gap: 4px; + min-width: 0; +} + +.admin-integrity-target-selection > [role="alert"] { + font-size: 10px; + grid-column: 2 / -1; +} + +@media (max-width: 680px) { + .admin-integrity-target-selection { + grid-template-columns: 26px minmax(0, 1fr); + } + + .admin-integrity-target-selection label { + grid-column: 2; + } +} + +@media (max-width: 390px) { + .admin-integrity-target { + padding: 9px; + } + + .admin-integrity-target-results > button { + grid-template-columns: 24px minmax(0, 1fr); + } + + .admin-integrity-target-meta { + display: none; + } +} diff --git a/frontend/src/components/admin/admin-storage-integrity-target.test.tsx b/frontend/src/components/admin/admin-storage-integrity-target.test.tsx new file mode 100644 index 0000000..9259d71 --- /dev/null +++ b/frontend/src/components/admin/admin-storage-integrity-target.test.tsx @@ -0,0 +1,197 @@ +import { useState, type ReactNode } from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { palettes } from "@/features/file/model"; +import { + fetchStorageIntegrityTargetVersions, + searchStorageIntegrityTargets, + type StorageIntegrityTargetCandidate, +} from "@/lib/drive-api"; +import { AdminStorageIntegrityTarget } from "./admin-storage-integrity-target"; + +vi.mock("@/lib/drive-api", async (importOriginal) => ({ + ...(await importOriginal()), + fetchStorageIntegrityTargetVersions: vi.fn(), + searchStorageIntegrityTargets: vi.fn(), +})); + +vi.mock("@/components/ui/app-icon", () => ({ + LocalIcon: ({ name }: { name: string }) => , +})); + +vi.mock("@/components/ui/tool-button", () => ({ + ToolButton: ({ + children, + label, + onClick, + type, + }: { + children: ReactNode; + label: string; + onClick?: () => void; + type?: "button" | "submit"; + }) => ( + + ), +})); + +vi.mock("@/components/ui/app-input", () => ({ + AppInput: ({ palette: _palette, ...props }: Record) => ( + + ), +})); + +vi.mock("@/components/ui/app-select", () => ({ + AppSelect: ({ + options, + palette: _palette, + ...props + }: { + options: Array<{ label: string; value: string }>; + palette: unknown; + }) => ( + + ), +})); + +vi.mock("@/i18n/react", () => ({ + useTranslations: () => (key: string) => key, +})); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(searchStorageIntegrityTargets).mockResolvedValue({ + items: [ + createNode({ id: "node-1", name: "archive.bin" }), + ], + limit: 20, + offset: 0, + total: 1, + }); + vi.mocked(fetchStorageIntegrityTargetVersions).mockResolvedValue([ + { + createdAt: "2026-08-13T12:00:00.000Z", + id: "version-2", + integrityStatus: "unknown", + lastVerifiedAt: null, + mimeType: "application/octet-stream", + nodeId: "node-1", + remark: "release", + sizeBytes: 2_048, + uploadedBy: "operator", + versionNumber: 2, + }, + ]); +}); + +afterEach(cleanup); + +describe("AdminStorageIntegrityTarget", () => { + it("searches when Enter is pressed without nesting another form", async () => { + render( + , + ); + + const input = screen.getByRole("textbox", { + name: "storageIntegrity.targetSearch", + }); + fireEvent.change(input, { target: { value: "archive" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + await waitFor(() => + expect(searchStorageIntegrityTargets).toHaveBeenCalledWith( + "workspace-1", + "archive", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ), + ); + }); + + it("searches within workspace, selects a file, then selects a version", async () => { + const onChange = vi.fn(); + function Harness() { + const [value, setValue] = useState({}); + return ( + { + onChange(next); + setValue(next); + }} + palette={palettes.light} + scope={{ kind: "workspace", workspaceId: "workspace-1" }} + value={value} + /> + ); + } + render(); + + fireEvent.change( + screen.getByRole("textbox", { name: "storageIntegrity.targetSearch" }), + { target: { value: "archive" } }, + ); + fireEvent.click( + screen.getByRole("button", { name: "storageIntegrity.searchFiles" }), + ); + + await waitFor(() => + expect(searchStorageIntegrityTargets).toHaveBeenCalledWith( + "workspace-1", + "archive", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ), + ); + fireEvent.click(await screen.findByRole("button", { name: "archive.bin" })); + expect(onChange).toHaveBeenLastCalledWith({ nodeId: "node-1" }); + await waitFor(() => + expect(fetchStorageIntegrityTargetVersions).toHaveBeenCalledWith( + "workspace-1", + "node-1", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ), + ); + + fireEvent.change( + await screen.findByRole("combobox", { + name: "storageIntegrity.targetVersion", + }), + { target: { value: "version-2" } }, + ); + expect(onChange).toHaveBeenLastCalledWith({ + nodeId: "node-1", + versionId: "version-2", + }); + }); +}); + +function createNode( + overrides: Partial = {}, +): StorageIntegrityTargetCandidate { + return { + id: "node-1", + integrityStatus: "unknown", + kind: "other", + lastVerifiedAt: null, + mimeType: "application/octet-stream", + name: "archive.bin", + path: "/archive.bin", + sizeBytes: 2_048, + updatedAt: "2026-08-13T12:00:00.000Z", + workspaceId: "workspace-1", + ...overrides, + }; +} diff --git a/frontend/src/components/admin/admin-storage-integrity-target.tsx b/frontend/src/components/admin/admin-storage-integrity-target.tsx new file mode 100644 index 0000000..18b05b0 --- /dev/null +++ b/frontend/src/components/admin/admin-storage-integrity-target.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { AppInput } from "@/components/ui/app-input"; +import { LocalIcon } from "@/components/ui/app-icon"; +import { AppSelect } from "@/components/ui/app-select"; +import { ToolButton } from "@/components/ui/tool-button"; +import type { AdminScope } from "@/features/admin/admin-scope"; +import { storageIntegrityStatusTextColor } from "@/features/admin/storage-integrity-colors"; +import { formatFileSize, type Locale, type Palette } from "@/features/file/model"; +import { useTranslations } from "@/i18n/react"; +import { + fetchStorageIntegrityTargetVersions, + searchStorageIntegrityTargets, + type StorageIntegrityTargetCandidate, + type StorageIntegrityTargetVersion, + type StorageIntegrityTarget, +} from "@/lib/drive-api"; +import "./admin-storage-integrity-target.css"; + +export function AdminStorageIntegrityTarget({ + locale, + onChange, + palette, + scope, + value, +}: { + locale: Locale; + onChange: (target: StorageIntegrityTarget) => void; + palette: Palette; + scope: AdminScope; + value: StorageIntegrityTarget; +}) { + const t = useTranslations(); + const workspaceId = scope.kind === "workspace" ? scope.workspaceId : null; + const scopeKey = workspaceId ? `workspace:${workspaceId}` : "all"; + const [query, setQuery] = useState(""); + const [searchState, setSearchState] = useState<{ + error: boolean; + items: StorageIntegrityTargetCandidate[]; + loading: boolean; + scopeKey: string; + searched: boolean; + }>({ error: false, items: [], loading: false, scopeKey, searched: false }); + const [selectedState, setSelectedState] = useState<{ + node: StorageIntegrityTargetCandidate | null; + scopeKey: string; + }>({ node: null, scopeKey }); + const [versionState, setVersionState] = useState<{ + error: boolean; + items: StorageIntegrityTargetVersion[]; + loading: boolean; + nodeId: string | null; + }>({ error: false, items: [], loading: false, nodeId: null }); + const searchControllerRef = useRef(null); + const versionControllerRef = useRef(null); + const visibleSearch = searchState.scopeKey === scopeKey ? searchState : null; + const selectedNode = + selectedState.scopeKey === scopeKey && + selectedState.node?.id === value.nodeId + ? selectedState.node + : null; + const visibleVersions = + versionState.nodeId === value.nodeId ? versionState : null; + + useEffect( + () => () => { + searchControllerRef.current?.abort(); + versionControllerRef.current?.abort(); + }, + [scopeKey], + ); + + const runSearch = async () => { + if (!workspaceId) return; + searchControllerRef.current?.abort(); + const controller = new AbortController(); + searchControllerRef.current = controller; + setSearchState({ + error: false, + items: visibleSearch?.items ?? [], + loading: true, + scopeKey, + searched: true, + }); + try { + const page = await searchStorageIntegrityTargets( + workspaceId, + query, + { signal: controller.signal }, + ); + if (controller.signal.aborted) return; + setSearchState({ + error: false, + items: page.items, + loading: false, + scopeKey, + searched: true, + }); + } catch (error) { + if (isAbortError(error)) return; + setSearchState({ + error: true, + items: [], + loading: false, + scopeKey, + searched: true, + }); + } + }; + + const searchOnEnter = (event: KeyboardEvent) => { + if (event.key !== "Enter" || event.nativeEvent.isComposing) return; + event.preventDefault(); + void runSearch(); + }; + + const selectNode = async (node: StorageIntegrityTargetCandidate) => { + if (!workspaceId) return; + versionControllerRef.current?.abort(); + const controller = new AbortController(); + versionControllerRef.current = controller; + setSelectedState({ node, scopeKey }); + setVersionState({ + error: false, + items: [], + loading: true, + nodeId: node.id, + }); + onChange({ nodeId: node.id }); + try { + const versions = await fetchStorageIntegrityTargetVersions( + workspaceId, + node.id, + { signal: controller.signal }, + ); + if (controller.signal.aborted) return; + setVersionState({ + error: false, + items: versions, + loading: false, + nodeId: node.id, + }); + } catch (error) { + if (isAbortError(error)) return; + setVersionState({ + error: true, + items: [], + loading: false, + nodeId: node.id, + }); + } + }; + + const clear = () => { + versionControllerRef.current?.abort(); + setSelectedState({ node: null, scopeKey }); + setVersionState({ error: false, items: [], loading: false, nodeId: null }); + onChange({}); + }; + + return ( +
+
+
+ {t("storageIntegrity.target")} + {t("storageIntegrity.targetHint")} +
+ {selectedNode ? ( + + + + ) : null} +
+ +
+ setQuery(event.target.value)} + onKeyDown={searchOnEnter} + palette={palette} + placeholder={t("storageIntegrity.targetSearchPlaceholder")} + value={query} + /> + void runSearch()} + palette={palette} + type="button" + visual="surface" + > + + +
+ +
+ {visibleSearch?.error ? ( + {t("storageIntegrity.targetSearchFailed")} + ) : visibleSearch?.searched && !visibleSearch.loading && visibleSearch.items.length === 0 ? ( + {t("storageIntegrity.targetNoResults")} + ) : null} +
+ + {visibleSearch?.items.length ? ( +
+ {visibleSearch.items.map((item) => ( + + ))} +
+ ) : null} + + {selectedNode ? ( +
+ + + + + {selectedNode.name} + {selectedNode.id} + + + {visibleVersions?.error ? ( + {t("storageIntegrity.versionsLoadFailed")} + ) : null} +
+ ) : null} +
+ ); +} + +function isAbortError(error: unknown) { + return error instanceof Error && error.name === "AbortError"; +} diff --git a/frontend/src/components/oauth/oauth-admin-parts.tsx b/frontend/src/components/oauth/oauth-admin-parts.tsx index 08581d1..b488764 100644 --- a/frontend/src/components/oauth/oauth-admin-parts.tsx +++ b/frontend/src/components/oauth/oauth-admin-parts.tsx @@ -50,6 +50,7 @@ export function OAuthSummary({ export function OAuthProviderGroup({ collapsed, + locale, onActivate, onCopy, onDeactivate, @@ -62,8 +63,10 @@ export function OAuthProviderGroup({ protectedActiveProviderId, savingKey, template, + timeZone, }: { collapsed: boolean; + locale?: string; onActivate: (provider: OAuthSettings) => void; onCopy: (value: string) => void; onDeactivate: (provider: OAuthSettings) => void; @@ -76,6 +79,7 @@ export function OAuthProviderGroup({ protectedActiveProviderId?: string; savingKey: string | null; template: OAuthProviderTemplate; + timeZone?: string; }) { const t = useTranslations(); @@ -106,18 +110,22 @@ export function OAuthProviderGroup({ {!collapsed ? ( -
-
- {t("admin.oauthConfigName")} - {t("admin.oauthClientId")} - {t("admin.oauthRedirectUri")} - {t("admin.oauthCreatedAt")} - {t("admin.oauthStatus")} - {t("admin.oauthActions")} +
+
+ {t("admin.oauthConfigName")} + {t("admin.oauthClientId")} + {t("admin.oauthRedirectUri")} + {t("admin.oauthCreatedAt")} + {t("admin.oauthStatus")} + {t("admin.oauthActions")}
{providers.length === 0 ? ( -
- {t("admin.oauthProviderEmpty")} +
+ {t("admin.oauthProviderEmpty")}
) : ( providers.map((provider) => ( @@ -129,10 +137,12 @@ export function OAuthProviderGroup({ onDelete={() => onDelete(provider)} onDuplicate={() => onDuplicate(provider)} onEdit={() => onEdit(provider)} + locale={locale} palette={palette} protectedActive={protectedActiveProviderId === provider.id} provider={provider} savingKey={savingKey} + timeZone={timeZone} /> )) )} @@ -143,6 +153,7 @@ export function OAuthProviderGroup({ } function OAuthProviderRow({ + locale, onActivate, onCopy, onDeactivate, @@ -153,7 +164,9 @@ function OAuthProviderRow({ protectedActive, provider, savingKey, + timeZone, }: { + locale?: string; onActivate: () => void; onCopy: (value: string) => void; onDeactivate: () => void; @@ -164,6 +177,7 @@ function OAuthProviderRow({ protectedActive: boolean; provider: OAuthSettings; savingKey: string | null; + timeZone?: string; }) { const t = useTranslations(); const profileLabel = @@ -172,22 +186,26 @@ function OAuthProviderRow({ : provider.providerProfile === "oauth2" ? "OAuth2" : t("admin.oauthCompatibilityMode"); + const busy = savingKey !== null; return ( -
+
{provider.displayName} {profileLabel}
onCopy(provider.clientId)} value={maskClientId(provider.clientId)} /> onCopy(provider.redirectUri)} value={provider.redirectUri || "--"} @@ -195,12 +213,14 @@ function OAuthProviderRow({ - {formatOAuthDate(provider.createdAt)} + {formatOAuthDate(provider.createdAt, locale, timeZone)} {provider.enabled ? ( ) : ( )} void; value: string; @@ -307,9 +332,14 @@ function CopyCell({ {value} - @@ -323,11 +353,12 @@ function maskClientId(clientId: string) { return `${value.slice(0, 6)}...${value.slice(-4)}`; } -function formatOAuthDate(value: string) { +function formatOAuthDate(value: string, locale?: string, timeZone?: string) { const date = new Date(value); if (Number.isNaN(date.getTime()) || date.getTime() === 0) return "--"; - return new Intl.DateTimeFormat(undefined, { + return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", + ...(timeZone ? { timeZone } : {}), }).format(date); } diff --git a/frontend/src/components/oauth/oauth-provider-dialog.tsx b/frontend/src/components/oauth/oauth-provider-dialog.tsx index ba42727..e5f0006 100644 --- a/frontend/src/components/oauth/oauth-provider-dialog.tsx +++ b/frontend/src/components/oauth/oauth-provider-dialog.tsx @@ -44,6 +44,7 @@ type OAuthProviderDialogProps = { secret: string; showSecret: boolean; testResult: OAuthConnectionTestResult | null; + validationErrorKey: string | null; }; export function OAuthProviderDialog({ @@ -63,6 +64,7 @@ export function OAuthProviderDialog({ secret, showSecret, testResult, + validationErrorKey, }: OAuthProviderDialogProps) { const t = useTranslations(); const template = getOAuthProviderTemplate(draft.providerKey); @@ -118,42 +120,44 @@ export function OAuthProviderDialog({ className="drive-oauth-dialog-body" >
-
-

- {t("admin.oauthSelectSupportedProvider")} -

-
- {oauthProviderTemplates.map((item) => { - const active = draft.providerKey === item.key; - return ( - - ); - })} -
-
+

+ {t("admin.oauthSelectSupportedProvider")} +

+
+ {oauthProviderTemplates.map((item) => { + const active = draft.providerKey === item.key; + return ( + + ); + })} +
+ + ) : null}
+ {validationErrorKey ? ( +
+ + {t(validationErrorKey)} +
+ ) : null} +
{ @@ -17,6 +19,9 @@ describe("admin routes", () => { expect(resolveAdminSystemSectionFromPath("/admin/system/storage/")).toBe( "storage", ); + expect(resolveAdminSystemSectionFromPath("/admin/system/integrity")).toBe( + "integrity", + ); expect(resolveAdminSystemSectionFromPath("/admin/external-share")).toBe( "external-share", ); @@ -36,6 +41,9 @@ describe("admin routes", () => { expect(getAdminSystemSectionPath("lifecycle")).toBe( "/admin/system/lifecycle", ); + expect(getAdminSystemSectionPath("integrity")).toBe( + "/admin/system/integrity", + ); expect(buildAdminUrl("/admin/audit", { kind: "all" })).toBe( "/admin/audit?scope=all", ); @@ -81,4 +89,23 @@ describe("admin routes", () => { ), ).toBe("/admin/status?scope=system"); }); + + it("canonicalizes storage integrity system scope to all", () => { + expect(getAdminSystemSectionScope("integrity", { kind: "system" })).toEqual( + { kind: "all" }, + ); + expect( + getAdminSystemSectionScope("integrity", { + kind: "workspace", + workspaceId: "workspace-1", + }), + ).toEqual({ kind: "workspace", workspaceId: "workspace-1" }); + }); + + it("lets each system settings section own its refresh state", () => { + expect(shouldShowAdminGlobalRefresh("system", "integrity")).toBe(false); + expect(shouldShowAdminGlobalRefresh("system", "storage")).toBe(false); + expect(shouldShowAdminGlobalRefresh("system", "oauth")).toBe(false); + expect(shouldShowAdminGlobalRefresh("overview", "integrity")).toBe(true); + }); }); diff --git a/frontend/src/features/admin/admin-routes.ts b/frontend/src/features/admin/admin-routes.ts index 73bcc72..7c05d58 100644 --- a/frontend/src/features/admin/admin-routes.ts +++ b/frontend/src/features/admin/admin-routes.ts @@ -8,6 +8,7 @@ export type AdminSystemSection = | "platform" | "oauth" | "storage" + | "integrity" | "lifecycle" | "external-share"; @@ -27,6 +28,7 @@ const panelsBySegment = new Map( const systemSectionSegments: Record = { "external-share": "external-share", + integrity: "integrity", lifecycle: "lifecycle", oauth: "oauth", platform: "platform", @@ -72,6 +74,15 @@ export function getAdminSystemSectionPath(section: AdminSystemSection) { : `/admin/system/${systemSectionSegments[section]}`; } +export function getAdminSystemSectionScope( + section: AdminSystemSection, + current: AdminScope, +) { + return section === "integrity" && current.kind === "system" + ? ({ kind: "all" } as const) + : current; +} + export function buildAdminUrl(path: string, scope: AdminScope) { const search = writeAdminScopeSearchParams(new URLSearchParams(), scope); return `${path}?${search.toString()}`; @@ -81,6 +92,13 @@ export function getAdminPanelScope(panel: AdminPanel, current: AdminScope) { return panel === "status" ? ({ kind: "system" } as const) : current; } +export function shouldShowAdminGlobalRefresh( + panel: AdminPanel, + _systemSection: AdminSystemSection, +) { + return panel !== "system"; +} + export function serializeAdminScope(scope: AdminScope) { return scope.kind === "workspace" ? `workspace:${scope.workspaceId}` diff --git a/frontend/src/features/admin/storage-integrity-colors.ts b/frontend/src/features/admin/storage-integrity-colors.ts new file mode 100644 index 0000000..49a49ee --- /dev/null +++ b/frontend/src/features/admin/storage-integrity-colors.ts @@ -0,0 +1,55 @@ +import type { Palette } from "@/features/file/model"; + +type StorageIntegrityVisualStatus = + | "cancelled" + | "completed" + | "failed" + | "matched" + | "mismatch" + | "pending" + | "queued" + | "running" + | "unknown" + | "verified"; + +const semanticWeight = 0.72; + +export function storageIntegrityStatusTextColor( + palette: Palette, + status: StorageIntegrityVisualStatus, +) { + const semanticColor = + status === "failed" + ? palette.danger + : status === "mismatch" || + status === "pending" || + status === "queued" || + status === "running" + ? palette.warning + : status === "matched" || + status === "verified" || + status === "completed" + ? palette.success + : palette.info; + return mixHexColors(semanticColor, palette.ink, semanticWeight); +} + +function mixHexColors(foreground: string, background: string, weight: number) { + const left = parseHexColor(foreground); + const right = parseHexColor(background); + if (!left || !right) return background; + const channels = left.map((channel, index) => + Math.round(channel * weight + right[index] * (1 - weight)), + ); + return `#${channels + .map((channel) => channel.toString(16).padStart(2, "0")) + .join("")}`; +} + +function parseHexColor(color: string) { + const match = color.match(/^#([\da-f]{6})$/i); + if (!match) return null; + return [0, 2, 4].map((offset) => + Number.parseInt(match[1].slice(offset, offset + 2), 16), + ); +} diff --git a/frontend/src/features/admin/use-storage-integrity.test.tsx b/frontend/src/features/admin/use-storage-integrity.test.tsx new file mode 100644 index 0000000..6f732c1 --- /dev/null +++ b/frontend/src/features/admin/use-storage-integrity.test.tsx @@ -0,0 +1,582 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AdminScope } from "./admin-scope"; +import type { + StorageIntegrityResult, + StorageIntegrityResultsResponse, + StorageIntegrityTask, +} from "@/lib/drive-api"; +import { + acknowledgeStorageIntegrityResult, + createStorageIntegrityTask, + fetchStorageIntegrityResults, + fetchStorageIntegritySummary, + fetchStorageIntegrityTask, + fetchStorageIntegrityTasks, + retryStorageIntegrityTask, +} from "@/lib/drive-api"; +import { + isStorageIntegrityTaskVisible, + useStorageIntegrity, +} from "./use-storage-integrity"; + +vi.mock("@/lib/drive-api", () => ({ + acknowledgeStorageIntegrityResult: vi.fn(), + createStorageIntegrityTask: vi.fn(), + fetchStorageIntegrityResults: vi.fn(), + fetchStorageIntegritySummary: vi.fn(), + fetchStorageIntegrityTask: vi.fn(), + fetchStorageIntegrityTasks: vi.fn(), + retryStorageIntegrityTask: vi.fn(), +})); + +const emptyResults = { items: [], limit: 25, offset: 0, total: 0 }; +const emptyHistory = { items: [], limit: 20, offset: 0, total: 0 }; +const emptySummary = { + counts: { failed: 0, mismatch: 0, pending: 0, unknown: 0, verified: 0 }, + generatedAt: "2026-08-13T12:00:00.000Z", + lastVerifiedAt: null, + scope: { kind: "all" as const }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchStorageIntegrityTasks).mockResolvedValue(emptyHistory); + vi.mocked(fetchStorageIntegrityResults).mockResolvedValue(emptyResults); + vi.mocked(fetchStorageIntegritySummary).mockResolvedValue(emptySummary); +}); + +describe("useStorageIntegrity", () => { + it("restores a visible task from the URL and loads its findings", async () => { + const task = createTask({ status: "completed" }); + vi.mocked(fetchStorageIntegrityTask).mockResolvedValue(task); + const onTaskIdChange = vi.fn(); + + const { result } = renderHook(() => + useStorageIntegrity({ + onTaskIdChange, + requestedTaskId: "task-1", + scope: { kind: "all" }, + }), + ); + + await waitFor(() => expect(result.current.task?.id).toBe("task-1")); + await waitFor(() => + expect(fetchStorageIntegrityResults).toHaveBeenCalledWith( + "task-1", + { limit: 25, offset: 0, status: "all" }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ), + ); + expect(result.current.error).toBeNull(); + expect(onTaskIdChange).not.toHaveBeenCalled(); + }); + + it("clears stale findings while a new filter or page is loading", async () => { + const task = createTask({ status: "completed" }); + const firstPage: StorageIntegrityResultsResponse = { + items: [createResult()], + limit: 25, + offset: 0, + total: 50, + }; + let resolveFiltered: + | ((page: StorageIntegrityResultsResponse) => void) + | undefined; + let resolveNextPage: + | ((page: StorageIntegrityResultsResponse) => void) + | undefined; + const filteredRequest = new Promise( + (resolve) => { + resolveFiltered = resolve; + }, + ); + const nextPageRequest = new Promise( + (resolve) => { + resolveNextPage = resolve; + }, + ); + vi.mocked(fetchStorageIntegrityTask).mockResolvedValue(task); + vi.mocked(fetchStorageIntegrityResults) + .mockResolvedValueOnce(firstPage) + .mockReturnValueOnce(filteredRequest) + .mockReturnValueOnce(nextPageRequest); + + const { result } = renderHook(() => + useStorageIntegrity({ + onTaskIdChange: vi.fn(), + requestedTaskId: task.id, + scope: { kind: "all" }, + }), + ); + + await waitFor(() => expect(result.current.results?.items).toHaveLength(1)); + + act(() => result.current.setResultFilter("mismatch")); + expect(result.current.loadingResults).toBe(true); + expect(result.current.results).toBeNull(); + await waitFor(() => + expect(fetchStorageIntegrityResults).toHaveBeenNthCalledWith( + 2, + task.id, + { limit: 25, offset: 0, status: "mismatch" }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ), + ); + + await act(async () => { + resolveFiltered?.({ ...firstPage, items: [createResult()] }); + await filteredRequest; + }); + await waitFor(() => expect(result.current.loadingResults).toBe(false)); + + act(() => result.current.setResultsOffset(25)); + expect(result.current.loadingResults).toBe(true); + expect(result.current.results).toBeNull(); + await waitFor(() => + expect(fetchStorageIntegrityResults).toHaveBeenNthCalledWith( + 3, + task.id, + { limit: 25, offset: 25, status: "mismatch" }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ), + ); + + await act(async () => { + resolveNextPage?.({ ...emptyResults, offset: 25 }); + await nextPageRequest; + }); + }); + + it("rejects a restored task outside the current scope", async () => { + vi.mocked(fetchStorageIntegrityTask).mockResolvedValue( + createTask({ + scope: "workspace", + workspaceId: "workspace-other", + }), + ); + const onTaskIdChange = vi.fn(); + + const { result } = renderHook(() => + useStorageIntegrity({ + onTaskIdChange, + requestedTaskId: "task-1", + scope: { kind: "workspace", workspaceId: "workspace-current" }, + }), + ); + + await waitFor(() => expect(result.current.error).toBe("scope-mismatch")); + expect(result.current.task).toBeNull(); + expect(onTaskIdChange).toHaveBeenCalledWith(null); + expect(fetchStorageIntegrityResults).not.toHaveBeenCalled(); + }); + + it("aborts old requests and ignores their response when scope changes", async () => { + let resolveOld: ((task: StorageIntegrityTask) => void) | undefined; + const oldTask = new Promise((resolve) => { + resolveOld = resolve; + }); + vi.mocked(fetchStorageIntegrityTask) + .mockReturnValueOnce(oldTask) + .mockResolvedValueOnce( + createTask({ + scope: "workspace", + workspaceId: "workspace-2", + }), + ); + const onTaskIdChange = vi.fn(); + const { result, rerender } = renderHook( + ({ scope }: { scope: AdminScope }) => + useStorageIntegrity({ + onTaskIdChange, + requestedTaskId: "task-1", + scope, + }), + { initialProps: { scope: { kind: "all" } as AdminScope } }, + ); + + await waitFor(() => expect(fetchStorageIntegrityTask).toHaveBeenCalled()); + const firstSignal = vi.mocked(fetchStorageIntegrityTask).mock.calls[0][1] + ?.signal; + rerender({ + scope: { kind: "workspace", workspaceId: "workspace-2" }, + }); + await waitFor(() => expect(fetchStorageIntegrityTask).toHaveBeenCalledTimes(2)); + expect(firstSignal?.aborted).toBe(true); + + await act(async () => resolveOld?.(createTask())); + await waitFor(() => + expect(result.current.task?.workspaceId).toBe("workspace-2"), + ); + }); + + it("ignores a start failure from a scope that is no longer active", async () => { + let rejectStart: ((error: Error) => void) | undefined; + const startRequest = new Promise((_resolve, reject) => { + rejectStart = reject; + }); + vi.mocked(createStorageIntegrityTask).mockReturnValue(startRequest); + const { result, rerender } = renderHook( + ({ scope }: { scope: AdminScope }) => + useStorageIntegrity({ + onTaskIdChange: vi.fn(), + requestedTaskId: null, + scope, + }), + { initialProps: { scope: { kind: "all" } as AdminScope } }, + ); + + let pendingStart: Promise | undefined; + act(() => { + pendingStart = result.current.start({ + batchSize: 100, + concurrency: 2, + maxAttempts: 3, + mode: "verify", + scope: "all", + }); + }); + rerender({ + scope: { kind: "workspace", workspaceId: "workspace-2" }, + }); + await act(async () => Promise.resolve()); + await act(async () => { + rejectStart?.(new Error("old scope start failed")); + await pendingStart; + }); + + expect(result.current.error).toBeNull(); + }); + + it("ignores a retry failure from a scope that is no longer active", async () => { + vi.mocked(fetchStorageIntegrityTask) + .mockResolvedValueOnce(createTask({ status: "completed" })) + .mockResolvedValueOnce( + createTask({ + scope: "workspace", + status: "completed", + workspaceId: "workspace-2", + }), + ); + let rejectRetry: ((error: Error) => void) | undefined; + const retryRequest = new Promise((_resolve, reject) => { + rejectRetry = reject; + }); + vi.mocked(retryStorageIntegrityTask).mockReturnValue(retryRequest); + const { result, rerender } = renderHook( + ({ scope }: { scope: AdminScope }) => + useStorageIntegrity({ + onTaskIdChange: vi.fn(), + requestedTaskId: "task-1", + scope, + }), + { initialProps: { scope: { kind: "all" } as AdminScope } }, + ); + await waitFor(() => expect(result.current.task?.scope).toBe("all")); + + let pendingRetry: Promise | undefined; + act(() => { + pendingRetry = result.current.retry(); + }); + rerender({ + scope: { kind: "workspace", workspaceId: "workspace-2" }, + }); + await waitFor(() => + expect(result.current.task?.workspaceId).toBe("workspace-2"), + ); + await act(async () => { + rejectRetry?.(new Error("old scope retry failed")); + await pendingRetry; + }); + + expect(result.current.error).toBeNull(); + }); + + it("ignores a manual refresh failure from a scope that is no longer active", async () => { + let rejectRefresh: ((error: Error) => void) | undefined; + const refreshRequest = new Promise((_resolve, reject) => { + rejectRefresh = reject; + }); + vi.mocked(fetchStorageIntegrityTask) + .mockResolvedValueOnce(createTask({ status: "completed" })) + .mockReturnValueOnce(refreshRequest) + .mockResolvedValueOnce( + createTask({ + scope: "workspace", + status: "completed", + workspaceId: "workspace-2", + }), + ); + const { result, rerender } = renderHook( + ({ scope }: { scope: AdminScope }) => + useStorageIntegrity({ + onTaskIdChange: vi.fn(), + requestedTaskId: "task-1", + scope, + }), + { initialProps: { scope: { kind: "all" } as AdminScope } }, + ); + await waitFor(() => expect(result.current.task?.scope).toBe("all")); + + act(() => result.current.refresh()); + rerender({ + scope: { kind: "workspace", workspaceId: "workspace-2" }, + }); + await waitFor(() => + expect(result.current.task?.workspaceId).toBe("workspace-2"), + ); + await act(async () => { + rejectRefresh?.(new Error("old scope refresh failed")); + await Promise.resolve(); + }); + + expect(result.current.error).toBeNull(); + }); + + it("keeps polling an active task after a transient polling failure", async () => { + vi.useFakeTimers(); + const running = createTask({ status: "running" }); + const completed = createTask({ status: "completed" }); + vi.mocked(fetchStorageIntegrityTask) + .mockResolvedValueOnce(running) + .mockRejectedValueOnce(new Error("temporary failure")) + .mockResolvedValueOnce(completed); + + const { result } = renderHook(() => + useStorageIntegrity({ + onTaskIdChange: vi.fn(), + pollIntervalMs: 100, + requestedTaskId: "task-1", + scope: { kind: "all" }, + }), + ); + + await act(async () => Promise.resolve()); + expect(result.current.task?.status).toBe("running"); + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + expect(result.current.error).toBe("temporary failure"); + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + expect(result.current.task?.status).toBe("completed"); + expect(result.current.error).toBeNull(); + expect(fetchStorageIntegrityTask).toHaveBeenCalledTimes(3); + vi.useRealTimers(); + }); + + it("refreshes the scope summary when polling reaches a terminal state", async () => { + vi.useFakeTimers(); + try { + vi.mocked(fetchStorageIntegrityTask) + .mockResolvedValueOnce(createTask({ status: "running" })) + .mockResolvedValueOnce(createTask({ status: "completed" })); + + const { result } = renderHook(() => + useStorageIntegrity({ + onTaskIdChange: vi.fn(), + pollIntervalMs: 100, + requestedTaskId: "task-1", + scope: { kind: "all" }, + }), + ); + + await act(async () => Promise.resolve()); + expect(result.current.task?.status).toBe("running"); + expect(fetchStorageIntegritySummary).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + await act(async () => Promise.resolve()); + + expect(result.current.task?.status).toBe("completed"); + expect(fetchStorageIntegritySummary).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("publishes a newly started task and retries into a different task", async () => { + const first = createTask({ id: "task-created", status: "queued" }); + const retried = createTask({ id: "task-retried", status: "queued" }); + vi.mocked(createStorageIntegrityTask).mockResolvedValue(first); + vi.mocked(retryStorageIntegrityTask).mockResolvedValue(retried); + const onTaskIdChange = vi.fn(); + const { result } = renderHook(() => + useStorageIntegrity({ + onTaskIdChange, + requestedTaskId: null, + scope: { kind: "all" }, + }), + ); + + await act(() => + result.current.start({ + batchSize: 100, + bandwidthLimitBytesPerSecond: null, + concurrency: 2, + maxAttempts: 3, + mode: "verify", + scope: "all", + }), + ); + expect(result.current.task?.id).toBe("task-created"); + expect(onTaskIdChange).toHaveBeenLastCalledWith("task-created"); + + await act(() => result.current.retry(["finding-1"])); + expect(result.current.task?.id).toBe("task-retried"); + expect(retryStorageIntegrityTask).toHaveBeenCalledWith("task-created", { + resultIds: ["finding-1"], + }); + expect(onTaskIdChange).toHaveBeenLastCalledWith("task-retried"); + }); + + it("acknowledges a finding once and refreshes results, summary, and history", async () => { + const finding = createResult(); + const acknowledged = createResult({ + acknowledgedAt: "2026-08-14T01:00:00.000Z", + acknowledgedBy: "admin-1", + }); + vi.mocked(fetchStorageIntegrityTask).mockResolvedValue( + createTask({ status: "completed" }), + ); + vi.mocked(fetchStorageIntegrityResults) + .mockResolvedValueOnce({ + items: [finding], + limit: 25, + offset: 0, + total: 1, + }) + .mockResolvedValue({ + items: [acknowledged], + limit: 25, + offset: 0, + total: 1, + }); + let resolveAcknowledgement: + | ((result: StorageIntegrityResult) => void) + | undefined; + vi.mocked(acknowledgeStorageIntegrityResult).mockReturnValue( + new Promise((resolve) => { + resolveAcknowledgement = resolve; + }), + ); + const { result } = renderHook(() => + useStorageIntegrity({ + onTaskIdChange: vi.fn(), + requestedTaskId: "task-1", + scope: { kind: "all" }, + }), + ); + await waitFor(() => expect(result.current.results?.items).toEqual([finding])); + + let acknowledgement: + | Promise + | undefined; + act(() => { + acknowledgement = result.current.acknowledge("result-1"); + }); + await waitFor(() => { + expect(result.current.operationPending).toBe(true); + expect(result.current.pendingAcknowledgementId).toBe("result-1"); + }); + act(() => { + void result.current.acknowledge("result-1"); + }); + expect(acknowledgeStorageIntegrityResult).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveAcknowledgement?.(acknowledged); + await acknowledgement; + }); + + expect(result.current.results?.items[0]).toEqual(acknowledged); + expect(result.current.operationPending).toBe(false); + expect(result.current.pendingAcknowledgementId).toBeNull(); + await waitFor(() => { + expect(fetchStorageIntegrityResults).toHaveBeenCalledTimes(2); + expect(fetchStorageIntegritySummary).toHaveBeenCalledTimes(2); + expect(fetchStorageIntegrityTasks).toHaveBeenCalledTimes(2); + }); + }); +}); + +describe("isStorageIntegrityTaskVisible", () => { + it("requires an exact all or workspace scope match", () => { + expect( + isStorageIntegrityTaskVisible(createTask(), { kind: "all" }), + ).toBe(true); + expect( + isStorageIntegrityTaskVisible(createTask(), { + kind: "workspace", + workspaceId: "workspace-1", + }), + ).toBe(false); + expect( + isStorageIntegrityTaskVisible( + createTask({ scope: "workspace", workspaceId: "workspace-1" }), + { kind: "workspace", workspaceId: "workspace-1" }, + ), + ).toBe(true); + }); +}); + +function createTask( + overrides: Partial = {}, +): StorageIntegrityTask { + return { + config: { + bandwidthLimitBytesPerSecond: null, + batchSize: 100, + concurrency: 2, + maxAttempts: 3, + }, + createdAt: "2026-08-13T12:00:00.000Z", + failureCode: null, + failureMessage: null, + finishedAt: "2026-08-13T12:02:00.000Z", + id: "task-1", + mode: "verify", + progress: { + bytesRead: 2_048, + failed: 0, + matched: 8, + mismatches: 2, + processed: 10, + total: 10, + }, + scope: "all", + startedAt: "2026-08-13T12:00:01.000Z", + status: "completed", + target: null, + workspaceId: null, + ...overrides, + }; +} + +function createResult( + overrides: Partial = {}, +): StorageIntegrityResult { + return { + acknowledgedAt: null, + acknowledgedBy: null, + actualHash: "sha256:actual", + actualSizeBytes: 2_048, + attempts: 1, + checkedAt: "2026-08-13T12:01:00.000Z", + errorCode: null, + errorMessage: null, + expectedHash: "sha256:expected", + expectedSizeBytes: 1_024, + id: "result-1", + nodeId: "node-1", + objectKey: "objects/archive.bin", + status: "mismatch", + taskId: "task-1", + versionId: "version-1", + workspaceId: "workspace-1", + ...overrides, + }; +} diff --git a/frontend/src/features/admin/use-storage-integrity.ts b/frontend/src/features/admin/use-storage-integrity.ts new file mode 100644 index 0000000..5021170 --- /dev/null +++ b/frontend/src/features/admin/use-storage-integrity.ts @@ -0,0 +1,626 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { AdminScope } from "./admin-scope"; +import { + acknowledgeStorageIntegrityResult, + createStorageIntegrityTask, + fetchStorageIntegrityResults, + fetchStorageIntegritySummary, + fetchStorageIntegrityTask, + fetchStorageIntegrityTasks, + retryStorageIntegrityTask, + type CreateStorageIntegrityTaskInput, + type StorageIntegrityResultFilter, + type StorageIntegrityResult, + type StorageIntegrityResultsResponse, + type StorageIntegrityTask, + type StorageIntegrityTasksResponse, + type StorageIntegritySummary, +} from "@/lib/drive-api"; + +type IntegrityAdminScope = Extract< + AdminScope, + { kind: "all" | "workspace" } +>; + +type UseStorageIntegrityOptions = { + onTaskIdChange: (taskId: string | null) => void; + pollIntervalMs?: number; + requestedTaskId: string | null; + scope: AdminScope; +}; + +const historyPageSize = 20; +const resultsPageSize = 25; + +export function useStorageIntegrity({ + onTaskIdChange, + pollIntervalMs = 1_500, + requestedTaskId, + scope: requestedScope, +}: UseStorageIntegrityOptions) { + const requestedWorkspaceId = + requestedScope.kind === "workspace" ? requestedScope.workspaceId : null; + const scope = useMemo( + (): IntegrityAdminScope => + requestedScope.kind === "workspace" && requestedWorkspaceId + ? { kind: "workspace", workspaceId: requestedWorkspaceId } + : { kind: "all" }, + [requestedScope.kind, requestedWorkspaceId], + ); + const scopeKey = serializeIntegrityScope(scope); + const currentScopeKeyRef = useRef(scopeKey); + const operationTokenRef = useRef(null); + const taskRef = useRef(null); + const [task, setTaskState] = useState(null); + const [history, setHistory] = + useState(null); + const [summary, setSummary] = useState(null); + const [results, setResults] = + useState(null); + const [historyOffset, setHistoryOffset] = useState(0); + const [resultsOffset, setResultsOffset] = useState(0); + const [resultFilter, setResultFilterState] = + useState("all"); + const [error, setError] = useState(null); + const [pollError, setPollError] = useState(null); + const [loadingTask, setLoadingTask] = useState(false); + const [loadingHistory, setLoadingHistory] = useState(true); + const [loadingSummary, setLoadingSummary] = useState(true); + const [loadingResults, setLoadingResults] = useState(false); + const [operationPending, setOperationPending] = useState(false); + const [pendingAcknowledgementId, setPendingAcknowledgementId] = useState< + string | null + >(null); + const [historyRevision, setHistoryRevision] = useState(0); + const [resultsRevision, setResultsRevision] = useState(0); + + const publishTask = useCallback((next: StorageIntegrityTask | null) => { + taskRef.current = next; + setTaskState(next); + if (next) { + setHistory((current) => patchTaskHistory(current, next)); + } + }, []); + + useEffect(() => { + currentScopeKeyRef.current = scopeKey; + }, [scopeKey]); + + useEffect(() => { + let disposed = false; + queueMicrotask(() => { + if (disposed) return; + setHistoryOffset(0); + setHistory(null); + setResultsOffset(0); + setResultFilterState("all"); + setResults(null); + setSummary(null); + setError(null); + setPollError(null); + operationTokenRef.current = null; + setOperationPending(false); + setPendingAcknowledgementId(null); + }); + return () => { + disposed = true; + }; + }, [scopeKey]); + + useEffect(() => { + const controller = new AbortController(); + const requestScopeKey = scopeKey; + let disposed = false; + queueMicrotask(() => { + if (!disposed) setLoadingSummary(true); + }); + void fetchStorageIntegritySummary(scope, { signal: controller.signal }) + .then((nextSummary) => { + if (disposed || currentScopeKeyRef.current !== requestScopeKey) return; + setSummary(nextSummary); + }) + .catch((requestError: unknown) => { + if (disposed || isAbortError(requestError)) return; + setError(errorMessage(requestError)); + }) + .finally(() => { + if (!disposed && currentScopeKeyRef.current === requestScopeKey) { + setLoadingSummary(false); + } + }); + return () => { + disposed = true; + controller.abort(); + }; + }, [historyRevision, scope, scopeKey]); + + useEffect(() => { + const controller = new AbortController(); + const requestScopeKey = scopeKey; + let disposed = false; + queueMicrotask(() => { + if (!disposed) setLoadingHistory(true); + }); + void fetchStorageIntegrityTasks( + scope, + { limit: historyPageSize, offset: historyOffset }, + { signal: controller.signal }, + ) + .then((page) => { + if (disposed || currentScopeKeyRef.current !== requestScopeKey) return; + setHistory(page); + }) + .catch((requestError: unknown) => { + if (disposed || isAbortError(requestError)) return; + setError(errorMessage(requestError)); + }) + .finally(() => { + if (!disposed && currentScopeKeyRef.current === requestScopeKey) { + setLoadingHistory(false); + } + }); + return () => { + disposed = true; + controller.abort(); + }; + }, [historyOffset, historyRevision, scope, scopeKey]); + + useEffect(() => { + const controller = new AbortController(); + const requestScopeKey = scopeKey; + let disposed = false; + + if (!requestedTaskId) { + queueMicrotask(() => { + if (disposed) return; + publishTask(null); + setResults(null); + setLoadingTask(false); + }); + return () => { + disposed = true; + controller.abort(); + }; + } + if ( + taskRef.current?.id === requestedTaskId && + isStorageIntegrityTaskVisible(taskRef.current, scope) + ) { + queueMicrotask(() => { + if (!disposed) setLoadingTask(false); + }); + return () => { + disposed = true; + controller.abort(); + }; + } + + queueMicrotask(() => { + if (disposed) return; + publishTask(null); + setResults(null); + setLoadingTask(true); + setError(null); + setPollError(null); + }); + void fetchStorageIntegrityTask(requestedTaskId, { + signal: controller.signal, + }) + .then((restoredTask) => { + if (disposed || currentScopeKeyRef.current !== requestScopeKey) return; + if (!isStorageIntegrityTaskVisible(restoredTask, scope)) { + setError("scope-mismatch"); + onTaskIdChange(null); + return; + } + publishTask(restoredTask); + }) + .catch((requestError: unknown) => { + if (disposed || isAbortError(requestError)) return; + setError(errorMessage(requestError)); + onTaskIdChange(null); + }) + .finally(() => { + if (!disposed && currentScopeKeyRef.current === requestScopeKey) { + setLoadingTask(false); + } + }); + + return () => { + disposed = true; + controller.abort(); + }; + }, [onTaskIdChange, publishTask, requestedTaskId, scope, scopeKey]); + + const polledTaskId = task?.id ?? null; + const polledTaskStatus = task?.status ?? null; + + useEffect(() => { + if ( + !polledTaskId || + (polledTaskStatus !== "queued" && polledTaskStatus !== "running") + ) { + return; + } + const requestScopeKey = scopeKey; + const controller = new AbortController(); + let disposed = false; + let timeoutId: ReturnType | null = null; + + const poll = () => { + timeoutId = setTimeout(() => { + void fetchStorageIntegrityTask(polledTaskId, { + signal: controller.signal, + }) + .then((nextTask) => { + if ( + disposed || + currentScopeKeyRef.current !== requestScopeKey || + !isStorageIntegrityTaskVisible(nextTask, scope) + ) { + return; + } + setPollError(null); + publishTask(nextTask); + setResultsRevision((value) => value + 1); + if (nextTask.status === "queued" || nextTask.status === "running") { + poll(); + } else { + setHistoryRevision((value) => value + 1); + } + }) + .catch((requestError: unknown) => { + if (!disposed && !isAbortError(requestError)) { + setPollError(errorMessage(requestError)); + poll(); + } + }); + }, pollIntervalMs); + }; + poll(); + + return () => { + disposed = true; + if (timeoutId !== null) clearTimeout(timeoutId); + controller.abort(); + }; + }, [ + pollIntervalMs, + polledTaskId, + polledTaskStatus, + publishTask, + scope, + scopeKey, + ]); + + useEffect(() => { + if (!task) { + let disposed = false; + queueMicrotask(() => { + if (disposed) return; + setResults(null); + setLoadingResults(false); + }); + return () => { + disposed = true; + }; + } + const controller = new AbortController(); + const requestScopeKey = scopeKey; + let disposed = false; + queueMicrotask(() => { + if (!disposed) setLoadingResults(true); + }); + void fetchStorageIntegrityResults( + task.id, + { + limit: resultsPageSize, + offset: resultsOffset, + status: resultFilter, + }, + { signal: controller.signal }, + ) + .then((page) => { + if (disposed || currentScopeKeyRef.current !== requestScopeKey) return; + setResults(page); + }) + .catch((requestError: unknown) => { + if (disposed || isAbortError(requestError)) return; + setError(errorMessage(requestError)); + }) + .finally(() => { + if (!disposed && currentScopeKeyRef.current === requestScopeKey) { + setLoadingResults(false); + } + }); + + return () => { + disposed = true; + controller.abort(); + }; + }, [resultFilter, resultsOffset, resultsRevision, scopeKey, task]); + + const start = useCallback( + async (input: CreateStorageIntegrityTaskInput) => { + if (operationTokenRef.current) return null; + const requestScopeKey = scopeKey; + const operationToken = Symbol("start-storage-integrity"); + operationTokenRef.current = operationToken; + setOperationPending(true); + setPendingAcknowledgementId(null); + setError(null); + setPollError(null); + try { + const nextTask = await createStorageIntegrityTask({ + ...input, + scope: scope.kind, + ...(scope.kind === "workspace" + ? { workspaceId: scope.workspaceId } + : { workspaceId: undefined }), + }); + if (currentScopeKeyRef.current !== requestScopeKey) return null; + if (!isStorageIntegrityTaskVisible(nextTask, scope)) { + setError("scope-mismatch"); + return null; + } + publishTask(nextTask); + setResults(null); + setResultsOffset(0); + setResultFilterState("all"); + setHistoryOffset(0); + setHistoryRevision((value) => value + 1); + onTaskIdChange(nextTask.id); + return nextTask; + } catch (requestError) { + if (currentScopeKeyRef.current === requestScopeKey) { + setError(errorMessage(requestError)); + } + return null; + } finally { + if (operationTokenRef.current === operationToken) { + operationTokenRef.current = null; + if (currentScopeKeyRef.current === requestScopeKey) { + setOperationPending(false); + } + } + } + }, + [onTaskIdChange, publishTask, scope, scopeKey], + ); + + const retry = useCallback( + async (resultIds?: string[]) => { + if (operationTokenRef.current) return null; + const currentTask = taskRef.current; + if (!currentTask) return null; + const requestScopeKey = scopeKey; + const operationToken = Symbol("retry-storage-integrity"); + operationTokenRef.current = operationToken; + setOperationPending(true); + setPendingAcknowledgementId(null); + setError(null); + setPollError(null); + try { + const nextTask = await retryStorageIntegrityTask(currentTask.id, { + ...(resultIds?.length ? { resultIds } : {}), + }); + if (currentScopeKeyRef.current !== requestScopeKey) return null; + if (nextTask.id === currentTask.id) { + setError("retry-reused-task"); + return null; + } + if (!isStorageIntegrityTaskVisible(nextTask, scope)) { + setError("scope-mismatch"); + return null; + } + publishTask(nextTask); + setResults(null); + setResultsOffset(0); + setResultFilterState("all"); + setHistoryOffset(0); + setHistoryRevision((value) => value + 1); + onTaskIdChange(nextTask.id); + return nextTask; + } catch (requestError) { + if (currentScopeKeyRef.current === requestScopeKey) { + setError(errorMessage(requestError)); + } + return null; + } finally { + if (operationTokenRef.current === operationToken) { + operationTokenRef.current = null; + if (currentScopeKeyRef.current === requestScopeKey) { + setOperationPending(false); + } + } + } + }, + [onTaskIdChange, publishTask, scope, scopeKey], + ); + + const acknowledge = useCallback( + async (resultId: string): Promise => { + if (operationTokenRef.current) return null; + const currentTask = taskRef.current; + if (!currentTask) return null; + const requestScopeKey = scopeKey; + const requestTaskId = currentTask.id; + const operationToken = Symbol("acknowledge-storage-integrity"); + operationTokenRef.current = operationToken; + setOperationPending(true); + setPendingAcknowledgementId(resultId); + setError(null); + setPollError(null); + try { + const acknowledged = await acknowledgeStorageIntegrityResult(resultId); + if ( + currentScopeKeyRef.current !== requestScopeKey || + taskRef.current?.id !== requestTaskId + ) { + return null; + } + if ( + acknowledged.id !== resultId || + acknowledged.taskId !== requestTaskId + ) { + setError("acknowledge-failed"); + return null; + } + setResults((current) => + current + ? { + ...current, + items: current.items.map((item) => + item.id === acknowledged.id ? acknowledged : item, + ), + } + : current, + ); + setResultsRevision((value) => value + 1); + setHistoryRevision((value) => value + 1); + return acknowledged; + } catch { + if ( + currentScopeKeyRef.current === requestScopeKey && + taskRef.current?.id === requestTaskId + ) { + setError("acknowledge-failed"); + } + return null; + } finally { + if (operationTokenRef.current === operationToken) { + operationTokenRef.current = null; + if (currentScopeKeyRef.current === requestScopeKey) { + setOperationPending(false); + setPendingAcknowledgementId(null); + } + } + } + }, + [scopeKey], + ); + + const selectTask = useCallback( + (taskId: string) => { + if (taskId !== taskRef.current?.id) onTaskIdChange(taskId); + }, + [onTaskIdChange], + ); + + const setResultFilter = useCallback( + (filter: StorageIntegrityResultFilter) => { + setResults(null); + setLoadingResults(true); + setResultFilterState(filter); + setResultsOffset(0); + }, + [], + ); + + const setResultsPage = useCallback((offset: number) => { + setResults(null); + setLoadingResults(true); + setResultsOffset(offset); + }, []); + + const refresh = useCallback(() => { + const requestScopeKey = scopeKey; + setError(null); + setPollError(null); + setHistoryRevision((value) => value + 1); + if (taskRef.current) { + const activeTaskId = taskRef.current.id; + void fetchStorageIntegrityTask(activeTaskId) + .then((nextTask) => { + if ( + currentScopeKeyRef.current === requestScopeKey && + taskRef.current?.id === activeTaskId && + isStorageIntegrityTaskVisible(nextTask, scope) + ) { + publishTask(nextTask); + setResultsRevision((value) => value + 1); + } + }) + .catch((requestError: unknown) => { + if ( + currentScopeKeyRef.current === requestScopeKey && + taskRef.current?.id === activeTaskId + ) { + setError(errorMessage(requestError)); + } + }); + } + }, [publishTask, scope, scopeKey]); + + return { + acknowledge, + error: error ?? pollError, + history, + historyOffset, + historyPageSize, + loadingHistory, + loadingResults, + loadingSummary, + loadingTask, + operationPending, + pendingAcknowledgementId, + refresh, + resultFilter, + results, + resultsOffset, + resultsPageSize, + retry, + selectTask, + setHistoryOffset, + setResultFilter, + setResultsOffset: setResultsPage, + start, + summary, + task, + }; +} + +export function isStorageIntegrityTaskVisible( + task: StorageIntegrityTask, + requestedScope: AdminScope, +) { + const scope = normalizeIntegrityScope(requestedScope); + return scope.kind === "all" + ? task.scope === "all" && task.workspaceId === null + : task.scope === "workspace" && task.workspaceId === scope.workspaceId; +} + +function normalizeIntegrityScope(scope: AdminScope): IntegrityAdminScope { + return scope.kind === "workspace" ? scope : { kind: "all" }; +} + +function serializeIntegrityScope(scope: IntegrityAdminScope) { + return scope.kind === "workspace" + ? `workspace:${scope.workspaceId}` + : "all"; +} + +function patchTaskHistory( + history: StorageIntegrityTasksResponse | null, + task: StorageIntegrityTask, +) { + if (!history) return history; + const existingIndex = history.items.findIndex((item) => item.id === task.id); + if (existingIndex < 0) return history; + return { + ...history, + items: history.items.map((item, index) => + index === existingIndex ? task : item, + ), + }; +} + +function isAbortError(error: unknown) { + return error instanceof Error && error.name === "AbortError"; +} + +function errorMessage(error: unknown) { + return error instanceof Error && error.message.trim() + ? error.message + : "request-failed"; +} diff --git a/frontend/src/i18n/admin-scope-messages.test.ts b/frontend/src/i18n/admin-scope-messages.test.ts index 6b83aec..ff8624f 100644 --- a/frontend/src/i18n/admin-scope-messages.test.ts +++ b/frontend/src/i18n/admin-scope-messages.test.ts @@ -10,4 +10,40 @@ describe("admin scope messages", () => { "Global (all workspaces and system)", ); }); + + it("keeps storage integrity labels and mode options independently translatable", () => { + expect(translateLocaleMessage("zh", "storageIntegrity.title")).toBe( + "存储完整性", + ); + expect(translateLocaleMessage("zh", "storageIntegrity.modeLabel")).toBe( + "模式", + ); + expect( + translateLocaleMessage("en", "storageIntegrity.mode.verify"), + ).toBe("Verify stored objects"); + }); + + it("localizes stable storage integrity failure codes without backend copy", () => { + expect( + translateLocaleMessage("en", "storageIntegrity.failure.taskFailed"), + ).toBe("The integrity check could not be completed."); + expect( + translateLocaleMessage("zh", "storageIntegrity.failure.leaseLost"), + ).toBe("任务租约已过期,完整性检查未能完成。"); + }); + + it("provides natural acknowledgement actions and status labels", () => { + expect( + translateLocaleMessage("en", "storageIntegrity.acknowledgeFinding"), + ).toBe("Acknowledge anomaly and allow automatic cleanup"); + expect( + translateLocaleMessage("zh", "storageIntegrity.acknowledgeFailed"), + ).toBe("无法确认该异常,请重试。"); + expect( + translateLocaleMessage("zh", "storageIntegrity.acknowledgedAt"), + ).toBe("已于 {time} 确认"); + expect( + translateLocaleMessage("zh", "storageIntegrity.findingsHint"), + ).toBe("展示哈希与大小证据;未确认的异常不会被自动清理"); + }); }); diff --git a/frontend/src/i18n/locales/en_US.tsln b/frontend/src/i18n/locales/en_US.tsln index eda015d..7415715 100644 --- a/frontend/src/i18n/locales/en_US.tsln +++ b/frontend/src/i18n/locales/en_US.tsln @@ -1502,6 +1502,93 @@ language: "ENGLISH" "settings.versionRetentionDays": "Version retention days" "settings.versionStorage": "Versions" "settings.workspaceQuota": "Workspace quota" +"storageIntegrity.acknowledgeFailed": "This anomaly could not be acknowledged. Try again." +"storageIntegrity.acknowledgeFinding": "Acknowledge anomaly and allow automatic cleanup" +"storageIntegrity.acknowledged": "Acknowledged" +"storageIntegrity.acknowledgedAt": "Acknowledged at {time}" +"storageIntegrity.actual": "Actual" +"storageIntegrity.attempts": "{count} attempts" +"storageIntegrity.bandwidthMib": "Bandwidth limit (MiB/s)" +"storageIntegrity.batchSize": "Batch size" +"storageIntegrity.bytesRead": "Bytes read" +"storageIntegrity.checkedAt": "Checked at" +"storageIntegrity.confirmAll": "I confirm this task may scan every workspace" +"storageIntegrity.concurrency": "Concurrency" +"storageIntegrity.count.failed": "Failed" +"storageIntegrity.count.mismatch": "Mismatch" +"storageIntegrity.count.pending": "Pending" +"storageIntegrity.count.unknown": "Unknown" +"storageIntegrity.count.verified": "Verified" +"storageIntegrity.currentTask": "Current task" +"storageIntegrity.currentTaskHint": "Live progress and immutable task outcome" +"storageIntegrity.expected": "Expected" +"storageIntegrity.failed": "Failed" +"storageIntegrity.failure.leaseLost": "The task lease expired before the integrity check could finish." +"storageIntegrity.failure.taskFailed": "The integrity check could not be completed." +"storageIntegrity.failure.unknown": "The integrity task ended unexpectedly." +"storageIntegrity.findings": "Findings" +"storageIntegrity.findingsHint": "Hash and size evidence; unacknowledged anomalies are excluded from automatic cleanup" +"storageIntegrity.history": "Task history" +"storageIntegrity.historyHint": "Open a prior task or restore it from the URL" +"storageIntegrity.kicker": "Integrity operations" +"storageIntegrity.lastVerifiedAt": "Last verified: {time}" +"storageIntegrity.loadFailed": "Integrity data could not be loaded. Try again." +"storageIntegrity.matched": "Matched" +"storageIntegrity.maxAttempts": "Maximum attempts" +"storageIntegrity.mismatches": "Mismatches" +"storageIntegrity.modeLabel": "Mode" +"storageIntegrity.mode.backfill": "Backfill missing hashes" +"storageIntegrity.mode.verify": "Verify stored objects" +"storageIntegrity.newTask": "New task" +"storageIntegrity.newTaskHint": "Set a target and operational limits before dispatch" +"storageIntegrity.nextPage": "Next {section} page" +"storageIntegrity.noCurrentTask": "Select a historical task or start a new integrity run." +"storageIntegrity.noFindings": "No findings match the current filter." +"storageIntegrity.noHistory": "No integrity tasks in this scope." +"storageIntegrity.nodeId": "Node ID" +"storageIntegrity.object": "Object" +"storageIntegrity.openTask": "Open task {task}" +"storageIntegrity.optional": "Optional" +"storageIntegrity.pageRange": "{start}–{end} of {total}" +"storageIntegrity.previousPage": "Previous {section} page" +"storageIntegrity.processed": "Processed" +"storageIntegrity.progress": "Integrity task progress" +"storageIntegrity.result.all": "All results" +"storageIntegrity.result.failed": "Failed" +"storageIntegrity.result.matched": "Matched" +"storageIntegrity.result.mismatch": "Mismatch" +"storageIntegrity.resultFilter": "Filter findings" +"storageIntegrity.resultLabel": "Result" +"storageIntegrity.retryAll": "Retry mismatches and failures" +"storageIntegrity.retryFinding": "Retry this finding" +"storageIntegrity.retryReusedTask": "The retry did not create a new task." +"storageIntegrity.scopeMismatch": "That task is outside the selected scope." +"storageIntegrity.selectTask": "Select a task to inspect its findings." +"storageIntegrity.start": "Start integrity task" +"storageIntegrity.status.cancelled": "Cancelled" +"storageIntegrity.status.completed": "Completed" +"storageIntegrity.status.failed": "Failed" +"storageIntegrity.status.queued": "Queued" +"storageIntegrity.status.running": "Running" +"storageIntegrity.subtitle": "Backfill SHA-256 metadata and verify stored bytes without destructive cleanup" +"storageIntegrity.summary": "Integrity state summary" +"storageIntegrity.target": "Optional file target" +"storageIntegrity.targetHint": "Leave empty for the selected scope, or search for one file and version" +"storageIntegrity.targetNoResults": "No active files match this search." +"storageIntegrity.targetSearch": "Search target files" +"storageIntegrity.targetSearchFailed": "Files could not be searched. Try again." +"storageIntegrity.targetSearchPlaceholder": "File name or path" +"storageIntegrity.targetVersion": "Target version" +"storageIntegrity.targetWorkspaceOnly": "Select a workspace scope to target one file or version. Global tasks always scan the selected global scope." +"storageIntegrity.searchFiles": "Search files" +"storageIntegrity.clearTarget": "Clear file target" +"storageIntegrity.currentObject": "Current object" +"storageIntegrity.versionOption": "Version {number} · {size} · {status}" +"storageIntegrity.versionsLoadFailed": "Versions could not be loaded." +"storageIntegrity.title": "Storage integrity" +"storageIntegrity.unlimited": "Unlimited" +"storageIntegrity.versionId": "Version ID" +"storageIntegrity.workspaceRun": "This task is restricted to the selected workspace." "time.lessThanSecond": "< 1s" "time.minutes": "{count}m" "time.minutesSeconds": "{minutes}m {seconds}s" diff --git a/frontend/src/i18n/locales/zh_CN.tsln b/frontend/src/i18n/locales/zh_CN.tsln index c3471c7..1a3b572 100644 --- a/frontend/src/i18n/locales/zh_CN.tsln +++ b/frontend/src/i18n/locales/zh_CN.tsln @@ -1502,6 +1502,93 @@ language: "中文-简体" "settings.versionRetentionDays": "历史版本保留天数" "settings.versionStorage": "历史版本" "settings.workspaceQuota": "工作区配额" +"storageIntegrity.acknowledgeFailed": "无法确认该异常,请重试。" +"storageIntegrity.acknowledgeFinding": "确认异常并解除自动清理保护" +"storageIntegrity.acknowledged": "已确认" +"storageIntegrity.acknowledgedAt": "已于 {time} 确认" +"storageIntegrity.actual": "实际值" +"storageIntegrity.attempts": "已尝试 {count} 次" +"storageIntegrity.bandwidthMib": "带宽上限(MiB/秒)" +"storageIntegrity.batchSize": "批次大小" +"storageIntegrity.bytesRead": "已读取数据" +"storageIntegrity.checkedAt": "检查时间" +"storageIntegrity.confirmAll": "我确认此任务可能扫描所有工作区" +"storageIntegrity.concurrency": "并发数" +"storageIntegrity.count.failed": "失败" +"storageIntegrity.count.mismatch": "不匹配" +"storageIntegrity.count.pending": "待处理" +"storageIntegrity.count.unknown": "未知" +"storageIntegrity.count.verified": "已验证" +"storageIntegrity.currentTask": "当前任务" +"storageIntegrity.currentTaskHint": "实时进度与不可变任务结果" +"storageIntegrity.expected": "预期值" +"storageIntegrity.failed": "失败" +"storageIntegrity.failure.leaseLost": "任务租约已过期,完整性检查未能完成。" +"storageIntegrity.failure.taskFailed": "完整性检查未能完成。" +"storageIntegrity.failure.unknown": "完整性任务意外终止。" +"storageIntegrity.findings": "检查结果" +"storageIntegrity.findingsHint": "展示哈希与大小证据;未确认的异常不会被自动清理" +"storageIntegrity.history": "任务历史" +"storageIntegrity.historyHint": "打开历史任务,或通过 URL 恢复任务上下文" +"storageIntegrity.kicker": "完整性运维" +"storageIntegrity.lastVerifiedAt": "最近验证:{time}" +"storageIntegrity.loadFailed": "无法加载完整性数据,请重试。" +"storageIntegrity.matched": "匹配" +"storageIntegrity.maxAttempts": "最大尝试次数" +"storageIntegrity.mismatches": "不匹配" +"storageIntegrity.modeLabel": "模式" +"storageIntegrity.mode.backfill": "回填缺失哈希" +"storageIntegrity.mode.verify": "验证存储对象" +"storageIntegrity.newTask": "新建任务" +"storageIntegrity.newTaskHint": "设置目标与运行限制后再调度" +"storageIntegrity.nextPage": "下一页{section}" +"storageIntegrity.noCurrentTask": "请选择历史任务,或新建完整性任务。" +"storageIntegrity.noFindings": "当前筛选条件下没有检查结果。" +"storageIntegrity.noHistory": "此范围内暂无完整性任务。" +"storageIntegrity.nodeId": "节点 ID" +"storageIntegrity.object": "对象" +"storageIntegrity.openTask": "打开任务 {task}" +"storageIntegrity.optional": "可选" +"storageIntegrity.pageRange": "第 {start}–{end} 条,共 {total} 条" +"storageIntegrity.previousPage": "上一页{section}" +"storageIntegrity.processed": "已处理" +"storageIntegrity.progress": "完整性任务进度" +"storageIntegrity.result.all": "全部结果" +"storageIntegrity.result.failed": "失败" +"storageIntegrity.result.matched": "匹配" +"storageIntegrity.result.mismatch": "不匹配" +"storageIntegrity.resultFilter": "筛选检查结果" +"storageIntegrity.resultLabel": "结果" +"storageIntegrity.retryAll": "重试不匹配项与失败项" +"storageIntegrity.retryFinding": "重试此检查项" +"storageIntegrity.retryReusedTask": "重试操作未创建新任务。" +"storageIntegrity.scopeMismatch": "该任务不属于当前选择的范围。" +"storageIntegrity.selectTask": "请选择任务以查看检查结果。" +"storageIntegrity.start": "启动完整性任务" +"storageIntegrity.status.cancelled": "已取消" +"storageIntegrity.status.completed": "已完成" +"storageIntegrity.status.failed": "失败" +"storageIntegrity.status.queued": "排队中" +"storageIntegrity.status.running": "运行中" +"storageIntegrity.subtitle": "回填 SHA-256 元数据并验证存储字节,全程不执行破坏性清理" +"storageIntegrity.summary": "完整性状态概览" +"storageIntegrity.target": "可选文件目标" +"storageIntegrity.targetHint": "留空表示当前范围,也可搜索并选择一个文件及其版本" +"storageIntegrity.targetNoResults": "没有符合条件的活跃文件。" +"storageIntegrity.targetSearch": "搜索目标文件" +"storageIntegrity.targetSearchFailed": "无法搜索文件,请重试。" +"storageIntegrity.targetSearchPlaceholder": "文件名或路径" +"storageIntegrity.targetVersion": "目标版本" +"storageIntegrity.targetWorkspaceOnly": "切换到工作区范围后才能指定单个文件或版本;全局任务始终扫描所选全局范围。" +"storageIntegrity.searchFiles": "搜索文件" +"storageIntegrity.clearTarget": "清除文件目标" +"storageIntegrity.currentObject": "当前对象" +"storageIntegrity.versionOption": "版本 {number} · {size} · {status}" +"storageIntegrity.versionsLoadFailed": "无法加载历史版本。" +"storageIntegrity.title": "存储完整性" +"storageIntegrity.unlimited": "不限制" +"storageIntegrity.versionId": "版本 ID" +"storageIntegrity.workspaceRun": "此任务仅在当前选择的工作区内执行。" "time.lessThanSecond": "1 秒内" "time.minutes": "{count} 分" "time.minutesSeconds": "{minutes} 分 {seconds} 秒" diff --git a/frontend/src/lib/drive-api-admin-types.ts b/frontend/src/lib/drive-api-admin-types.ts index 48a3869..9757393 100644 --- a/frontend/src/lib/drive-api-admin-types.ts +++ b/frontend/src/lib/drive-api-admin-types.ts @@ -5,6 +5,7 @@ import type { PasskeySettings, StorageSettings, StorageUsage, + FileVersionResponse, } from "./drive-api-types"; export type AdminAuditActor = AuditEventResponse["actor"]; @@ -148,3 +149,164 @@ export type AdminAuthPolicyResponse = { auth: AuthSettings; passkey: PasskeySettings; }; + +export type StorageIntegrityScope = "all" | "workspace"; +export type StorageIntegritySummaryScope = + | { kind: "all" } + | { kind: "workspace"; workspaceId: string }; +export type StorageIntegrityMode = "backfill" | "verify"; +export type StorageIntegrityTaskStatus = + | "queued" + | "running" + | "completed" + | "failed" + | "cancelled"; +export type StorageIntegrityResultStatus = + | "matched" + | "mismatch" + | "failed"; + +export type StorageIntegrityTarget = { + nodeId?: string; + versionId?: string; +}; + +export type CreateStorageIntegrityTaskInput = { + batchSize: number; + bandwidthLimitBytesPerSecond?: number | null; + concurrency: number; + maxAttempts: number; + mode: StorageIntegrityMode; + scope: StorageIntegrityScope; + target?: StorageIntegrityTarget; + workspaceId?: string; +}; + +export type StorageIntegrityTask = { + config: { + batchSize: number; + bandwidthLimitBytesPerSecond: number | null; + concurrency: number; + maxAttempts: number; + }; + createdAt: string; + failureCode?: string | null; + failureMessage?: string | null; + finishedAt: string | null; + id: string; + mode: StorageIntegrityMode; + progress: { + bytesRead: number; + failed: number; + matched: number; + mismatches: number; + processed: number; + total: number; + }; + scope: StorageIntegrityScope; + startedAt: string | null; + status: StorageIntegrityTaskStatus; + target: StorageIntegrityTarget | null; + workspaceId: string | null; +}; + +export type StorageIntegrityResult = { + acknowledgedAt: string | null; + acknowledgedBy: string | null; + actualHash: string | null; + attempts: number; + checkedAt: string; + errorCode?: string | null; + errorMessage?: string | null; + expectedHash: string | null; + expectedSizeBytes: number | null; + id: string; + nodeId: string | null; + objectKey: string; + actualSizeBytes: number | null; + status: StorageIntegrityResultStatus; + taskId: string; + versionId: string | null; + workspaceId: string; +}; + +export type StorageIntegrityResultFilter = + | "all" + | StorageIntegrityResultStatus; + +export type StorageIntegrityResultsResponse = { + items: StorageIntegrityResult[]; + limit: number; + offset: number; + total: number; +}; + +export type StorageIntegrityTasksResponse = { + items: StorageIntegrityTask[]; + limit: number; + offset: number; + total: number; +}; + +export type StorageIntegrityTasksQuery = { + limit: number; + offset: number; +}; + +export type StorageIntegritySummary = { + counts: { + failed: number; + mismatch: number; + pending: number; + unknown: number; + verified: number; + }; + generatedAt: string; + lastVerifiedAt: string | null; + scope: StorageIntegritySummaryScope; +}; + +export type StorageIntegrityResultsQuery = { + limit: number; + offset: number; + status: StorageIntegrityResultFilter; +}; + +export type RetryStorageIntegrityTaskInput = { + resultIds?: string[]; +}; + +export type StorageIntegrityTargetCandidate = { + id: string; + integrityStatus: + | "unknown" + | "pending" + | "verified" + | "mismatch" + | "failed"; + kind: "doc" | "sheet" | "image" | "video" | "archive" | "other"; + mimeType: string; + name: string; + path: string; + lastVerifiedAt: string | null; + sizeBytes: number | null; + updatedAt: string; + workspaceId: string; +}; + +export type StorageIntegrityTargetsResponse = { + items: StorageIntegrityTargetCandidate[]; + limit: number; + offset: number; + total: number; +}; + +export type StorageIntegrityTargetVersion = FileVersionResponse & { + integrityStatus: + | "unknown" + | "pending" + | "verified" + | "mismatch" + | "failed"; + lastVerifiedAt: string | null; +}; diff --git a/frontend/src/lib/drive-api-admin.test.ts b/frontend/src/lib/drive-api-admin.test.ts index b8abee1..fb50017 100644 --- a/frontend/src/lib/drive-api-admin.test.ts +++ b/frontend/src/lib/drive-api-admin.test.ts @@ -1,6 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + acknowledgeStorageIntegrityResult, + createStorageIntegrityTask, fetchAdminAuditEvents, + fetchStorageIntegrityTasks, + fetchStorageIntegrityResults, + fetchStorageIntegritySummary, + fetchStorageIntegrityTargetVersions, + fetchStorageIntegrityTask, + retryStorageIntegrityTask, + searchStorageIntegrityTargets, updateAdminAuthPolicy, updateAdminStoragePolicy, } from "./drive-api-admin"; @@ -8,6 +17,8 @@ import type { AdminAuditEventsResponse, AdminAuthPolicyResponse, AdminStoragePolicyResponse, + StorageIntegrityResult, + StorageIntegritySummary, } from "./drive-api-admin-types"; const responseBody: AdminAuditEventsResponse = { @@ -209,3 +220,374 @@ describe("atomic admin policy api", () => { expect(init.body).toBe(JSON.stringify(input)); }); }); + +describe("storage integrity api", () => { + beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("starts a scoped verification task with its operational limits", async () => { + const task = createIntegrityTask(); + const fetchMock = vi.fn().mockResolvedValue(Response.json(task)); + vi.stubGlobal("fetch", fetchMock); + const input = { + batchSize: 250, + bandwidthLimitBytesPerSecond: 8_388_608, + concurrency: 3, + maxAttempts: 4, + mode: "verify" as const, + scope: "workspace" as const, + target: { nodeId: " node-1 ", versionId: "version-2" }, + workspaceId: " workspace-1 ", + }; + + await expect(createStorageIntegrityTask(input)).resolves.toEqual(task); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("/api/admin/storage-integrity/tasks"); + expect(init.method).toBe("POST"); + expect(JSON.parse(String(init.body))).toEqual({ + ...input, + target: { nodeId: "node-1", versionId: "version-2" }, + workspaceId: "workspace-1", + }); + }); + + it("never leaks a workspace identifier into an all-scope task", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(Response.json(createIntegrityTask({ scope: "all" }))); + vi.stubGlobal("fetch", fetchMock); + + await createStorageIntegrityTask({ + batchSize: 100, + concurrency: 2, + maxAttempts: 3, + mode: "backfill", + scope: "all", + target: { nodeId: "stale-node", versionId: "stale-version" }, + workspaceId: "stale-workspace", + }); + + const body = JSON.parse( + String((fetchMock.mock.calls[0][1] as RequestInit).body), + ) as Record; + expect(body).not.toHaveProperty("workspaceId"); + expect(body).not.toHaveProperty("target"); + }); + + it("rejects workspace tasks without a workspace identifier", async () => { + await expect( + createStorageIntegrityTask({ + batchSize: 100, + concurrency: 2, + maxAttempts: 3, + mode: "verify", + scope: "workspace", + workspaceId: " ", + }), + ).rejects.toThrow("Workspace scope requires workspaceId"); + }); + + it("rejects task limits outside the backend operating envelope", async () => { + const base = { + batchSize: 100, + concurrency: 2, + maxAttempts: 3, + mode: "verify" as const, + scope: "all" as const, + }; + + await expect( + createStorageIntegrityTask({ ...base, batchSize: 501 }), + ).rejects.toThrow("batchSize must be between 1 and 500"); + await expect( + createStorageIntegrityTask({ ...base, concurrency: 9 }), + ).rejects.toThrow("concurrency must be between 1 and 8"); + await expect( + createStorageIntegrityTask({ + ...base, + bandwidthLimitBytesPerSecond: 1_000_000_001, + }), + ).rejects.toThrow( + "bandwidthLimitBytesPerSecond must be between 1 and 1000000000", + ); + }); + + it("loads a task and a filtered result page with abort support", async () => { + const task = createIntegrityTask(); + const page = { items: [], limit: 25, offset: 50, total: 125 }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json(task)) + .mockResolvedValueOnce(Response.json(page)); + vi.stubGlobal("fetch", fetchMock); + const controller = new AbortController(); + + await fetchStorageIntegrityTask("task/one", { + signal: controller.signal, + }); + await expect( + fetchStorageIntegrityResults( + "task/one", + { limit: 25, offset: 50, status: "mismatch" }, + { signal: controller.signal }, + ), + ).resolves.toEqual(page); + + expect(fetchMock.mock.calls[0][0]).toBe( + "/api/admin/storage-integrity/tasks/task%2Fone", + ); + const [rawUrl, init] = fetchMock.mock.calls[1] as [string, RequestInit]; + const url = new URL(rawUrl, "http://localhost"); + expect(url.pathname).toBe( + "/api/admin/storage-integrity/tasks/task%2Fone/results", + ); + expect(Object.fromEntries(url.searchParams)).toEqual({ + limit: "25", + offset: "50", + status: "mismatch", + }); + expect(init.signal).toBe(controller.signal); + }); + + it("lists task history for an explicit workspace scope", async () => { + const page = { + items: [createIntegrityTask()], + limit: 20, + offset: 40, + total: 61, + }; + const fetchMock = vi.fn().mockResolvedValue(Response.json(page)); + vi.stubGlobal("fetch", fetchMock); + const controller = new AbortController(); + + await expect( + fetchStorageIntegrityTasks( + { kind: "workspace", workspaceId: "workspace/one" }, + { limit: 20, offset: 40 }, + { signal: controller.signal }, + ), + ).resolves.toEqual(page); + + const [rawUrl, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const url = new URL(rawUrl, "http://localhost"); + expect(url.pathname).toBe("/api/admin/storage-integrity/tasks"); + expect(Object.fromEntries(url.searchParams)).toEqual({ + limit: "20", + offset: "40", + scope: "workspace", + workspaceId: "workspace/one", + }); + expect(init.signal).toBe(controller.signal); + }); + + it("loads scope summary independently from task history", async () => { + const summary = { + counts: { + failed: 2, + mismatch: 3, + pending: 4, + unknown: 5, + verified: 6, + }, + generatedAt: "2026-08-13T12:00:00.000Z", + lastVerifiedAt: "2026-08-13T11:00:00.000Z", + scope: { kind: "all" }, + } satisfies StorageIntegritySummary; + const fetchMock = vi.fn().mockResolvedValue(Response.json(summary)); + vi.stubGlobal("fetch", fetchMock); + const controller = new AbortController(); + + await expect( + fetchStorageIntegritySummary( + { kind: "all" }, + { signal: controller.signal }, + ), + ).resolves.toEqual(summary); + + const [rawUrl, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const url = new URL(rawUrl, "http://localhost"); + expect(url.pathname).toBe("/api/admin/storage-integrity/summary"); + expect(Object.fromEntries(url.searchParams)).toEqual({ scope: "all" }); + expect(init.signal).toBe(controller.signal); + }); + + it("preserves the workspace discriminator returned by the summary API", async () => { + const summary = { + counts: { + failed: 0, + mismatch: 0, + pending: 1, + unknown: 2, + verified: 3, + }, + generatedAt: "2026-08-13T12:00:00.000Z", + lastVerifiedAt: null, + scope: { kind: "workspace", workspaceId: "workspace-1" }, + } satisfies StorageIntegritySummary; + const fetchMock = vi.fn().mockResolvedValue(Response.json(summary)); + vi.stubGlobal("fetch", fetchMock); + + await expect( + fetchStorageIntegritySummary({ + kind: "workspace", + workspaceId: "workspace-1", + }), + ).resolves.toEqual(summary); + + const url = new URL(fetchMock.mock.calls[0][0] as string, "http://localhost"); + expect(Object.fromEntries(url.searchParams)).toEqual({ + scope: "workspace", + workspaceId: "workspace-1", + }); + }); + + it("searches integrity targets through storage.manage-scoped admin routes", async () => { + const targets = { items: [], limit: 20, offset: 0, total: 0 }; + const versions = [ + { + createdAt: "2026-08-13T12:00:00.000Z", + id: "version-1", + integrityStatus: "unknown", + lastVerifiedAt: null, + mimeType: "application/octet-stream", + nodeId: "node/one", + remark: "", + sizeBytes: 10, + uploadedBy: "admin", + versionNumber: 1, + }, + ]; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json(targets)) + .mockResolvedValueOnce(Response.json(versions)); + vi.stubGlobal("fetch", fetchMock); + const controller = new AbortController(); + + await searchStorageIntegrityTargets(" workspace-1 ", " report ", { + signal: controller.signal, + }); + await fetchStorageIntegrityTargetVersions("workspace-1", "node/one", { + signal: controller.signal, + }); + + const targetsUrl = new URL( + fetchMock.mock.calls[0][0] as string, + "http://localhost", + ); + expect(targetsUrl.pathname).toBe("/api/admin/storage-integrity/targets"); + expect(Object.fromEntries(targetsUrl.searchParams)).toEqual({ + limit: "20", + offset: "0", + query: "report", + workspaceId: "workspace-1", + }); + const versionsUrl = new URL( + fetchMock.mock.calls[1][0] as string, + "http://localhost", + ); + expect(versionsUrl.pathname).toBe( + "/api/admin/storage-integrity/targets/node%2Fone/versions", + ); + expect(versionsUrl.searchParams.get("workspaceId")).toBe("workspace-1"); + }); + + it("retries selected findings into a new task", async () => { + const nextTask = createIntegrityTask({ id: "task-2", status: "queued" }); + const fetchMock = vi.fn().mockResolvedValue(Response.json(nextTask)); + vi.stubGlobal("fetch", fetchMock); + + await expect( + retryStorageIntegrityTask("task-1", { resultIds: ["result-1"] }), + ).resolves.toEqual(nextTask); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("/api/admin/storage-integrity/tasks/task-1/retry"); + expect(init.method).toBe("POST"); + expect(init.body).toBe(JSON.stringify({ resultIds: ["result-1"] })); + }); + + it("acknowledges an anomalous integrity result", async () => { + const acknowledged = createIntegrityResult({ + acknowledgedAt: "2026-08-14T01:00:00.000Z", + acknowledgedBy: "admin-1", + }); + const fetchMock = vi.fn().mockResolvedValue(Response.json(acknowledged)); + vi.stubGlobal("fetch", fetchMock); + + await expect( + acknowledgeStorageIntegrityResult("result/one"), + ).resolves.toEqual(acknowledged); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + "/api/admin/storage-integrity/results/result%2Fone/acknowledge", + ); + expect(init.method).toBe("POST"); + }); +}); + +function createIntegrityResult( + overrides: Partial = {}, +): StorageIntegrityResult { + return { + acknowledgedAt: null, + acknowledgedBy: null, + actualHash: "sha256:actual", + actualSizeBytes: 2_048, + attempts: 1, + checkedAt: "2026-08-13T12:01:00.000Z", + errorCode: null, + errorMessage: null, + expectedHash: "sha256:expected", + expectedSizeBytes: 1_024, + id: "result-1", + nodeId: "node-1", + objectKey: "objects/archive.bin", + status: "mismatch", + taskId: "task-1", + versionId: "version-1", + workspaceId: "workspace-1", + ...overrides, + }; +} + +function createIntegrityTask( + overrides: Record = {}, +) { + return { + config: { + bandwidthLimitBytesPerSecond: 8_388_608, + batchSize: 250, + concurrency: 3, + maxAttempts: 4, + }, + createdAt: "2026-08-13T12:00:00.000Z", + failureCode: null, + failureMessage: null, + finishedAt: null, + id: "task-1", + mode: "verify", + progress: { + bytesRead: 4_096, + failed: 0, + matched: 4, + mismatches: 1, + processed: 5, + total: 10, + }, + scope: "workspace", + startedAt: "2026-08-13T12:00:01.000Z", + status: "running", + target: { nodeId: "node-1", versionId: "version-2" }, + workspaceId: "workspace-1", + ...overrides, + }; +} diff --git a/frontend/src/lib/drive-api-admin.ts b/frontend/src/lib/drive-api-admin.ts index 01daa6a..b971ebc 100644 --- a/frontend/src/lib/drive-api-admin.ts +++ b/frontend/src/lib/drive-api-admin.ts @@ -10,6 +10,17 @@ import type { AdminAuthPolicyResponse, AdminStoragePolicyInput, AdminStoragePolicyResponse, + CreateStorageIntegrityTaskInput, + RetryStorageIntegrityTaskInput, + StorageIntegrityResult, + StorageIntegrityResultsQuery, + StorageIntegrityResultsResponse, + StorageIntegrityTask, + StorageIntegritySummary, + StorageIntegrityTasksQuery, + StorageIntegrityTasksResponse, + StorageIntegrityTargetsResponse, + StorageIntegrityTargetVersion, } from "./drive-api-admin-types"; export function fetchAdminOverview( @@ -83,6 +94,173 @@ export function updateAdminAuthPolicy(input: AdminAuthPolicyInput) { }); } +const storageIntegrityTasksPath = "/admin/storage-integrity/tasks"; + +export async function createStorageIntegrityTask( + input: CreateStorageIntegrityTaskInput, +) { + return requestDriveApi(storageIntegrityTasksPath, { + body: JSON.stringify(normalizeStorageIntegrityTaskInput(input)), + method: "POST", + }); +} + +export function fetchStorageIntegrityTasks( + scope: Extract, + page: StorageIntegrityTasksQuery, + options: AdminAuditRequestOptions = {}, +) { + const query = createAdminScopeQuery(scope); + query.set("limit", String(page.limit)); + query.set("offset", String(page.offset)); + return requestDriveApi( + `${storageIntegrityTasksPath}?${query.toString()}`, + { signal: options.signal }, + ); +} + +export function fetchStorageIntegritySummary( + scope: Extract, + options: AdminAuditRequestOptions = {}, +) { + const query = createAdminScopeQuery(scope); + return requestDriveApi( + `/admin/storage-integrity/summary?${query.toString()}`, + { signal: options.signal }, + ); +} + +export function fetchStorageIntegrityTask( + taskId: string, + options: AdminAuditRequestOptions = {}, +) { + return requestDriveApi( + `${storageIntegrityTasksPath}/${encodeURIComponent(taskId)}`, + { signal: options.signal }, + ); +} + +export function fetchStorageIntegrityResults( + taskId: string, + query: StorageIntegrityResultsQuery, + options: AdminAuditRequestOptions = {}, +) { + const search = new URLSearchParams({ + limit: String(query.limit), + offset: String(query.offset), + status: query.status, + }); + return requestDriveApi( + `${storageIntegrityTasksPath}/${encodeURIComponent(taskId)}/results?${search.toString()}`, + { signal: options.signal }, + ); +} + +export function retryStorageIntegrityTask( + taskId: string, + input: RetryStorageIntegrityTaskInput = {}, +) { + return requestDriveApi( + `${storageIntegrityTasksPath}/${encodeURIComponent(taskId)}/retry`, + { + body: JSON.stringify(input), + method: "POST", + }, + ); +} + +export function acknowledgeStorageIntegrityResult(resultId: string) { + return requestDriveApi( + `/admin/storage-integrity/results/${encodeURIComponent(resultId)}/acknowledge`, + { method: "POST" }, + ); +} + +export function searchStorageIntegrityTargets( + workspaceId: string, + query: string, + options: AdminAuditRequestOptions = {}, +) { + const search = new URLSearchParams({ + limit: "20", + offset: "0", + query: query.trim(), + workspaceId: requireWorkspaceId(workspaceId), + }); + return requestDriveApi( + `/admin/storage-integrity/targets?${search.toString()}`, + { signal: options.signal }, + ); +} + +export function fetchStorageIntegrityTargetVersions( + workspaceId: string, + nodeId: string, + options: AdminAuditRequestOptions = {}, +) { + const query = new URLSearchParams({ + workspaceId: requireWorkspaceId(workspaceId), + }); + return requestDriveApi( + `/admin/storage-integrity/targets/${encodeURIComponent(nodeId)}/versions?${query.toString()}`, + { signal: options.signal }, + ); +} + +function normalizeStorageIntegrityTaskInput( + input: CreateStorageIntegrityTaskInput, +): CreateStorageIntegrityTaskInput { + assertIntegerRange(input.batchSize, 1, 500, "batchSize"); + assertIntegerRange(input.concurrency, 1, 8, "concurrency"); + if (input.bandwidthLimitBytesPerSecond != null) { + assertIntegerRange( + input.bandwidthLimitBytesPerSecond, + 1, + 1_000_000_000, + "bandwidthLimitBytesPerSecond", + ); + } + const workspaceId = input.workspaceId?.trim(); + if (input.scope === "workspace" && !workspaceId) { + throw new Error("Workspace scope requires workspaceId"); + } + const nodeId = input.target?.nodeId?.trim(); + const versionId = input.target?.versionId?.trim(); + const target = + input.scope === "workspace" && (nodeId || versionId) + ? { nodeId, versionId } + : undefined; + + return { + batchSize: input.batchSize, + bandwidthLimitBytesPerSecond: + input.bandwidthLimitBytesPerSecond ?? null, + concurrency: input.concurrency, + maxAttempts: input.maxAttempts, + mode: input.mode, + scope: input.scope, + ...(target ? { target } : {}), + ...(input.scope === "workspace" ? { workspaceId } : {}), + }; +} + +function assertIntegerRange( + value: number, + min: number, + max: number, + field: string, +) { + if (!Number.isInteger(value) || value < min || value > max) { + throw new Error(`${field} must be between ${min} and ${max}`); + } +} + +function requireWorkspaceId(workspaceId: string) { + const normalized = workspaceId.trim(); + if (!normalized) throw new Error("Workspace scope requires workspaceId"); + return normalized; +} + function appendOptional( query: URLSearchParams, key: string, diff --git a/frontend/src/views/drive/admin-app.tsx b/frontend/src/views/drive/admin-app.tsx index 112d36d..1fa901a 100644 --- a/frontend/src/views/drive/admin-app.tsx +++ b/frontend/src/views/drive/admin-app.tsx @@ -11,6 +11,7 @@ import { import { AdminHealthCenter } from "@/components/admin/admin-health-center"; import { AdminOverviewDashboard } from "@/components/admin/admin-overview-dashboard"; import { AdminScopeSelector } from "@/components/admin/admin-scope-selector"; +import { AdminStorageIntegrityPanel } from "@/components/admin/admin-storage-integrity-panel"; import { AdminUnsavedChangesProvider } from "@/components/admin/unsaved-changes-provider"; import { showAppToast } from "@/components/ui/app-toast-store"; import { UserAccountMenu } from "@/components/ui/user-account-menu"; @@ -31,9 +32,11 @@ import { getAdminPanelPath, getAdminPanelScope, getAdminSystemSectionPath, + getAdminSystemSectionScope, resolveAdminPanelFromPath, resolveAdminSystemSectionFromPath, serializeAdminScope, + shouldShowAdminGlobalRefresh, type AdminPanel, type AdminSystemSection, } from "@/features/admin/admin-routes"; @@ -114,6 +117,12 @@ const systemSettingSections: Array<{ labelKey: "settings.storagePolicy", subtitleKey: "settings.storagePolicySubtitle", }, + { + icon: "shield", + id: "integrity", + labelKey: "storageIntegrity.title", + subtitleKey: "storageIntegrity.subtitle", + }, { icon: "trash", id: "lifecycle", @@ -198,9 +207,18 @@ function AdminPanelGate({ const reconciledScope = workspaceQuery.data ? reconcileAdminScope(requestedScope, workspaces) : requestedScope; - return getAdminPanelScope(activePanel, reconciledScope); + const panelScope = getAdminPanelScope(activePanel, reconciledScope); + return activePanel === "system" + ? getAdminSystemSectionScope(activeSystemSection, panelScope) + : panelScope; }, - [activePanel, requestedScope, workspaceQuery.data, workspaces], + [ + activePanel, + activeSystemSection, + requestedScope, + workspaceQuery.data, + workspaces, + ], ); const workspaceId = scope.kind === "workspace" ? scope.workspaceId : null; const scopeKey = serializeAdminScope(scope); @@ -281,7 +299,9 @@ function AdminPanelGate({ const openSystemSection = useCallback( (section: AdminSystemSection) => { const nextPath = getAdminSystemSectionPath(section); - router.push(buildAdminUrl(nextPath, scope)); + router.push( + buildAdminUrl(nextPath, getAdminSystemSectionScope(section, scope)), + ); }, [router, scope], ); @@ -320,10 +340,30 @@ function AdminPanelGate({ offset: 0, }) : writeAdminScopeSearchParams(searchParams, nextScope); + if (activePanel === "system" && activeSystemSection === "integrity") { + next.delete("task"); + } const query = next.toString(); router.replace(`${pathname}${query ? `?${query}` : ""}`); }, - [activePanel, auditFilters, pathname, router, searchParams], + [ + activePanel, + activeSystemSection, + auditFilters, + pathname, + router, + searchParams, + ], + ); + + const changeIntegrityTask = useCallback( + (taskId: string | null) => { + const next = writeAdminScopeSearchParams(searchParams, scope); + if (taskId?.trim()) next.set("task", taskId.trim()); + else next.delete("task"); + router.replace(`${pathname}?${next.toString()}`); + }, + [pathname, router, scope, searchParams], ); useEffect(() => { @@ -419,13 +459,15 @@ function AdminPanelGate({ : t(activeSystemSectionMeta.subtitleKey); const panelOwnsHeading = activePanel === "status" || - (activePanel === "system" && activeSystemSection === "oauth"); + (activePanel === "system" && + (activeSystemSection === "oauth" || activeSystemSection === "integrity")); const showsScopeSelector = activePanel === "overview" || activePanel === "audit" || (activePanel === "system" && (activeSystemSection === "storage" || - activeSystemSection === "external-share")); + activeSystemSection === "external-share" || + activeSystemSection === "integrity")); const adminRefreshing = siteQuery.refreshing || workspaceQuery.refreshing || @@ -522,7 +564,9 @@ function AdminPanelGate({ })}
- +
{t("settings.usedStorage")} @@ -548,6 +592,12 @@ function AdminPanelGate({ {showsScopeSelector ? ( - void refreshAdminData()} - > - - + {shouldShowAdminGlobalRefresh( + activePanel, + activeSystemSection, + ) ? ( + void refreshAdminData()} + > + + + ) : null} ) : activeSystemSection === "oauth" ? ( - + + ) : activeSystemSection === "integrity" ? ( + ) : activeSystemSection === "storage" && !workspaceId ? (
diff --git a/frontend/src/views/drive/drive-oauth-admin-settings.test.tsx b/frontend/src/views/drive/drive-oauth-admin-settings.test.tsx index 88353f0..aff0210 100644 --- a/frontend/src/views/drive/drive-oauth-admin-settings.test.tsx +++ b/frontend/src/views/drive/drive-oauth-admin-settings.test.tsx @@ -32,30 +32,41 @@ vi.mock("@/components/common/ui/loading-state", () => ({ vi.mock("@/components/oauth/oauth-admin-parts", () => ({ OAuthProviderGroup: ({ + onActivate, onEdit, providers, }: { + onActivate: (provider: OAuthSettings) => void; onEdit: (provider: OAuthSettings) => void; providers: OAuthSettings[]; - }) => providers[0] ? ( - - ) : null, + }) => + providers[0] ? ( + <> + + + + ) : null, OAuthSummary: () => null, })); vi.mock("@/components/oauth/oauth-provider-dialog", () => ({ OAuthProviderDialog: ({ draft, + onClose, onDraftChange, onSecretChange, secret, }: { draft: OAuthSettings; + onClose: () => void; onDraftChange: (draft: OAuthSettings) => void; onSecretChange: (secret: string) => void; secret: string; }) => (
+ @@ -244,6 +255,15 @@ afterEach(() => { }); describe("OAuthAdminSettingsPage unsaved editor", () => { + it("keeps provider data visible when the auth-settings request fails", async () => { + driveApi.fetchAuthSettings.mockRejectedValue(new Error("auth unavailable")); + + renderPage(); + + expect(await screen.findByText("edit-provider")).toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent("api-error"); + }); + it("registers secret-only changes and keeps them when navigation is cancelled", async () => { renderPage(); const { secret } = await openEditor(); @@ -287,4 +307,36 @@ describe("OAuthAdminSettingsPage unsaved editor", () => { }), ); }); + + it("asks before closing a dirty provider editor", async () => { + renderPage(); + const { draft } = await openEditor(); + fireEvent.change(draft, { target: { value: "Draft Provider" } }); + fireEvent.click(screen.getByText("close-editor")); + + expect(screen.getByText("admin.unsavedTitle")).toBeInTheDocument(); + expect(screen.getByLabelText("oauth-editor")).toBeInTheDocument(); + + fireEvent.click( + screen.getByRole("button", { name: "admin.unsavedDiscard" }), + ); + await waitFor(() => + expect(screen.queryByLabelText("oauth-editor")).not.toBeInTheDocument(), + ); + }); + + it("does not open the editor after activating a provider", async () => { + driveApi.activateOAuthProvider.mockResolvedValue({ + ...provider, + enabled: true, + }); + renderPage(); + + fireEvent.click(await screen.findByText("activate-provider")); + + await waitFor(() => + expect(driveApi.activateOAuthProvider).toHaveBeenCalledWith("oauth-1"), + ); + expect(screen.queryByLabelText("oauth-editor")).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/views/drive/drive-oauth-admin-settings.tsx b/frontend/src/views/drive/drive-oauth-admin-settings.tsx index 54bfceb..a1320da 100644 --- a/frontend/src/views/drive/drive-oauth-admin-settings.tsx +++ b/frontend/src/views/drive/drive-oauth-admin-settings.tsx @@ -2,6 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useUnsavedChangesSection } from "@/components/admin/use-unsaved-changes-section"; +import { + UnsavedChangesDialog, + type UnsavedChangesDialogAction, +} from "@/components/admin/unsaved-changes-dialog"; import { LdrsLoadingState } from "@/components/common/ui/loading-state"; import { OAuthProviderGroup, @@ -62,7 +66,15 @@ type OAuthEditorSnapshot = { secretCleared: boolean; }; -export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { +export function OAuthAdminSettingsPage({ + locale, + palette, + timeZone, +}: { + locale?: string; + palette: Palette; + timeZone?: string; +}) { const t = useTranslations(); const systemBaseUrl = useMemo( () => (typeof window === "undefined" ? "" : window.location.origin), @@ -79,7 +91,12 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { const [enablePromptOpen, setEnablePromptOpen] = useState(false); const [editorBaseline, setEditorBaseline] = useState(null); + const [editorCloseError, setEditorCloseError] = useState(null); + const [editorClosePending, setEditorClosePending] = + useState(null); + const [editorClosePromptOpen, setEditorClosePromptOpen] = useState(false); const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); const [modalMode, setModalMode] = useState("create"); const [providerFilter, setProviderFilter] = useState< OAuthProviderKey | "all" @@ -94,27 +111,57 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { const [testResult, setTestResult] = useState( null, ); + const [validationErrorKey, setValidationErrorKey] = useState( + null, + ); const showToast = useCallback( (message: string, tone: AppToastTone = "success") => showAppToast({ title: message, tone }), [], ); - const refresh = useCallback(async (preferredId?: string) => { - const [providerResponse, authResponse] = await Promise.all([ + const copyOAuthValue = useCallback( + (value: string) => { + void copyTextToClipboard(value) + .then(() => showToast(t("actions.copy"))) + .catch(() => showToast(t("errors.unknown"), "error")); + }, + [showToast, t], + ); + const refresh = useCallback(async () => { + const [providerResult, authResult] = await Promise.allSettled([ fetchOAuthProviders(), fetchAuthSettings(), ]); - setProviders(providerResponse.providers); - setAuthSettings(authResponse); - if (preferredId) { - const next = providerResponse.providers.find( - (provider) => provider.id === preferredId, - ); - if (next) setDraft(next); + if (providerResult.status === "fulfilled") { + setProviders(providerResult.value.providers); } - return providerResponse; + if (authResult.status === "fulfilled") { + setAuthSettings(authResult.value); + } + if (providerResult.status === "rejected") throw providerResult.reason; + if (authResult.status === "rejected") throw authResult.reason; + setLoadError(null); + return providerResult.value; }, []); + const reportLoadFailure = useCallback( + (error: unknown) => { + const message = getDriveApiErrorMessage(error, t, { + fallbackKey: "admin.loadFailed", + scope: "form", + }); + setLoadError(message); + return message; + }, + [t], + ); + const syncAfterMutation = useCallback(async () => { + try { + await refresh(); + } catch (error) { + reportLoadFailure(error); + } + }, [refresh, reportLoadFailure]); useEffect(() => { let cancelled = false; @@ -122,14 +169,7 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { if (cancelled) return; void refresh() .catch((error) => { - if (!cancelled) - showToast( - getDriveApiErrorMessage(error, t, { - fallbackKey: "admin.loadFailed", - scope: "form", - }), - "error", - ); + if (!cancelled) showToast(reportLoadFailure(error), "error"); }) .finally(() => { if (!cancelled) setLoading(false); @@ -138,7 +178,7 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { return () => { cancelled = true; }; - }, [refresh, showToast, t]); + }, [refresh, reportLoadFailure, showToast]); const filteredGroups = useMemo(() => { const normalizedQuery = query.trim().toLowerCase(); @@ -178,8 +218,11 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { setSecret(""); setSecretCleared(false); setShowSecret(false); - setSavingKey(null); setTestResult(null); + setValidationErrorKey(null); + setEditorCloseError(null); + setEditorClosePending(null); + setEditorClosePromptOpen(false); }; const openCreate = (providerKey: OAuthProviderKey = "google") => { const nextDraft = createOAuthDraft( @@ -190,6 +233,7 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { setSecret(""); setSecretCleared(false); setTestResult(null); + setValidationErrorKey(null); setEditorBaseline(createEditorSnapshot(nextDraft)); setDraft(nextDraft); }; @@ -198,6 +242,7 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { setSecret(""); setSecretCleared(false); setTestResult(null); + setValidationErrorKey(null); setEditorBaseline(createEditorSnapshot(provider)); setDraft(cloneOAuthDraft(provider)); }; @@ -217,6 +262,7 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { setSecret(""); setSecretCleared(false); setTestResult(null); + setValidationErrorKey(null); setEditorBaseline(createEditorSnapshot(nextDraft)); setDraft(nextDraft); }; @@ -224,6 +270,7 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { setSecret(""); setSecretCleared(false); setTestResult(null); + setValidationErrorKey(null); setDraft((current) => { const next = createOAuthDraft(template, systemBaseUrl); return current?.id @@ -245,13 +292,16 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { }; const testDraft = () => { + if (savingKey) return; const input = providerInput(false); if (!draft || !input) return; const validation = validateOAuthDraft(draft, secret); if (!validation.valid) { + setValidationErrorKey(validation.errorKey); showToast(t(validation.errorKey), "error"); return; } + setValidationErrorKey(null); setSavingKey("test"); setTestResult(null); void testOAuthProvider(input) @@ -281,9 +331,11 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { if (!draft || !input) throw new Error("OAuth editor is not open"); const validation = validateOAuthDraft(draft, secret); if (nextEnabled && !validation.valid) { + setValidationErrorKey(validation.errorKey); showToast(t(validation.errorKey), "error"); throw new Error(validation.errorKey); } + setValidationErrorKey(null); setSavingKey(enabled ? "activate" : "save"); const request = modalMode === "edit" @@ -291,20 +343,21 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { : createOAuthProvider(input); try { const provider = await request; - await refresh(provider.id); + setProviders((current) => upsertOAuthProvider(current, provider)); showToast(enabled ? t("admin.oauthActivated") : t("admin.saved")); resetEditor(); if (enabled && authSettings && !authSettings.oauthEnabled) { setEnablePromptOpen(true); } + await syncAfterMutation(); } catch (error) { showToast( - getDriveApiErrorMessage(error, t, { - fallbackKey: "admin.saveFailed", - scope: "form", - }), - "error", - ); + getDriveApiErrorMessage(error, t, { + fallbackKey: "admin.saveFailed", + scope: "form", + }), + "error", + ); throw error; } finally { setSavingKey(null); @@ -321,6 +374,7 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { setSecretCleared(baseline.secretCleared); setShowSecret(false); setTestResult(null); + setValidationErrorKey(null); }; const editorDirty = isOAuthEditorDirty( draft, @@ -336,20 +390,38 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { onSave: () => persistDraft(false), }); + const requestEditorClose = () => { + if (!editorDirty) { + resetEditor(); + return; + } + setEditorCloseError(null); + setEditorClosePromptOpen(true); + }; + const saveBeforeEditorClose = () => { + setEditorCloseError(null); + setEditorClosePending("save"); + void persistDraft(false) + .catch(() => setEditorCloseError(t("admin.unsavedSaveFailed"))) + .finally(() => setEditorClosePending(null)); + }; + const setProviderActive = (provider: OAuthSettings, enabled: boolean) => { + if (savingKey) return; const key = `${enabled ? "activate" : "deactivate"}:${provider.id}`; setSavingKey(key); const request = enabled ? activateOAuthProvider(provider.id) : updateOAuthProvider(provider.id, { enabled: false }); void request - .then((next) => refresh(next.id)) - .then(() => { + .then(async (next) => { + setProviders((current) => upsertOAuthProvider(current, next)); showToast( enabled ? t("admin.oauthActivated") : t("admin.oauthDeactivated"), ); if (enabled && authSettings && !authSettings.oauthEnabled) setEnablePromptOpen(true); + await syncAfterMutation(); }) .catch((error) => showToast( @@ -364,7 +436,7 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { }; const enableGlobalOAuth = () => { - if (!authSettings) return; + if (!authSettings || savingKey) return; setSavingKey("global-auth"); void updateAuthSettings({ localEnabled: authSettings.localEnabled, @@ -408,6 +480,7 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { setSecret(""); setSecretCleared(true); setTestResult(null); + setValidationErrorKey(null); setDraft((current) => current ? { ...current, clientSecretConfigured: false } : current, ); @@ -415,10 +488,17 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { return; } const { provider } = confirmation; + if (savingKey) return; setSavingKey(`delete:${provider.id}`); void deleteOAuthProvider(provider.id) - .then(() => refresh()) - .then(() => showToast(t("admin.oauthDeleted"))) + .then(async () => { + setProviders((current) => + current.filter((item) => item.id !== provider.id), + ); + setConfirmation(null); + showToast(t("admin.oauthDeleted")); + await syncAfterMutation(); + }) .catch((error) => showToast( getDriveApiErrorMessage(error, t, { @@ -430,10 +510,17 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { ) .finally(() => { setSavingKey(null); - setConfirmation(null); }); }; + const refreshOAuthData = () => { + if (savingKey) return; + setSavingKey("refresh"); + void refresh() + .catch((error) => showToast(reportLoadFailure(error), "error")) + .finally(() => setSavingKey(null)); + }; + if (loading) return (
@@ -452,10 +539,28 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { >

{t("admin.oauthSettings")}

+ {loadError ? ( +
+ + + {loadError} + + + + +
+ ) : null}
openCreate()} @@ -486,6 +591,7 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { !authSettings.oauthEnabled && summary.active > 0 ? ( setEnablePromptOpen(true)} palette={palette} @@ -502,6 +608,7 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) {
setProviderFilter( event.target.value as OAuthProviderKey | "all", @@ -519,6 +626,7 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { /> setStatusFilter(event.target.value as OAuthStatusFilter) } @@ -535,6 +643,7 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { setQuery(event.target.value)} palette={palette} placeholder={t("admin.oauthSearchPlaceholder")} @@ -546,8 +655,7 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { label={t("actions.refresh")} palette={palette} onClick={() => { - setSavingKey("refresh"); - void refresh().finally(() => setSavingKey(null)); + refreshOAuthData(); }} size="sm" visual="surface" @@ -557,7 +665,7 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) {
- {filteredGroups.length === 0 ? ( + {loadError && providers.length === 0 ? null : filteredGroups.length === 0 ? (
{t("admin.oauthProviderEmpty")} @@ -567,12 +675,9 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { setProviderActive(provider, true)} - onCopy={(value) => - void copyTextToClipboard(value).then(() => - showToast(t("actions.copy")), - ) - } + onCopy={copyOAuthValue} onDeactivate={(provider) => setProviderActive(provider, false)} onDelete={removeProvider} onDuplicate={openDuplicate} @@ -590,6 +695,7 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { protectedActiveProviderId={protectedActiveProviderId} savingKey={savingKey} template={template} + timeZone={timeZone} /> )) )} @@ -601,21 +707,19 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { draft={draft} mode={modalMode} onClearSecret={() => setConfirmation({ kind: "clear-secret" })} - onClose={resetEditor} - onCopy={(value) => - void copyTextToClipboard(value).then(() => - showToast(t("actions.copy")), - ) - } + onClose={requestEditorClose} + onCopy={copyOAuthValue} onDraftChange={(nextDraft) => { setDraft(nextDraft); setTestResult(null); + setValidationErrorKey(null); }} onSave={saveDraft} onSecretChange={(value) => { setSecret(value); setSecretCleared(false); setTestResult(null); + setValidationErrorKey(null); }} onSelectTemplate={selectTemplate} onShowSecretChange={setShowSecret} @@ -625,8 +729,26 @@ export function OAuthAdminSettingsPage({ palette }: { palette: Palette }) { secret={secret} showSecret={showSecret} testResult={testResult} + validationErrorKey={validationErrorKey} /> ) : null} + setEditorClosePromptOpen(false)} + onDiscard={resetEditor} + onSave={saveBeforeEditorClose} + open={editorClosePromptOpen} + palette={palette} + pendingAction={editorClosePending} + /> setEnablePromptOpen(false)} @@ -776,3 +898,25 @@ function isOAuthEditorDirty( secretCleared !== baseline.secretCleared ); } + +function upsertOAuthProvider( + current: OAuthSettings[], + nextProvider: OAuthSettings, +) { + let found = false; + const next = current.map((provider) => { + if (provider.id === nextProvider.id) { + found = true; + return nextProvider; + } + if ( + nextProvider.enabled && + provider.providerKey === nextProvider.providerKey && + provider.enabled + ) { + return { ...provider, enabled: false }; + } + return provider; + }); + return found ? next : [...next, nextProvider]; +} diff --git a/frontend/src/views/drive/styles/admin-oauth.css b/frontend/src/views/drive/styles/admin-oauth.css index 7c4af28..3031ed2 100644 --- a/frontend/src/views/drive/styles/admin-oauth.css +++ b/frontend/src/views/drive/styles/admin-oauth.css @@ -13,7 +13,7 @@ container-name: oauth-admin; container-type: inline-size; display: grid; - gap: 12px; + gap: 14px; min-height: 0; min-width: 0; overflow: visible; @@ -22,7 +22,8 @@ .admin-shell .drive-oauth-toolbar { background: var(--drive-ui-surface); border: 1px solid var(--drive-ui-line); - border-radius: 8px; + border-radius: 12px; + box-shadow: var(--drive-ui-shadow-tight); min-width: 0; overflow: hidden; } @@ -30,10 +31,10 @@ .admin-shell .drive-oauth-admin-overview { align-items: center; display: grid; - gap: 16px; + gap: 20px; grid-template-columns: minmax(0, 1fr) auto; min-width: 0; - padding: 11px 13px; + padding: 15px 17px; } .admin-shell .drive-oauth-overview-actions { @@ -46,9 +47,9 @@ .admin-shell .drive-oauth-overview-actions .icedr-tool-button, .admin-shell .drive-oauth-filterbar > .icedr-tool-button { - height: 34px; - min-width: 34px; - width: 34px; + height: 38px; + min-width: 38px; + width: 38px; } .admin-shell .drive-oauth-summary { @@ -62,8 +63,8 @@ .admin-shell .drive-oauth-summary > div { align-content: center; display: grid; - gap: 2px; - min-height: 38px; + gap: 3px; + min-height: 44px; min-width: 0; padding: 0 13px; } @@ -74,7 +75,7 @@ .admin-shell .drive-oauth-summary dt { color: var(--drive-ui-muted); - font-size: 11px; + font-size: 12px; font-weight: 690; line-height: 15px; margin: 0; @@ -83,9 +84,9 @@ .admin-shell .drive-oauth-summary dd { color: var(--drive-ui-ink); - font-size: 16px; + font-size: 19px; font-weight: 840; - line-height: 20px; + line-height: 23px; margin: 0; } @@ -104,9 +105,9 @@ gap: 8px; grid-template-columns: minmax(142px, 174px) minmax(132px, 158px) minmax(220px, 1fr) - 34px; + 38px; min-width: 0; - padding: 9px 13px; + padding: 11px 17px; } .admin-shell .drive-oauth-filterbar .icedr-app-select-shell, @@ -114,12 +115,12 @@ background: var(--drive-ui-surface); border-color: var(--drive-ui-line); box-shadow: none; - min-height: 36px; + min-height: 40px; } .admin-shell .drive-oauth-filterbar .icedr-app-select, .admin-shell .drive-oauth-filterbar .icedr-app-input { - font-size: 12px; + font-size: 13px; font-weight: 650; } @@ -127,14 +128,14 @@ align-items: center; background: var(--drive-ui-surface); border: 1px solid var(--drive-ui-line); - border-radius: 8px; + border-radius: 10px; color: var(--drive-ui-subtle); display: grid; gap: 7px; grid-template-columns: 16px minmax(0, 1fr); - min-height: 36px; + min-height: 40px; min-width: 0; - padding: 0 9px; + padding: 0 11px; } .admin-shell .drive-oauth-search-field .icedr-app-input { @@ -142,7 +143,7 @@ border: 0; border-radius: 0; box-shadow: none; - min-height: 32px; + min-height: 36px; min-width: 0; padding-inline: 0; } @@ -154,7 +155,7 @@ .admin-shell .drive-oauth-provider-groups { display: grid; - gap: 10px; + gap: 12px; min-width: 0; padding: 0; } @@ -162,8 +163,8 @@ .admin-shell .drive-oauth-provider-group { background: var(--drive-ui-surface); border: 1px solid var(--drive-ui-line); - border-radius: 8px; - box-shadow: var(--drive-ui-shadow-1); + border-radius: 12px; + box-shadow: var(--drive-ui-shadow-tight); min-width: 0; overflow: hidden; } @@ -174,10 +175,10 @@ border: 0; color: var(--drive-ui-ink); display: grid; - gap: 10px; - grid-template-columns: 30px minmax(0, 1fr) auto 18px; - min-height: 48px; - padding: 7px 12px; + gap: 12px; + grid-template-columns: 34px minmax(0, 1fr) auto 18px; + min-height: 56px; + padding: 9px 15px; text-align: left; width: 100%; } @@ -198,7 +199,7 @@ .admin-shell .drive-oauth-provider-group-copy strong { display: block; - font-size: 14px; + font-size: 15px; font-weight: 820; line-height: 20px; overflow: hidden; @@ -213,9 +214,9 @@ border-radius: 999px; color: var(--drive-ui-muted); display: inline-flex; - font-size: 11px; + font-size: 12px; font-weight: 760; - height: 23px; + height: 26px; justify-content: center; padding: 0 8px; white-space: nowrap; @@ -239,16 +240,16 @@ .admin-shell .drive-oauth-table-head { background: var(--drive-ui-surface-soft); color: var(--drive-ui-muted); - font-size: 11px; + font-size: 12px; font-weight: 760; - min-height: 36px; - padding: 0 12px; + min-height: 40px; + padding: 0 15px; } .admin-shell .drive-oauth-table-row { border-top: 1px solid var(--ui-border-subtle); - min-height: 54px; - padding: 8px 12px; + min-height: 64px; + padding: 10px 15px; } .admin-shell .drive-oauth-table-head + .drive-oauth-table-row { @@ -265,12 +266,12 @@ .admin-shell .drive-oauth-config-name { display: grid; - gap: 1px; + gap: 2px; } .admin-shell .drive-oauth-config-name strong { color: var(--drive-ui-ink); - font-size: 12px; + font-size: 13px; font-weight: 790; line-height: 17px; } @@ -278,7 +279,7 @@ .admin-shell .drive-oauth-config-name small, .admin-shell .drive-oauth-muted { color: var(--drive-ui-muted); - font-size: 11px; + font-size: 12px; font-weight: 620; line-height: 16px; } @@ -292,7 +293,7 @@ .admin-shell .drive-oauth-copy-cell > span { color: var(--drive-ui-muted); - font-size: 11px; + font-size: 12px; font-weight: 680; min-width: 0; } @@ -331,9 +332,9 @@ .admin-shell .drive-oauth-row-actions .icedr-tool-button { background: transparent; border-color: transparent; - height: 30px; - min-width: 30px; - width: 30px; + height: 32px; + min-width: 32px; + width: 32px; } .admin-shell .drive-oauth-row-actions .icedr-tool-button:hover { @@ -346,11 +347,11 @@ align-items: center; color: var(--drive-ui-muted); display: flex; - font-size: 12px; + font-size: 13px; font-weight: 680; gap: 8px; justify-content: center; - min-height: 76px; + min-height: 96px; padding: 16px; } @@ -378,7 +379,7 @@ } .admin-shell .drive-oauth-filterbar { - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 34px; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 38px; } .admin-shell .drive-oauth-search-field { @@ -467,11 +468,11 @@ .admin-shell .drive-oauth-filterbar { gap: 7px; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 34px; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 38px; } .admin-shell .drive-oauth-provider-group-header { - grid-template-columns: 30px minmax(0, 1fr) auto 16px; + grid-template-columns: 34px minmax(0, 1fr) auto 16px; padding-inline: 10px; } @@ -509,7 +510,7 @@ --drive-ui-surface-raised: var(--ui-surface-raised); --drive-ui-surface-soft: var(--ui-surface-subtle); --drive-ui-warning: var(--ui-warning); - border-radius: 8px; + border-radius: 12px; display: flex; flex-direction: column; min-height: 0; @@ -521,7 +522,7 @@ display: block; flex: 0 0 auto; margin: 0; - padding: 14px 16px 12px; + padding: 18px 20px 15px; } .drive-oauth-dialog-title-row { @@ -540,10 +541,10 @@ .drive-oauth-dialog-heading h2 { color: var(--drive-ui-ink); - font-size: 18px; + font-size: 20px; font-weight: 830; letter-spacing: 0; - line-height: 24px; + line-height: 26px; margin: 0; overflow-wrap: anywhere; } @@ -566,14 +567,14 @@ .drive-oauth-dialog-context strong { color: var(--drive-ui-ink); - font-size: 12px; + font-size: 13px; font-weight: 780; line-height: 17px; } .drive-oauth-dialog-context small { color: var(--drive-ui-subtle); - font-size: 11px; + font-size: 12px; font-weight: 650; line-height: 16px; } @@ -597,14 +598,14 @@ background: var(--drive-ui-surface-soft); border-bottom: 1px solid var(--drive-ui-line); display: grid; - gap: 9px; + gap: 11px; min-width: 0; - padding: 12px 16px; + padding: 15px 20px; } .drive-oauth-dialog-section-title { color: var(--drive-ui-ink); - font-size: 12px; + font-size: 13px; font-weight: 780; letter-spacing: 0; line-height: 18px; @@ -613,7 +614,7 @@ .drive-oauth-template-grid { display: grid; - gap: 7px; + gap: 8px; grid-template-columns: repeat(6, minmax(0, 1fr)); min-width: 0; } @@ -622,15 +623,14 @@ align-items: center; background: var(--drive-ui-surface); border: 1px solid var(--drive-ui-line); - border-radius: 8px; + border-radius: 10px; color: var(--drive-ui-ink); display: grid; - gap: 7px; - grid-template-columns: 28px minmax(0, 1fr); - min-height: 50px; + gap: 9px; + grid-template-columns: 30px minmax(0, 1fr); + min-height: 58px; min-width: 0; - padding: 7px 8px; - position: relative; + padding: 9px 10px; text-align: left; } @@ -645,17 +645,6 @@ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--oauth-provider-accent) 12%, transparent); } -.drive-oauth-template-option[data-active="true"]::after { - background: var(--oauth-provider-accent); - border-radius: 999px; - content: ""; - height: 4px; - position: absolute; - right: 7px; - top: 7px; - width: 4px; -} - .drive-oauth-template-option:focus-visible { outline: 2px solid var(--oauth-provider-accent); outline-offset: 1px; @@ -683,23 +672,23 @@ .drive-oauth-template-option strong { color: var(--drive-ui-ink); - font-size: 11px; + font-size: 12px; font-weight: 780; line-height: 16px; } .drive-oauth-template-option small { color: var(--drive-ui-subtle); - font-size: 10px; + font-size: 11px; font-weight: 620; line-height: 14px; } .drive-oauth-form-panel { display: grid; - gap: 13px; + gap: 16px; min-width: 0; - padding: 14px 16px 16px; + padding: 18px 20px 20px; } .drive-oauth-form-heading { @@ -714,14 +703,14 @@ .drive-oauth-form-columns { align-items: start; display: grid; - gap: 12px 16px; + gap: 14px 18px; grid-template-columns: repeat(2, minmax(0, 1fr)); min-width: 0; } .drive-oauth-form-column { display: grid; - gap: 11px; + gap: 13px; min-width: 0; } @@ -732,7 +721,7 @@ .drive-oauth-form-panel .icedr-field-label { color: var(--drive-ui-muted); - font-size: 11px; + font-size: 12px; font-weight: 720; line-height: 16px; } @@ -741,7 +730,7 @@ background: var(--drive-ui-surface); border-color: var(--drive-ui-line); box-shadow: none; - min-height: 37px; + min-height: 42px; min-width: 0; } @@ -754,14 +743,14 @@ } .drive-oauth-copy-field .icedr-tool-button { - height: 34px; - min-width: 34px; - width: 34px; + height: 38px; + min-width: 38px; + width: 38px; } .drive-oauth-advanced { border: 1px solid var(--drive-ui-line); - border-radius: 8px; + border-radius: 10px; min-width: 0; overflow: hidden; } @@ -775,8 +764,8 @@ gap: 8px; grid-template-columns: 16px minmax(0, 1fr) 14px; list-style: none; - min-height: 38px; - padding: 0 11px; + min-height: 42px; + padding: 0 13px; user-select: none; } @@ -798,7 +787,7 @@ } .drive-oauth-advanced summary span { - font-size: 11px; + font-size: 12px; font-weight: 740; } @@ -809,7 +798,7 @@ .drive-oauth-policy-title { color: var(--drive-ui-muted); - font-size: 11px; + font-size: 12px; font-weight: 720; line-height: 16px; } @@ -869,7 +858,7 @@ } .drive-oauth-policy-toggle span { - font-size: 11px; + font-size: 12px; font-weight: 650; line-height: 16px; min-width: 0; @@ -879,7 +868,7 @@ align-items: center; color: var(--drive-ui-blue-600); display: inline-flex; - font-size: 11px; + font-size: 12px; font-weight: 720; gap: 5px; line-height: 16px; @@ -920,7 +909,7 @@ } .drive-oauth-test-result > header strong { - font-size: 11px; + font-size: 12px; font-weight: 780; line-height: 16px; } @@ -948,7 +937,7 @@ } .drive-oauth-test-result small { - font-size: 9px; + font-size: 10px; font-weight: 700; } @@ -961,7 +950,7 @@ gap: 8px; justify-content: space-between; margin: 0; - padding: 9px 12px; + padding: 11px 16px; } .drive-oauth-dialog-actions-primary { @@ -972,9 +961,23 @@ } .drive-oauth-dialog-actions .icedr-tool-button { - height: 34px; - min-width: 34px; - width: 34px; + height: 38px; + min-width: 38px; + width: 38px; +} + +.drive-oauth-validation-message { + align-items: center; + background: color-mix(in srgb, var(--ui-danger) 8%, var(--drive-ui-surface)); + border: 1px solid color-mix(in srgb, var(--ui-danger) 30%, var(--drive-ui-line)); + border-radius: 10px; + color: var(--ui-danger); + display: flex; + font-size: 12px; + font-weight: 720; + line-height: 18px; + min-height: 40px; + padding: 9px 12px; } .drive-oauth-dialog-actions .drive-oauth-dialog-action-activate { diff --git a/frontend/src/views/drive/styles/admin.css b/frontend/src/views/drive/styles/admin.css index 54e67dc..a55aed7 100644 --- a/frontend/src/views/drive/styles/admin.css +++ b/frontend/src/views/drive/styles/admin.css @@ -1,8 +1,8 @@ .admin-shell { - background: var(--ui-canvas); + background: color-mix(in srgb, var(--ui-canvas) 94%, var(--ui-surface)); color: var(--drive-ui-ink); display: grid; - grid-template-columns: 256px minmax(0, 1fr); + grid-template-columns: 244px minmax(0, 1fr); height: 100dvh; min-height: 0; overflow: hidden; @@ -16,7 +16,7 @@ } .admin-sidebar { - background: color-mix(in srgb, var(--ui-surface) 86%, transparent); + background: var(--ui-surface); border-right: 1px solid var(--drive-ui-line); display: flex; flex-direction: column; @@ -24,7 +24,7 @@ min-height: 0; min-width: 0; overflow-y: auto; - padding: 30px 18px 22px; + padding: 24px 14px 18px; position: sticky; top: 0; } @@ -37,7 +37,9 @@ display: flex; gap: 12px; min-width: 0; - padding: 0 4px 28px; + border-bottom: 1px solid var(--ui-border-subtle); + margin: 0 4px 22px; + padding: 0 2px 20px; text-align: left; } @@ -48,7 +50,7 @@ } .admin-brand span { - font-size: 24px; + font-size: 22px; font-weight: 850; } @@ -57,14 +59,14 @@ font-size: 12px; font-weight: 760; line-height: 16px; - margin: 0 8px 10px; + margin: 0 10px 9px; } .admin-panel-nav { display: flex; flex: 1 1 auto; flex-direction: column; - gap: 6px; + gap: 5px; min-width: 0; } @@ -78,22 +80,21 @@ align-items: center; background: transparent; border: 1px solid transparent; - border-radius: var(--drive-ui-radius); + border-radius: 10px; color: var(--drive-ui-muted); display: inline-flex; font-size: 14px; font-weight: 760; gap: 10px; justify-content: flex-start; - min-height: 42px; + min-height: 40px; padding: 0 12px; text-align: left; transition: background-color 160ms var(--drive-ui-ease), border-color 160ms var(--drive-ui-ease), box-shadow 160ms var(--drive-ui-ease), - color 160ms var(--drive-ui-ease), - transform 160ms var(--drive-ui-ease); + color 160ms var(--drive-ui-ease); width: 100%; } @@ -101,11 +102,10 @@ background: var(--ui-surface-subtle); border-color: var(--drive-ui-line); color: var(--drive-ui-ink); - transform: translateX(2px); } .admin-panel-nav button:active { - transform: translateX(2px) scale(0.985); + background: color-mix(in srgb, var(--ui-accent) 10%, var(--ui-surface)); } .admin-panel-nav button[data-active="true"] { @@ -144,7 +144,6 @@ .admin-panel-subnav button:hover { background: var(--ui-surface-subtle); border-color: var(--drive-ui-line); - transform: translateX(1px); } .admin-panel-subnav button[data-active="true"] { @@ -157,20 +156,25 @@ .admin-sidebar-status { align-items: center; border: 1px solid var(--drive-ui-line); - border-radius: var(--drive-ui-radius); + background: var(--ui-surface-subtle); + border-radius: 10px; display: flex; gap: 10px; margin-top: 18px; - padding: 14px 12px; + padding: 12px; } -.admin-sidebar-status-dot { - background: var(--drive-ui-success); - border-radius: 999px; - box-shadow: 0 0 0 5px color-mix(in srgb, var(--ui-success) 12%, transparent); +.admin-sidebar-status-icon { + align-items: center; + background: color-mix(in srgb, var(--ui-accent) 10%, var(--ui-surface)); + border: 1px solid color-mix(in srgb, var(--ui-accent) 22%, var(--ui-border-default)); + border-radius: 8px; + color: var(--drive-ui-blue-600); + display: inline-flex; flex: 0 0 auto; - height: 8px; - width: 8px; + height: 32px; + justify-content: center; + width: 32px; } .admin-sidebar-status div { @@ -179,7 +183,7 @@ min-width: 0; } -.admin-sidebar-status span:not(.admin-sidebar-status-dot):first-child { +.admin-sidebar-status div > span:first-child { color: var(--drive-ui-ink); font-size: 13px; font-weight: 780; @@ -205,13 +209,13 @@ .admin-header { align-items: center; - background: color-mix(in srgb, var(--ui-surface) 90%, transparent); + background: var(--ui-surface); border-bottom: 1px solid var(--drive-ui-line); display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) auto; - min-height: 72px; - padding: 0 30px; + min-height: 68px; + padding: 0 26px; position: sticky; top: 0; z-index: 50; @@ -258,10 +262,16 @@ overscroll-behavior: contain; overflow-x: hidden; overflow-y: auto; - padding: 24px 28px 36px; + padding: 26px 26px 40px; scrollbar-gutter: stable; } +.admin-workspace > * { + margin-inline: auto; + max-width: 1600px; + width: 100%; +} + .admin-workspace-heading { align-items: flex-start; display: flex; @@ -289,7 +299,7 @@ .admin-workspace-heading h1 { color: var(--drive-ui-ink); - font-size: 25px; + font-size: 26px; font-weight: 840; line-height: 31px; margin: 0; @@ -944,7 +954,7 @@ } } -@media (max-width: 980px) { +@media (max-width: 780px) { .admin-shell { align-content: start; grid-template-columns: 100%; @@ -981,7 +991,9 @@ } .admin-brand { + border-bottom: 0; gap: 8px; + margin: 0; padding: 0; } @@ -1038,7 +1050,7 @@ } .admin-panel-nav button:hover { - transform: translateY(-1px); + background: var(--ui-surface-subtle); } .admin-panel-nav button[data-active="true"] { @@ -1086,9 +1098,14 @@ .admin-panel-nav-group[data-panel="system"][data-expanded="true"] { flex-basis: auto; overflow: visible; + order: -1; padding-top: 0; } + .admin-panel-subnav button[data-active="true"] { + order: -1; + } + .admin-panel-nav::-webkit-scrollbar { display: none; } @@ -1109,6 +1126,10 @@ } @media (max-width: 560px) { + .admin-header-actions { + flex-wrap: wrap; + } + .admin-workspace-heading h1 { font-size: 22px; line-height: 28px; diff --git a/frontend/tests/e2e/admin-oauth-visual.spec.ts b/frontend/tests/e2e/admin-oauth-visual.spec.ts new file mode 100644 index 0000000..874acd2 --- /dev/null +++ b/frontend/tests/e2e/admin-oauth-visual.spec.ts @@ -0,0 +1,335 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; +import { resolve } from "node:path"; + +const now = "2026-09-20T12:00:00.000Z"; +const workspaceId = "workspace-ui"; +const screenshotRoot = resolve(process.cwd(), "../output/playwright"); +const corsHeaders = { + "Access-Control-Allow-Headers": "Authorization, Content-Type", + "Access-Control-Allow-Methods": "DELETE, GET, OPTIONS, PATCH, POST", + "Access-Control-Allow-Origin": "*", +}; + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.setItem("icedr.auth.token", "visual-admin-token"); + window.localStorage.setItem("icedr.ui.themePreference", "light"); + }); + await mockApplicationApi(page); +}); + +test("keeps the admin and OAuth layouts readable across breakpoints", async ({ + page, +}) => { + await page.setViewportSize({ height: 900, width: 1440 }); + await page.goto("/admin/system/oauth"); + + await expect( + page.getByRole("heading", { name: "OAuth configuration" }), + ).toBeVisible(); + await expect(page.locator(".admin-sidebar-status-icon")).toBeVisible(); + await expect(page.locator(".admin-sidebar-status-dot")).toHaveCount(0); + await expect(page.locator(".drive-oauth-provider-group")).toHaveCount(1); + await page.waitForTimeout(450); + await expectNoHorizontalOverflow(page); + await expectNoOverlap(page, ".admin-header-title", ".admin-header-actions"); + await page.screenshot({ + fullPage: true, + path: resolve(screenshotRoot, "oauth-admin-final-1440.png"), + }); + + await page.setViewportSize({ height: 900, width: 867 }); + await expectSidebarBesideMain(page); + await expectNoHorizontalOverflow(page); + await expectNoOverlap( + page, + ".drive-oauth-provider-group-copy", + ".drive-oauth-provider-count", + ); + await page.screenshot({ + fullPage: true, + path: resolve(screenshotRoot, "oauth-admin-final-867.png"), + }); + + await page.getByRole("button", { name: "Add provider" }).click(); + await expect( + page.getByRole("heading", { name: "Add OAuth provider" }), + ).toBeVisible(); + await expect(page.locator(".drive-oauth-template-option")).toHaveCount(6); + await page.waitForTimeout(500); + await expectNoHorizontalOverflow(page); + await page.screenshot({ + fullPage: true, + path: resolve(screenshotRoot, "oauth-dialog-final-867.png"), + }); + await page.getByRole("button", { name: "Cancel" }).click(); + + await page.setViewportSize({ height: 844, width: 390 }); + await expect( + page.getByRole("heading", { name: "OAuth configuration" }), + ).toBeVisible(); + await expectActiveMobileAdminNavigationInView(page); + await expectNoHorizontalOverflow(page); + await page.screenshot({ + fullPage: true, + path: resolve(screenshotRoot, "oauth-admin-final-mobile.png"), + }); +}); + +test("hides file actions and the decorative icon for an empty root", async ({ + page, +}) => { + await page.setViewportSize({ height: 900, width: 1440 }); + await page.goto("/"); + + const emptyState = page.locator(".drive-empty-state"); + await expect(emptyState).toBeVisible(); + await expect(emptyState).toHaveCSS("opacity", "1"); + await expect(emptyState.locator(".drive-empty-state-title")).toBeVisible(); + await page.waitForTimeout(450); + await expect(emptyState.locator(".drive-empty-state-icon")).toHaveCount(0); + await expect(emptyState.locator(".drive-empty-state-actions")).toHaveCount(0); + await expect(page.locator(".drive-toolbar-action-group")).toHaveCount(0); + await expectNoHorizontalOverflow(page); + await page.screenshot({ + fullPage: true, + path: resolve(screenshotRoot, "drive-empty-root-final.png"), + }); +}); + +async function expectNoHorizontalOverflow(page: Page) { + await expect + .poll(() => + page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ) + .toBe(true); +} + +async function expectSidebarBesideMain(page: Page) { + const boxes = await page.evaluate(() => { + const sidebar = document.querySelector(".admin-sidebar"); + const main = document.querySelector(".admin-main"); + if (!sidebar || !main) return null; + const a = sidebar.getBoundingClientRect(); + const b = main.getBoundingClientRect(); + return { + mainLeft: b.left, + sidebarRight: a.right, + sidebarWidth: a.width, + }; + }); + expect(boxes).not.toBeNull(); + expect(boxes?.sidebarWidth ?? 0).toBeGreaterThan(220); + expect(boxes?.mainLeft ?? 0).toBeGreaterThanOrEqual( + (boxes?.sidebarRight ?? 0) - 1, + ); +} + +async function expectActiveMobileAdminNavigationInView(page: Page) { + const box = await page + .locator('.admin-panel-subnav button[data-active="true"]') + .boundingBox(); + expect(box).not.toBeNull(); + expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(390); +} + +async function expectNoOverlap(page: Page, left: string, right: string) { + const boxes = await page.evaluate( + ([leftSelector, rightSelector]) => { + const leftElement = document.querySelector(leftSelector); + const rightElement = document.querySelector(rightSelector); + if (!leftElement || !rightElement) return null; + const a = leftElement.getBoundingClientRect(); + const b = rightElement.getBoundingClientRect(); + return { + a: { bottom: a.bottom, left: a.left, right: a.right, top: a.top }, + b: { bottom: b.bottom, left: b.left, right: b.right, top: b.top }, + }; + }, + [left, right] as const, + ); + expect(boxes).not.toBeNull(); + const overlaps = Boolean( + boxes && + boxes.a.left < boxes.b.right && + boxes.a.right > boxes.b.left && + boxes.a.top < boxes.b.bottom && + boxes.a.bottom > boxes.b.top, + ); + expect(overlaps).toBe(false); +} + +async function mockApplicationApi(page: Page) { + await page.route("**/api/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname.replace(/^\/api/, "") || "/"; + const method = request.method(); + if (method === "OPTIONS") { + return route.fulfill({ headers: corsHeaders, status: 204 }); + } + if (method === "GET" && path === "/setup/status") { + return json(route, { + bootstrapCompleted: true, + databaseAvailable: true, + needsSetup: false, + }); + } + if (method === "GET" && path === "/auth/me") { + return json(route, adminUser()); + } + if (method === "GET" && path === "/site/settings/public") { + return json(route, { authLogoDataUrl: null, siteName: "ICEDR" }); + } + if ( + method === "GET" && + path === "/site/settings/public/translations" + ) { + return json(route, { bundles: [] }); + } + if (method === "GET" && path === "/workspaces") { + return json(route, [workspace()]); + } + if (method === "GET" && path === "/auth/settings") { + return json(route, { + localEnabled: true, + minimumAuthenticationMethods: 1, + oauthConfigured: true, + oauthEnabled: false, + passkeyConfigured: true, + passkeyEnabled: true, + updatedAt: now, + }); + } + if ( + method === "GET" && + path === "/identity/oauth/settings/providers" + ) { + const provider = oauthProvider(); + return json(route, { + activeProvider: provider, + configured: true, + providers: [provider], + }); + } + if (method === "GET" && path === "/shares") { + return json(route, []); + } + if (method === "GET" && path === "/file-nodes") { + return json(route, []); + } + if (method === "GET" && path === "/transfers") { + return json(route, []); + } + if (method === "GET" && path === `/workspaces/${workspaceId}/share-settings`) { + return json(route, shareSettings()); + } + if (method === "GET" && path === "/storage/usage") { + return json(route, storageUsage(url.searchParams.get("spaceScope"))); + } + return json(route, { message: `Unhandled ${method} ${path}` }, 404); + }); +} + +async function json(route: Route, body: unknown, status = 200) { + await route.fulfill({ + body: JSON.stringify(body), + headers: { ...corsHeaders, "Content-Type": "application/json" }, + status, + }); +} + +function adminUser() { + return { + avatarUrl: null, + createdAt: now, + displayName: "Admin", + email: "admin@example.com", + id: "admin-user", + locale: "en", + role: "admin", + theme: "light", + timezone: "UTC", + }; +} + +function workspace() { + return { + createdAt: now, + id: workspaceId, + memberCount: 1, + name: "Operations", + rootNodeId: "root-ui", + updatedAt: now, + }; +} + +function oauthProvider() { + return { + allowedEmailDomains: ["example.com"], + allowSignup: true, + audience: "", + authorizationUrl: "", + clientId: "oauth-client-1234567890", + clientSecretConfigured: true, + configured: true, + createdAt: now, + displayName: "Google Workspace", + enabled: true, + id: "oauth-google", + issuerUrl: "https://accounts.google.com", + linkByVerifiedEmail: true, + providerKey: "google", + providerMode: "standard", + providerProfile: "oidc", + redirectUri: "http://127.0.0.1:13000/callback", + requireVerifiedEmail: true, + scopes: "openid email profile", + tokenUrl: "", + updatedAt: now, + userinfoUrl: "", + }; +} + +function shareSettings() { + return { + allowedDomains: [], + anonymousAccess: "blocked", + audit: { + alerts: true, + anomaly: true, + downloads: true, + ip: true, + userAgent: true, + }, + defaultExpiresDays: 7, + emailRule: "any", + maxExpiresDays: 30, + allowPermanent: false, + updatedAt: now, + workspaceId, + }; +} + +function storageUsage(spaceScope: string | null) { + return { + activeBytes: 0, + defaultUserQuotaBytes: null, + fileCount: 0, + folderCount: 0, + quotaBytes: null, + quotaSource: "unlimited", + spaceScope: spaceScope ?? "workspace", + storagePolicyQuotaBytes: null, + trashBytes: 0, + trashFileCount: 0, + updatedAt: now, + usagePercent: null, + usedBytes: 0, + versionBytes: 0, + versionCount: 0, + workspaceId, + }; +} diff --git a/frontend/tests/e2e/storage-integrity.spec.ts b/frontend/tests/e2e/storage-integrity.spec.ts new file mode 100644 index 0000000..f821ee3 --- /dev/null +++ b/frontend/tests/e2e/storage-integrity.spec.ts @@ -0,0 +1,354 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const now = "2026-08-13T12:00:00.000Z"; +const acknowledgedAt = "2026-08-14T01:00:00.000Z"; +const workspaceId = "workspace-alpha"; +const corsHeaders = { + "Access-Control-Allow-Headers": "Authorization, Content-Type", + "Access-Control-Allow-Methods": "GET, OPTIONS, POST", + "Access-Control-Allow-Origin": "*", +}; + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.setItem("icedr.auth.token", "storage-integrity-admin"); + window.localStorage.setItem("icedr.ui.themePreference", "light"); + }); +}); + +test("runs a targeted task, cancels polling, acknowledges a finding, and retries", async ({ + page, +}) => { + const state = await mockStorageIntegrityApi(page); + await page.goto("/admin/system/integrity?scope=all"); + + await expect(page).toHaveURL(/\/admin\/system\/integrity\?scope=all$/); + await expect(page.getByRole("heading", { name: "Storage integrity" })).toBeVisible(); + await expect(page.getByTestId("integrity-count-unknown")).toContainText("11"); + await expect(page.getByTestId("integrity-count-verified")).toContainText("37"); + const scope = page.getByLabel("Data scope"); + await expect(scope.locator("option[value='scope:system']")).toHaveCount(0); + + const start = page.getByRole("button", { name: "Start integrity task" }); + await expect(start).toBeDisabled(); + await expect(page.getByLabel("Search target files")).toHaveCount(0); + await expect( + page.getByText( + "Select a workspace scope to target one file or version. Global tasks always scan the selected global scope.", + ), + ).toBeVisible(); + await scope.selectOption(`workspace:${workspaceId}`); + await expect(page).toHaveURL( + new RegExp(`/admin/system/integrity\\?workspace=${workspaceId}$`), + ); + await page.getByLabel("Search target files").fill("archive"); + await page.getByRole("button", { name: "Search files" }).click(); + await page.getByRole("button", { name: "archive.bin" }).click(); + await page.getByLabel("Target version").selectOption("version-2"); + await start.click(); + + await expect(page).toHaveURL(/task=task-created/); + await expect.poll(() => state.createdTaskBody).toMatchObject({ + batchSize: 100, + concurrency: 2, + maxAttempts: 3, + mode: "verify", + scope: "workspace", + target: { nodeId: "node-1", versionId: "version-2" }, + workspaceId, + }); + await expect(page.getByText("objects/archive.bin")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Retry this finding" }), + ).toHaveCount(0); + + await scope.selectOption("scope:all"); + await expect(page).toHaveURL(/\/admin\/system\/integrity\?scope=all$/); + await expect(page.getByText("Select a historical task or start a new integrity run.")).toBeVisible(); + const readsAfterScopeChange = state.taskReads.get("task-created") ?? 0; + await page.waitForTimeout(1_800); + expect(state.taskReads.get("task-created") ?? 0).toBe(readsAfterScopeChange); + + await page.goto( + `/admin/system/integrity?workspace=${workspaceId}&task=task-workspace`, + ); + const objectKey = page.getByText("objects/archive.bin"); + await expect(objectKey).toBeVisible(); + await expect(objectKey).toHaveAttribute("title", "objects/archive.bin"); + await expect(page.getByText("sha256:expected")).toBeVisible(); + await expect(page.getByText("sha256:actual")).toBeVisible(); + await expect(page.getByTestId("integrity-expected-size")).toHaveText("1 KB"); + await expect(page.getByTestId("integrity-actual-size")).toHaveText("2 KB"); + await expect(page.getByRole("button", { name: /delete/i })).toHaveCount(0); + + const acknowledge = page.getByRole("button", { + name: "Acknowledge anomaly and allow automatic cleanup", + }); + await expect(acknowledge).toBeVisible(); + await acknowledge.click(); + await expect.poll(() => state.acknowledgedResultId).toBe("finding-1"); + await expect(page.getByText("Acknowledged", { exact: true })).toBeVisible(); + await expect(page.locator(".admin-integrity-acknowledged time")).toContainText( + "Aug 14, 2026", + ); + await expect(acknowledge).toHaveCount(0); + await expect(page.getByRole("button", { name: /delete/i })).toHaveCount(0); + + await page.getByRole("button", { name: "Retry mismatches and failures" }).click(); + await expect(page).toHaveURL(/task=task-retried/); + expect(state.retrySourceTaskId).toBe("task-workspace"); +}); + +test("keeps the operational layout separated at desktop and 390px", async ({ + page, +}) => { + await mockStorageIntegrityApi(page); + await page.setViewportSize({ height: 900, width: 1440 }); + await page.goto("/admin/system/integrity?scope=all&task=task-history"); + await expect(page.getByRole("heading", { name: "Storage integrity" })).toBeVisible(); + const historyTask = page.getByRole("button", { + name: "Open task task-history", + }); + await expect(historyTask).toHaveAccessibleDescription("Completed 10 / 10"); + await expect( + historyTask.locator(".admin-integrity-history-icon"), + ).toHaveAttribute("data-status", "mismatch"); + await expectNoOverlap( + page, + ".admin-integrity-commandbar > div", + ".admin-integrity-commandbar .icedr-tool-button", + ); + await expect(page.locator(".admin-integrity-zone .admin-integrity-zone")).toHaveCount(0); + + await page.setViewportSize({ height: 844, width: 390 }); + await expect(page.getByRole("heading", { name: "Storage integrity" })).toBeVisible(); + await expect.poll(() => + page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true); + await expectNoOverlap( + page, + ".admin-integrity-zone-heading-actions > div", + ".admin-integrity-zone-heading-actions .icedr-tool-button", + ); + await expect(page.getByLabel("Integrity task progress")).toBeVisible(); +}); + +async function expectNoOverlap(page: Page, left: string, right: string) { + const boxes = await page.evaluate( + ([leftSelector, rightSelector]) => { + const leftElement = document.querySelector(leftSelector); + const rightElement = document.querySelector(rightSelector); + if (!leftElement || !rightElement) return null; + const a = leftElement.getBoundingClientRect(); + const b = rightElement.getBoundingClientRect(); + return { a: { bottom: a.bottom, left: a.left, right: a.right, top: a.top }, b: { bottom: b.bottom, left: b.left, right: b.right, top: b.top } }; + }, + [left, right] as const, + ); + expect(boxes).not.toBeNull(); + const overlaps = Boolean( + boxes && + boxes.a.left < boxes.b.right && + boxes.a.right > boxes.b.left && + boxes.a.top < boxes.b.bottom && + boxes.a.bottom > boxes.b.top, + ); + expect(overlaps).toBe(false); +} + +async function mockStorageIntegrityApi(page: Page) { + const state = { + acknowledgedResultId: null as string | null, + createdTaskBody: null as Record | null, + findingAcknowledged: false, + findingTaskId: null as string | null, + retrySourceTaskId: null as string | null, + taskReads: new Map(), + }; + await page.route("**/api/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname.replace(/^\/api/, "") || "/"; + const method = request.method(); + if (method === "OPTIONS") return route.fulfill({ headers: corsHeaders, status: 204 }); + if (method === "GET" && path === "/setup/status") { + return json(route, { bootstrapCompleted: true, databaseAvailable: true, needsSetup: false }); + } + if (method === "GET" && path === "/auth/me") return json(route, adminUser()); + if (method === "GET" && path === "/site/settings/public") { + return json(route, { authLogoDataUrl: null, siteName: "ICEDR" }); + } + if (method === "GET" && path === "/site/settings/public/translations") { + return json(route, { bundles: [] }); + } + if (method === "GET" && path === "/workspaces") { + return json(route, [workspace()]); + } + if (method === "GET" && path === "/admin/storage-integrity/summary") { + return json(route, { + counts: { failed: 2, mismatch: 3, pending: 5, unknown: 11, verified: 37 }, + generatedAt: now, + lastVerifiedAt: now, + scope: + url.searchParams.get("scope") === "workspace" + ? { + kind: "workspace", + workspaceId: url.searchParams.get("workspaceId") ?? workspaceId, + } + : { kind: "all" }, + }); + } + if (method === "GET" && path === "/admin/storage-integrity/tasks") { + return json(route, { + items: [task("task-history", "completed", url.searchParams.get("scope") === "workspace")], + limit: Number(url.searchParams.get("limit") ?? 20), + offset: Number(url.searchParams.get("offset") ?? 0), + total: 1, + }); + } + if (method === "POST" && path === "/admin/storage-integrity/tasks") { + state.createdTaskBody = request.postDataJSON() as Record; + return json(route, task("task-created", "running", true)); + } + const taskMatch = path.match(/^\/admin\/storage-integrity\/tasks\/([^/]+)$/); + if (method === "GET" && taskMatch) { + const id = decodeURIComponent(taskMatch[1]); + state.taskReads.set(id, (state.taskReads.get(id) ?? 0) + 1); + return json( + route, + task( + id, + id === "task-created" ? "running" : "completed", + id === "task-created" || id === "task-workspace", + ), + ); + } + const resultsMatch = path.match(/^\/admin\/storage-integrity\/tasks\/([^/]+)\/results$/); + if (method === "GET" && resultsMatch) { + const id = decodeURIComponent(resultsMatch[1]); + state.findingTaskId = id; + return json(route, { + items: + id === "task-retried" + ? [] + : [finding(id, state.findingAcknowledged)], + limit: 25, + offset: 0, + total: id === "task-retried" ? 0 : 1, + }); + } + if ( + method === "POST" && + path === "/admin/storage-integrity/results/finding-1/acknowledge" + ) { + state.acknowledgedResultId = "finding-1"; + state.findingAcknowledged = true; + return json(route, finding(state.findingTaskId ?? "task-workspace", true)); + } + const retryMatch = path.match(/^\/admin\/storage-integrity\/tasks\/([^/]+)\/retry$/); + if (method === "POST" && retryMatch) { + state.retrySourceTaskId = decodeURIComponent(retryMatch[1]); + return json(route, task("task-retried", "queued", true)); + } + if (method === "GET" && path === "/admin/storage-integrity/targets") { + return json(route, { items: [fileNode()], limit: 20, offset: 0, total: 1 }); + } + if (method === "GET" && path === "/admin/storage-integrity/targets/node-1/versions") { + return json(route, [fileVersion()]); + } + return json(route, { message: `Unhandled ${method} ${path}` }, 404); + }); + return state; +} + +async function json(route: Route, body: unknown, status = 200) { + await route.fulfill({ + body: JSON.stringify(body), + headers: { ...corsHeaders, "Content-Type": "application/json" }, + status, + }); +} + +function task( + id: string, + status: "queued" | "running" | "completed", + workspace = false, +) { + const terminal = status === "completed"; + return { + config: { bandwidthLimitBytesPerSecond: null, batchSize: 100, concurrency: 2, maxAttempts: 3 }, + createdAt: now, + failureCode: null, + failureMessage: null, + finishedAt: terminal ? now : null, + id, + mode: "verify", + progress: { bytesRead: 2_048, failed: 0, matched: 8, mismatches: 2, processed: 10, total: 10 }, + scope: workspace ? "workspace" : "all", + startedAt: now, + status, + target: null, + workspaceId: workspace ? workspaceId : null, + }; +} + +function finding(taskId: string, acknowledged = false) { + return { + acknowledgedAt: acknowledged ? acknowledgedAt : null, + acknowledgedBy: acknowledged ? "admin-user" : null, + actualHash: "sha256:actual", + actualSizeBytes: 2_048, + attempts: 2, + checkedAt: now, + errorCode: null, + errorMessage: null, + expectedHash: "sha256:expected", + expectedSizeBytes: 1_024, + id: "finding-1", + nodeId: "node-1", + objectKey: "objects/archive.bin", + status: "mismatch", + taskId, + versionId: "version-2", + workspaceId, + }; +} + +function fileNode() { + return { + archivedAt: null, + archivedBy: null, + createdAt: now, + hasContent: true, + id: "node-1", + integrityStatus: "unknown", + kind: "other", + lastVerifiedAt: null, + mimeType: "application/octet-stream", + name: "archive.bin", + originalParentNodeId: null, + originalPath: null, + owner: "Operator", + ownerUserId: "admin-user", + parentNodeId: null, + path: "/archive.bin", + previewCapability: { downloadOnly: true, maxPreviewBytes: null, reason: "unknown-type", renderMode: "download-only", sanitized: false, supported: false }, + sizeBytes: 2_048, + spaceScope: "workspace", + starred: false, + updatedAt: now, + workspaceId, + }; +} + +function fileVersion() { + return { createdAt: now, id: "version-2", integrityStatus: "unknown", lastVerifiedAt: null, mimeType: "application/octet-stream", nodeId: "node-1", remark: "release", sizeBytes: 2_048, uploadedBy: "admin-user", versionNumber: 2 }; +} + +function adminUser() { + return { avatarUrl: null, createdAt: now, displayName: "Integrity Admin", email: "admin@example.com", id: "admin-user", locale: "en", role: "admin", theme: "light", timezone: "UTC" }; +} + +function workspace() { + return { createdAt: now, id: workspaceId, memberCount: 3, name: "Alpha", rootNodeId: "root-alpha", updatedAt: now }; +} From 4d6ccfb0e24efade02a2d5cdf7122528dd81d860 Mon Sep 17 00:00:00 2001 From: Cloudwhile <86512509+Cloudwhile@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:51:25 +0800 Subject: [PATCH 4/6] =?UTF-8?q?refactor(ui):=20=E4=BD=BF=E7=94=A8=20Reicon?= =?UTF-8?q?=20=E7=BB=9F=E4=B8=80=E5=BA=94=E7=94=A8=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package.json | 4 +- frontend/src/components/ui/app-icon.tsx | 88 +++++++++++++------------ pnpm-lock.yaml | 24 +++---- 3 files changed, 61 insertions(+), 55 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index c4ea61b..1de9101 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,11 +20,11 @@ "echarts": "^6.1.0", "framer-motion": "^12.23.24", "ldrs": "^1.1.9", - "lucide-react": "^1.17.0", "mammoth": "^1.12.0", "marked": "^18.0.4", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "reicon-react": "1.2.5" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/frontend/src/components/ui/app-icon.tsx b/frontend/src/components/ui/app-icon.tsx index a37cfe5..f7e650f 100644 --- a/frontend/src/components/ui/app-icon.tsx +++ b/frontend/src/components/ui/app-icon.tsx @@ -2,10 +2,9 @@ import { useState, type CSSProperties, type ReactNode } from "react"; import { - ALargeSmall, + AlertTriangle, AtSign, Ban, - ArrowUpDown, Bell, Calendar, Check, @@ -13,55 +12,56 @@ import { ChevronLeft, ChevronRight, ChevronUp, - ClipboardPaste, - CircleStop, - CircleUserRound, + CircleInfo, + ClipboardImport, Clock, Copy, Download, + Earth, + Envelope, Eye, - Expand, File, FileText, Folder, - Globe, - Grid3x3, + Grid, House, Image, Import, - Info, Key, Laptop, - Link2, + Link, Lock, - Mail, + Maximize, Menu, Minus, Moon, - MoreHorizontal, + MoreH, Pause, - Pencil, + Pen, Play, Plus, - RefreshCw, + Refresh, Save, + Scissors, Search, Settings, - Share2, + Share, Shield, - Scissors, - SlidersHorizontal, + Sliders, + Sort, Star, + StopCircle, Sun, + Text, Timer, - Trash2, - TriangleAlert, + Trash, Upload, UserCheck, + UserCircle, Users, X, - type LucideIcon, -} from "lucide-react"; + type IconComponent, +} from "reicon-react"; import type { DriveItem, LocalIconName, Palette } from "@/features/file/model"; import { getItemExtensionIconName, getItemKind, itemColor, kindIcons } from "@/features/file/model"; @@ -76,8 +76,8 @@ export type LocalIconProps = { const unavailableExtensionIconNames = new Set(); -const iconByName: Record = { - abc: ALargeSmall, +const iconByName: Record = { + abc: Text, arrow_down: ChevronDown, arrow_left: ChevronLeft, arrow_right: ChevronRight, @@ -91,50 +91,50 @@ const iconByName: Record = { dark_mode: Moon, document: FileText, download: Download, - earth: Globe, - exclamation: TriangleAlert, + earth: Earth, + exclamation: AlertTriangle, file: File, folder: Folder, - grid: Grid3x3, + grid: Grid, house: House, image: Image, import: Import, - info: Info, + info: CircleInfo, key: Key, laptop: Laptop, - link: Link2, + link: Link, lock: Lock, - mail: Mail, + mail: Envelope, mention: AtSign, menu: Menu, - menu7: MoreHorizontal, + menu7: MoreH, minus: Minus, notification: Bell, pause: Pause, - paste: ClipboardPaste, - pencil: Pencil, + paste: ClipboardImport, + pencil: Pen, play: Play, plus: Plus, - refresh: RefreshCw, + refresh: Refresh, save: Save, search: Search, settings: Settings, - share2: Share2, + share2: Share, shield: Shield, - slider: SlidersHorizontal, - sort: ArrowUpDown, + slider: Sliders, + sort: Sort, star: Star, - stop: CircleStop, + stop: StopCircle, sun: Sun, tick: Check, time: Timer, - trash: Trash2, + trash: Trash, upload: Upload, user_check: UserCheck, user_group: Users, - user_avatar: CircleUserRound, + user_avatar: UserCircle, visible: Eye, - expand: Expand, + expand: Maximize, }; export function LocalIcon({ @@ -164,7 +164,13 @@ export function LocalIcon({ ...style, }} > -
); } diff --git a/frontend/src/views/drive/drive-files.test.tsx b/frontend/src/views/drive/drive-files.test.tsx index 1b189e9..403a139 100644 --- a/frontend/src/views/drive/drive-files.test.tsx +++ b/frontend/src/views/drive/drive-files.test.tsx @@ -32,6 +32,19 @@ const items: DriveItem[] = ["report.txt", "notes.txt", "brief.txt"].map((name, i const gridItems = [...items, ...items.map((item, index) => ({ ...item, id: `file-${index + 4}`, name: `copy-${item.name}` }))]; describe("FilesModule", () => { + it.each([ + [null, "files.emptyRootTitle"], + ["folder-empty", "files.emptyFolderTitle"], + ] as const)("renders a quiet empty state at %s without action buttons or an icon", (currentFolderId, title) => { + const { container } = render( + , + ); + + expect(screen.getByRole("status")).toHaveTextContent(title); + expect(container.querySelector(".drive-empty-state-actions")).not.toBeInTheDocument(); + expect(container.querySelector(".drive-empty-state-icon")).not.toBeInTheDocument(); + }); + it("leaves multi-selection actions to the workspace toolbar", () => { const { container } = render( { }); }); -function KeyboardFilesHarness({ fileItems = items, viewMode }: { fileItems?: DriveItem[]; viewMode: "grid" | "list" }) { +function KeyboardFilesHarness({ + currentFolderId = null, + fileItems = items, + viewMode, +}: { + currentFolderId?: string | null; + fileItems?: DriveItem[]; + viewMode: "grid" | "list"; +}) { const [selected, setSelected] = useState([]); return ( item.value === "new-folder" && !item.disabled && item.onClick); - const uploadItem = createMenuItems.find((item) => item.value === "upload" && !item.disabled && item.onClick); const blankNavigationItems = [ currentFolderId ? { icon: , label: t("files.parentDirectory"), onClick: onBlankGoUp, value: "go-up" } : null, currentFolderId ? { icon: , label: t("actions.goRoot"), onClick: onBlankGoRoot, value: "go-root" } : null, @@ -271,22 +269,11 @@ export function FilesModule({ ...(onClearSearch ? [{ icon: "cross" as const, label: t("app.searchClear"), onClick: onClearSearch }] : []), { icon: "refresh", label: t("app.refresh"), onClick: onRetrySearch ?? onBlankRefresh }, ] - : collectionState === "folder-empty" - ? [ - { icon: "arrow_up", label: t("files.parentDirectory"), onClick: onBlankGoUp }, - { icon: "house", label: t("actions.goRoot"), onClick: onBlankGoRoot }, - ...(createFolderItem?.onClick ? [{ icon: "folder" as const, label: t("actions.newFolder"), onClick: createFolderItem.onClick }] : []), - ...(uploadItem?.onClick ? [{ icon: "upload" as const, label: t("app.upload"), onClick: uploadItem.onClick }] : []), - ] - : collectionState === "root-empty" - ? [ - ...(createFolderItem?.onClick ? [{ icon: "folder" as const, label: t("actions.newFolder"), onClick: createFolderItem.onClick }] : []), - ...(uploadItem?.onClick ? [{ icon: "upload" as const, label: t("app.upload"), onClick: uploadItem.onClick }] : []), - { icon: "refresh", label: t("app.refresh"), onClick: onBlankRefresh }, - ] - : collectionState === "trash-empty" || collectionState === "collection-empty" - ? [{ icon: "refresh", label: t("app.refresh"), onClick: onBlankRefresh }] - : []; + : collectionState === "folder-empty" || collectionState === "root-empty" + ? [] + : collectionState === "trash-empty" || collectionState === "collection-empty" + ? [{ icon: "refresh", label: t("app.refresh"), onClick: onBlankRefresh }] + : []; return ( diff --git a/frontend/src/views/drive/drive-layout.test.tsx b/frontend/src/views/drive/drive-layout.test.tsx new file mode 100644 index 0000000..74cd0c4 --- /dev/null +++ b/frontend/src/views/drive/drive-layout.test.tsx @@ -0,0 +1,86 @@ +import { cleanup, render, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { palettes } from "@/features/file/model"; +import { WorkspaceBar, type WorkspaceBarProps } from "./drive-layout"; + +vi.mock("@/i18n/react", () => ({ + useLocale: () => "en_US", + useTranslations: () => (key: string) => key, +})); + +afterEach(cleanup); + +const noop = () => undefined; + +function renderWorkspaceBar(overrides: Partial = {}) { + return render( + , + ); +} + +describe("WorkspaceBar", () => { + it("does not render the three file actions without an action target", () => { + const { container } = renderWorkspaceBar(); + + expect( + container.querySelector(".drive-toolbar-action-group"), + ).not.toBeInTheDocument(); + }); + + it("uses restore actions for a selected trash item", () => { + const { container } = renderWorkspaceBar({ + activeNav: "trash", + hasActionTarget: true, + selectionCount: 1, + selectionMenuItems: [ + { label: "actions.restore", onClick: noop, value: "restore" }, + { + label: "actions.deletePermanently", + onClick: noop, + value: "delete", + }, + ], + }); + const desktopActions = container.querySelector( + ".drive-workspace-tools-desktop .drive-toolbar-action-group", + ); + + expect(desktopActions).not.toBeNull(); + expect( + within(desktopActions as HTMLElement).getByRole("button", { + name: "actions.restore", + }), + ).toBeInTheDocument(); + expect( + within(desktopActions as HTMLElement).getByRole("button", { + name: "actions.deletePermanently", + }), + ).toBeInTheDocument(); + expect( + within(desktopActions as HTMLElement).queryByRole("button", { + name: "actions.share", + }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/views/drive/drive-layout.tsx b/frontend/src/views/drive/drive-layout.tsx index be2c013..c7dc368 100644 --- a/frontend/src/views/drive/drive-layout.tsx +++ b/frontend/src/views/drive/drive-layout.tsx @@ -558,7 +558,9 @@ function SpaceScopeSelector({ onClick={() => setOpen((value) => !value)} > - + + + {activeLabel} {activeMeta} @@ -763,32 +765,47 @@ export function WorkspaceBar({ ) : null} {isFileListView ? ( <> -
- - - - - - - - - -
+ {hasActionTarget ? ( +
+ {activeNav === "trash" ? ( + <> + + + + + + + + ) : ( + <> + + + + + + + + )} + + + +
+ ) : null}
diff --git a/frontend/src/views/drive/styles/sidebar.css b/frontend/src/views/drive/styles/sidebar.css index 9031254..c883604 100644 --- a/frontend/src/views/drive/styles/sidebar.css +++ b/frontend/src/views/drive/styles/sidebar.css @@ -69,7 +69,7 @@ min-width: 0; } -.drive-space-trigger-main > span:first-child { +.drive-space-trigger-icon { align-items: center; background: linear-gradient( 180deg, @@ -78,8 +78,10 @@ ); border: 1px solid color-mix(in srgb, var(--drive-ui-blue-600) 18%, var(--drive-ui-line)); border-radius: var(--drive-ui-radius); + box-sizing: border-box; color: var(--drive-ui-blue-600); display: inline-flex; + flex: 0 0 34px; height: 34px; justify-content: center; box-shadow: inset 0 1px 0 color-mix(in srgb, var(--drive-ui-surface) 78%, transparent), 0 8px 14px color-mix(in srgb, var(--drive-ui-blue-600) 12%, transparent); diff --git a/frontend/tests/e2e/drive-responsive.spec.ts b/frontend/tests/e2e/drive-responsive.spec.ts index 48e2618..882d803 100644 --- a/frontend/tests/e2e/drive-responsive.spec.ts +++ b/frontend/tests/e2e/drive-responsive.spec.ts @@ -98,7 +98,7 @@ test.describe("responsive Drive workspace", () => { await expect(reportRow).toBeFocused(); }); - test("provides contextual root, folder, search, and trash empty-state actions", async ({ page }) => { + test("keeps root and folder empty states quiet while retaining recovery actions", async ({ page }) => { await page.setViewportSize({ width: 1024, height: 768 }); const api = await mockDriveApi(page, { activeNodes: [] }); await seedAuthenticatedDrive(page); @@ -106,14 +106,22 @@ test.describe("responsive Drive workspace", () => { await page.goto("/"); let emptyState = page.locator('.drive-empty-state[data-state="root-empty"]'); await expect(emptyState).toContainText("This space is empty"); - await expectEmptyStateActions(emptyState, ["New folder", "Upload", "Refresh"]); + await expect(emptyState.locator(".drive-empty-state-icon")).toHaveCount(0); + await expect(emptyState.locator(".drive-empty-state-actions")).toHaveCount(0); + + const spaceIcon = page.locator(".drive-space-trigger-icon"); + await expect(spaceIcon).toHaveCSS("height", "34px"); + await expect(spaceIcon).toHaveCSS("width", "34px"); + await expect(spaceIcon.locator("svg")).toHaveAttribute("height", "17"); + await expect(spaceIcon.locator("svg")).toHaveAttribute("width", "17"); api.activeNodes = [folderNode("folder-empty", "Empty Folder", null)]; await page.reload(); await page.getByRole("button", { name: "Empty Folder", exact: true }).click(); emptyState = page.locator('.drive-empty-state[data-state="folder-empty"]'); await expect(emptyState).toContainText("This folder is empty"); - await expectEmptyStateActions(emptyState, ["Up one level", "Go home", "New folder", "Upload"]); + await expect(emptyState.locator(".drive-empty-state-icon")).toHaveCount(0); + await expect(emptyState.locator(".drive-empty-state-actions")).toHaveCount(0); api.activeNodes = [reportFile()]; api.searchItems = []; @@ -220,9 +228,10 @@ test.describe("responsive Drive workspace", () => { const emptyState = page.locator('.drive-empty-state[data-state="root-empty"]'); const hud = page.getByRole("button", { name: "Transfers" }); const toolbar = page.locator(".drive-mobile-workspace-tools:visible"); + await expect(emptyState).toBeVisible(); await expect(hud).toBeVisible(); await expect(toolbar).toBeVisible(); - await emptyState.getByRole("button", { name: "Refresh" }).click(); + await page.locator(".drive-header").getByRole("button", { name: "Refresh" }).click(); const notification = page.locator(".workspace-notification:visible"); await expect(notification).toContainText("Workspace refreshed"); From 9742cf4a8233facb5577c62fb73601e1a562ee5d Mon Sep 17 00:00:00 2001 From: Cloudwhile <86512509+Cloudwhile@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:03:08 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(drive):=20=E6=81=A2=E5=A4=8D=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=AB=AF=E5=88=B7=E6=96=B0=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在移动工具栏接入工作区刷新状态,并让响应式 E2E 使用真实可见入口,修复 PR #60 的 CI 超时。 --- frontend/src/views/drive/drive-layout.test.tsx | 16 ++++++++++++++++ frontend/src/views/drive/drive-layout.tsx | 7 +++++++ frontend/src/views/drive/drive-workbench.tsx | 2 ++ frontend/tests/e2e/drive-responsive.spec.ts | 2 +- 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/drive/drive-layout.test.tsx b/frontend/src/views/drive/drive-layout.test.tsx index 74cd0c4..0e0783b 100644 --- a/frontend/src/views/drive/drive-layout.test.tsx +++ b/frontend/src/views/drive/drive-layout.test.tsx @@ -24,10 +24,12 @@ function renderWorkspaceBar(overrides: Partial = {}) { onDownloadSelection={noop} onNavigateFolder={noop} onNavigateRoot={noop} + onRefresh={noop} onShareSelection={noop} onToggleFilters={noop} onTriggerUpload={noop} palette={palettes.light} + refreshing={false} rootLabel="Drive" selectionCount={0} selectionMenuItems={[]} @@ -48,6 +50,20 @@ describe("WorkspaceBar", () => { ).not.toBeInTheDocument(); }); + it("keeps the mobile refresh action available and disables it while refreshing", () => { + const { container } = renderWorkspaceBar({ refreshing: true }); + const mobileToolbar = container.querySelector( + ".drive-mobile-workspace-tools", + ); + + expect(mobileToolbar).not.toBeNull(); + expect( + within(mobileToolbar as HTMLElement).getByRole("button", { + name: "app.refresh", + }), + ).toBeDisabled(); + }); + it("uses restore actions for a selected trash item", () => { const { container } = renderWorkspaceBar({ activeNav: "trash", diff --git a/frontend/src/views/drive/drive-layout.tsx b/frontend/src/views/drive/drive-layout.tsx index c7dc368..d0bbb41 100644 --- a/frontend/src/views/drive/drive-layout.tsx +++ b/frontend/src/views/drive/drive-layout.tsx @@ -656,10 +656,12 @@ export type WorkspaceBarProps = { onClearSelection: () => void; onNavigateFolder: (id: string) => void; onNavigateRoot: () => void; + onRefresh: () => void; onShareSelection: () => void; onToggleFilters: () => void; onTriggerUpload: () => void; palette: Palette; + refreshing: boolean; rootLabel: string; selectionCount: number; selectionMenuItems: AppMenuItem[]; @@ -678,10 +680,12 @@ export function WorkspaceBar({ onDownloadSelection, onNavigateFolder, onNavigateRoot, + onRefresh, onShareSelection, onToggleFilters, onTriggerUpload, palette, + refreshing, rootLabel, selectionCount, selectionMenuItems, @@ -895,6 +899,9 @@ export function WorkspaceBar({ ) : (
+ + + {isPathView ? ( <> diff --git a/frontend/src/views/drive/drive-workbench.tsx b/frontend/src/views/drive/drive-workbench.tsx index 114dd6a..363cb51 100644 --- a/frontend/src/views/drive/drive-workbench.tsx +++ b/frontend/src/views/drive/drive-workbench.tsx @@ -902,10 +902,12 @@ export function DriveWorkbench({ onDownloadSelection={() => downloadItems(toolbarActionTargets)} onNavigateFolder={navigateFolderPath} onNavigateRoot={openRoot} + onRefresh={refreshWorkspace} onShareSelection={() => shareItems(toolbarActionTargets)} onToggleFilters={toggleFilters} onTriggerUpload={triggerUpload} palette={palette} + refreshing={workspaceRefreshing} rootLabel={currentSpaceRootLabel} selectionCount={selectedItems.length} selectionMenuItems={toolbarSelectionMenuItems} diff --git a/frontend/tests/e2e/drive-responsive.spec.ts b/frontend/tests/e2e/drive-responsive.spec.ts index 882d803..4411728 100644 --- a/frontend/tests/e2e/drive-responsive.spec.ts +++ b/frontend/tests/e2e/drive-responsive.spec.ts @@ -231,7 +231,7 @@ test.describe("responsive Drive workspace", () => { await expect(emptyState).toBeVisible(); await expect(hud).toBeVisible(); await expect(toolbar).toBeVisible(); - await page.locator(".drive-header").getByRole("button", { name: "Refresh" }).click(); + await toolbar.getByRole("button", { name: "Refresh" }).click(); const notification = page.locator(".workspace-notification:visible"); await expect(notification).toContainText("Workspace refreshed");