socket_patch_cli/json_envelope.rs
1//! Unified JSON output envelope shared across every subcommand.
2//!
3//! Every `--json` invocation of socket-patch (whether `scan`, `apply`,
4//! `get`, `list`, `gc`/`repair`, `remove`, or `rollback`) emits the same
5//! top-level shape:
6//!
7//! ```json
8//! {
9//! "command": "scan" | "apply" | "get" | ...,
10//! "status": "success" | "partialFailure" | "error" | "noManifest" | ...,
11//! "dryRun": false,
12//! "events": [ { "action": "...", "purl": "...", ... }, ... ],
13//! "summary": { "applied": 0, "downloaded": 0, ... }
14//! // "error": { "code": ..., "message": ... } — present only on failure
15//! }
16//! ```
17//!
18//! The `events` array is the load-bearing payload — each entry describes
19//! one observable thing that happened during the run (a patch was
20//! downloaded, applied, skipped, etc.). A downstream consumer (PR-comment
21//! bot, dashboard, log shipper) only needs to learn this single vocabulary
22//! to interpret output from every subcommand.
23//!
24//! See `CLI_CONTRACT.md` for the per-subcommand action matrix and example
25//! `jq` recipes.
26
27use serde::Serialize;
28
29pub use socket_patch_core::patch::sidecars::{
30 SidecarAdvisory, SidecarAdvisoryCode, SidecarFile, SidecarFileAction, SidecarRecord,
31 SidecarSeverity,
32};
33
34/// Top-level JSON envelope emitted by every `--json` invocation.
35#[derive(Debug, Clone, Serialize)]
36#[serde(rename_all = "camelCase")]
37pub struct Envelope {
38 /// Which subcommand produced this output. Lets a generic consumer
39 /// (one that doesn't know which subcommand it's piping) route on it.
40 pub command: Command,
41 /// High-level success/failure summary. Use `Status::PartialFailure`
42 /// when at least one event has `action = Failed` but the run as a
43 /// whole completed.
44 pub status: Status,
45 /// True if the command was a preview (`--dry-run`, `--prune-dry-run`,
46 /// etc.). When true, `events` describe what *would* happen — no disk
47 /// state was modified.
48 pub dry_run: bool,
49 /// Per-patch (and per-artifact) observations from the run. Ordering
50 /// is best-effort: events appear in the order the engine produced
51 /// them, but downstream consumers should not rely on it.
52 pub events: Vec<PatchEvent>,
53 /// Aggregate counts derived from `events`. Pre-computed so consumers
54 /// don't need to re-walk the array.
55 pub summary: Summary,
56 /// Set when the command itself failed before producing meaningful
57 /// events (manifest unreadable, network unreachable in non-offline
58 /// mode, etc.). Implies `events` is empty.
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub error: Option<EnvelopeError>,
61 /// Per-package sidecar fixup records. Each entry describes what
62 /// the post-apply integrity fixup did for one package — rewriting
63 /// `.cargo-checksum.json`, deleting `.nupkg.metadata`, surfacing
64 /// an advisory for PyPI / gem / Go, etc.
65 ///
66 /// Top-level (not per-event) so consumers can iterate sidecar
67 /// outcomes directly with `jq '.sidecars[]'`. Records carry
68 /// `purl` so a consumer that needs the matching apply event can
69 /// JOIN against `events[]`.
70 ///
71 /// Empty (and omitted from JSON via `skip_serializing_if`) for
72 /// commands that don't produce sidecar work — `rollback`,
73 /// `repair`, `list`, etc. — and for apply runs against ecosystems
74 /// with no sidecar contract (e.g. npm).
75 #[serde(skip_serializing_if = "Vec::is_empty")]
76 pub sidecars: Vec<SidecarRecord>,
77}
78
79impl Envelope {
80 /// Build a fresh envelope. `summary` starts at zero — callers are
81 /// expected to push events with `Envelope::record` (or update fields
82 /// directly) so summary stays consistent with the event list.
83 pub fn new(command: Command) -> Self {
84 Self {
85 command,
86 status: Status::Success,
87 dry_run: false,
88 events: Vec::new(),
89 summary: Summary::default(),
90 error: None,
91 sidecars: Vec::new(),
92 }
93 }
94
95 /// Append an event and bump the matching summary counter. Centralizes
96 /// the "events list must agree with summary counts" invariant so per-
97 /// command code can't drift.
98 ///
99 /// Recording a `Failed` event also marks the run as a partial failure
100 /// (unless it's already a hard `Error`), enforcing the `status`
101 /// invariant documented on [`Envelope::status`] here rather than
102 /// relying on every command to remember a follow-up
103 /// `mark_partial_failure` call. A run can never end up reporting
104 /// `Success` while carrying a `Failed` event.
105 pub fn record(&mut self, event: PatchEvent) {
106 self.summary.bump(event.action);
107 if matches!(event.action, PatchAction::Failed) {
108 self.mark_partial_failure();
109 }
110 self.events.push(event);
111 }
112
113 /// Append a sidecar fixup record. Called once per `ApplyResult`
114 /// whose `sidecar` field is `Some`. Order matches the order
115 /// `apply` processed packages, which is best-effort.
116 pub fn record_sidecar(&mut self, sidecar: SidecarRecord) {
117 self.sidecars.push(sidecar);
118 }
119
120 /// Mark the run as a partial failure. Idempotent.
121 pub fn mark_partial_failure(&mut self) {
122 if !matches!(self.status, Status::Error) {
123 self.status = Status::PartialFailure;
124 }
125 }
126
127 /// Mark the run as a top-level error (replaces any prior status).
128 pub fn mark_error(&mut self, error: EnvelopeError) {
129 self.status = Status::Error;
130 self.error = Some(error);
131 }
132
133 /// Serialize as pretty JSON for printing.
134 pub fn to_pretty_json(&self) -> String {
135 serde_json::to_string_pretty(self).expect("envelope serialize")
136 }
137}
138
139/// One observable thing that happened during a run.
140#[derive(Debug, Clone, Serialize)]
141#[serde(rename_all = "camelCase")]
142pub struct PatchEvent {
143 /// What happened. See [`PatchAction`] for the full vocabulary.
144 pub action: PatchAction,
145 /// The package PURL this event is about, when applicable. Always set
146 /// for patch-level events; omitted for artifact-level events that
147 /// don't trace to a specific package.
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub purl: Option<String>,
150 /// The patch UUID, when known. Always set when the event is about a
151 /// specific patch record; omitted for cleanup events that affect
152 /// many patches at once.
153 #[serde(skip_serializing_if = "Option::is_none")]
154 pub uuid: Option<String>,
155 /// The UUID this patch replaced. Set only on `Updated` events so a
156 /// consumer can diff a manifest update — the new UUID lives in
157 /// `uuid`, the one it overwrote here. Omitted for every other action.
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub old_uuid: Option<String>,
160 /// Files touched by an `Applied` / `Verified` / `Removed` event.
161 /// Empty for actions that don't operate on files (e.g. `Downloaded`).
162 #[serde(skip_serializing_if = "Vec::is_empty")]
163 pub files: Vec<PatchEventFile>,
164 /// Human-readable explanation for `Skipped` or `Failed` events.
165 /// Machine consumers should prefer `error_code` for routing decisions.
166 #[serde(skip_serializing_if = "Option::is_none")]
167 pub reason: Option<String>,
168 /// Stable, lowercase, snake_case reason tag for programmatic routing.
169 /// Examples: `already_patched`, `package_not_installed`,
170 /// `hash_mismatch`, `no_local_source`, `paid_required`.
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub error_code: Option<String>,
173 /// Underlying error message for `Failed` events.
174 #[serde(skip_serializing_if = "Option::is_none")]
175 pub error: Option<String>,
176 /// Command-specific additional fields. Consumers MUST NOT depend on
177 /// the shape of this object — different subcommands attach different
178 /// keys here. Used today for `list` (vulnerabilities, license, tier,
179 /// description) and `scan` (discovered metadata not covered by the
180 /// other event fields).
181 #[serde(skip_serializing_if = "Option::is_none")]
182 pub details: Option<serde_json::Value>,
183}
184
185impl PatchEvent {
186 /// Construct an event with only the required `action` and `purl`.
187 /// Use the `with_*` builders to attach optional fields.
188 pub fn new(action: PatchAction, purl: impl Into<String>) -> Self {
189 Self {
190 action,
191 purl: Some(purl.into()),
192 uuid: None,
193 old_uuid: None,
194 files: Vec::new(),
195 reason: None,
196 error_code: None,
197 error: None,
198 details: None,
199 }
200 }
201
202 /// Construct an event that isn't scoped to a single package (e.g. a
203 /// repair run that swept orphan blobs).
204 pub fn artifact(action: PatchAction) -> Self {
205 Self {
206 action,
207 purl: None,
208 uuid: None,
209 old_uuid: None,
210 files: Vec::new(),
211 reason: None,
212 error_code: None,
213 error: None,
214 details: None,
215 }
216 }
217
218 pub fn with_uuid(mut self, uuid: impl Into<String>) -> Self {
219 self.uuid = Some(uuid.into());
220 self
221 }
222
223 /// Attach the UUID this event's patch replaced. Use on `Updated`
224 /// events so consumers can diff against the prior manifest entry;
225 /// serializes as `oldUuid`.
226 pub fn with_old_uuid(mut self, old_uuid: impl Into<String>) -> Self {
227 self.old_uuid = Some(old_uuid.into());
228 self
229 }
230
231 pub fn with_files(mut self, files: Vec<PatchEventFile>) -> Self {
232 self.files = files;
233 self
234 }
235
236 pub fn with_reason(
237 mut self,
238 code: impl Into<String>,
239 message: impl Into<String>,
240 ) -> Self {
241 self.error_code = Some(code.into());
242 self.reason = Some(message.into());
243 self
244 }
245
246 pub fn with_error(
247 mut self,
248 code: impl Into<String>,
249 message: impl Into<String>,
250 ) -> Self {
251 self.error_code = Some(code.into());
252 self.error = Some(message.into());
253 self
254 }
255
256 /// Attach command-specific extra fields. See [`PatchEvent::details`]
257 /// for the contract — consumers should not depend on the shape.
258 pub fn with_details(mut self, details: serde_json::Value) -> Self {
259 self.details = Some(details);
260 self
261 }
262}
263
264/// One file referenced by a patch event.
265#[derive(Debug, Clone, Serialize)]
266#[serde(rename_all = "camelCase")]
267pub struct PatchEventFile {
268 /// Path relative to the package directory (e.g. `package/index.js`).
269 pub path: String,
270 /// True if the file's content was verified to match the expected
271 /// hash. For an `Applied` event this means post-write verification
272 /// succeeded; for `Verified` (dry-run) it means pre-write hashes
273 /// matched expectation.
274 pub verified: bool,
275 /// Which strategy produced the patched bytes — only set for `Applied`
276 /// events. One of `package`, `diff`, `blob`.
277 #[serde(skip_serializing_if = "Option::is_none")]
278 pub applied_via: Option<AppliedVia>,
279}
280
281/// What kind of thing happened to a patch.
282///
283/// Serializes to camelCase strings — e.g. `Applied` → `"applied"`,
284/// `Downloaded` → `"downloaded"` (a hypothetical multi-word variant would
285/// lower-camel, e.g. `FooBar` → `"fooBar"`). The full vocabulary is part of
286/// the CLI contract; new variants are MINOR-safe but renames are MAJOR.
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
288#[serde(rename_all = "camelCase")]
289pub enum PatchAction {
290 /// `scan`: a patch exists upstream for this package, but no action
291 /// taken yet (no `--apply` / `--sync`).
292 Discovered,
293 /// `get` / `scan --apply` / `apply` (online): patch bytes were
294 /// fetched from the registry.
295 Downloaded,
296 /// `apply` / `scan --sync`: patch was applied to disk. `files`
297 /// enumerates which files changed.
298 Applied,
299 /// `apply` / `scan --sync`: patch replaced an older patch (the
300 /// manifest already had a different UUID for this PURL). `oldUuid`
301 /// carries the previous UUID.
302 Updated,
303 /// `apply` / `scan` / `get`: the patch was a no-op — already
304 /// applied, not in scope, or filtered out. `errorCode` carries the
305 /// reason tag.
306 Skipped,
307 /// Any command: an attempt failed. `errorCode` is the routing tag,
308 /// `error` is the human message.
309 Failed,
310 /// `gc` / `repair` / `remove` / `rollback`: data was removed from
311 /// `.socket/` (or from disk in the rollback case).
312 Removed,
313 /// `apply --dry-run` / `scan --dry-run`: patch *would* apply
314 /// cleanly. `files` lists what would change.
315 Verified,
316}
317
318/// Patch-source strategy used to apply a file. Mirrors the existing
319/// `socket_patch_core::patch::apply::AppliedVia` enum, but lives here so
320/// the JSON layer doesn't depend on core internals.
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
322#[serde(rename_all = "camelCase")]
323pub enum AppliedVia {
324 Package,
325 Diff,
326 Blob,
327}
328
329impl AppliedVia {
330 pub fn from_core(via: socket_patch_core::patch::apply::AppliedVia) -> Self {
331 use socket_patch_core::patch::apply::AppliedVia as Core;
332 match via {
333 Core::Package => AppliedVia::Package,
334 Core::Diff => AppliedVia::Diff,
335 Core::Blob => AppliedVia::Blob,
336 }
337 }
338}
339
340/// Which subcommand produced the envelope. Serializes lowercase.
341#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
342#[serde(rename_all = "camelCase")]
343pub enum Command {
344 Apply,
345 Rollback,
346 Get,
347 Scan,
348 List,
349 Remove,
350 Repair,
351 Setup,
352 Unlock,
353 Vex,
354}
355
356
357/// Top-level status. Serializes camelCase.
358#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
359#[serde(rename_all = "camelCase")]
360pub enum Status {
361 Success,
362 PartialFailure,
363 Error,
364 /// Special case for `apply`: the manifest doesn't exist yet, so
365 /// there's nothing to apply. Distinct from `Success` because some
366 /// consumers want to early-exit on this state.
367 NoManifest,
368 /// `get` / `scan`: the requested patch requires a paid plan but the
369 /// caller's API token isn't entitled. Distinct from `Error` so PR
370 /// bots can post a "upgrade your plan" comment instead of failing.
371 PaidRequired,
372 /// `remove` / `rollback`: the patch identifier didn't resolve to
373 /// anything in the local manifest.
374 NotFound,
375}
376
377/// Pre-aggregated counts across all events in this envelope. Field names
378/// match `PatchAction` variants for clarity.
379#[derive(Debug, Clone, Default, Serialize)]
380#[serde(rename_all = "camelCase")]
381pub struct Summary {
382 pub discovered: u32,
383 pub downloaded: u32,
384 pub applied: u32,
385 pub updated: u32,
386 pub skipped: u32,
387 pub failed: u32,
388 pub removed: u32,
389 pub verified: u32,
390}
391
392impl Summary {
393 fn bump(&mut self, action: PatchAction) {
394 match action {
395 PatchAction::Discovered => self.discovered += 1,
396 PatchAction::Downloaded => self.downloaded += 1,
397 PatchAction::Applied => self.applied += 1,
398 PatchAction::Updated => self.updated += 1,
399 PatchAction::Skipped => self.skipped += 1,
400 PatchAction::Failed => self.failed += 1,
401 PatchAction::Removed => self.removed += 1,
402 PatchAction::Verified => self.verified += 1,
403 }
404 }
405}
406
407/// Top-level error payload set when the command failed before producing
408/// patch events.
409#[derive(Debug, Clone, Serialize)]
410#[serde(rename_all = "camelCase")]
411pub struct EnvelopeError {
412 /// Routing tag — examples: `manifest_unreadable`, `network_error`,
413 /// `not_found`, `paid_required`.
414 pub code: String,
415 /// Human-readable message.
416 pub message: String,
417}
418
419impl EnvelopeError {
420 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
421 Self {
422 code: code.into(),
423 message: message.into(),
424 }
425 }
426}
427
428// ---------------------------------------------------------------------------
429// Tests — pin the JSON serialization shape that downstream consumers see.
430// ---------------------------------------------------------------------------
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 #[test]
437 fn action_tags_round_trip() {
438 // Each variant's serde representation must match the
439 // documented snake_case tag.
440 for (action, tag) in [
441 (PatchAction::Discovered, "discovered"),
442 (PatchAction::Downloaded, "downloaded"),
443 (PatchAction::Applied, "applied"),
444 (PatchAction::Updated, "updated"),
445 (PatchAction::Skipped, "skipped"),
446 (PatchAction::Failed, "failed"),
447 (PatchAction::Removed, "removed"),
448 (PatchAction::Verified, "verified"),
449 ] {
450 let serialized = serde_json::to_string(&action).unwrap();
451 assert_eq!(serialized, format!("\"{tag}\""));
452 }
453 }
454
455 #[test]
456 fn empty_envelope_has_stable_shape() {
457 let env = Envelope::new(Command::Scan);
458 let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
459 let mut keys: Vec<&str> = v.as_object().unwrap().keys().map(|s| s.as_str()).collect();
460 keys.sort();
461 // `error` is skipped when None, so it shouldn't appear.
462 assert_eq!(keys, vec!["command", "dryRun", "events", "status", "summary"]);
463 assert_eq!(v["command"], "scan");
464 assert_eq!(v["status"], "success");
465 assert_eq!(v["dryRun"], false);
466 assert_eq!(v["events"].as_array().unwrap().len(), 0);
467 }
468
469 #[test]
470 fn record_keeps_summary_in_sync() {
471 let mut env = Envelope::new(Command::Apply);
472 env.record(PatchEvent::new(PatchAction::Applied, "pkg:npm/foo@1.0.0"));
473 env.record(PatchEvent::new(PatchAction::Downloaded, "pkg:npm/foo@1.0.0"));
474 env.record(
475 PatchEvent::new(PatchAction::Skipped, "pkg:npm/bar@2.0.0")
476 .with_reason("already_patched", "Files match afterHash"),
477 );
478
479 assert_eq!(env.summary.applied, 1);
480 assert_eq!(env.summary.downloaded, 1);
481 assert_eq!(env.summary.skipped, 1);
482 assert_eq!(env.events.len(), 3);
483 }
484
485 #[test]
486 fn recording_failed_event_marks_partial_failure() {
487 // The `status` invariant — "PartialFailure when any event has
488 // action = Failed" — must be enforced by `record` itself, not
489 // left to each command to remember. Otherwise a Success envelope
490 // can carry a `failed` event (and a non-zero `summary.failed`).
491 let mut env = Envelope::new(Command::Apply);
492 env.record(PatchEvent::new(PatchAction::Applied, "pkg:npm/foo@1.0.0"));
493 assert_eq!(env.status, Status::Success);
494 env.record(
495 PatchEvent::new(PatchAction::Failed, "pkg:npm/bar@2.0.0")
496 .with_error("apply_failed", "boom"),
497 );
498 assert_eq!(env.status, Status::PartialFailure);
499 assert_eq!(env.summary.failed, 1);
500 }
501
502 #[test]
503 fn recording_failed_event_does_not_demote_hard_error() {
504 // A prior hard error outranks the per-event partial failure that
505 // `record` raises — recording a Failed event must not downgrade
506 // Error to PartialFailure regardless of ordering.
507 let mut env = Envelope::new(Command::Apply);
508 env.mark_error(EnvelopeError::new("manifest_unreadable", "bad json"));
509 env.record(
510 PatchEvent::new(PatchAction::Failed, "pkg:npm/bar@2.0.0")
511 .with_error("apply_failed", "boom"),
512 );
513 assert_eq!(env.status, Status::Error);
514 }
515
516 #[test]
517 fn updated_event_carries_old_uuid() {
518 // The CLI contract promises `oldUuid` on `updated` events. The
519 // new UUID lives in `uuid`; the replaced one in `oldUuid`.
520 let event = PatchEvent::new(PatchAction::Updated, "pkg:npm/foo@1.0.0")
521 .with_uuid("uuid-new")
522 .with_old_uuid("uuid-old");
523 let v: serde_json::Value =
524 serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
525 assert_eq!(v["action"], "updated");
526 assert_eq!(v["uuid"], "uuid-new");
527 assert_eq!(v["oldUuid"], "uuid-old");
528 }
529
530 #[test]
531 fn old_uuid_omitted_when_unset() {
532 // Non-Updated events must not leak an `oldUuid` key.
533 let event = PatchEvent::new(PatchAction::Applied, "pkg:npm/foo@1.0.0");
534 let v: serde_json::Value =
535 serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
536 assert!(!v.as_object().unwrap().contains_key("oldUuid"));
537 }
538
539 #[test]
540 fn skipped_event_omits_uuid_and_files() {
541 let event = PatchEvent::new(PatchAction::Skipped, "pkg:npm/foo@1.0.0")
542 .with_reason("package_not_installed", "no matching package on disk");
543 let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
544 let obj = v.as_object().unwrap();
545 assert!(!obj.contains_key("uuid"));
546 assert!(!obj.contains_key("files"));
547 assert!(!obj.contains_key("oldUuid"));
548 assert!(!obj.contains_key("error"));
549 assert_eq!(obj.get("errorCode").and_then(|v| v.as_str()), Some("package_not_installed"));
550 assert_eq!(obj.get("reason").and_then(|v| v.as_str()), Some("no matching package on disk"));
551 }
552
553 #[test]
554 fn applied_event_with_files_includes_applied_via() {
555 let event = PatchEvent::new(PatchAction::Applied, "pkg:npm/foo@1.0.0")
556 .with_uuid("uuid-2222")
557 .with_files(vec![
558 PatchEventFile {
559 path: "package/index.js".into(),
560 verified: true,
561 applied_via: Some(AppliedVia::Diff),
562 },
563 PatchEventFile {
564 path: "package/lib/util.js".into(),
565 verified: true,
566 applied_via: Some(AppliedVia::Blob),
567 },
568 ]);
569 let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
570 let files = v["files"].as_array().unwrap();
571 assert_eq!(files.len(), 2);
572 assert_eq!(files[0]["path"], "package/index.js");
573 assert_eq!(files[0]["verified"], true);
574 assert_eq!(files[0]["appliedVia"], "diff");
575 assert_eq!(files[1]["appliedVia"], "blob");
576 }
577
578 #[test]
579 fn mark_partial_failure_does_not_clobber_error() {
580 let mut env = Envelope::new(Command::Apply);
581 env.mark_error(EnvelopeError::new("manifest_unreadable", "bad json"));
582 env.mark_partial_failure();
583 // mark_error wins — we don't want a sequence of marks to demote
584 // a hard error to a partial failure.
585 assert_eq!(env.status, Status::Error);
586 }
587
588 #[test]
589 fn top_level_error_serializes_inline() {
590 let mut env = Envelope::new(Command::Get);
591 env.mark_error(EnvelopeError::new("paid_required", "Patch requires paid plan"));
592 let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
593 assert_eq!(v["status"], "error");
594 assert_eq!(v["error"]["code"], "paid_required");
595 assert_eq!(v["error"]["message"], "Patch requires paid plan");
596 }
597
598 #[test]
599 fn status_serializes_camel_case() {
600 // PartialFailure is the high-traffic one — confirm camelCase.
601 let mut env = Envelope::new(Command::Apply);
602 env.mark_partial_failure();
603 let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
604 assert_eq!(v["status"], "partialFailure");
605 }
606
607 #[test]
608 fn artifact_event_omits_purl() {
609 // GC sweep events aren't scoped to a single PURL.
610 let event = PatchEvent::artifact(PatchAction::Removed)
611 .with_reason("orphan_blob", "Blob not referenced by any manifest entry");
612 let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
613 let obj = v.as_object().unwrap();
614 assert!(!obj.contains_key("purl"));
615 assert_eq!(obj["action"], "removed");
616 assert_eq!(obj["errorCode"], "orphan_blob");
617 }
618}