diff --git a/.changes/020-dedup-no-blind-rewrite.md b/.changes/020-dedup-no-blind-rewrite.md new file mode 100644 index 000000000..960a6d89a --- /dev/null +++ b/.changes/020-dedup-no-blind-rewrite.md @@ -0,0 +1,14 @@ +--- +group: platform +component: Framework +impact: compatible +category: Fixed +--- + +## Summary + +A duplicate file upload no longer rewrites the existing stored blob when the blob store's existence check returns an error. The upload instead fails with "Failed to verify stored file content. Please retry." Backend details are logged at error level via `Log.critical`. A check that reports the blob present still reuses it; a check that reports it missing still restores the uploaded content. This does not change how the Local backend reports existence-check results. + +A concurrent upload that loses a state-store conflict may already have written its own provisional blob. It attempts to delete that provisional blob; it does not rewrite the winner's existing blob when the winner's existence check returns an error. This check does not prevent another actor from removing a blob later. + +No code or data changes are needed in applications. To verify locally, run `./dev test "ContentDedup" nhcore-test-service` and confirm `dedup fails closed when the existence check errors` passes. diff --git a/codemap/api-hot.md b/codemap/api-hot.md index 0f4a16cbc..462deea94 100644 --- a/codemap/api-hot.md +++ b/codemap/api-hot.md @@ -10,10 +10,10 @@ codemap/signatures/ · type-directed search: ./dev api "" ## Task -- `yield :: value -> Task w value` +- `yield :: value -> Task w value` - `ignoreError :: Task err Unit -> Task w Unit` - `mapError :: (err1 -> err2) -> Task err1 value -> Task err2 value` -- `throw :: err -> Task err w` +- `throw :: err -> Task err w` - `fromIO :: IO value -> Task w value` - `asResult :: Task err value -> Task err2 (Result err value)` - `when :: Bool -> Task err Unit -> Task err Unit` @@ -72,10 +72,10 @@ codemap/signatures/ · type-directed search: ./dev api "" ## Log - `debug :: HasCallStack => Text -> Task w Unit` -- `warn :: HasCallStack => Text -> Task w Unit` +- `warn :: HasCallStack => Text -> Task w Unit` - `withScope :: Array (Text, Text) -> Task err value -> Task err value` - `info :: HasCallStack => Text -> Task w Unit` -- `critical :: HasCallStack => Text -> Task w Unit` +- `critical :: HasCallStack => Text -> Task w Unit` ## Map @@ -294,19 +294,19 @@ when WRITING TESTS; they are not feature-code frequency signal. ## Task -- `mapError :: (err1 -> err2) -> Task err1 value -> Task err2 value` -- `yield :: value -> Task w value` -- `asResult :: Task err value -> Task err2 (Result err value)` +- `mapError :: (err1 -> err2) -> Task err1 value -> Task err2 value` +- `yield :: value -> Task w value` +- `asResult :: Task err value -> Task err2 (Result err value)` - `mapArray :: (element -> Task err output) -> Array element -> Task err (Array output)` -- `throw :: err -> Task err w` +- `throw :: err -> Task err w` - `andThen :: (input -> Task err output) -> Task err input -> Task err output` - `fromIO :: IO value -> Task w value` - `map :: (input -> output) -> Task err input -> Task err output` ## Array -- `length :: Array a -> Int` -- `empty :: Array a` +- `length :: Array a -> Int` +- `empty :: Array a` - `fromLinkedList :: LinkedList a -> Array a` - `map :: (a -> b) -> Array a -> Array b` - `get :: Int -> Array a -> Maybe a` @@ -316,8 +316,8 @@ when WRITING TESTS; they are not feature-code frequency signal. ## Text -- `contains :: Text -> Text -> Bool` -- `toBytes :: Text -> Bytes` +- `contains :: Text -> Text -> Bool` +- `toBytes :: Text -> Bytes` - `fromLinkedList :: LinkedList Char -> Text` - `length :: Text -> Int` - `repeat :: Int -> Text -> Text` @@ -360,9 +360,9 @@ when WRITING TESTS; they are not feature-code frequency signal. ## ConcurrentVar -- `containing :: value -> Task w (ConcurrentVar value)` -- `modify :: (value -> value) -> ConcurrentVar value -> Task w Unit` -- `peek :: ConcurrentVar value -> Task w value` +- `containing :: value -> Task w (ConcurrentVar value)` +- `modify :: (value -> value) -> ConcurrentVar value -> Task w Unit` +- `peek :: ConcurrentVar value -> Task w value` - `get :: ConcurrentVar value -> Task w value` - `set :: value -> ConcurrentVar value -> Task w ()` - `new :: forall value w. Task w (ConcurrentVar value)` diff --git a/core/service/Service/FileUpload/Web.hs b/core/service/Service/FileUpload/Web.hs index 1b46441d0..dcf603611 100644 --- a/core/service/Service/FileUpload/Web.hs +++ b/core/service/Service/FileUpload/Web.hs @@ -420,28 +420,30 @@ normalUploadFlow config blobStore stateStore ownerHash filename contentType cont -- | Guard a deduplication match against blob loss (issue #713). -- -- Before returning an existing 'FileRef', verify its blob is still present in --- the 'BlobStore'; if it is missing — or its presence cannot be confirmed (the --- existence check itself errored) — re-store the caller's content under the --- SAME blob key so the returned reference is never dangling. Healing in place --- (same key, no state-store write) preserves dedup identity and is robust on --- every backend: unlike falling through to a fresh upload, it cannot collide --- with the (owner, content_hash) unique constraint and re-return the stale ref. +-- the 'BlobStore'; only when the store confirms it is missing, re-store the +-- caller's content under the SAME blob key so the returned reference is never +-- dangling. Healing in place (same key, no state-store write) preserves dedup +-- identity and is robust on every backend: unlike falling through to a fresh +-- upload, it cannot collide with the (owner, content_hash) unique constraint +-- and re-return the stale ref. -- -- The re-stored bytes are the caller's own content, matched to the existing -- entry by the same owner-scoped content hash, so healing never crosses owners --- or writes different content. 'BlobStore.store' overwrites idempotently, so a --- redundant re-store (e.g. after a transient existence-check error) is harmless. +-- or writes different content. +-- +-- An errored existence check leaves presence unknown: rewriting then would +-- overwrite a blob we cannot see (and, on S3, pay for the transfer), so the +-- request fails instead. The real error stays in the server log; the client +-- sees a generic message (S8). ensureBlobPresent :: BlobStore -> BlobKey -> Bytes -> Task Text Unit ensureBlobPresent blobStore blobKey content = do existsResult <- blobStore.exists blobKey |> Task.asResult blobPresent <- case existsResult of Ok present -> Task.yield present Err existsErr -> do - -- Presence unknown: keep the real error in the server log, then treat as - -- missing and heal (re-store is idempotent). - Log.warn [fmt|Dedup blob existence check errored (#{show existsErr}); treating as missing|] + Log.critical [fmt|Dedup blob existence check errored (#{show existsErr}); refusing to rewrite|] |> Task.ignoreError - Task.yield False + Task.throw "Failed to verify stored file content. Please retry." Task.unless blobPresent do Log.warn "Deduplicated blob unavailable; self-healing by re-storing content" |> Task.ignoreError diff --git a/core/test/Service/FileUpload/ContentDedupSpec.hs b/core/test/Service/FileUpload/ContentDedupSpec.hs index 0b6418585..3b0349552 100644 --- a/core/test/Service/FileUpload/ContentDedupSpec.hs +++ b/core/test/Service/FileUpload/ContentDedupSpec.hs @@ -1,6 +1,8 @@ module Service.FileUpload.ContentDedupSpec where +import Array qualified import Bytes qualified +import ConcurrentVar qualified import Core import Data.Char (isDigit) import Directory qualified @@ -228,6 +230,24 @@ spec = do response2 <- uploadFile env "owner1" "file.txt" "text/plain" content response2.blobKey |> shouldBe response1.blobKey + it "duplicate upload of a present blob makes no store call" \_ -> do + withTestDedupEnv \env -> do + let content = "duplicate no-store content" |> Text.toBytes + response1 <- uploadFile env "owner1" "file.txt" "text/plain" content + storeCalls <- ConcurrentVar.containing (0 :: Int) + let observedBlobStore = + (env.blobStore) + { store = \key bytes -> do + ConcurrentVar.modify (\n -> n + 1) storeCalls + env.blobStore.store key bytes + } + response2 <- + handleUploadImpl env.config observedBlobStore env.stateStore "owner1" "file.txt" "text/plain" content + response2.fileRef |> shouldBe response1.fileRef + response2.blobKey |> shouldBe response1.blobKey + writes <- ConcurrentVar.peek storeCalls + writes |> shouldBe 0 + it "duplicate upload returns original metadata (filename from first upload)" \_ -> do withTestDedupEnv \env -> do let content = "duplicate filename content" |> Text.toBytes @@ -338,10 +358,12 @@ spec = do -- Blob Loss Self-Heal (issue #713) -- -- Dedup must never return a FileRef whose blob has been lost. Before - -- returning a match, the blob's presence is verified; if it is missing - -- (or its presence cannot be confirmed), the content is re-stored under - -- the SAME blob key so the reference is healed in place — no new FileRef, - -- no state mutation, robust for both in-memory and Postgres backends. + -- returning a match, the blob's presence is verified; only if the store + -- confirms it is missing is the content re-stored under the SAME blob key + -- so the reference is healed in place — no new FileRef, no state + -- mutation, robust for both in-memory and Postgres backends. When the + -- existence check itself errors, presence is unknown: the upload fails + -- closed and nothing is re-stored. -- ========================================================================== describe "Blob Loss Self-Heal (issue #713)" do it "dedup self-heals a missing Pending blob on re-upload" \_ -> do @@ -387,21 +409,82 @@ spec = do |> Task.mapError (\e -> [fmt|retrieve failed: #{show e}|]) computeContentHash restored |> shouldBe (computeContentHash content) - it "dedup self-heals when the existence check errors" \_ -> do + it "dedup fails closed when the existence check errors" \_ -> do withTestDedupEnv \env -> do - let content = "self-heal on exists-error content" |> Text.toBytes + let content = "fail-closed on exists-error content" |> Text.toBytes response1 <- uploadFile env "owner1" "file.txt" "text/plain" content - env.blobStore.delete response1.blobKey - |> Task.mapError (\e -> [fmt|delete failed: #{show e}|]) - -- Make the existence check itself fail; heal must still occur + -- Presence is unknown, not known-missing: the blob is still there, + -- only the check fails. The upload must not rewrite it blindly. + storeCalls <- ConcurrentVar.containing (0 :: Int) let faultingBlobStore = (env.blobStore) - { exists = \_ -> Task.throw (StorageError "injected exists failure") } - response2 <- + { exists = \_ -> Task.throw (StorageError "injected exists failure") + , store = \_ _ -> ConcurrentVar.modify (\n -> n + 1) storeCalls + } + result <- handleUploadImpl env.config faultingBlobStore env.stateStore "owner1" "file.txt" "text/plain" content - blobPresent <- env.blobStore.exists response2.blobKey - |> Task.mapError (\e -> [fmt|exists failed: #{show e}|]) - blobPresent |> shouldBe True + |> Task.asResult + case result of + Ok _ -> fail "expected the upload to fail when blob presence is unknown" + Err msg -> do + msg |> shouldBe "Failed to verify stored file content. Please retry." + (Text.contains "injected" msg) |> shouldBe False + rewrites <- ConcurrentVar.peek storeCalls + rewrites |> shouldBe 0 + -- The original blob is untouched and still served. + restored <- env.blobStore.retrieve response1.blobKey + |> Task.mapError (\e -> [fmt|retrieve failed: #{show e}|]) + restored |> shouldBe content + + it "dedup conflict retry cleans only its provisional blob when winner check errors" \_ -> do + withTestDedupEnv \env -> do + let content = "conflict retry winner content" |> Text.toBytes + winner <- uploadFile env "owner1" "file.txt" "text/plain" content + lookups <- ConcurrentVar.containing (0 :: Int) + storedKeys <- ConcurrentVar.containing (Array.empty :: Array BlobKey) + deletedKeys <- ConcurrentVar.containing (Array.empty :: Array BlobKey) + let racingStateStore = + (env.stateStore) + { findByContentHash = \owner contentHash -> do + count <- ConcurrentVar.peek lookups + ConcurrentVar.modify (\value -> value + 1) lookups + if count == 0 + then Task.yield Nothing + else env.stateStore.findByContentHash owner contentHash + , updateState = \_ _ -> Task.throw "injected unique constraint violation (23505)" + } + let observedBlobStore = + (env.blobStore) + { exists = \_ -> Task.throw (StorageError "injected winner exists failure") + , store = \key bytes -> do + ConcurrentVar.modify (Array.push key) storedKeys + env.blobStore.store key bytes + , delete = \key -> do + env.blobStore.delete key + ConcurrentVar.modify (Array.push key) deletedKeys + } + result <- + handleUploadImpl env.config observedBlobStore racingStateStore "owner1" "file.txt" "text/plain" content + |> Task.asResult + case result of + Ok _ -> fail "expected the conflict retry to fail when the winner check errors" + Err message -> message |> shouldBe "Failed to verify stored file content. Please retry." + lookupCount <- ConcurrentVar.peek lookups + lookupCount |> shouldBe 2 + writes <- ConcurrentVar.peek storedKeys + deletes <- ConcurrentVar.peek deletedKeys + Array.length writes |> shouldBe 1 + deletes |> shouldBe writes + case Array.first writes of + Nothing -> fail "expected a provisional blob key" + Just provisionalKey -> do + provisionalKey |> shouldNotBe winner.blobKey + provisionalPresent <- env.blobStore.exists provisionalKey + |> Task.mapError (\err -> [fmt|exists failed: #{show err}|]) + provisionalPresent |> shouldBe False + winnerContent <- env.blobStore.retrieve winner.blobKey + |> Task.mapError (\err -> [fmt|retrieve failed: #{show err}|]) + winnerContent |> shouldBe content it "dedup surfaces a generic error (no dangling ref) when re-store fails" \_ -> do withTestDedupEnv \env -> do diff --git a/docs/changes/001-fileupload-dedup-blob-existence-check.md b/docs/changes/001-fileupload-dedup-blob-existence-check.md index 15c0050ed..8c7cdb5d0 100644 --- a/docs/changes/001-fileupload-dedup-blob-existence-check.md +++ b/docs/changes/001-fileupload-dedup-blob-existence-check.md @@ -8,7 +8,9 @@ content keeps short-circuiting to a `FileRef` whose blob is gone, and every downstream read fails permanently with `File blob is missing from storage`. This change makes dedup self-healing: before returning a match, verify the blob exists and, if it does not, re-store the content under the same blob key so the -returned reference is never dangling. +matched blob is restored when the backend reports it missing. This is a +check-time guarantee, not a promise that no other actor can later remove the +blob; change 020 separately defines the returned-error boundary. ```yaml spec issue: issue#713 # issue#NNN or adhoc: @@ -41,7 +43,7 @@ losing a blob), so the reproductions are `integration`. | C1 | Re-uploading content whose deduplicated **Pending** blob was lost returns a `FileRef` whose blob is present again (self-heal), not a dangling ref | `hspec:nhcore-test-service:core/test/Service/FileUpload/ContentDedupSpec.hs#dedup self-heals a missing Pending blob on re-upload` | integration | filesystem:real | | C2 | Same self-heal when the dedup match is a **Confirmed** file | `hspec:nhcore-test-service:core/test/Service/FileUpload/ContentDedupSpec.hs#dedup self-heals a missing Confirmed blob on re-upload` | integration | filesystem:real | | C3 | Self-heal preserves dedup identity — same `FileRef` and same `blobKey` as the original — and the re-stored blob retrieves the exact uploaded content | `hspec:nhcore-test-service:core/test/Service/FileUpload/ContentDedupSpec.hs#dedup self-heal preserves FileRef/blobKey and restores exact content` | integration | filesystem:real | -| C4 | When the existence check itself errors, dedup still heals (re-stores) rather than returning a possibly-dangling ref | `hspec:nhcore-test-service:core/test/Service/FileUpload/ContentDedupSpec.hs#dedup self-heals when the existence check errors` | integration | filesystem:real | +| C4 | When the existence check itself errors, dedup fails closed (no blind re-store, generic client error) — behavior revised by change 020 | `hspec:nhcore-test-service:core/test/Service/FileUpload/ContentDedupSpec.hs#dedup fails closed when the existence check errors` | integration | filesystem:real | | C5 | Healthy path preserved — when the blob is present, a duplicate upload still returns the existing `FileRef`/`blobKey` unchanged | `hspec:nhcore-test-service:core/test/Service/FileUpload/ContentDedupSpec.hs#duplicate upload returns same blobKey as original` | integration | filesystem:real | ## User impact @@ -74,8 +76,9 @@ missing-blob guard (`ensureBlobPresent`) is applied at **all four** sites that return a pre-existing `FileRef` — the two dedup branches (Pending/Confirmed) and the two 23505 concurrent-race retry branches in `normalUploadFlow` — since the race-retry path returns a stored `blobKey` with the same absent check and is the -same defect class. The race branches only fire under the Postgres unique -constraint, so they are covered structurally by the shared helper (proven by -C1–C4 on the in-memory store) rather than by a separate, non-deterministic -concurrency criterion. `handleDownloadImpl`'s `File blob is missing from storage` +same defect class. The real race branches use the Postgres unique +constraint. Change 020 C4 additionally tests the retry path with an injected +`23505` result and real filesystem cleanup; it is a deterministic regression, +not a PostgreSQL concurrency test. The shared helper also provides structural +coverage across Pending and Confirmed retry states. `handleDownloadImpl`'s `File blob is missing from storage` remains a detect-only backstop, unchanged. diff --git a/docs/changes/020-dedup-no-blind-rewrite.md b/docs/changes/020-dedup-no-blind-rewrite.md new file mode 100644 index 000000000..df5a7d6e0 --- /dev/null +++ b/docs/changes/020-dedup-no-blind-rewrite.md @@ -0,0 +1,113 @@ +# Change 020: Stop dedup rewrites when the blob existence check returns an error + +Change 001 made file-upload deduplication self-healing: when a duplicate +upload matches an existing `FileRef`, `ensureBlobPresent` checks that the blob +is still in the `BlobStore` and re-stores it if it is gone. One branch of that +helper goes too far: when the **existence check itself errors**, it logs a +warning, treats the blob as missing and re-stores it. On a remote object store +(S3, proposed in change 017), a failed existence check can therefore trigger +an unnecessary overwrite and transfer on each retry. The maintainer's review +of the S3 work asked to "adjust it so that it doesn't rewrite stuff". This +change keeps the self-heal for a *confirmed* missing blob and fails the request +when the existence check returns `Err`. This does not change the Local +backend's existence-test semantics: `Ok False` still selects self-healing, +including when that backend reports an inaccessible path as absent. + +```yaml spec +issue: adhoc:dedup-no-blind-rewrite +kind: bug +touches: [file-upload] +breaking: false +new-dependency: false +new-capability: false +new-extension-point: false +``` + +## Contract delta + +Internal-only fix inside the non-exported helper `ensureBlobPresent` in +`Service.FileUpload.Web`. No public signature changes. + +```diff signatures +``` + +### Behavior + +| `exists` result | Before (change 001) | After | +|---|---|---| +| `Ok True` | return existing ref | unchanged | +| `Ok False` | `Log.warn`, re-`store`, return ref | unchanged | +| `Err e` | `Log.warn`, treat as missing, re-`store` | `Log.critical` with the real error, **no store call**, request fails with the generic message `"Failed to verify stored file content. Please retry."` (same S8 convention as the existing re-store failure path: detail in the server log only) | + +The 23505 concurrent-race retry branches in `normalUploadFlow` are untouched; +they call the same helper and inherit the new rule. That path still writes +(and then best-effort deletes) its own provisional blob before the retry +lookup reaches the helper; the guarantee here is only that the deduplicated +existing blob is not rewritten when its existence check returns `Err`. +A successful check describes presence at check time; it cannot guarantee that +another actor will not remove the blob before or after the upload returns. + +### Scoped principles and regression checks + +- Branch on the backend result, not an inferred filesystem condition: C1 + checks returned `Err`; C2 preserves `Ok False` healing for both file states; + C3 preserves `Ok True` identity and zero stores. +- Keep client errors exact and generic: C1 checks the complete message and + original bytes. Backend details stay in the server log at error level via + `Log.critical`. +- Count writes by blob identity in the conflict path: C4 forces first lookup + miss, an injected `23505` state update, retry lookup hit and winner `exists` + error. It checks one provisional store and successful deletion of that same + key, absence after deletion, winner exclusion, unchanged winner bytes and + C1's exact error. This is real filesystem I/O with an injected conflict, + not proof of a PostgreSQL race. Cleanup remains best-effort if deletion fails. + +No Local error-classification change, locking redesign or permanent blob +presence guarantee is included. + +## Criteria + +`kind: bug`: C1 is the reproduction. It replaces the test +`dedup self-heals when the existence check errors` from change 001 (C4 there), +whose expectation encoded the behavior this change removes. **Rewording an +existing expectation requires maintainer approval** (AGENTS.md +non-negotiable; `scripts/guards/expectation-guard.py` flags it): the +maintainer must create `.agents/allow-expectation-edits` locally or apply the +`expectations-approved` label on the PR. Change 001's C4 row and the attested +match in `docs/changes/test-surfaces.json` are updated to the new test name so +`./dev spec-check` stays consistent. + +| ID | Behavior | Proving test | Level | Boundary | +|----|----------|--------------|-------|----------| +| C1 | When `exists` errors, the upload fails with exactly the generic message `"Failed to verify stored file content. Please retry."` (no backend error leaked), `store` is never called, and the original blob is untouched | `hspec:nhcore-test-service:core/test/Service/FileUpload/ContentDedupSpec.hs#dedup fails closed when the existence check errors` | integration | filesystem:real | +| C2 | Confirmed-missing blobs still self-heal (Pending and Confirmed matches) | `hspec:nhcore-test-service:core/test/Service/FileUpload/ContentDedupSpec.hs#dedup self-heals a missing Pending blob on re-upload`
`hspec:nhcore-test-service:core/test/Service/FileUpload/ContentDedupSpec.hs#dedup self-heals a missing Confirmed blob on re-upload` | integration | filesystem:real | +| C3 | Healthy path preserved: a present blob returns the existing `FileRef`/`blobKey` without a store call | `hspec:nhcore-test-service:core/test/Service/FileUpload/ContentDedupSpec.hs#duplicate upload of a present blob makes no store call` | integration | filesystem:real | +| C4 | Injected conflict retry fails with C1’s exact error after successfully deleting only its provisional blob; two lookups, one store/delete at the same non-winner key, winner bytes unchanged | `hspec:nhcore-test-service:core/test/Service/FileUpload/ContentDedupSpec.hs#dedup conflict retry cleans only its provisional blob when winner check errors` | integration | filesystem:real | + +## User impact + +None breaking. A duplicate upload whose blob-store existence check fails now +returns an error ("Failed to verify stored file content. Please retry.") +instead of silently rewriting the blob. The client retries; the operator sees +the real backend error at error level via `Log.critical` in the server log. +Uploads whose existence check reports present or missing behave exactly as +before. Local backend existence-test semantics are unchanged. + +## ADR + +Not required — no trigger (breaking / new-dependency / new-capability / +new-extension-point all false). The rule is the standard fail-closed one: +do not rewrite the existing key when its existence check returns `Err`. + +### Continuation approval and historical red evidence + +The first published change020 commit contains spec, test and implementation +together. That history does not show a red-only commit; it does not establish +that a private red receipt never existed. The maintainer must verify a genuine +receipt or explicitly decide how to accept a clearly labelled after-the-fact +reproduction. Such a reproduction is new evidence, not historical red-first +compliance, and must not rewrite published history to simulate it. The current +fixed head is not a new red reproduction. Existing `expectations-approved` +authorization is limited to the previously approved expectation change, not a +new Gate 1 continuation or final Gate 2. No additional behavior or expectation +changes are authorized by this proposal. diff --git a/docs/changes/test-surfaces.json b/docs/changes/test-surfaces.json index 08eb0ae1f..71bf4779d 100644 --- a/docs/changes/test-surfaces.json +++ b/docs/changes/test-surfaces.json @@ -155,7 +155,16 @@ "kind": "hspec", "suite": "nhcore-test-service", "path": "core/test/Service/FileUpload/ContentDedupSpec.hs", - "match": "dedup self-heals when the existence check errors", + "match": "dedup fails closed when the existence check errors", + "boundary": "filesystem:real", + "expected_examples": 1, + "fixture": "Service.FileUpload.ContentDedup" + }, + { + "kind": "hspec", + "suite": "nhcore-test-service", + "path": "core/test/Service/FileUpload/ContentDedupSpec.hs", + "match": "dedup conflict retry cleans only its provisional blob when winner check errors", "boundary": "filesystem:real", "expected_examples": 1, "fixture": "Service.FileUpload.ContentDedup" @@ -169,6 +178,15 @@ "expected_examples": 1, "fixture": "Service.FileUpload.ContentDedup" }, + { + "kind": "hspec", + "suite": "nhcore-test-service", + "path": "core/test/Service/FileUpload/ContentDedupSpec.hs", + "match": "duplicate upload of a present blob makes no store call", + "boundary": "filesystem:real", + "expected_examples": 1, + "fixture": "Service.FileUpload.ContentDedup" + }, { "kind": "hspec", "suite": "nhcore-test-service",