mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
528 lines
22 KiB
TypeScript
528 lines
22 KiB
TypeScript
import { db as testDb } from '../../../../src/db/database';
|
|
import { AppSettings } from '../../../../src/db/entities/AppSettings.entity';
|
|
import { encrypt_api_key } from '../../../../src/nest/common/crypto/apiKeyCrypto';
|
|
import { StorageAdminService } from '../../../../src/nest/storage/storage-admin.service';
|
|
import { StorageEventsService } from '../../../../src/nest/storage/storage-events.service';
|
|
import { StorageJobsService } from '../../../../src/nest/storage/storage-jobs.service';
|
|
import {
|
|
StorageRegistryService,
|
|
BACKENDS_KEY,
|
|
CATEGORIES_KEY,
|
|
} from '../../../../src/nest/storage/storage-registry.service';
|
|
import { StorageStatsService } from '../../../../src/nest/storage/storage-stats.service';
|
|
import { StorageService } from '../../../../src/nest/storage/storage.service';
|
|
import { StorageConflictError } from '../../../../src/nest/storage/storage.types';
|
|
import { deleteRows } from '../../../helpers/factories/rows';
|
|
import { readAppSetting, setAppSetting } from '../../../helpers/factories/settings';
|
|
import { createTestUnitOfWork, createTestAppSettingsRepo, sharedTestOrm } from '../../../helpers/test-uow';
|
|
import { Logger } from '@nestjs/common';
|
|
import { MASKED_SETTING_VALUE, type StorageConfig } from '@trek/shared';
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
|
|
|
|
vi.mock('../../../../src/db/database', async () => {
|
|
const { createSnapshotTestDb } = await import('../../../helpers/db-mock');
|
|
const db = createSnapshotTestDb();
|
|
return { db, closeDb: () => {}, reinitialize: () => {} };
|
|
});
|
|
|
|
const tmpDirs: string[] = [];
|
|
function makeTmpDir(): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-storage-admin-'));
|
|
tmpDirs.push(dir);
|
|
return dir;
|
|
}
|
|
async function setSetting(key: string, value: string): Promise<void> {
|
|
await setAppSetting(await sharedTestOrm(testDb), key, value);
|
|
}
|
|
async function readRow(key: string): Promise<string | undefined> {
|
|
return (await readAppSetting(await sharedTestOrm(testDb), key)) ?? undefined;
|
|
}
|
|
|
|
const S3_OPTIONS = {
|
|
endpoint: 'http://127.0.0.1:9000',
|
|
bucket: 'trek',
|
|
accessKeyId: 'ak',
|
|
secretAccessKey: 'sk-plain',
|
|
region: 'us-east-1',
|
|
keyPrefix: '',
|
|
retries: 1,
|
|
timeoutMs: 30000,
|
|
};
|
|
|
|
/** Real registry + real service over the in-memory DB. */
|
|
async function makeService(opts: { uploadsRoot?: string } = {}) {
|
|
const uploadsRoot = opts.uploadsRoot ?? makeTmpDir();
|
|
await setSetting(
|
|
BACKENDS_KEY,
|
|
JSON.stringify([{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } }]),
|
|
);
|
|
const env = { placePhotoDir: undefined };
|
|
const uow = await createTestUnitOfWork(testDb);
|
|
const appSettings = await createTestAppSettingsRepo(testDb);
|
|
const registry = new StorageRegistryService(
|
|
appSettings,
|
|
env,
|
|
new StorageEventsService(),
|
|
uow,
|
|
(await sharedTestOrm(testDb)).orm,
|
|
);
|
|
await registry.onModuleInit();
|
|
const storage = new StorageService(registry);
|
|
const jobs = new StorageJobsService(registry);
|
|
const stats = new StorageStatsService(storage, appSettings);
|
|
const service = new StorageAdminService(appSettings, registry, storage, jobs, stats, uow);
|
|
return { service, registry, uploadsRoot, stats, jobs, uow };
|
|
}
|
|
|
|
/** The settings-owned document the service persists (uploads override + extras). */
|
|
function configWith(uploadsRoot: string, extra: Partial<StorageConfig> = {}): StorageConfig {
|
|
return {
|
|
backends: [{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } }, ...(extra.backends ?? [])],
|
|
categories: extra.categories ?? {},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* applyConfig now requires the current version (optimistic concurrency,
|
|
* audit #7) — most tests just want "the current one", read fresh at call
|
|
* time so a second call in the same test picks up the bump from the first.
|
|
*/
|
|
async function put(service: StorageAdminService, config: StorageConfig): Promise<void> {
|
|
await service.applyConfig({ ...config, version: (await await service.state()).version });
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await deleteRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'storage.%' } });
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
while (tmpDirs.length) fs.rmSync(tmpDirs.pop()!, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('StorageAdminService.state', () => {
|
|
it('STORADM-001 renders the effective world: sources, categories-per-backend, flags', async () => {
|
|
const { service, uploadsRoot } = await makeService();
|
|
const state = await service.state();
|
|
const uploads = state.backends.find((b) => b.name === 'uploads-local')!;
|
|
expect(uploads).toMatchObject({ type: 'local', source: 'settings', options: { root: uploadsRoot } });
|
|
expect(uploads.categories).toContain('files');
|
|
expect(uploads.categories).not.toContain('backups');
|
|
expect(state.backends.find((b) => b.name === 'backups-local')).toMatchObject({ source: 'built-in' });
|
|
expect(state.categories.backups).toEqual({ backend: 'backups-local', source: 'default' });
|
|
expect(state.seedFilePresent).toBe(false);
|
|
expect(state.health).toEqual({ replicaFailures: [] });
|
|
});
|
|
|
|
it('STORADM-002 masks exactly the secret fields (accessKeyId stays visible)', async () => {
|
|
const { service, uploadsRoot } = await makeService();
|
|
await put(
|
|
service,
|
|
configWith(uploadsRoot, {
|
|
backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }],
|
|
categories: { backups: 'off-box' },
|
|
}),
|
|
);
|
|
const offBox = (await service.state()).backends.find((b) => b.name === 'off-box')!;
|
|
expect(offBox.options.secretAccessKey).toBe(MASKED_SETTING_VALUE);
|
|
expect(offBox.options.accessKeyId).toBe('ak');
|
|
});
|
|
|
|
it('STORADM-003 surfaces replica failures through StorageService.health()', async () => {
|
|
const { service, registry } = await makeService();
|
|
registry.recordReplicaFailure({ backend: 'nas', key: 'backup-1.zip', op: 'put', error: 'disk full', at: 123 });
|
|
expect((await service.state()).health.replicaFailures).toEqual([
|
|
{ backend: 'nas', key: 'backup-1.zip', op: 'put', error: 'disk full', at: 123 },
|
|
]);
|
|
});
|
|
|
|
it('STORADM-027 configError is null on a clean load, and mirrors registry.lastLoadError() after a bad reload', async () => {
|
|
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
const { service, registry } = await makeService();
|
|
expect((await service.state()).configError).toBeNull();
|
|
|
|
await setSetting(CATEGORIES_KEY, 'garbage {');
|
|
await registry.reload();
|
|
expect((await service.state()).configError).toBe(registry.lastLoadError());
|
|
expect((await service.state()).configError).not.toBeNull();
|
|
});
|
|
|
|
it('STORADM-026 state embeds usage (null until computed) and live backfill statuses', async () => {
|
|
const { service, stats } = await makeService();
|
|
expect((await service.state()).usage).toBeNull();
|
|
expect((await service.state()).backfills).toEqual([]);
|
|
await stats.scan();
|
|
expect((await service.state()).usage).not.toBeNull();
|
|
expect((await service.state()).usage!.computedAt).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('StorageAdminService.applyConfig', () => {
|
|
it('STORADM-010 happy path: persists both rows, reloads, new config is live', async () => {
|
|
const { service, registry, uploadsRoot } = await makeService();
|
|
const nasRoot = makeTmpDir();
|
|
await put(
|
|
service,
|
|
configWith(uploadsRoot, {
|
|
backends: [{ name: 'nas-backups', type: 'local', options: { root: nasRoot } }],
|
|
categories: { backups: 'nas-backups' },
|
|
}),
|
|
);
|
|
expect(registry.resolve('backups').backendName).toBe('nas-backups'); // reload() ran
|
|
expect(JSON.parse((await readRow(CATEGORIES_KEY))!)).toEqual({ backups: 'nas-backups' });
|
|
});
|
|
|
|
it('STORADM-011 encrypts plaintext secrets at rest', async () => {
|
|
const { service, uploadsRoot } = await makeService();
|
|
await put(
|
|
service,
|
|
configWith(uploadsRoot, {
|
|
backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }],
|
|
categories: { backups: 'off-box' },
|
|
}),
|
|
);
|
|
const stored = JSON.parse((await readRow(BACKENDS_KEY))!) as Array<{
|
|
name: string;
|
|
options: Record<string, unknown>;
|
|
}>;
|
|
const secret = String(stored.find((b) => b.name === 'off-box')!.options.secretAccessKey);
|
|
expect(secret.startsWith('enc:v1:')).toBe(true);
|
|
});
|
|
|
|
it('STORADM-012 mask echo preserves the stored ciphertext byte-for-byte', async () => {
|
|
const { service, uploadsRoot } = await makeService();
|
|
await put(
|
|
service,
|
|
configWith(uploadsRoot, {
|
|
backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }],
|
|
categories: { backups: 'off-box' },
|
|
}),
|
|
);
|
|
const before = JSON.parse((await readRow(BACKENDS_KEY))!) as Array<{
|
|
name: string;
|
|
options: Record<string, unknown>;
|
|
}>;
|
|
const cipherBefore = before.find((b) => b.name === 'off-box')!.options.secretAccessKey;
|
|
|
|
await put(
|
|
service,
|
|
configWith(uploadsRoot, {
|
|
backends: [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, secretAccessKey: MASKED_SETTING_VALUE } }],
|
|
categories: { backups: 'off-box' },
|
|
}),
|
|
);
|
|
const after = JSON.parse((await readRow(BACKENDS_KEY))!) as Array<{
|
|
name: string;
|
|
options: Record<string, unknown>;
|
|
}>;
|
|
expect(after.find((b) => b.name === 'off-box')!.options.secretAccessKey).toBe(cipherBefore);
|
|
});
|
|
|
|
it('STORADM-013 a mask on a renamed/new backend throws the re-enter error, persists nothing', async () => {
|
|
const { service, uploadsRoot } = await makeService();
|
|
const before = await readRow(BACKENDS_KEY);
|
|
await expect(
|
|
put(
|
|
service,
|
|
configWith(uploadsRoot, {
|
|
backends: [
|
|
{ name: 'brand-new', type: 's3', options: { ...S3_OPTIONS, secretAccessKey: MASKED_SETTING_VALUE } },
|
|
],
|
|
categories: {},
|
|
}),
|
|
),
|
|
).rejects.toThrow("re-enter the secret 'secretAccessKey' for 'brand-new'");
|
|
expect(await readRow(BACKENDS_KEY)).toBe(before);
|
|
});
|
|
|
|
it('STORADM-014 a plaintext secret saves without an explicit ENCRYPTION_KEY and is still encrypted at rest', async () => {
|
|
// No key-presence gate: the implicit key covers encryption when ENCRYPTION_KEY is unset.
|
|
const { service, uploadsRoot } = await makeService();
|
|
await put(
|
|
service,
|
|
configWith(uploadsRoot, {
|
|
backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }],
|
|
categories: {},
|
|
}),
|
|
);
|
|
const stored = JSON.parse((await readRow(BACKENDS_KEY))!) as Array<{
|
|
name: string;
|
|
options: Record<string, string>;
|
|
}>;
|
|
const offBox = stored.find((b) => b.name === 'off-box')!;
|
|
expect(offBox.options.secretAccessKey.startsWith('enc:v1:')).toBe(true);
|
|
});
|
|
|
|
it('STORADM-015 an encrypted (mask-echoed or enc:v1:) secret resaves fine', async () => {
|
|
// Resaving stored ciphertext must not lock admins out.
|
|
const { service, uploadsRoot } = await makeService();
|
|
await put(
|
|
service,
|
|
configWith(uploadsRoot, {
|
|
backends: [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, secretAccessKey: encrypt_api_key('sk') } }],
|
|
categories: { backups: 'off-box' },
|
|
}),
|
|
);
|
|
expect(await readRow(BACKENDS_KEY)).toBeDefined();
|
|
});
|
|
|
|
it('STORADM-016 preview() refusals surface verbatim and persist nothing', async () => {
|
|
const { service, uploadsRoot, registry } = await makeService();
|
|
const before = await readRow(CATEGORIES_KEY);
|
|
await expect(put(service, configWith(uploadsRoot, { categories: { backups: 'nope' } }))).rejects.toThrow(
|
|
"category 'backups' maps to unknown backend 'nope'",
|
|
);
|
|
expect(await readRow(CATEGORIES_KEY)).toBe(before);
|
|
expect(registry.resolve('backups').backendName).toBe('backups-local'); // live state untouched
|
|
});
|
|
|
|
it('STORADM-018 a mask sentinel in a NON-secret field throws and persists nothing', async () => {
|
|
const { service, uploadsRoot } = await makeService();
|
|
const before = await readRow(BACKENDS_KEY);
|
|
await expect(
|
|
put(
|
|
service,
|
|
configWith(uploadsRoot, {
|
|
backends: [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, accessKeyId: MASKED_SETTING_VALUE } }],
|
|
categories: {},
|
|
}),
|
|
),
|
|
).rejects.toThrow(
|
|
"backend 'off-box' field 'accessKeyId' is the mask sentinel — a mask can never become a stored value",
|
|
);
|
|
expect(await readRow(BACKENDS_KEY)).toBe(before);
|
|
});
|
|
|
|
it('STORADM-017 persists both rows in ONE transaction and reloads once', async () => {
|
|
const { service, registry, uploadsRoot, uow } = await makeService();
|
|
// The one transaction is now UnitOfWork.transactional, not db.transaction.
|
|
const txSpy = vi.spyOn(uow, 'transactional');
|
|
const reloadSpy = vi.spyOn(registry, 'reload');
|
|
await put(service, configWith(uploadsRoot));
|
|
expect(txSpy).toHaveBeenCalledTimes(1);
|
|
expect(reloadSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('STORADM-019 applyConfig invokes the dissolved-job cancellation after reload', async () => {
|
|
const { service, registry, uploadsRoot, jobs } = await makeService();
|
|
const reloadSpy = vi.spyOn(registry, 'reload');
|
|
const cancelSpy = vi.spyOn(jobs, 'cancelJobsForMissingBackends');
|
|
await put(service, configWith(uploadsRoot));
|
|
expect(cancelSpy).toHaveBeenCalledTimes(1);
|
|
expect(reloadSpy).toHaveBeenCalledTimes(1);
|
|
const reloadOrder = reloadSpy.mock.invocationCallOrder[0]!;
|
|
const cancelOrder = cancelSpy.mock.invocationCallOrder[0]!;
|
|
expect(cancelOrder).toBeGreaterThan(reloadOrder);
|
|
});
|
|
|
|
it('STORADM-030 a successful save bumps the version counter by exactly one', async () => {
|
|
const { service, uploadsRoot } = await makeService();
|
|
expect((await service.state()).version).toBe(0);
|
|
await put(service, configWith(uploadsRoot));
|
|
expect((await service.state()).version).toBe(1);
|
|
await put(service, configWith(uploadsRoot));
|
|
expect((await service.state()).version).toBe(2);
|
|
});
|
|
|
|
it('STORADM-031 a stale submitted version throws StorageConflictError and persists nothing (→ 409, audit #7)', async () => {
|
|
const { service, uploadsRoot } = await makeService();
|
|
await put(service, configWith(uploadsRoot)); // version is now 1
|
|
const beforeBackends = await readRow(BACKENDS_KEY);
|
|
const beforeCategories = await readRow(CATEGORIES_KEY);
|
|
await expect(
|
|
service.applyConfig({ ...configWith(uploadsRoot), version: 0 }), // stale — current is 1
|
|
).rejects.toThrow(StorageConflictError);
|
|
try {
|
|
await service.applyConfig({ ...configWith(uploadsRoot), version: 0 });
|
|
expect.unreachable('should have thrown');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(StorageConflictError);
|
|
expect((err as StorageConflictError).currentVersion).toBe(1);
|
|
expect((err as StorageConflictError).submittedVersion).toBe(0);
|
|
}
|
|
expect(await readRow(BACKENDS_KEY)).toBe(beforeBackends);
|
|
expect(await readRow(CATEGORIES_KEY)).toBe(beforeCategories);
|
|
expect((await service.state()).version).toBe(1); // unchanged — the conflicting save never wrote
|
|
});
|
|
|
|
it('STORADM-032 the version check runs before preview/unmask — a stale submit never reaches those refusals', async () => {
|
|
// If the version check ran AFTER preview, this would throw the registry's
|
|
// "unknown backend" StorageBackendError instead of StorageConflictError.
|
|
const { service, uploadsRoot } = await makeService();
|
|
await put(service, configWith(uploadsRoot)); // version is now 1
|
|
await expect(
|
|
service.applyConfig({ ...configWith(uploadsRoot, { categories: { backups: 'nope' } }), version: 0 }),
|
|
).rejects.toThrow(StorageConflictError);
|
|
});
|
|
|
|
it('STORADM-033 regression (audit #7): a migration flip bumps the version; a stale admin PUT built before the flip 409s and the flip survives untouched', async () => {
|
|
const { service, registry, uploadsRoot } = await makeService();
|
|
const destRoot = makeTmpDir();
|
|
// The admin loads the form: draft carries version 0, categories.files → uploads-local.
|
|
await put(
|
|
service,
|
|
configWith(uploadsRoot, {
|
|
backends: [{ name: 'dest', type: 'local', options: { root: destRoot } }],
|
|
categories: {},
|
|
}),
|
|
); // version is now 1; 'files' still defaults to uploads-local
|
|
const staleDraft = configWith(uploadsRoot, {
|
|
backends: [{ name: 'dest', type: 'local', options: { root: destRoot } }],
|
|
categories: {}, // the admin's draft never touched 'files'
|
|
});
|
|
const staleVersion = (await service.state()).version; // 1 — what the admin's form is holding
|
|
|
|
// Meanwhile, a category migration flips 'files' to 'dest' — the exact
|
|
// write path assignCategory uses, bumping the shared version counter.
|
|
await registry.assignCategory('files', 'dest');
|
|
expect(registry.snapshot().categories.files.backend).toBe('dest');
|
|
expect((await service.state()).version).toBe(2); // the flip bumped it past the admin's stale draft
|
|
|
|
// The admin's stale PUT (still holding version 1, and no opinion on
|
|
// 'files') must 409, NOT silently reassign 'files' back to its old default.
|
|
await expect(service.applyConfig({ ...staleDraft, version: staleVersion })).rejects.toThrow(StorageConflictError);
|
|
|
|
// The flip survives untouched.
|
|
expect(registry.snapshot().categories.files.backend).toBe('dest');
|
|
expect((await service.state()).version).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('StorageAdminService.testBackend', () => {
|
|
it('STORADM-020 probes a healthy local candidate: ok with one green target', async () => {
|
|
const { service } = await makeService();
|
|
const result = await service.testBackend({ name: 'cand', type: 'local', options: { root: makeTmpDir() } });
|
|
expect(result).toEqual({ ok: true, targets: [{ name: 'cand', ok: true }] });
|
|
});
|
|
|
|
it('STORADM-021 an unreachable s3 candidate fails fast with a per-target error (no registry impact)', async () => {
|
|
const { service, registry } = await makeService();
|
|
const result = await service.testBackend({
|
|
name: 'cand',
|
|
type: 's3',
|
|
options: {
|
|
endpoint: 'http://127.0.0.1:1',
|
|
bucket: 'trek',
|
|
accessKeyId: 'ak',
|
|
secretAccessKey: 'sk',
|
|
region: 'us-east-1',
|
|
keyPrefix: '',
|
|
retries: 0,
|
|
timeoutMs: 200,
|
|
},
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
expect(result.targets[0]!.ok).toBe(false);
|
|
expect(result.targets[0]!.error).toBeTruthy();
|
|
expect(registry.resolve('backups').backendName).toBe('backups-local'); // state untouched
|
|
});
|
|
|
|
it('STORADM-022 a mirror is probed per target, replica failures reported individually', async () => {
|
|
const goodRoot = makeTmpDir();
|
|
// Not yet a file: registry.build() eagerly driver.init()s EVERY local
|
|
// backend (used or not) during applyConfig's preview, so a root that's
|
|
// already a file at that point would make applyConfig itself throw
|
|
// (unrelated to testBackend). Register it as a fresh, empty dir, then
|
|
// corrupt it afterward — testBackend's ephemeralDriverFor re-runs
|
|
// init() fresh at probe time and hits the same EEXIST there instead.
|
|
const badRoot = path.join(makeTmpDir(), 'a-file');
|
|
const { service, uploadsRoot } = await makeService();
|
|
await put(
|
|
service,
|
|
configWith(uploadsRoot, {
|
|
backends: [
|
|
{ name: 'good-local', type: 'local', options: { root: goodRoot } },
|
|
{ name: 'bad-local', type: 'local', options: { root: badRoot } },
|
|
],
|
|
categories: {},
|
|
}),
|
|
);
|
|
fs.rmSync(badRoot, { recursive: true, force: true });
|
|
fs.writeFileSync(badRoot, 'not a dir');
|
|
const result = await service.testBackend({
|
|
name: 'cand-mirror',
|
|
type: 'mirror',
|
|
options: { primary: 'good-local', replicas: ['bad-local'] },
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
expect(result.targets).toEqual([
|
|
{ name: 'good-local', ok: true },
|
|
expect.objectContaining({ name: 'bad-local', ok: false }),
|
|
]);
|
|
});
|
|
|
|
it('STORADM-023 a mirror referencing an unknown or mirror-typed backend throws (→ 400)', async () => {
|
|
const { service } = await makeService();
|
|
await expect(
|
|
service.testBackend({ name: 'm', type: 'mirror', options: { primary: 'nope', replicas: [] } }),
|
|
).rejects.toThrow("mirror 'm' references unknown backend 'nope'");
|
|
});
|
|
|
|
it('STORADM-024 unmasks a stored backend by name before probing (mask echo works on /test)', async () => {
|
|
const { service, uploadsRoot } = await makeService();
|
|
const root = makeTmpDir();
|
|
await put(
|
|
service,
|
|
configWith(uploadsRoot, {
|
|
backends: [{ name: 'nas', type: 'local', options: { root } }],
|
|
categories: {},
|
|
}),
|
|
);
|
|
// local has no secrets — the unmask contract is pinned via an s3 mask with no counterpart:
|
|
await expect(
|
|
service.testBackend({
|
|
name: 'ghost',
|
|
type: 's3',
|
|
options: {
|
|
endpoint: 'http://127.0.0.1:1',
|
|
bucket: 'trek',
|
|
accessKeyId: 'ak',
|
|
secretAccessKey: MASKED_SETTING_VALUE,
|
|
region: 'us-east-1',
|
|
keyPrefix: '',
|
|
retries: 0,
|
|
timeoutMs: 200,
|
|
},
|
|
}),
|
|
).rejects.toThrow("re-enter the secret 'secretAccessKey' for 'ghost'");
|
|
});
|
|
|
|
it('STORADM-025 a mirror with a stored s3 replica decrypts the enc:v1: secret for the probe', async () => {
|
|
const goodRoot = makeTmpDir();
|
|
const { service, uploadsRoot } = await makeService();
|
|
await put(
|
|
service,
|
|
configWith(uploadsRoot, {
|
|
backends: [
|
|
{ name: 'good-local', type: 'local', options: { root: goodRoot } },
|
|
{
|
|
name: 'off-box',
|
|
type: 's3',
|
|
options: { ...S3_OPTIONS, endpoint: 'http://127.0.0.1:1', retries: 0, timeoutMs: 200 },
|
|
},
|
|
],
|
|
categories: {},
|
|
}),
|
|
);
|
|
// applyConfig encrypted the secret at rest — the snapshot the mirror
|
|
// expansion reads holds ciphertext, so this probe exercises the full
|
|
// snapshot → decryptBackendSecrets → ephemeral S3Driver chain.
|
|
expect(await readRow(BACKENDS_KEY)).toContain('enc:v1:');
|
|
const result = await service.testBackend({
|
|
name: 'cand-mirror',
|
|
type: 'mirror',
|
|
options: { primary: 'good-local', replicas: ['off-box'] },
|
|
});
|
|
expect(result.targets).toEqual([
|
|
{ name: 'good-local', ok: true },
|
|
expect.objectContaining({ name: 'off-box', ok: false }),
|
|
]);
|
|
// Decrypt succeeded: the replica failed on the network. A decrypt failure
|
|
// would reject the whole call (decryptBackendSecrets throws), not mark a target.
|
|
expect(result.targets[1]!.error).not.toContain('could not decrypt');
|
|
});
|
|
});
|