mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
305b113d5f
* feat(storage): storage key validation, driver contract suite, LocalDriver * feat(storage): MirrorDriver composite backend * feat(server): add TREK_UPLOADS_DIR env var for the uploads root * feat(storage): settings-backed registry, StorageService facade, module wiring * test(storage): pin src/nest/storage coverage thresholds * feat(storage): multer upload options factory * refactor(files): spool trip-file uploads through storage * refactor(collab): spool note attachments through storage; normalize files/ prefix * refactor(journey): spool journey media uploads through storage * refactor(trips): spool trip cover upload through storage * refactor(places): spool place-image and collection uploads through storage * refactor(auth): spool avatar upload through storage * fix(server): consolidate allowed_file_types onto AllowedFileTypesService * fix(storage): default defParamCharset to utf8 in the upload factory * test(db): re-point the trailing-migration guard at the trip_files prefix strip * fix(storage): age-gate the boot spool sweep * test(files): key FILE-P02 residue checks on upload size * test(storage): raise src/nest/storage coverage pin * refactor(platform): serve /uploads routes through storage * refactor(files): serve trip-file downloads through storage * refactor(memories): serve photo bytes through storage * refactor(maps): stream place-photo bytes through storage * refactor(share): stream shared place-photo bytes through storage * refactor(journey): serve public journey assets through storage * fix(share): answer place-photo cache miss with 204 like the maps proxy * test(server): close serving-swap gaps surfaced by the full tier * test(storage): raise src/nest/storage branches pin * refactor(place-photos): route place-photo cache through storage * refactor(places): reclaim place images and collection covers through storage * refactor(trips): trip covers through storage * refactor(auth): avatar reclaim through storage * refactor(collab): note-file deletion through storage * refactor(files): trash deletion through storage * refactor(memories): trek photo cache internals through storage * refactor(memories): thumbnail service category-addressed * refactor(memories): immich upload reads through storage; drop uploads-root * refactor(journey): photo deletion through storage * fix(memories): reclaim orphaned trek photo cache objects in the ttl sweep * feat(journey): journey-thumbs orphan sweep and delete-site derived-artifact reclaim * test(server): close slice-4 gaps surfaced by the full tier * test(coverage): raise slice-4 domain pins * refactor(backup): list, existence and delete through storage * refactor(backup): download through storage sendToResponse * refactor(backup): create backup through per-category storage enumeration * refactor(backup): restore and upload-restore through storage * refactor(backup): auto-backup retention through storage * refactor(server): boot dir-creation to driver init * test(server): close slice-5 gaps surfaced by the coverage tier * test(coverage): raise slice-5 backup pin * test(server): resolve async backupFileExists mocks for the tests typecheck * test(server): pin ranged-read stat and zero-byte roundtrip in the storage driver contract * feat(server): TREK_S3_* env wiring — schema, s3 namespace, all-or-nothing boot rule * feat(server): S3 driver core — aws-lite client seam, stat/delete, driver-owned deadlines * refactor(server): type the aws-lite S3 client via s3-types reference * feat(server): S3 driver getStream and list — ranged full-object stat, iterator pagination * feat(server): S3 driver put — bounded-peek routing, Upload inactivity deadline * fix(server): S3 driver Upload monitoring — Transform interposition, stream cleanup * feat(server): env-declared s3-main storage backend; settings-declared s3 rejected * test(server): env-gated S3 driver contract suite against MinIO * fix(server): S3 driver zero-byte put — work around aws4 duplicate Content-Length signing defect * fix(server): scope the zero-byte put workaround to buffered bodies; bind test MinIO to localhost * ci(server): MinIO-backed S3 driver contract job * ci(server): align s3-contract shim comment and MinIO bind with references * docs: S3 storage backend configuration and off-box backup mirroring * docs: restore README formatting, keep S3 additions only * ci(server): make live-MinIO contract runs manual-only (AIStor licensing) MinIO's community line is discontinued entirely; the maintained successor, AIStor, requires a license key that can't safely live in a shared CI runner. The s3-contract CI job is removed; live-provider contract runs against MinIO/AIStor are now a manual, license-gated procedure documented in docker-compose.minio-test.yml's header. * chore(server): final-review polish — stale CI refs, dockerignore key, boundary pins, zero-byte tempfile contract case * expose admin ui * feat(shared): storage backend type registry, wire schemas, category relocation * feat(server): accept settings-declared s3 backends — shared-schema options, decrypt-at-build, registry preview() * feat(server): remove TREK_S3_*/TREK_UPLOADS_DIR — tombstone boot rule, built-in override as the relocation path * feat(server): deriveSecurity.encryptionKeySet and storage secret plumbing (gate, encrypt, unmask, mask, redact) * chore(server): drop storage-secrets import that only Task 8's probe helper will use * feat(server): seed-once storage-config.json boot import — fail closed on any invalid seed * feat(server): registry snapshot() — effective storage config with provenance * feat(server): api/admin/storage GET/PUT — masked secrets, preview-gated saves, wired reload, audits * feat(server): api/admin/storage/test — per-target ephemeral probes, mirror targets probed individually * test(server): e2e coverage for api/admin/storage — guards, managed refusal, envelope, probes * test(server): give e2e harness DBs the app_settings table the storage registry reads at boot * test(coverage): raise pins after the storage admin-config slice * fix(server): final-review polish — encryption-gate wording, all-field sentinel guard, README/e2e notes * test(server): storage slice-1 review follow-ups — mirror s3-replica probe decrypt, all-encrypted seed without key, test-route 401 * feat(i18n): storage admin domain + tab label in every locale * feat(client): adminApi storage endpoints — getStorage, updateStorage, testStorageBackend * feat(client): storage admin draft-document helpers + relative-time util * feat(client): schema-driven storage BackendForm — fields by kind, mask echo, encryption banner * feat(client): admin storage panel — backends list, category map, health strip, one-PUT save * feat(client): storage tab on the admin page * feat(client): storage admin panel and section on the phone shell * fix(client): block renaming a storage backend onto an existing name * feat(server): allow mirrors on any storage category — lift the backups-only rule * feat(i18n): replicas-on-primary mirror strings in every locale * feat(client): mirror fold/synthesize model helpers for replicas-on-primary * feat(client): BackendForm mirror-targets composer; raw mirror type hidden * feat(client): desktop storage panel folds mirrors into their primaries * feat(client): phone storage panel folds mirrors into their primaries * fix(client): strip removed backends from remaining mirror replicas — no phantom refs * feat(storage): retire photos as a configurable category — served-only via the ServedCategory seam * feat(i18n): descriptive storage category names and descriptions in every locale * feat(client): descriptive category names, descriptions and id badges in the storage panel * docs(storage): fix stale nine-category comments after the eight-category shrink * docs: storage configuration via the admin UI and seed file — README + compose * docs(wiki): Admin Storage page and storage cross-links * docs(server): storage domain blueprint entry; TREK_PLACE_PHOTO_DIR tombstone clarifier * refactor(server): drop the storage env-var tombstones — the variables never shipped * docs: storage config is simply new in v4 — drop the removed-variables story * feat(shared): backfill-status and usage wire schemas on the storage admin state * feat(server): MirrorDriver.backfill — prefix-scoped, size-compared, cancellable replica catch-up * feat(server): one-at-a-time storage backfill jobs + the storage events bridge * feat(server): storage usage scan — nightly cron, on-demand service, persisted with computedAt * feat(server): replica_failure admin notifications — events bridge, hourly debounce, channel registration * feat(i18n): replica-failure notification text in every locale * feat(server): storage backfill and stats-refresh admin endpoints * feat(client): adminApi backfill and stats-refresh endpoints * feat(i18n): storage sync and usage strings in every locale * feat(client): desktop storage panel — sync now, usage display, backfill polling * feat(client): phone storage panel — sync now, usage display, backfill polling * fix(client): per-category usage line on the phone storage panel * docs: sync now, usage stats and replica-failure notifications * test(server): admin scope now includes replica_failure, not just version_available Commit6db135daadded replica_failure to ADMIN_SCOPED_EVENTS but left NPREF-005b asserting the admin scope contained only version_available. Update the expectation to match. * test(server): cover storage backfill/stats/jobs branches to hold the coverage pin * fix(client): guard storage admin polling against stale responses racing a save A poll GET issued before a save's PUT resolves could land after the save applied its fresher response, overwriting it with the pre-save world and re-deriving the draft from that stale snapshot. useStorageAdmin now tracks a state-application sequence number and drops any refreshState() response whose sequence has moved on, or that resolves while a save is in flight. Also guard the fire-and-forget refreshState() calls in startBackfill/ cancelBackfill against unhandled rejections, matching the poll's style. * fix(server): report 404 before 409 when a backfill start names an unknown mirror startBackfill checked the global busy flag before validating that :name is a mirror routed by some category, so POST .../backends/garbage/backfill answered 409 instead of 404 whenever a sync was already running. Resolve and validate the target first (BackfillTargetError -> 404), then check busy (BackfillBusyError -> 409). * feat(client): two-column category grid on the storage panel * feat(server): plugin files.getContent reads through the storage layer * feat(server): plugin files.create writes through the storage layer * refactor(server): drop FilesService.resolveFilePath, last direct-fs seam in the plugin surface * fix(server): map StorageInvalidKeyError to the plugin file accessibility envelope * feat(storage): allow credentialed backends without an explicit ENCRYPTION_KEY Secrets are still always encrypted at rest; without an explicit key the implicit data-directory key is used. Removes the plaintext-secret refusal from the admin PUT and the seed import, the encryptionReady GET field, the save-blocking banner in both admin shells, and the now-consumerless security.encryptionKeySet env derivation. Docs now recommend (not require) an explicit key, since the implicit key rides inside backup archives. * test(client): pin the stale-poll release to the pre-save GET in FE-ADMIN-STOR-024 The stub deferred EVERY poll GET and rebound the release handle to the newest one, so a tick landing after the save was released with the pre-save payload — a response no real backend can produce, which the seq guard rightly applied. Only the first (genuinely stale) GET is held now; later GETs answer with the world the mock server currently holds. Also drops the encryptionReady fixture field removed from the schema in the previous commit. * fix(storage): backfill puts carry content-type, stat failures report op stat, progress clamps * feat(storage): category migration job — copy, flip, delta sweep, reclaimable tally * feat(storage): migration start/cancel endpoints and client API * test(client): storage admin fixtures carry the new migrations field * fix(storage): migration POST answers 200 and validates its body via the shared schema * fix(storage): cancel running storage jobs when a config save removes their backend * fix(notifications): in-app replica-failure row carries the suppressed count * feat(client): migration prompt, queue, and progress on the storage panel * fix(client): migration queue holds a synchronous single-flight lock * feat(client): migration prompt and progress on the mobile storage panel * fix(server): register category-migration routes in the managed-route allow list StorageAdminController.migrationStart/migrationCancel were added under the controller-level @ManagedForbidden() decorator but never added to MANAGED_ROUTE_ALLOW_LIST, so validateManagedRoutes() threw "Managed surface changed" on every buildApp() call, failing 46 test files (any test booting the real app). * test(server): cover category-migration copy/sweep branches to hold the storage coverage pin Adds MIG-005 (a target object already matching size/key is skipped in the copy phase itself, not just the delta sweep) and MIG-006 (a copy failure during the delta sweep is counted without undoing the flip that already happened). src/nest/storage/**'s branch coverage was 83.91%, just under the 84% pin. * docs(storage): category migration — move existing objects on reassign * fix(storage): close the pre-flip cancel window and cancel re-routed migrations A cancel landing between the last copy iteration and the category flip was silently ignored, and a config save that re-routed the migrating category to a third, still-defined backend never stopped the in-flight migration. Also guards assignCategory against writing an unknown backend name, and fixes a missing `suppressed` notification param being treated as truthy suppression. * fix(client): migrations keep mirrors, queue waits on backfills, sweep failures surface Reassigning a category onto a mirrored primary now posts the migration to the mirror's wire name instead of the bare primary, so replication survives the move. The queued-migration effect also waits out a running backfill (the server's one-job rule spans both), and a done migration with sweep failures now surfaces a doneFailures line in both shells, translated into every locale. * ci(server): run the S3 contract suite against MinIO Resurrects the s3-contract job removed in6fbff835, adapted for AIStor: writes the MINIO_LICENSE_KEY secret to a license file, boots the licensed container, and runs the contract suite hermetically (no --coverage) so the storage coverage pin keeps holding from unit tests alone. Gated to same-repo PRs since fork PRs have no access to the license secret. * fix(server): backup archives uploads from a remote primary createBackup() enumerated uploads through withLocalFile, which downloads a remote object to a temp file, hands it to the callback, then deletes it before archiver ever reads it — archiver reads entries lazily during finalize(), so a remote (S3) primary silently backed up zero upload bytes. Add StorageService.getLocalPathOrNull(category, name): a narrow locality probe that returns a real path only when one exists on disk right now (the fs-existence check is the fail-safe half of the contract — a driver-reported path that vanished between list() and here falls back too, not just a path-less remote driver). The enumeration loop uses it directly when available (zero-copy, unchanged for a local primary) and otherwise streams the object into a per-backup staging dir under the backups spool, cleaned up in the existing finally alongside zipSpool/dbSnap. Also wire archive.on('warning', reject): archiver treats a missing/unreadable entry as a warning, not an error, and skips it by default — which would silently drop the entry from an otherwise "successful" backup. * fix(server): client aborts stop 500ing and leaking streams storage.service.ts sendToResponse: the local res.sendFile branch now treats ECONNABORTED/ECONNRESET/ERR_STREAM_PREMATURE_CLOSE/syscall=write as success, mirroring res.sendFile's own default callback (the contract storageStaticHandler already implements caller-side). The remote branch replaces the hand-rolled pipe+Promise with pipeline(stream, res), which destroys the source stream on any failure — fixing the leaked S3 keep-alive socket on a client abort — while swallowing the same client-abort error set; a real source error still rejects so callers keep their miss contracts. This centralizes the fix for every caller that streams through sendToResponse: files-download, journey-public, photo-resolver, trek-photo-cache, backup downloads, and /uploads/photos. trek-exception.filter.ts now no-ops (destroying the socket) when headers are already sent, so no filter path ever writes onto a response in flight. maps.controller.ts and share.controller.ts's place-photo bytes proxies switch their direct getStream+pipe to pipeline() with the same abort-tolerant catch, keeping the existing 204-empty fallback for pre-header failures. * fix(storage): mirror read fallback consults replicas on primary miss MirrorDriver.getStream/stat now fall back to replicas on a primary miss (StorageNotFoundError / null stat), not just an infra error — binding decision: a read succeeds if ANY member holds the object. Only a deterministic StorageInvalidKeyError skips fallback. stat() gets a dedicated fallback loop instead of reusing fromReplicas: a replica returning null means "keep looking," which the generic catch-and-continue helper would have short-circuited on. StorageService.sendToResponse/withLocalFile stop treating composite driver.stat() as proof the object is at localPath; both now fs-check the path itself and fall through to the stream branch when it's missing, which is what actually reaches a MirrorDriver's replicas. * feat(storage): remote serving parity — content-type, validators, range Local backends get content type, ETag/Last-Modified, conditional GET, Range and HEAD for free from res.sendFile; the proxying branch every remote backend (and a local primary whose file vanished) falls through to had none of it — every request re-streamed the whole object with no cache validator and no way to seek. sendStreamed() reproduces the contract from ONE pre-flight driver.stat(): a miss throws before any header is set (the caller's 404/204/next() contract), and that same stat then answers 304s, 416s, HEADs and suffix ranges without ever opening a body. The decisions live in a new pure module, http-serving.ts: - validatorsFor: the driver's ETag, else express/send's weak W/"<size hex>-<mtime hex>". mtimeMs 0 (S3 can report it) means no Last-Modified and no derived tag — serving uncached beats a 1970 header that reads as fresh forever. - isNotModified: If-None-Match (weak compare, *, list) wins over If-Modified-Since, per RFC 9110 §13.2.2. - decideRange: single range only, inclusive clamp, suffix computed from the pre-stat size, unsatisfiable → 416, If-Range strong-exact or full entity. A multi-range or malformed spec is answered with the full object rather than multipart/byteranges. If-Match/If-Unmodified-Since stay out of scope. Range math uses the pre-stat size as the single source of truth, the ranged getStream stat is never re-consulted, and the pipeline abort semantics are unchanged. S3Driver now surfaces the bucket's ETag on stat()/getStream() (on a ranged GET it is still the full object's tag). aws-lite strips the tag's surrounding quotes — verified live against AIStor RELEASE.2026-08-07 — and an unquoted entity-tag is not a valid HTTP ETag, so the driver restores exactly the quotes the SDK removed and nothing else. * fix(server): key rotation covers storage.backends The S3 storage backend's secretAccessKey is encrypted at rest inside the app_settings row keyed storage.backends, but scripts/migrate-encryption.ts didn't know about it — after a key rotation that secret stayed encrypted under the old key and silently failed to decrypt on the next read. The script now re-encrypts secretAccessKey for every s3 backend inside the storage.backends JSON array, using a pinned copy of the secret-field list (it deliberately can't import @trek/shared). The parity test pins that copy against storageSecretFields('s3') so a new secret field can't be missed silently. * fix(server): restore reloads the storage registry restoreFromZip closed and reopened the DB but never reloaded the storage registry, so uploads rehydration ran against the STALE pre-restore driver/category map instead of the config the restored archive's own travel.db carries (audit #4, major). StorageService gains reloadConfig(), a narrow passthrough to the (unexported) registry's reload() — same shape as health(). backup.impl's restoreFromZip now calls it right after reinitialize() and right before the uploads wipe/rehydrateUploads/plugin staging, so rehydrated bytes land where the restored config says. When reopen fails (reinitFailed), reload and rehydration are skipped entirely — already reported as "restart required", and there is no live DB handle for the registry to read anyway. * fix(server): category migration rewrites keys across prefix changes Category migration copied objects to the destination backend using the SOURCE key verbatim. A mode-A ('' prefix) source migrating onto a prefixed backend (or vice versa) landed objects at keys the registry could never resolve post-flip (audit #8). Extracts StorageRegistryService.keyPrefixFor(category, backendName) from build()'s inline conditional so startMigration can compute the destination prefix before the flip. runMigration now maps every destination key (destPrefix + key.slice(sourcePrefix.length)) in the copy skip-check, the put, and the delta sweep; the reclaimable tally stays source-keyed. Equal-prefix migrations are unaffected (identity mapping). * fix(plugins): await the now-async place/collection services in RPCs PlacesService.update/remove and CollectionsService.deletePlace became async when they started deleting storage objects, but their plugin RPC handlers still called them synchronously: the null/false refusal check compared against an always-truthy Promise, and places.update broadcast a Promise instead of the resolved place. Both handlers now await the service call; mapCollectionError is async-aware (accepts a sync-or- async thunk) so the sync callers (create/update/savePlace/copyToTrip) keep working through the same path. Verified the RPC host already awaits handler results, so no change was needed there. * fix(storage): backfill Sync now gains a deletion sweep The Sync now backfill copied primary objects to replicas but never removed replica objects the primary no longer has, leaving mirror read-fallback able to serve stale data indefinitely after a failed replica delete. backfill() now sweeps each replica post-copy: any replica key absent from the primary key-set is re-checked against primary.stat (a race gate against the primary regaining the key) before being deleted. Adds a deleted counter end-to-end (driver, jobs service, shared schema, both admin shells, all 23 locales) and a new ReplicaFailure 'list' op for a replica whose listing itself fails during the sweep. * feat(storage): optimistic concurrency for admin config saves A stale admin PUT of storage settings could silently undo a category migration's just-flipped assignment. Adds a version counter (app_settings row storage.config_version) bumped inside the same transaction by both writers — applyConfig and the migration flip (assignCategory). applyConfig compares the submitted version first and throws StorageConflictError, mapped to 409 ahead of the blanket 400. The client carries the draft's loaded version through every local edit and maps a 409 to a distinct "settings changed underneath you" message, refreshing state while preserving the dirty draft for review. * fix(storage): surface a failed config load + redact secretAccessKey Registry load() now records the exact parse/validate failure as lastLoadError() instead of silently falling back to last-good config or built-in defaults; StorageAdminService exposes it as configError on the admin state (storageAdminStateSchema extended additively), and AdminStoragePanel renders it as a warning banner naming the error. Save stays enabled — it's the recovery path — but an admin can no longer save over a broken stored config without knowing. Also adds 'secretaccesskey' to globalMiddleware's SENSITIVE_KEYS request-log redaction list, so storage admin PUT bodies never leak the S3 secret into debug logs. * fix(client): admin storage panel state races Migration prompt now recomputes computeMigrationCandidates(draft, state) on render and again at confirm time, so a stale open-time snapshot never drives stripCategories or the migration queue; adds a Cancel button to the dialog. A failed startMigration POST explicitly clears the remaining queue with a toast naming the dropped categories and refreshes state, instead of leaving it invisibly stranded; a "Queued: X, Y" line renders while the queue is non-empty. Testing a mirrored row now expands the draft primary + replicas client-side and probes each individually, instead of sending the mirror stub to the server (which resolved targets from its own live snapshot, silently testing saved options over unsaved draft edits). A completed/cancelled/errored backfill now renders its terminal status alongside the Sync now button instead of hiding it, so it can be re-run without a page reload. * fix(storage): refuse shared replica backends so the sync sweep cannot cross categories A mirror's "Sync now" sweep makes each replica MATCH the primary: it lists the replica under every prefix the mirror's categories occupy and deletes the keys the primary doesn't hold. The backups category's prefix is '' — the replica's entire root — so making a category-serving backend (say uploads-local) a replica of a backups mirror meant the first sync deleted every user upload. Nothing forbade the config: validateConfig only checked that mirror targets existed and weren't nested, and the admin picker offered every backend. The sweep is correct, so the fix is at the config level: - validateConfig refuses a backend that is both a mirror replica and a category target (directly, or as the primary of a mirror a category routes to), and refuses a backend replicating two mirrors whose swept prefixes overlap — '' overlaps everything. Disjoint prefixes stay legal. - assignCategory (the migration flip, the other writer of the settings rows) refuses routing a category onto a backend that is currently a replica. - The admin mirror-target picker (both shells) only offers backends that serve no category; already-selected targets stay listed so they can be unchecked. A category assigned to a replica still surfaces the server's 400 verbatim. Existing shared-replica fixtures were rewritten onto dedicated replicas. * fix(client): a 409 conflict save is recoverable without a page reload After a 409 the admin storage panel was a dead end. refreshState leaves a dirty draft's version alone on purpose (audit #7: the background poll must never float a mid-edit draft past a migration flip it never carried), setDraft re-attaches that same version to every later edit, and save() resubmits it — so every retry 409s again and only a full page reload got the operator out, while the banner told them to "review and save again". The version block stays. Instead the conflict banner now offers the one action that can clear it: discard the local edits and reload the saved settings — a fresh GET applied through applyState, re-deriving the draft (and its version) and clearing dirty/saveError. Bumping the version in refreshState, or only in the 409 branch, was rejected: either resurrects audit #7, since a full-document save would then revert a flip the draft never carried. storage.saveConflict now points at that action instead of implying a plain re-save works (all 23 locales). The escape lives in the shared hook, so the phone panel renders it too. * fix(client): deflake the queued-migration-waits-out-backfill panel test FE-ADMIN-STOR-037 seeds a running backfill, so the panel polls GET from first render — but the stub served the post-save world (files: off-box) before the PUT had landed, which no real server would do. A poll resolving while the confirm dialog was open made the confirm-time candidate recompute see from === to, so nothing was queued and the migration never POSTed. The GET stub now stays faithful (pre-save world until the PUT lands, usage included so candidates keep their object counts), and the test deterministically forces the once-flaky interleaving: it waits for polls to land while the dialog is open and asserts the candidate survives them. * fix(storage): surface the underlying cause in probe target errors The admin storage Test probe reported only the driver wrapper's outer message ("put failed for '…' on 'probe-s3'"), dropping the cause that says WHY — connection refused vs missing bucket. Flatten the error's cause chain into the probe result, appending an S3-style code when it isn't already in the message. * fix(storage): surface the underlying cause in replica-failure reports Same defect as the probe fix, one layer over: MirrorDriver's reportReplicaFailure captured only the wrapper's message, so replica-failure notifications said "put failed" without the why. Move the probe's cause-chain flattener into storage.types.ts as describeError and use it in both call sites. * fix(storage): strip ContentType from multipart Upload calls (MalformedXML) Every multipart S3 put carrying a contentType — every backup zip and photo over the 10MB threshold — failed with 400 MalformedXML. Upstream @aws-lite/s3's Upload leaks the ContentType param into its final CompleteMultipartUpload headers ({ ...xml, ...paramHeaders }), clobbering application/xml, which flips @aws-lite/client's payload serializer to JSON — so the server receives a JSON parts list where XML belongs. Confirmed live against MinIO AIStor; any S3 refuses the same. uploadMonitored now drops ContentType before s3.Upload. Multipart objects are stored without a content-type, which TREK never reads back (serving derives Content-Type from the key/caller, never object metadata); single-request PutObject paths keep sending it. Also closes the coverage gap that let this ship: the live contract suite never crossed MULTIPART_THRESHOLD, so s3.Upload had never run against a real server — it now round-trips a >10MB put carrying a contentType. --------- Co-authored-by: Maurice <mauriceboe@icloud.com>
947 lines
41 KiB
TypeScript
947 lines
41 KiB
TypeScript
import { describe, it, expect, afterEach } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { EventEmitter } from 'node:events';
|
|
import { PassThrough, Readable } from 'node:stream';
|
|
import type { Response } from 'express';
|
|
import { LocalDriver } from '../../../../src/nest/storage/drivers/local.driver';
|
|
import type { ReplicaFailure } from '../../../../src/nest/storage/drivers/mirror.driver';
|
|
import type { StorageRegistryService, ResolvedCategory } from '../../../../src/nest/storage/storage-registry.service';
|
|
import { StorageService } from '../../../../src/nest/storage/storage.service';
|
|
import {
|
|
StorageInvalidKeyError,
|
|
StorageNotFoundError,
|
|
type ByteRange,
|
|
type ObjectStat,
|
|
type StorageDriver,
|
|
} from '../../../../src/nest/storage/storage.types';
|
|
|
|
// Facade over a stub registry (the weather.controller.test.ts casting style)
|
|
// backed by a REAL LocalDriver, so key composition, prefix stripping, and both
|
|
// sendToResponse/withLocalFile branches are exercised against actual files.
|
|
|
|
const tmpDirs: string[] = [];
|
|
function makeTmpDir(): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-facade-'));
|
|
tmpDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
interface Fixture {
|
|
storage: StorageService;
|
|
driver: LocalDriver;
|
|
root: string;
|
|
tempDir: string;
|
|
failures: ReplicaFailure[];
|
|
}
|
|
|
|
function makeFixture(keyPrefix = 'files/'): Fixture {
|
|
const root = makeTmpDir();
|
|
const tempDir = makeTmpDir();
|
|
const driver = new LocalDriver({ id: 'stub-local', root });
|
|
driver.init({ cleanSpool: true });
|
|
const failures: ReplicaFailure[] = [];
|
|
const registry = {
|
|
resolve: (): ResolvedCategory => ({ driver, keyPrefix, backendName: 'stub-local' }),
|
|
tempDir: () => tempDir,
|
|
replicaFailures: () => failures,
|
|
} as unknown as StorageRegistryService;
|
|
return { storage: new StorageService(registry), driver, root, tempDir, failures };
|
|
}
|
|
|
|
/** A driver with no getLocalPath — the remote-driver branch every helper must handle. */
|
|
function makeStreamOnlyFixture(contents: string): Fixture & { driverCalls: string[] } {
|
|
const fx = makeFixture('');
|
|
const driverCalls: string[] = [];
|
|
const bytes = Buffer.from(contents);
|
|
const stat: ObjectStat = { key: 'remote.bin', size: bytes.length, mtimeMs: 1 };
|
|
const streamOnly: StorageDriver = {
|
|
id: 'stub-remote',
|
|
put: async () => undefined,
|
|
getStream: async (key: string) => {
|
|
driverCalls.push(`getStream:${key}`);
|
|
return { stream: Readable.from(bytes), stat: { ...stat, key } };
|
|
},
|
|
stat: async (key: string) => ({ ...stat, key }),
|
|
delete: async () => undefined,
|
|
list: async function* () {
|
|
yield* [] as ObjectStat[];
|
|
},
|
|
// no getLocalPath, no getSpoolDir
|
|
};
|
|
const registry = {
|
|
resolve: (): ResolvedCategory => ({ driver: streamOnly, keyPrefix: '', backendName: 'stub-remote' }),
|
|
tempDir: () => fx.tempDir,
|
|
replicaFailures: () => fx.failures,
|
|
} as unknown as StorageRegistryService;
|
|
return { ...fx, storage: new StorageService(registry), driverCalls };
|
|
}
|
|
|
|
/**
|
|
* A driver whose `getLocalPath` resolves to a path that is NOT on disk, but
|
|
* whose `getStream` still succeeds — the façade-level stand-in for a
|
|
* MirrorDriver whose primary lost the file (failed delete, disk hiccup, …)
|
|
* while a replica still holds it. `getLocalPath` on MirrorDriver only ever
|
|
* consults the primary (mirror.driver.ts), so the façade must never trust
|
|
* that path as proof of presence — it has to fs-check it and fall through to
|
|
* the stream branch (which is what actually reaches the replica) when it's
|
|
* gone.
|
|
*/
|
|
function makeGhostLocalPathFixture(contents: string): Fixture & { driverCalls: string[] } {
|
|
const fx = makeFixture('files/');
|
|
const driverCalls: string[] = [];
|
|
const bytes = Buffer.from(contents);
|
|
const stat: ObjectStat = { key: 'files/ghost.bin', size: bytes.length, mtimeMs: 1 };
|
|
const ghostPath = path.join(fx.root, 'files', 'ghost.bin'); // resolvable, but never written
|
|
const driver: StorageDriver = {
|
|
id: 'stub-ghost-local',
|
|
put: async () => undefined,
|
|
getStream: async (key: string) => {
|
|
driverCalls.push(`getStream:${key}`);
|
|
return { stream: Readable.from(bytes), stat: { ...stat, key } };
|
|
},
|
|
stat: async (key: string) => ({ ...stat, key }), // the composite still resolves the object
|
|
delete: async () => undefined,
|
|
list: async function* () {
|
|
yield* [] as ObjectStat[];
|
|
},
|
|
getLocalPath: () => ghostPath,
|
|
};
|
|
const registry = {
|
|
resolve: (): ResolvedCategory => ({ driver, keyPrefix: 'files/', backendName: 'stub-ghost-local' }),
|
|
tempDir: () => fx.tempDir,
|
|
replicaFailures: () => fx.failures,
|
|
} as unknown as StorageRegistryService;
|
|
return { ...fx, storage: new StorageService(registry), driverCalls };
|
|
}
|
|
|
|
afterEach(() => {
|
|
while (tmpDirs.length) fs.rmSync(tmpDirs.pop()!, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('StorageService key composition', () => {
|
|
it('composes keyPrefix + name on writes and strips it back in list()', async () => {
|
|
const fx = makeFixture('files/');
|
|
await fx.storage.put('files', 'a.pdf', Readable.from('doc'));
|
|
await fx.storage.put('files', 'nested/b.pdf', Readable.from('doc2'));
|
|
|
|
// on disk under the driver prefix
|
|
expect(fs.existsSync(path.join(fx.root, 'files/a.pdf'))).toBe(true);
|
|
|
|
const names: string[] = [];
|
|
for await (const stat of fx.storage.list('files')) names.push(stat.key);
|
|
expect(names.sort()).toEqual(['a.pdf', 'nested/b.pdf']); // category-relative
|
|
|
|
const nested: string[] = [];
|
|
for await (const stat of fx.storage.list('files', 'nested/')) nested.push(stat.key);
|
|
expect(nested).toEqual(['nested/b.pdf']);
|
|
});
|
|
|
|
it('rejects a name that composes into an invalid key', async () => {
|
|
const fx = makeFixture('files/');
|
|
await expect(fx.storage.put('files', '../escape.bin', Readable.from('x'))).rejects.toBeInstanceOf(
|
|
StorageInvalidKeyError,
|
|
);
|
|
await expect(fx.storage.stat('files', '.tmp/spool.part')).rejects.toBeInstanceOf(StorageInvalidKeyError);
|
|
});
|
|
|
|
it('delegates stat/exists/delete/getStream with the composed key', async () => {
|
|
const fx = makeFixture('files/');
|
|
await fx.storage.put('files', 'c.bin', Readable.from('12345'));
|
|
|
|
expect((await fx.storage.stat('files', 'c.bin'))!.size).toBe(5);
|
|
expect(await fx.storage.exists('files', 'c.bin')).toBe(true);
|
|
expect(await fx.storage.exists('files', 'nope.bin')).toBe(false);
|
|
|
|
const { stream, stat } = await fx.storage.getStream('files', 'c.bin', { start: 1, end: 3 });
|
|
expect(stat.size).toBe(5);
|
|
const chunks: Buffer[] = [];
|
|
for await (const c of stream) chunks.push(Buffer.from(c as Buffer));
|
|
expect(Buffer.concat(chunks).toString()).toBe('234');
|
|
|
|
await fx.storage.delete('files', 'c.bin');
|
|
expect(await fx.storage.exists('files', 'c.bin')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('StorageService spool + temp dirs', () => {
|
|
it('spoolDirFor returns the backend spool for local drivers and tempDir() otherwise', () => {
|
|
const fx = makeFixture();
|
|
expect(fx.storage.spoolDirFor('files')).toBe(path.join(fs.realpathSync(fx.root), '.tmp'));
|
|
expect(fx.storage.tempDir()).toBe(fx.tempDir);
|
|
|
|
const remote = makeStreamOnlyFixture('x');
|
|
expect(remote.storage.spoolDirFor('files')).toBe(remote.tempDir);
|
|
});
|
|
});
|
|
|
|
describe('StorageService withLocalFile', () => {
|
|
it('hands local drivers their real path', async () => {
|
|
const fx = makeFixture('files/');
|
|
await fx.storage.put('files', 'img.jpg', Readable.from('jpeg bytes'));
|
|
|
|
const result = await fx.storage.withLocalFile('files', 'img.jpg', async (absPath) => {
|
|
expect(absPath).toBe(path.join(fs.realpathSync(fx.root), 'files/img.jpg'));
|
|
return fs.readFileSync(absPath, 'utf8');
|
|
});
|
|
expect(result).toBe('jpeg bytes');
|
|
});
|
|
|
|
it('throws StorageNotFoundError for a local miss', async () => {
|
|
const fx = makeFixture('files/');
|
|
await expect(fx.storage.withLocalFile('files', 'ghost.jpg', async () => 'never')).rejects.toBeInstanceOf(
|
|
StorageNotFoundError,
|
|
);
|
|
});
|
|
|
|
it('downloads to tempDir for path-less drivers and cleans up, even when fn throws', async () => {
|
|
const remote = makeStreamOnlyFixture('remote bytes');
|
|
|
|
let seenPath = '';
|
|
const result = await remote.storage.withLocalFile('files', 'remote.bin', async (absPath) => {
|
|
seenPath = absPath;
|
|
return fs.readFileSync(absPath, 'utf8');
|
|
});
|
|
expect(result).toBe('remote bytes');
|
|
expect(seenPath.startsWith(remote.tempDir + path.sep)).toBe(true);
|
|
expect(fs.existsSync(seenPath)).toBe(false); // cleaned up
|
|
|
|
await expect(
|
|
remote.storage.withLocalFile('files', 'remote.bin', async (absPath) => {
|
|
seenPath = absPath;
|
|
throw new Error('processing failed');
|
|
}),
|
|
).rejects.toThrow('processing failed');
|
|
expect(fs.existsSync(seenPath)).toBe(false); // cleaned up on throw too
|
|
});
|
|
|
|
it('falls through to the stream branch when getLocalPath resolves but the file is not actually on disk', async () => {
|
|
// Composite-driver stand-in (task C9-i): the façade must fs-check the
|
|
// local path itself rather than trust driver.stat() as "the file is at
|
|
// localPath" — driver.stat here still says present (a replica has it).
|
|
const fx = makeGhostLocalPathFixture('replica bytes');
|
|
|
|
const result = await fx.storage.withLocalFile('files', 'ghost.bin', async (absPath) =>
|
|
fs.readFileSync(absPath, 'utf8'),
|
|
);
|
|
|
|
expect(result).toBe('replica bytes');
|
|
expect(fx.driverCalls).toEqual(['getStream:files/ghost.bin']); // reached the stream branch
|
|
});
|
|
});
|
|
|
|
describe('StorageService getLocalPathOrNull', () => {
|
|
it('returns the real path for a local driver whose object exists on disk', async () => {
|
|
const fx = makeFixture('files/');
|
|
await fx.storage.put('files', 'img.jpg', Readable.from('jpeg bytes'));
|
|
|
|
const localPath = await fx.storage.getLocalPathOrNull('files', 'img.jpg');
|
|
expect(localPath).toBe(path.join(fs.realpathSync(fx.root), 'files/img.jpg'));
|
|
});
|
|
|
|
it('returns null for a path-less (remote) driver', async () => {
|
|
const remote = makeStreamOnlyFixture('remote bytes');
|
|
expect(await remote.storage.getLocalPathOrNull('files', 'remote.bin')).toBeNull();
|
|
// The probe never falls back to streaming itself — it's a pure locality check.
|
|
expect(remote.driverCalls).toEqual([]);
|
|
});
|
|
|
|
it('returns null for a local miss (no fs.existsSync throw)', async () => {
|
|
const fx = makeFixture('files/');
|
|
expect(await fx.storage.getLocalPathOrNull('files', 'ghost.jpg')).toBeNull();
|
|
});
|
|
|
|
it('fail-safe: returns null when the local path exists in the driver API but the file has vanished from disk', async () => {
|
|
// Binding controller ruling: "local path available" means the path exists
|
|
// on disk. Simulates a file deleted between storage.list() and this call
|
|
// (or by another process) — the caller must fall back to streaming rather
|
|
// than push a path that will 404/throw when read.
|
|
const fx = makeFixture('files/');
|
|
await fx.storage.put('files', 'raced.jpg', Readable.from('bytes'));
|
|
fs.rmSync(path.join(fs.realpathSync(fx.root), 'files/raced.jpg'));
|
|
|
|
expect(await fx.storage.getLocalPathOrNull('files', 'raced.jpg')).toBeNull();
|
|
});
|
|
|
|
it('rejects a name that composes into an invalid key', async () => {
|
|
const fx = makeFixture('files/');
|
|
await expect(fx.storage.getLocalPathOrNull('files', '../escape.bin')).rejects.toBeInstanceOf(
|
|
StorageInvalidKeyError,
|
|
);
|
|
});
|
|
});
|
|
|
|
interface MockRes {
|
|
res: Response & { headersSent: boolean; statusCode: number };
|
|
sendFileCalls: Array<{ name: string; root: string }>;
|
|
headers: Record<string, string>;
|
|
body: () => Promise<string>;
|
|
}
|
|
|
|
/** What the serving path reads off `res.req` — express always populates it. */
|
|
interface ReqInit {
|
|
method?: string;
|
|
headers?: Record<string, string | string[]>;
|
|
}
|
|
|
|
// Real headersSent semantics (mirroring http.ServerResponse): flips true on
|
|
// the FIRST write/end, before that point it's false — never a static value.
|
|
// This matters here specifically: the remote branch's abort-swallow is
|
|
// gated on `res.headersSent`, so a mock that fakes it as an inert boolean
|
|
// would hide a regression where the gate stops actually gating (see CRITICAL
|
|
// finding on task C3 review: the pre-fix code swallowed a pre-header S3
|
|
// source ECONNRESET as if it were a client abort).
|
|
function makeRes(req: ReqInit = {}): MockRes {
|
|
const sink = new PassThrough();
|
|
const sendFileCalls: Array<{ name: string; root: string }> = [];
|
|
const headers: Record<string, string> = {};
|
|
const collected: Buffer[] = [];
|
|
sink.on('data', (c: Buffer) => collected.push(c));
|
|
const res = Object.assign(sink, {
|
|
headersSent: false,
|
|
statusCode: 200,
|
|
// The remote branch reads res.req for method + conditional/Range headers,
|
|
// exactly as the local branch's res.sendFile already does. It must be a
|
|
// real EventEmitter: node's end-of-stream (which pipeline() drives) treats
|
|
// a `.req` on the destination as an http request and cleans up listeners
|
|
// on it — a plain object literal there throws
|
|
// "stream.req.removeListener is not a function" out of pipeline.
|
|
req: Object.assign(new EventEmitter(), { method: req.method ?? 'GET', headers: req.headers ?? {} }),
|
|
setHeader: (key: string, value: string) => {
|
|
// Node's real ServerResponse.setHeader THROWS ERR_INVALID_CHAR on a
|
|
// control character in the value. Reproduced here because it is
|
|
// reachable in production: Content-Disposition is built from
|
|
// client-supplied filenames (files-download.controller.ts feeds it
|
|
// multer's originalname), so a name carrying a CR/LF makes staging
|
|
// fail mid-serve — a mock that silently accepts anything would hide
|
|
// the resource leak that failure can cause.
|
|
if (/[\r\n]/.test(String(value))) {
|
|
throw Object.assign(new Error(`Invalid character in header content ["${key}"]`), {
|
|
code: 'ERR_INVALID_CHAR',
|
|
});
|
|
}
|
|
headers[key] = value;
|
|
return res;
|
|
},
|
|
// Node's getHeader is case-insensitive; the serving path uses it to see
|
|
// whether the CALLER already staged a Content-Type (trek-photo-cache
|
|
// does, for its extension-less `.bin` objects), so the mock must be too
|
|
// — a case-sensitive lookup here would hide a real precedence bug.
|
|
getHeader: (key: string): string | undefined => {
|
|
const found = Object.keys(headers).find((name) => name.toLowerCase() === key.toLowerCase());
|
|
return found === undefined ? undefined : headers[found];
|
|
},
|
|
// express's res.set — the form every caller actually uses to stage
|
|
// headers before handing the response to sendToResponse.
|
|
set: (key: string, value: string) => {
|
|
headers[key] = value;
|
|
return res;
|
|
},
|
|
sendFile: (name: string, opts: { root: string }, cb: (err?: Error) => void) => {
|
|
sendFileCalls.push({ name, root: opts.root });
|
|
cb();
|
|
},
|
|
});
|
|
const realWrite = sink.write.bind(sink) as (...a: unknown[]) => unknown;
|
|
const realEnd = sink.end.bind(sink) as (...a: unknown[]) => unknown;
|
|
res.write = ((...args: unknown[]) => {
|
|
res.headersSent = true;
|
|
return realWrite(...args);
|
|
}) as unknown as typeof res.write;
|
|
res.end = ((...args: unknown[]) => {
|
|
res.headersSent = true;
|
|
return realEnd(...args);
|
|
}) as unknown as typeof res.end;
|
|
return {
|
|
res: res as unknown as MockRes['res'],
|
|
sendFileCalls,
|
|
headers,
|
|
// sendToResponse resolves on 'finish', so by the time a test reads the
|
|
// body the sink has already flushed everything into `collected`.
|
|
body: async () => Buffer.concat(collected).toString(),
|
|
};
|
|
}
|
|
|
|
describe('StorageService sendToResponse', () => {
|
|
it('serves local files via root-relative res.sendFile (the files-download quirk)', async () => {
|
|
const fx = makeFixture('files/');
|
|
await fx.storage.put('files', 'doc.pdf', Readable.from('%PDF'));
|
|
|
|
const mock = makeRes();
|
|
await fx.storage.sendToResponse('files', 'doc.pdf', mock.res, {
|
|
contentType: 'application/pdf',
|
|
disposition: 'inline',
|
|
});
|
|
|
|
expect(mock.sendFileCalls).toEqual([
|
|
{ name: 'doc.pdf', root: path.join(fs.realpathSync(fx.root), 'files') },
|
|
]);
|
|
expect(mock.headers['Content-Type']).toBe('application/pdf');
|
|
expect(mock.headers['Content-Disposition']).toBe('inline');
|
|
});
|
|
|
|
it('throws StorageNotFoundError on a miss (callers decide 404/204/next())', async () => {
|
|
const fx = makeFixture('files/');
|
|
const mock = makeRes();
|
|
await expect(fx.storage.sendToResponse('files', 'ghost.pdf', mock.res)).rejects.toBeInstanceOf(
|
|
StorageNotFoundError,
|
|
);
|
|
expect(mock.sendFileCalls).toEqual([]);
|
|
});
|
|
|
|
it('stream-pipes with Content-Length for path-less drivers', async () => {
|
|
const remote = makeStreamOnlyFixture('streamed body');
|
|
const mock = makeRes();
|
|
|
|
await remote.storage.sendToResponse('files', 'remote.bin', mock.res, { contentType: 'image/jpeg' });
|
|
|
|
expect(mock.sendFileCalls).toEqual([]); // no local fast-path available
|
|
expect(mock.headers['Content-Length']).toBe(String('streamed body'.length));
|
|
expect(mock.headers['Content-Type']).toBe('image/jpeg');
|
|
expect(await mock.body()).toBe('streamed body');
|
|
});
|
|
|
|
it('falls through to the stream branch when getLocalPath resolves but the file is not actually on disk', async () => {
|
|
// Composite-driver stand-in (task C9-i): sendToResponse must not use
|
|
// driver.stat() as "the file is at localPath" — it fs-checks localPath
|
|
// itself, and on absence falls through to the branch that actually
|
|
// reaches a MirrorDriver's replica.
|
|
const fx = makeGhostLocalPathFixture('replica bytes');
|
|
const mock = makeRes();
|
|
|
|
await fx.storage.sendToResponse('files', 'ghost.bin', mock.res, { contentType: 'image/jpeg' });
|
|
|
|
expect(mock.sendFileCalls).toEqual([]); // the local path was never trusted
|
|
expect(fx.driverCalls).toEqual(['getStream:files/ghost.bin']);
|
|
expect(mock.headers['Content-Type']).toBe('image/jpeg');
|
|
expect(await mock.body()).toBe('replica bytes');
|
|
});
|
|
|
|
// Local (res.sendFile) branch: a client abort mid-download surfaces
|
|
// through the sendFile callback's err argument, never a throw. Mirrors the
|
|
// exact contract storageStaticHandler (platform.routes.ts) implements
|
|
// caller-side for the /uploads/* static mounts.
|
|
describe('local branch — sendFile callback abort tolerance', () => {
|
|
function makeAbortRes(err: { code?: string; syscall?: string } | undefined): MockRes {
|
|
const mock = makeRes();
|
|
mock.res.sendFile = ((_name: string, _opts: unknown, cb: (e?: Error) => void) => {
|
|
cb(err as Error | undefined);
|
|
}) as unknown as Response['sendFile'];
|
|
return mock;
|
|
}
|
|
|
|
it('resolves (not rejects) when sendFile fails with ECONNABORTED', async () => {
|
|
const fx = makeFixture('files/');
|
|
await fx.storage.put('files', 'doc.pdf', Readable.from('%PDF'));
|
|
const mock = makeAbortRes({ code: 'ECONNABORTED' });
|
|
|
|
await expect(fx.storage.sendToResponse('files', 'doc.pdf', mock.res)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('resolves (not rejects) when sendFile fails with syscall write (broken pipe)', async () => {
|
|
const fx = makeFixture('files/');
|
|
await fx.storage.put('files', 'doc.pdf', Readable.from('%PDF'));
|
|
const mock = makeAbortRes({ syscall: 'write' });
|
|
|
|
await expect(fx.storage.sendToResponse('files', 'doc.pdf', mock.res)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('rejects on a real sendFile error unrelated to a client abort', async () => {
|
|
const fx = makeFixture('files/');
|
|
await fx.storage.put('files', 'doc.pdf', Readable.from('%PDF'));
|
|
const boom = Object.assign(new Error('disk read error'), { code: 'EIO' });
|
|
const mock = makeAbortRes(boom);
|
|
|
|
await expect(fx.storage.sendToResponse('files', 'doc.pdf', mock.res)).rejects.toBe(boom);
|
|
});
|
|
});
|
|
|
|
// Remote (pipeline) branch: pipeline destroys BOTH ends on any failure —
|
|
// the fix for the S3 keep-alive socket leak — while a client abort must
|
|
// still resolve the caller's promise rather than reject/500.
|
|
describe('remote branch — pipeline abort tolerance and source cleanup', () => {
|
|
function makeAbortingStreamFixture(err: { code?: string; syscall?: string; message?: string }) {
|
|
const fx = makeFixture('');
|
|
let destroyed = false;
|
|
const stream = new PassThrough();
|
|
const origDestroy = stream.destroy.bind(stream);
|
|
stream.destroy = ((e?: Error) => {
|
|
destroyed = true;
|
|
return origDestroy(e);
|
|
}) as typeof stream.destroy;
|
|
const stat: ObjectStat = { key: 'remote.bin', size: 100, mtimeMs: 1 };
|
|
const streamOnly: StorageDriver = {
|
|
id: 'stub-remote-abort',
|
|
put: async () => undefined,
|
|
getStream: async () => ({ stream, stat }),
|
|
stat: async () => stat,
|
|
delete: async () => undefined,
|
|
list: async function* () {
|
|
yield* [] as ObjectStat[];
|
|
},
|
|
};
|
|
const registry = {
|
|
resolve: (): ResolvedCategory => ({ driver: streamOnly, keyPrefix: '', backendName: 'stub-remote-abort' }),
|
|
tempDir: () => fx.tempDir,
|
|
replicaFailures: () => fx.failures,
|
|
} as unknown as StorageRegistryService;
|
|
return {
|
|
storage: new StorageService(registry),
|
|
stream,
|
|
isSourceDestroyed: () => destroyed,
|
|
fail: () => stream.destroy(Object.assign(new Error(err.message ?? 'aborted'), err)),
|
|
};
|
|
}
|
|
|
|
it('resolves and destroys the source stream on a POST-header ERR_STREAM_PREMATURE_CLOSE (real mid-download client abort)', async () => {
|
|
const fx = makeAbortingStreamFixture({});
|
|
const mock = makeRes();
|
|
const pending = fx.storage.sendToResponse('files', 'remote.bin', mock.res);
|
|
// A real "mid-download" abort only happens after at least one chunk
|
|
// has already reached res — push one through before aborting, so
|
|
// res.headersSent genuinely flips true (the gate this test exists to
|
|
// exercise), not just because the mock says so.
|
|
fx.stream.write(Buffer.from('partial'));
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(mock.res.headersSent).toBe(true);
|
|
// Destroying the destination mid-pipe is what a real client abort does
|
|
// to res — pipeline reports it as ERR_STREAM_PREMATURE_CLOSE.
|
|
mock.res.destroy();
|
|
|
|
await expect(pending).resolves.toBeUndefined();
|
|
expect(fx.isSourceDestroyed()).toBe(true); // no leaked S3 keep-alive socket
|
|
});
|
|
|
|
it('resolves (swallows) a POST-header source ECONNRESET — a genuine mid-download network drop looks identical to a client abort', async () => {
|
|
const fx = makeAbortingStreamFixture({ code: 'ECONNRESET' });
|
|
const mock = makeRes();
|
|
const pending = fx.storage.sendToResponse('files', 'remote.bin', mock.res);
|
|
fx.stream.write(Buffer.from('partial'));
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(mock.res.headersSent).toBe(true);
|
|
fx.fail();
|
|
|
|
await expect(pending).resolves.toBeUndefined();
|
|
});
|
|
|
|
// CRITICAL regression case (task C3 review): the driver's raw source
|
|
// stream (e.g. the S3 SDK's HTTP response) can itself throw an
|
|
// abort-LOOKING code (ECONNRESET/ERR_STREAM_PREMATURE_CLOSE) for a real
|
|
// reason — a network blip — with NO client involved at all, before any
|
|
// byte has reached res. The pre-fix code swallowed this unconditionally
|
|
// and resolved sendToResponse as a silent empty-body success; the fix
|
|
// gates the swallow on res.headersSent so a pre-header failure of ANY
|
|
// code — including one that looks abort-like — always rejects, and the
|
|
// caller's miss contract (404/204/next()) still runs.
|
|
it('rejects a PRE-header source ECONNRESET (a real S3 network blip, not a client abort)', async () => {
|
|
const fx = makeAbortingStreamFixture({ code: 'ECONNRESET' });
|
|
const mock = makeRes();
|
|
const pending = fx.storage.sendToResponse('files', 'remote.bin', mock.res);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(mock.res.headersSent).toBe(false); // no bytes ever reached res
|
|
fx.fail();
|
|
|
|
await expect(pending).rejects.toThrow('aborted');
|
|
});
|
|
|
|
it('rejects a PRE-header source error unrelated to any abort code', async () => {
|
|
const fx = makeAbortingStreamFixture({ code: 'EPIPE_UNRELATED', message: 'upstream reset' });
|
|
const mock = makeRes();
|
|
const pending = fx.storage.sendToResponse('files', 'remote.bin', mock.res);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(mock.res.headersSent).toBe(false);
|
|
fx.fail();
|
|
|
|
await expect(pending).rejects.toThrow('upstream reset');
|
|
});
|
|
|
|
it('rejects a POST-header source error once headers are sent but the error is not abort-like', async () => {
|
|
const fx = makeAbortingStreamFixture({ code: 'EIO', message: 'disk read error' });
|
|
const mock = makeRes();
|
|
const pending = fx.storage.sendToResponse('files', 'remote.bin', mock.res);
|
|
fx.stream.write(Buffer.from('partial'));
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(mock.res.headersSent).toBe(true);
|
|
fx.fail();
|
|
|
|
await expect(pending).rejects.toThrow('disk read error');
|
|
});
|
|
});
|
|
});
|
|
|
|
// Remote-serving parity (task C4). The local branch inherits express send's
|
|
// content-type / ETag / Last-Modified / conditional-GET / Range / HEAD
|
|
// machinery from res.sendFile; a path-less backend has none of it, so the
|
|
// stream branch has to reproduce the same contract from one pre-flight stat.
|
|
// This suite pins the wiring — the state machine itself is proved in
|
|
// http-serving.test.ts.
|
|
describe('StorageService sendToResponse — remote serving parity', () => {
|
|
const MTIME = 5000;
|
|
const LAST_MODIFIED = new Date(MTIME).toUTCString();
|
|
const BODY = 'abcdefghij'; // 10 bytes: indices 0-9
|
|
|
|
interface ServingFixture {
|
|
storage: StorageService;
|
|
getStreamCalls: Array<{ key: string; range?: ByteRange }>;
|
|
statCalls: string[];
|
|
/** Whether the most recently opened driver stream was destroyed. */
|
|
streamDestroyed: () => boolean;
|
|
}
|
|
|
|
function makeServingFixture(
|
|
opts: { body?: string; mtimeMs?: number; etag?: string; missing?: boolean; vanishesBeforeGet?: boolean } = {},
|
|
): ServingFixture {
|
|
const tempDir = makeTmpDir();
|
|
const bytes = Buffer.from(opts.body ?? BODY);
|
|
const base: ObjectStat = {
|
|
key: 'photo.jpg',
|
|
size: bytes.length,
|
|
mtimeMs: opts.mtimeMs ?? MTIME,
|
|
etag: opts.etag,
|
|
};
|
|
const getStreamCalls: Array<{ key: string; range?: ByteRange }> = [];
|
|
const statCalls: string[] = [];
|
|
let destroyed = false;
|
|
const driver: StorageDriver = {
|
|
id: 'stub-serving',
|
|
put: async () => undefined,
|
|
getStream: async (key: string, range?: ByteRange) => {
|
|
getStreamCalls.push({ key, range });
|
|
// `vanishesBeforeGet` is the stat→get delete race: the pre-flight stat
|
|
// resolved, the object was gone by the time the body was opened.
|
|
if (opts.missing || opts.vanishesBeforeGet) throw new StorageNotFoundError(key);
|
|
const slice = range ? bytes.subarray(range.start, (range.end ?? bytes.length - 1) + 1) : bytes;
|
|
// The returned stream stands in for an open S3 socket / fd: whoever
|
|
// takes it owns closing it, so destruction has to be observable.
|
|
const stream = Readable.from(slice);
|
|
const origDestroy = stream.destroy.bind(stream);
|
|
stream.destroy = ((err?: Error) => {
|
|
destroyed = true;
|
|
return origDestroy(err);
|
|
}) as typeof stream.destroy;
|
|
return { stream, stat: { ...base, key } };
|
|
},
|
|
stat: async (key: string) => {
|
|
statCalls.push(key);
|
|
return opts.missing ? null : { ...base, key };
|
|
},
|
|
delete: async () => undefined,
|
|
list: async function* () {
|
|
yield* [] as ObjectStat[];
|
|
},
|
|
// no getLocalPath — the universal fallback branch
|
|
};
|
|
const registry = {
|
|
resolve: (): ResolvedCategory => ({ driver, keyPrefix: '', backendName: 'stub-serving' }),
|
|
tempDir: () => tempDir,
|
|
replicaFailures: () => [],
|
|
} as unknown as StorageRegistryService;
|
|
return { storage: new StorageService(registry), getStreamCalls, statCalls, streamDestroyed: () => destroyed };
|
|
}
|
|
|
|
describe('content type', () => {
|
|
it('defaults Content-Type from the key extension', async () => {
|
|
const fx = makeServingFixture();
|
|
const mock = makeRes();
|
|
await fx.storage.sendToResponse('photos-trek', 'photo.jpg', mock.res);
|
|
expect(mock.headers['Content-Type']).toBe('image/jpeg');
|
|
});
|
|
|
|
it('falls back to application/octet-stream for an unknown extension', async () => {
|
|
const fx = makeServingFixture();
|
|
const mock = makeRes();
|
|
await fx.storage.sendToResponse('photos-trek', 'blob.weird', mock.res);
|
|
expect(mock.headers['Content-Type']).toBe('application/octet-stream');
|
|
});
|
|
|
|
it('lets an explicit opts.contentType win over the derived one', async () => {
|
|
const fx = makeServingFixture();
|
|
const mock = makeRes();
|
|
await fx.storage.sendToResponse('photos-trek', 'photo.jpg', mock.res, {
|
|
contentType: 'image/webp',
|
|
disposition: 'attachment; filename="p.webp"',
|
|
});
|
|
expect(mock.headers['Content-Type']).toBe('image/webp');
|
|
expect(mock.headers['Content-Disposition']).toBe('attachment; filename="p.webp"');
|
|
});
|
|
|
|
it('keeps a Content-Type the caller staged on the response itself', async () => {
|
|
// trek-photo-cache.service.ts's exact shape: its objects are
|
|
// extension-less `.bin` blobs whose real type lives in a meta table, so
|
|
// it res.set()s the type and passes no opts. Deriving over that would
|
|
// ship every S3-backed cached thumbnail as application/octet-stream.
|
|
const fx = makeServingFixture();
|
|
const mock = makeRes();
|
|
mock.res.set('Content-Type', 'image/webp');
|
|
mock.res.set('Cache-Control', 'public, max-age=3600');
|
|
|
|
await fx.storage.sendToResponse('photos-trek', 'cached.bin', mock.res);
|
|
|
|
expect(mock.headers['Content-Type']).toBe('image/webp');
|
|
expect(mock.headers['Cache-Control']).toBe('public, max-age=3600');
|
|
expect(await mock.body()).toBe(BODY);
|
|
});
|
|
|
|
it('lets opts.contentType override even a staged header', async () => {
|
|
const fx = makeServingFixture();
|
|
const mock = makeRes();
|
|
mock.res.set('Content-Type', 'image/webp');
|
|
|
|
await fx.storage.sendToResponse('photos-trek', 'cached.bin', mock.res, { contentType: 'image/avif' });
|
|
|
|
expect(mock.headers['Content-Type']).toBe('image/avif');
|
|
});
|
|
});
|
|
|
|
describe('validators', () => {
|
|
it('emits the driver ETag, Last-Modified and Accept-Ranges', async () => {
|
|
const fx = makeServingFixture({ etag: '"s3tag"' });
|
|
const mock = makeRes();
|
|
await fx.storage.sendToResponse('photos-trek', 'photo.jpg', mock.res);
|
|
expect(mock.headers['ETag']).toBe('"s3tag"');
|
|
expect(mock.headers['Last-Modified']).toBe(LAST_MODIFIED);
|
|
expect(mock.headers['Accept-Ranges']).toBe('bytes');
|
|
expect(mock.headers['Content-Length']).toBe('10');
|
|
expect(await mock.body()).toBe(BODY);
|
|
});
|
|
|
|
it('derives a weak size-mtime ETag when the driver has none', async () => {
|
|
const fx = makeServingFixture();
|
|
const mock = makeRes();
|
|
await fx.storage.sendToResponse('photos-trek', 'photo.jpg', mock.res);
|
|
expect(mock.headers['ETag']).toBe(`W/"a-${MTIME.toString(16)}"`);
|
|
});
|
|
|
|
it('emits NO ETag and NO Last-Modified when the backend reports mtimeMs 0', async () => {
|
|
// S3-compatible backends can answer without a LastModified; a 1970
|
|
// header would be "fresh forever" to any revalidating client.
|
|
const fx = makeServingFixture({ mtimeMs: 0 });
|
|
const mock = makeRes();
|
|
await fx.storage.sendToResponse('photos-trek', 'photo.jpg', mock.res);
|
|
expect(mock.headers['ETag']).toBeUndefined();
|
|
expect(mock.headers['Last-Modified']).toBeUndefined();
|
|
expect(await mock.body()).toBe(BODY); // still served, just uncached
|
|
});
|
|
});
|
|
|
|
describe('conditional GET', () => {
|
|
it('answers 304 to a matching If-None-Match with validators, no body and no getStream', async () => {
|
|
const fx = makeServingFixture({ etag: '"s3tag"' });
|
|
const mock = makeRes({ headers: { 'if-none-match': '"s3tag"' } });
|
|
|
|
await fx.storage.sendToResponse('photos-trek', 'photo.jpg', mock.res);
|
|
|
|
expect(mock.res.statusCode).toBe(304);
|
|
expect(mock.headers['ETag']).toBe('"s3tag"');
|
|
expect(mock.headers['Last-Modified']).toBe(LAST_MODIFIED);
|
|
expect(mock.headers['Content-Length']).toBeUndefined();
|
|
expect(mock.headers['Content-Type']).toBeUndefined();
|
|
expect(fx.getStreamCalls).toEqual([]); // the whole point: one HEAD, zero bytes fetched
|
|
expect(fx.statCalls).toEqual(['photo.jpg']);
|
|
expect(await mock.body()).toBe('');
|
|
});
|
|
|
|
it('answers 304 to an If-Modified-Since at or after the mtime', async () => {
|
|
const fx = makeServingFixture();
|
|
const mock = makeRes({ headers: { 'if-modified-since': LAST_MODIFIED } });
|
|
|
|
await fx.storage.sendToResponse('photos-trek', 'photo.jpg', mock.res);
|
|
|
|
expect(mock.res.statusCode).toBe(304);
|
|
expect(fx.getStreamCalls).toEqual([]);
|
|
});
|
|
|
|
it('serves 200 when the If-None-Match tag is stale', async () => {
|
|
const fx = makeServingFixture({ etag: '"s3tag"' });
|
|
const mock = makeRes({ headers: { 'if-none-match': '"old"' } });
|
|
|
|
await fx.storage.sendToResponse('photos-trek', 'photo.jpg', mock.res);
|
|
|
|
expect(mock.res.statusCode).toBe(200);
|
|
expect(await mock.body()).toBe(BODY);
|
|
});
|
|
});
|
|
|
|
describe('range requests', () => {
|
|
it('answers 206 with Content-Range, the adjusted length, and passes the range to the driver', async () => {
|
|
const fx = makeServingFixture({ etag: '"s3tag"' });
|
|
const mock = makeRes({ headers: { range: 'bytes=2-5' } });
|
|
|
|
await fx.storage.sendToResponse('photos-trek', 'photo.jpg', mock.res);
|
|
|
|
expect(mock.res.statusCode).toBe(206);
|
|
expect(mock.headers['Content-Range']).toBe('bytes 2-5/10');
|
|
expect(mock.headers['Content-Length']).toBe('4');
|
|
expect(mock.headers['Accept-Ranges']).toBe('bytes');
|
|
expect(fx.getStreamCalls).toEqual([{ key: 'photo.jpg', range: { start: 2, end: 5 } }]);
|
|
expect(await mock.body()).toBe('cdef');
|
|
});
|
|
|
|
it('resolves an open-ended range against the pre-stat size', async () => {
|
|
const fx = makeServingFixture();
|
|
const mock = makeRes({ headers: { range: 'bytes=7-' } });
|
|
|
|
await fx.storage.sendToResponse('photos-trek', 'photo.jpg', mock.res);
|
|
|
|
expect(mock.headers['Content-Range']).toBe('bytes 7-9/10');
|
|
expect(fx.getStreamCalls[0].range).toEqual({ start: 7, end: 9 });
|
|
expect(await mock.body()).toBe('hij');
|
|
});
|
|
|
|
it('clamps an end past EOF instead of asking the driver for bytes that do not exist', async () => {
|
|
const fx = makeServingFixture();
|
|
const mock = makeRes({ headers: { range: 'bytes=8-9999' } });
|
|
|
|
await fx.storage.sendToResponse('photos-trek', 'photo.jpg', mock.res);
|
|
|
|
expect(mock.headers['Content-Range']).toBe('bytes 8-9/10');
|
|
expect(fx.getStreamCalls[0].range).toEqual({ start: 8, end: 9 });
|
|
});
|
|
|
|
it('resolves a suffix range from the pre-stat size (the reason the path stats first)', async () => {
|
|
const fx = makeServingFixture();
|
|
const mock = makeRes({ headers: { range: 'bytes=-3' } });
|
|
|
|
await fx.storage.sendToResponse('photos-trek', 'photo.jpg', mock.res);
|
|
|
|
expect(mock.res.statusCode).toBe(206);
|
|
expect(mock.headers['Content-Range']).toBe('bytes 7-9/10');
|
|
expect(fx.getStreamCalls[0].range).toEqual({ start: 7, end: 9 });
|
|
expect(await mock.body()).toBe('hij');
|
|
});
|
|
|
|
it('answers 416 with Content-Range bytes */size and never touches the driver body', async () => {
|
|
const fx = makeServingFixture();
|
|
const mock = makeRes({ headers: { range: 'bytes=50-60' } });
|
|
|
|
await fx.storage.sendToResponse('photos-trek', 'photo.jpg', mock.res);
|
|
|
|
expect(mock.res.statusCode).toBe(416);
|
|
expect(mock.headers['Content-Range']).toBe('bytes */10');
|
|
expect(fx.getStreamCalls).toEqual([]);
|
|
expect(await mock.body()).toBe('');
|
|
});
|
|
|
|
it('serves the full 200 for a multi-range or malformed Range (no multipart/byteranges)', async () => {
|
|
for (const range of ['bytes=0-1,4-5', 'bytes=abc']) {
|
|
const fx = makeServingFixture();
|
|
const mock = makeRes({ headers: { range } });
|
|
|
|
await fx.storage.sendToResponse('photos-trek', 'photo.jpg', mock.res);
|
|
|
|
expect(mock.res.statusCode).toBe(200);
|
|
expect(mock.headers['Content-Range']).toBeUndefined();
|
|
expect(mock.headers['Content-Length']).toBe('10');
|
|
expect(fx.getStreamCalls[0].range).toBeUndefined();
|
|
expect(await mock.body()).toBe(BODY);
|
|
}
|
|
});
|
|
|
|
it('honours a strong If-Range match and ignores the range on a mismatch', async () => {
|
|
const hit = makeServingFixture({ etag: '"s3tag"' });
|
|
const hitRes = makeRes({ headers: { range: 'bytes=2-5', 'if-range': '"s3tag"' } });
|
|
await hit.storage.sendToResponse('photos-trek', 'photo.jpg', hitRes.res);
|
|
expect(hitRes.res.statusCode).toBe(206);
|
|
|
|
const miss = makeServingFixture({ etag: '"s3tag"' });
|
|
const missRes = makeRes({ headers: { range: 'bytes=2-5', 'if-range': '"changed"' } });
|
|
await miss.storage.sendToResponse('photos-trek', 'photo.jpg', missRes.res);
|
|
expect(missRes.res.statusCode).toBe(200);
|
|
expect(missRes.headers['Content-Length']).toBe('10');
|
|
expect(await missRes.body()).toBe(BODY);
|
|
});
|
|
});
|
|
|
|
describe('HEAD', () => {
|
|
it('sends the full headers with no body and no getStream call', async () => {
|
|
const fx = makeServingFixture({ etag: '"s3tag"' });
|
|
const mock = makeRes({ method: 'HEAD' });
|
|
|
|
await fx.storage.sendToResponse('photos-trek', 'photo.jpg', mock.res);
|
|
|
|
expect(mock.res.statusCode).toBe(200);
|
|
expect(mock.headers['Content-Length']).toBe('10');
|
|
expect(mock.headers['Content-Type']).toBe('image/jpeg');
|
|
expect(mock.headers['ETag']).toBe('"s3tag"');
|
|
expect(mock.headers['Accept-Ranges']).toBe('bytes');
|
|
expect(fx.getStreamCalls).toEqual([]);
|
|
expect(await mock.body()).toBe('');
|
|
});
|
|
|
|
it('answers a ranged HEAD with 206 headers, still without fetching bytes', async () => {
|
|
const fx = makeServingFixture();
|
|
const mock = makeRes({ method: 'HEAD', headers: { range: 'bytes=2-5' } });
|
|
|
|
await fx.storage.sendToResponse('photos-trek', 'photo.jpg', mock.res);
|
|
|
|
expect(mock.res.statusCode).toBe(206);
|
|
expect(mock.headers['Content-Range']).toBe('bytes 2-5/10');
|
|
expect(mock.headers['Content-Length']).toBe('4');
|
|
expect(fx.getStreamCalls).toEqual([]);
|
|
expect(await mock.body()).toBe('');
|
|
});
|
|
});
|
|
|
|
it('destroys the already-opened stream when header staging throws (no leaked S3 socket)', async () => {
|
|
// The body opens before headers are staged, so staging is now the last
|
|
// thing that can fail with a live stream in hand — and it CAN fail:
|
|
// res.setHeader raises ERR_INVALID_CHAR on a control character, and
|
|
// files-download.controller.ts builds Content-Disposition out of
|
|
// multer's client-supplied originalname. Nothing pipes the stream on
|
|
// that path, so nothing else would ever close it.
|
|
const fx = makeServingFixture();
|
|
const mock = makeRes();
|
|
|
|
await expect(
|
|
fx.storage.sendToResponse('photos-trek', 'photo.jpg', mock.res, {
|
|
disposition: 'attachment; filename="evil\r\nX-Injected: 1"',
|
|
}),
|
|
).rejects.toMatchObject({ code: 'ERR_INVALID_CHAR' });
|
|
|
|
expect(fx.getStreamCalls).toHaveLength(1); // the body WAS opened…
|
|
expect(fx.streamDestroyed()).toBe(true); // …and closed again before the throw escaped
|
|
expect(mock.res.headersSent).toBe(false);
|
|
});
|
|
|
|
it('leaves the response pristine when getStream fails after a ranged decision', async () => {
|
|
// The stat→get delete race on a Range request. A caller that recovers on
|
|
// the SAME response object — trek-photo-cache.service.ts falls back to a
|
|
// cache miss on `StorageNotFoundError && !res.headersSent`, and
|
|
// photo-resolver.service.ts then re-serves through that very res — would
|
|
// otherwise inherit a 206 status and a stale Content-Range from an
|
|
// attempt that never produced a byte, even if the retry serves in full.
|
|
const fx = makeServingFixture({ vanishesBeforeGet: true });
|
|
const mock = makeRes({ headers: { range: 'bytes=2-5' } });
|
|
|
|
await expect(fx.storage.sendToResponse('photos-trek', 'photo.jpg', mock.res)).rejects.toBeInstanceOf(
|
|
StorageNotFoundError,
|
|
);
|
|
|
|
expect(mock.res.headersSent).toBe(false);
|
|
expect(mock.res.statusCode).toBe(200); // never left on 206
|
|
expect(mock.headers).toEqual({}); // no Content-Range / Content-Length / validator residue
|
|
expect(fx.getStreamCalls).toEqual([{ key: 'photo.jpg', range: { start: 2, end: 5 } }]);
|
|
});
|
|
|
|
it('throws StorageNotFoundError from the pre-flight stat, before any header is set', async () => {
|
|
const fx = makeServingFixture({ missing: true });
|
|
const mock = makeRes();
|
|
|
|
await expect(fx.storage.sendToResponse('photos-trek', 'gone.jpg', mock.res)).rejects.toBeInstanceOf(
|
|
StorageNotFoundError,
|
|
);
|
|
expect(mock.headers).toEqual({});
|
|
expect(fx.getStreamCalls).toEqual([]); // the miss costs one HEAD, not a GET
|
|
});
|
|
});
|
|
|
|
describe('StorageService health', () => {
|
|
it('surfaces the registry replica-failure ring', () => {
|
|
const fx = makeFixture();
|
|
fx.failures.push({ backend: 'nas', key: 'backup-1.zip', op: 'put', error: 'disk full', at: 1 });
|
|
expect(fx.storage.health().replicaFailures).toHaveLength(1);
|
|
expect(fx.storage.health().replicaFailures[0].backend).toBe('nas');
|
|
});
|
|
});
|