Sales-process coverage (launch-readiness Initiative 4): - exhaustive: full 7-stage sales journey + illegal-skip rejection + deposit total + tenancy/berth-sold; multi-berth EOI berth-range; EOI pathway parity (in-app vs Documenso, shared EoiContext); mobile-viewport journey. - realapi (Documenso-gated, opt-in): generate-and-sign + post-EOI stages. - integration: Documenso DOCUMENT_COMPLETED webhook idempotency (3x replay -> single file/audit write); storage backend swap (s3 <-> filesystem) with a real on-disk filesystem round-trip. - visual: Reports UI snapshot cases (baselines captured separately). 1615 unit/integration pass; tsc + lint clean. Test-only change (specs are not bundled into the app image) - no app behavior modified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
252 lines
10 KiB
TypeScript
252 lines
10 KiB
TypeScript
/**
|
|
* 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<StorageBackendName, Map<string, Buffer>> = {
|
|
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<typeof import('@/lib/storage/filesystem')>();
|
|
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<void> {
|
|
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<typeof import('@/lib/storage/filesystem')>(
|
|
'@/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 });
|
|
}
|
|
});
|
|
});
|