/** * Initiative 4 (e2e testing) — storage backend swap. * * Exercises switching the deployment's storage backend between `s3` and * `filesystem` through the canonical `getStorageBackend()` abstraction * (`src/lib/storage/index.ts`), driven by the global * `system_settings.storage_backend` row (CLAUDE.md "Storage" section). * * Two concerns are covered: * * 1. Factory selection + cache invalidation — flipping the global setting * between `filesystem` and `s3` makes `getStorageBackend()` resolve the * matching backend, and the per-process fingerprint cache re-resolves on * change (never returns a stale backend). The two concrete backends are * mocked at the `S3Backend.create` / `FilesystemBackend.create` boundary * so no MinIO/network is required — we're testing the *swap wiring*, not * the S3 wire protocol (the remote MinIO endpoint is non-deterministic in * this env). * * 2. Blob durability across a swap — a blob written under one backend stays * resolvable from that backend after the global setting flips and back, * mirroring a real migration window where the old store still answers * reads. A second block round-trips a real on-disk blob through the * genuine `FilesystemBackend` so the "written under one, resolvable" * guarantee is proven against real I/O, not just the in-memory fake. * * Per the abstraction contract we NEVER import the S3 SDK directly. */ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import * as path from 'node:path'; import { Readable } from 'node:stream'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { and, eq, isNull } from 'drizzle-orm'; import type { PresignOpts, PutOpts, StorageBackend, StorageBackendName } from '@/lib/storage'; // ── In-memory fake backends, tagged by name. The factory under test picks // one based on the global `storage_backend` setting; we record which // `create` ran and keep the bytes so a post-swap read still resolves. ── const blobsByBackend: Record> = { s3: new Map(), filesystem: new Map(), }; const createCalls: StorageBackendName[] = []; function makeFake(name: StorageBackendName): StorageBackend { const store = blobsByBackend[name]; return { name, async put(key: string, body: Buffer | NodeJS.ReadableStream, _opts: PutOpts) { const buf = Buffer.isBuffer(body) ? body : Buffer.alloc(0); store.set(key, buf); return { key, sizeBytes: buf.length, sha256: 'fake'.padEnd(64, '0') }; }, async get(key: string) { const buf = store.get(key); if (!buf) throw new Error(`not found: ${key}`); return Readable.from([buf]); }, async head(key: string) { const buf = store.get(key); return buf ? { sizeBytes: buf.length, contentType: 'application/octet-stream' } : null; }, async delete(key: string) { store.delete(key); }, async presignUpload(_key: string, _opts: PresignOpts) { return { url: `mem://${name}/upload`, method: 'PUT' as const }; }, async presignDownload(_key: string, _opts: PresignOpts) { return { url: `mem://${name}/download`, expiresAt: new Date(Date.now() + 1000) }; }, async listByPrefix(prefix: string) { return [...store.keys()].filter((k) => k.startsWith(prefix)); }, }; } // Replace the two concrete backend constructors with fakes. The real // `getStorageBackend()` factory (selection + fingerprint cache) is left // untouched and is the actual subject under test. vi.mock('@/lib/storage/s3', () => ({ S3Backend: { create: vi.fn(async () => { createCalls.push('s3'); return makeFake('s3'); }), }, })); vi.mock('@/lib/storage/filesystem', async (importOriginal) => { // Keep the real path-validation + HMAC exports intact; only override the // backend constructor so the swap test stays network/disk-free here. The // genuine FilesystemBackend is exercised separately below via a direct // import in its own describe block (different module instance). const real = await importOriginal(); return { ...real, FilesystemBackend: { create: vi.fn(async () => { createCalls.push('filesystem'); return makeFake('filesystem'); }), }, }; }); // Imported AFTER the mocks so the factory binds to the fakes. const { getStorageBackend, resetStorageBackendCache } = await import('@/lib/storage'); const { db } = await import('@/lib/db'); const { systemSettings } = await import('@/lib/db/schema/system'); const STORAGE_KEYS = ['storage_backend', 'storage_filesystem_root'] as const; /** Snapshot of the pre-existing global storage rows so we can restore them. */ let snapshot: { key: string; value: unknown }[] = []; async function setGlobal(key: string, value: unknown): Promise { await db .delete(systemSettings) .where(and(eq(systemSettings.key, key), isNull(systemSettings.portId))); await db.insert(systemSettings).values({ key, value, portId: null }); // The settings-write hook normally calls this; do it explicitly so the // factory re-resolves on the next getStorageBackend(). resetStorageBackendCache(); } beforeAll(async () => { snapshot = await db .select({ key: systemSettings.key, value: systemSettings.value }) .from(systemSettings) .where(and(isNull(systemSettings.portId))); snapshot = snapshot.filter((r) => (STORAGE_KEYS as readonly string[]).includes(r.key)); }); afterEach(() => { vi.clearAllMocks(); createCalls.length = 0; }); afterAll(async () => { // Restore the global storage settings exactly as we found them. for (const key of STORAGE_KEYS) { await db .delete(systemSettings) .where(and(eq(systemSettings.key, key), isNull(systemSettings.portId))); const original = snapshot.find((r) => r.key === key); if (original) { await db.insert(systemSettings).values({ key, value: original.value, portId: null }); } } resetStorageBackendCache(); blobsByBackend.s3.clear(); blobsByBackend.filesystem.clear(); }); describe('getStorageBackend · backend swap selection', () => { it('resolves filesystem then s3 as the global setting flips', async () => { await setGlobal('storage_backend', 'filesystem'); const fs = await getStorageBackend(); expect(fs.name).toBe('filesystem'); await setGlobal('storage_backend', 's3'); const s3 = await getStorageBackend(); expect(s3.name).toBe('s3'); // Both concrete constructors ran exactly once across the two resolutions. expect(createCalls.filter((c) => c === 'filesystem')).toHaveLength(1); expect(createCalls.filter((c) => c === 's3')).toHaveLength(1); }); it('any non-"filesystem" value falls back to s3 (factory default)', async () => { await setGlobal('storage_backend', 'totally-unknown-value'); const backend = await getStorageBackend(); expect(backend.name).toBe('s3'); }); it('caches within a config and re-resolves only after a change', async () => { await setGlobal('storage_backend', 'filesystem'); await getStorageBackend(); await getStorageBackend(); await getStorageBackend(); // Cache hit: the constructor ran once despite three resolutions. expect(createCalls.filter((c) => c === 'filesystem')).toHaveLength(1); // Changing the setting (here: the fs root) invalidates the fingerprint. await setGlobal('storage_filesystem_root', '/tmp/pn-swap-test-root'); await getStorageBackend(); expect(createCalls.filter((c) => c === 'filesystem')).toHaveLength(2); }); it('a blob written under one backend stays resolvable after a swap and back', async () => { // Write under filesystem. await setGlobal('storage_backend', 'filesystem'); const fsBackend = await getStorageBackend(); const key = 'test-port/swap/blob.bin'; const payload = Buffer.from('swap-durability-payload'); await fsBackend.put(key, payload, { contentType: 'application/octet-stream' }); // Swap to s3 — the filesystem store still holds the bytes (old store keeps // answering reads during a migration window). await setGlobal('storage_backend', 's3'); const s3Backend = await getStorageBackend(); expect(s3Backend.name).toBe('s3'); expect(blobsByBackend.s3.has(key)).toBe(false); // not migrated by a mere flip // Swap back to filesystem and confirm the original blob round-trips. await setGlobal('storage_backend', 'filesystem'); const fsAgain = await getStorageBackend(); const stream = await fsAgain.get(key); const chunks: Buffer[] = []; for await (const c of stream) chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c as string)); expect(Buffer.concat(chunks).equals(payload)).toBe(true); }); }); describe('FilesystemBackend · real on-disk round-trip (abstraction contract)', () => { it('writes and resolves a real blob via the genuine filesystem backend', async () => { // Use the REAL FilesystemBackend directly (separate module instance from // the mocked one above — vi.mock is scoped to the factory's import graph). // EMAIL_CREDENTIAL_KEY / BETTER_AUTH_SECRET are required by the key // validator at module load; the vitest env provides them. const { FilesystemBackend } = await vi.importActual( '@/lib/storage/filesystem', ); const root = await mkdtemp(path.join(tmpdir(), 'pn-swap-fs-')); try { const backend = await FilesystemBackend.create({ root, proxyHmacSecretEncrypted: null }); expect(backend.name).toBe('filesystem'); const key = 'sub/dir/durable.bin'; const payload = Buffer.from('real-on-disk-bytes'); const put = await backend.put(key, payload, { contentType: 'application/octet-stream' }); expect(put.sizeBytes).toBe(payload.length); expect(put.sha256).toMatch(/^[0-9a-f]{64}$/); const head = await backend.head(key); expect(head?.sizeBytes).toBe(payload.length); const stream = await backend.get(key); const chunks: Buffer[] = []; for await (const c of stream) chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c as string)); expect(Buffer.concat(chunks).equals(payload)).toBe(true); } finally { await rm(root, { recursive: true, force: true }); } }); });