aevum-cli 0.7.1__tar.gz → 0.7.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aevum-cli
3
- Version: 0.7.1
3
+ Version: 0.7.2
4
4
  Summary: Aevum -- command-line interface for operating Aevum nodes.
5
5
  Project-URL: Homepage, https://aevum.build
6
6
  Project-URL: Repository, https://github.com/aevum-labs/aevum
7
7
  License: Apache-2.0
8
- Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Development Status :: 4 - Beta
9
9
  Classifier: Intended Audience :: Developers
10
10
  Classifier: License :: OSI Approved :: Apache Software License
11
11
  Classifier: Programming Language :: Python :: 3.11
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "aevum-cli"
3
- version = "0.7.1"
3
+ version = "0.7.2"
4
4
  description = "Aevum -- command-line interface for operating Aevum nodes."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
7
7
  license = { text = "Apache-2.0" }
8
8
  classifiers = [
9
- "Development Status :: 3 - Alpha",
9
+ "Development Status :: 4 - Beta",
10
10
  "Intended Audience :: Developers",
11
11
  "License :: OSI Approved :: Apache Software License",
12
12
  "Programming Language :: Python :: 3.11",
@@ -87,18 +87,42 @@ def init(
87
87
 
88
88
  @app.command()
89
89
  def verify(
90
- session_id: Annotated[str, typer.Argument(help="Session ID to verify")],
90
+ receipt_or_session: Annotated[
91
+ str,
92
+ typer.Argument(
93
+ help=(
94
+ "Session ID to verify (queries local DB), "
95
+ "or path to a receipt JSON file (no DB required)."
96
+ )
97
+ ),
98
+ ],
91
99
  state_dir: Annotated[
92
100
  Path,
93
101
  typer.Option("--state-dir", "-s"),
94
102
  ] = _DEFAULT_STATE,
95
103
  ) -> None:
96
104
  """
97
- Verify a session's Merkle root and signatures.
105
+ Verify a session or receipt file.
106
+
107
+ Session mode: aevum verify <session_id>
108
+ Re-reads stored events, recomputes Merkle root. Requires local DB.
109
+
110
+ Receipt mode: aevum verify receipt.json
111
+ Reads a self-contained receipt file (produced by `aevum receipt`).
112
+ Verifies hash chain without accessing a local DB.
98
113
 
99
- Re-reads the stored session events from SQLite, recomputes the
100
- Merkle root, and compares it to the signed root in the sigchain.
114
+ Exit 0 = VERIFIED. Exit 1 = TAMPERED or not found.
101
115
  """
116
+ receipt_path = Path(receipt_or_session)
117
+ if receipt_path.exists() and receipt_path.suffix in (".cbor", ".cose"):
118
+ _verify_cose_receipt(receipt_path)
119
+ elif receipt_path.exists() and receipt_path.suffix == ".json":
120
+ _verify_receipt_file(receipt_path)
121
+ else:
122
+ _verify_session(receipt_or_session, state_dir)
123
+
124
+
125
+ def _verify_session(session_id: str, state_dir: Path) -> None:
102
126
  db_path = state_dir / "aevum.db"
103
127
  if not db_path.exists():
104
128
  typer.echo(f"Database not found: {db_path}", err=True)
@@ -131,6 +155,215 @@ def verify(
131
155
  raise typer.Exit(code=1) from None
132
156
 
133
157
 
158
+ def _compute_merkle_from_entries(entries: list[dict[str, Any]]) -> str:
159
+ """Replicates SessionRecord.compute_merkle_root() over receipt entry dicts."""
160
+ if not entries:
161
+ return hashlib.sha256(b"").hexdigest()
162
+ sorted_entries = sorted(entries, key=lambda e: e["sequence"])
163
+ leaves = [
164
+ hashlib.sha256((e["input_hash"] + e["output_hash"]).encode("ascii")).hexdigest()
165
+ for e in sorted_entries
166
+ ]
167
+ current = leaves
168
+ while len(current) > 1:
169
+ next_level: list[str] = []
170
+ for i in range(0, len(current), 2):
171
+ left = current[i]
172
+ right = current[i + 1] if i + 1 < len(current) else left
173
+ next_level.append(hashlib.sha256((left + right).encode("ascii")).hexdigest())
174
+ current = next_level
175
+ return current[0]
176
+
177
+
178
+ def _verify_cose_receipt(receipt_path: Path) -> None:
179
+ """Verify a COSE_Sign1 receipt file (.cbor / .cose) via the verify command."""
180
+ try:
181
+ import cbor2
182
+ import nacl.exceptions
183
+ import nacl.signing
184
+ from aevum.core.receipt import AevumReceipt
185
+ except ImportError as exc:
186
+ typer.echo(f"Missing dependency: {exc}", err=True)
187
+ raise typer.Exit(code=1) from None
188
+
189
+ try:
190
+ raw = receipt_path.read_bytes()
191
+ cose = cbor2.loads(raw)
192
+ except Exception as exc: # noqa: BLE001
193
+ typer.echo(f"INVALID: cannot read or decode {receipt_path}: {exc}", err=True)
194
+ raise typer.Exit(code=1) from None
195
+
196
+ if not isinstance(cose, list) or len(cose) != 4:
197
+ typer.echo("INVALID: not a 4-element COSE_Sign1 array", err=True)
198
+ raise typer.Exit(code=1)
199
+
200
+ protected_bstr, unprotected, payload_bstr, signature_bytes = cose
201
+
202
+ try:
203
+ protected = cbor2.loads(protected_bstr)
204
+ except Exception as exc: # noqa: BLE001
205
+ typer.echo(f"INVALID: cannot decode protected header: {exc}", err=True)
206
+ raise typer.Exit(code=1) from None
207
+
208
+ alg = protected.get(1)
209
+ if alg != -8:
210
+ typer.echo(
211
+ f"UNSUPPORTED ALGORITHM: alg={alg!r} (expected -8 for EdDSA/Ed25519)", err=True
212
+ )
213
+ raise typer.Exit(code=2)
214
+
215
+ try:
216
+ receipt = AevumReceipt.model_validate(cbor2.loads(payload_bstr))
217
+ except Exception as exc: # noqa: BLE001
218
+ typer.echo(f"INVALID: cannot decode receipt payload: {exc}", err=True)
219
+ raise typer.Exit(code=1) from None
220
+
221
+ sig_structure = cbor2.dumps(["Signature1", protected_bstr, b"", payload_bstr])
222
+ digest = hashlib.sha3_256(sig_structure).digest()
223
+
224
+ pub_key_bytes: bytes | None = None
225
+ ed25519_pub_path = _DEFAULT_STATE / "ed25519.pub"
226
+ if ed25519_pub_path.exists():
227
+ pub_key_bytes = ed25519_pub_path.read_bytes()
228
+
229
+ if pub_key_bytes is None:
230
+ typer.echo(
231
+ typer.style("STRUCTURE VALID (signature not checked)", fg=typer.colors.YELLOW)
232
+ )
233
+ typer.echo(" Set ~/.aevum/ed25519.pub to verify the Ed25519 signature.")
234
+ typer.echo(f" Action: {receipt.action}")
235
+ typer.echo(f" Principal: {receipt.principal}")
236
+ typer.echo(f" At: {receipt.occurred_at}")
237
+ return
238
+
239
+ try:
240
+ verify_key = nacl.signing.VerifyKey(pub_key_bytes)
241
+ verify_key.verify(digest, bytes(signature_bytes))
242
+ except nacl.exceptions.BadSignatureError:
243
+ typer.echo(typer.style("TAMPERED", fg=typer.colors.RED), err=True)
244
+ raise typer.Exit(code=1) from None
245
+ except Exception as exc: # noqa: BLE001
246
+ typer.echo(f"SIGNATURE INVALID: {exc}", err=True)
247
+ raise typer.Exit(code=1) from None
248
+
249
+ typer.echo(
250
+ typer.style(
251
+ f"Session {receipt.agent_id[:12]}... VERIFIED", fg=typer.colors.GREEN
252
+ )
253
+ )
254
+ typer.echo(f" Action: {receipt.action}")
255
+ typer.echo(f" Principal: {receipt.principal}")
256
+ typer.echo(f" At: {receipt.occurred_at}")
257
+
258
+
259
+ def _verify_receipt_file(receipt_path: Path) -> None:
260
+ """Verify a receipt JSON file without database access."""
261
+ try:
262
+ receipt = json.loads(receipt_path.read_text(encoding="utf-8"))
263
+ except (json.JSONDecodeError, OSError) as exc:
264
+ typer.echo(f"Cannot read receipt file: {exc}", err=True)
265
+ raise typer.Exit(code=1) from None
266
+
267
+ required = {"session_id", "entry_count", "entries", "exported_at", "merkle_root"}
268
+ missing = required - set(receipt.keys())
269
+ if missing:
270
+ typer.echo(f"Invalid receipt: missing fields {sorted(missing)}", err=True)
271
+ raise typer.Exit(code=1)
272
+
273
+ session_id = receipt["session_id"]
274
+ entries = receipt["entries"]
275
+ entry_count = receipt["entry_count"]
276
+ stored_root = receipt["merkle_root"]
277
+
278
+ if len(entries) != entry_count:
279
+ typer.echo(
280
+ typer.style(f"Session {session_id[:12]}... TAMPERED", fg=typer.colors.RED),
281
+ err=True,
282
+ )
283
+ typer.echo(
284
+ f" Entry count mismatch: expected {entry_count}, found {len(entries)}",
285
+ err=True,
286
+ )
287
+ raise typer.Exit(code=1)
288
+
289
+ try:
290
+ recomputed_root = _compute_merkle_from_entries(entries)
291
+ except (KeyError, TypeError) as exc:
292
+ typer.echo(f"Invalid receipt entries: {exc}", err=True)
293
+ raise typer.Exit(code=1) from None
294
+
295
+ if recomputed_root != stored_root:
296
+ typer.echo(
297
+ typer.style(f"Session {session_id[:12]}... TAMPERED", fg=typer.colors.RED),
298
+ err=True,
299
+ )
300
+ typer.echo(" Merkle root mismatch:", err=True)
301
+ typer.echo(f" Stored: {stored_root[:16]}...", err=True)
302
+ typer.echo(f" Recomputed: {recomputed_root[:16]}...", err=True)
303
+ raise typer.Exit(code=1)
304
+
305
+ typer.echo(typer.style(f"Session {session_id[:12]}... VERIFIED", fg=typer.colors.GREEN))
306
+ typer.echo(f" Merkle root: {recomputed_root[:16]}...")
307
+ typer.echo(f" Entries: {len(entries)}")
308
+ typer.echo(f" Exported at: {receipt.get('exported_at', 'unknown')}")
309
+
310
+
311
+ @app.command()
312
+ def receipt(
313
+ session_id: Annotated[str, typer.Argument(help="Session ID to export as a receipt")],
314
+ state_dir: Annotated[
315
+ Path,
316
+ typer.Option("--state-dir", "-s"),
317
+ ] = _DEFAULT_STATE,
318
+ ) -> None:
319
+ """
320
+ Export a self-contained JSON receipt for a session.
321
+
322
+ Prints JSON to stdout — redirect to save:
323
+ aevum receipt <session_id> > proof.json
324
+
325
+ Verify the receipt offline (no DB required):
326
+ aevum verify proof.json
327
+ """
328
+ import datetime as dt
329
+
330
+ db_path = state_dir / "aevum.db"
331
+ if not db_path.exists():
332
+ typer.echo(f"Database not found: {db_path}", err=True)
333
+ raise typer.Exit(code=1)
334
+
335
+ try:
336
+ from aevum.core.replay import ReplayEngine
337
+ engine = ReplayEngine(db_path)
338
+ record = engine.load_session_record(session_id)
339
+
340
+ entries = [
341
+ {
342
+ "sequence": ev.sequence,
343
+ "event_type": ev.event_type.value,
344
+ "input_hash": ev.input_hash,
345
+ "output_hash": ev.output_hash,
346
+ "occurred_at": ev.occurred_at.isoformat(),
347
+ "latency_ms": ev.latency_ms,
348
+ }
349
+ for ev in record.events
350
+ ]
351
+
352
+ receipt_doc = {
353
+ "session_id": record.session_id,
354
+ "exported_at": dt.datetime.now(dt.UTC).isoformat(),
355
+ "entry_count": len(entries),
356
+ "merkle_root": record.merkle_root,
357
+ "entries": entries,
358
+ }
359
+
360
+ typer.echo(json.dumps(receipt_doc, indent=2))
361
+
362
+ except ValueError as exc:
363
+ typer.echo(f"Session not found: {exc}", err=True)
364
+ raise typer.Exit(code=1) from None
365
+
366
+
134
367
  @app.command(name="audit-pack")
135
368
  def audit_pack(
136
369
  session_id: Annotated[str, typer.Argument(help="Session ID")],
@@ -321,7 +321,7 @@ class TestCLIHelp:
321
321
  result = runner.invoke(app, ["--help"])
322
322
  assert result.exit_code == 0
323
323
  clean = strip_ansi(result.output)
324
- for cmd in ("init", "verify", "audit-pack", "conform", "replay"):
324
+ for cmd in ("init", "verify", "receipt", "audit-pack", "conform", "replay"):
325
325
  assert cmd in clean
326
326
 
327
327
  def test_help_still_shows_existing_commands(self) -> None:
@@ -332,7 +332,7 @@ class TestCLIHelp:
332
332
  assert cmd in clean
333
333
 
334
334
  def test_each_new_command_has_help(self) -> None:
335
- for cmd in ("init", "verify", "audit-pack", "conform", "replay"):
335
+ for cmd in ("init", "verify", "receipt", "audit-pack", "conform", "replay"):
336
336
  result = runner.invoke(app, [cmd, "--help"])
337
337
  assert result.exit_code == 0, f"--help failed for command: {cmd}"
338
338
 
@@ -350,3 +350,210 @@ class TestCLIHelp:
350
350
  result = runner.invoke(app, ["replay", "--help"])
351
351
  assert result.exit_code == 0
352
352
  assert "verbose" in strip_ansi(result.output).lower()
353
+
354
+
355
+ _EMPTY_MERKLE = hashlib.sha256(b"").hexdigest()
356
+
357
+ _DB_SCHEMA = """
358
+ CREATE TABLE sessions (
359
+ session_id TEXT PRIMARY KEY, commit_type TEXT,
360
+ principal TEXT, purpose TEXT, started_at TEXT,
361
+ closed_at TEXT, event_count INTEGER, fact_count INTEGER,
362
+ checkpoint_count INTEGER, merkle_root TEXT,
363
+ ed25519_sig TEXT, mldsa65_sig TEXT, ed25519_pub TEXT,
364
+ mldsa65_pub TEXT, tsa_token TEXT, sigchain_entry_id INTEGER
365
+ );
366
+ CREATE TABLE session_events (
367
+ event_id TEXT PRIMARY KEY, session_id TEXT,
368
+ sequence INTEGER, event_type TEXT, occurred_at TEXT,
369
+ input_hash TEXT, output_hash TEXT, latency_ms INTEGER
370
+ );
371
+ """
372
+
373
+
374
+ def _receipt_merkle_root(entries: list[dict]) -> str: # type: ignore[type-arg]
375
+ """Compute the same Merkle root as _compute_merkle_from_entries for test fixtures."""
376
+ if not entries:
377
+ return hashlib.sha256(b"").hexdigest()
378
+ sorted_entries = sorted(entries, key=lambda e: e["sequence"])
379
+ leaves = [
380
+ hashlib.sha256((e["input_hash"] + e["output_hash"]).encode("ascii")).hexdigest()
381
+ for e in sorted_entries
382
+ ]
383
+ current = leaves
384
+ while len(current) > 1:
385
+ nxt = []
386
+ for i in range(0, len(current), 2):
387
+ left = current[i]
388
+ right = current[i + 1] if i + 1 < len(current) else left
389
+ nxt.append(hashlib.sha256((left + right).encode("ascii")).hexdigest())
390
+ current = nxt
391
+ return current[0]
392
+
393
+
394
+ def _make_receipt_doc(
395
+ session_id: str = "test-session-001",
396
+ n_entries: int = 0,
397
+ tamper_count: bool = False,
398
+ tamper_root: bool = False,
399
+ ) -> dict: # type: ignore[type-arg]
400
+ entries = []
401
+ for i in range(n_entries):
402
+ entries.append({
403
+ "sequence": i,
404
+ "event_type": "relate",
405
+ "input_hash": hashlib.sha256(f"in-{i}".encode()).hexdigest(),
406
+ "output_hash": hashlib.sha256(f"out-{i}".encode()).hexdigest(),
407
+ "occurred_at": "2026-01-01T00:00:00+00:00",
408
+ "latency_ms": 5,
409
+ })
410
+ return {
411
+ "session_id": session_id,
412
+ "exported_at": "2026-01-01T01:00:00+00:00",
413
+ "entry_count": len(entries) + (1 if tamper_count else 0),
414
+ "merkle_root": ("a" * 64) if tamper_root else _receipt_merkle_root(entries),
415
+ "entries": entries,
416
+ }
417
+
418
+
419
+ class TestReceiptCommand:
420
+ def _make_db(self, tmp_path: Path, session_id: str = "sess-r01") -> None:
421
+ db = tmp_path / "aevum.db"
422
+ conn = sqlite3.connect(str(db))
423
+ conn.executescript(_DB_SCHEMA)
424
+ now = datetime.now(UTC).isoformat()
425
+ conn.execute(
426
+ "INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?,?,?,NULL,NULL,NULL,NULL,NULL,NULL)",
427
+ (session_id, "complete", "alice", "test", now, now, 0, 0, 0, _EMPTY_MERKLE),
428
+ )
429
+ conn.commit()
430
+ conn.close()
431
+
432
+ def test_receipt_help(self) -> None:
433
+ result = runner.invoke(app, ["receipt", "--help"])
434
+ assert result.exit_code == 0
435
+ assert "receipt" in strip_ansi(result.output).lower()
436
+
437
+ def test_receipt_missing_db_exits_1(self, tmp_path: Path) -> None:
438
+ result = runner.invoke(
439
+ app, ["receipt", "sess-r01", "--state-dir", str(tmp_path)]
440
+ )
441
+ assert result.exit_code == 1
442
+ assert "not found" in strip_ansi(result.output).lower()
443
+
444
+ def test_receipt_valid_session_outputs_json(self, tmp_path: Path) -> None:
445
+ self._make_db(tmp_path)
446
+ result = runner.invoke(
447
+ app, ["receipt", "sess-r01", "--state-dir", str(tmp_path)]
448
+ )
449
+ assert result.exit_code == 0
450
+ doc = json.loads(strip_ansi(result.output))
451
+ assert doc["session_id"] == "sess-r01"
452
+
453
+ def test_receipt_output_has_required_fields(self, tmp_path: Path) -> None:
454
+ self._make_db(tmp_path)
455
+ result = runner.invoke(
456
+ app, ["receipt", "sess-r01", "--state-dir", str(tmp_path)]
457
+ )
458
+ assert result.exit_code == 0
459
+ doc = json.loads(strip_ansi(result.output))
460
+ for field in ("session_id", "entry_count", "entries", "exported_at", "merkle_root"):
461
+ assert field in doc, f"Missing field: {field}"
462
+
463
+ def test_receipt_empty_session_has_correct_merkle_root(self, tmp_path: Path) -> None:
464
+ self._make_db(tmp_path)
465
+ result = runner.invoke(
466
+ app, ["receipt", "sess-r01", "--state-dir", str(tmp_path)]
467
+ )
468
+ assert result.exit_code == 0
469
+ doc = json.loads(strip_ansi(result.output))
470
+ assert doc["entry_count"] == 0
471
+ assert doc["merkle_root"] == _EMPTY_MERKLE
472
+
473
+ def test_receipt_missing_session_exits_1(self, tmp_path: Path) -> None:
474
+ self._make_db(tmp_path)
475
+ result = runner.invoke(
476
+ app, ["receipt", "no-such-session", "--state-dir", str(tmp_path)]
477
+ )
478
+ assert result.exit_code == 1
479
+
480
+
481
+ class TestVerifyReceiptFile:
482
+ def _write_receipt(self, tmp_path: Path, doc: dict) -> Path: # type: ignore[type-arg]
483
+ f = tmp_path / "receipt.json"
484
+ f.write_text(json.dumps(doc), encoding="utf-8")
485
+ return f
486
+
487
+ def test_verify_receipt_file_valid_exits_0(self, tmp_path: Path) -> None:
488
+ f = self._write_receipt(tmp_path, _make_receipt_doc())
489
+ result = runner.invoke(app, ["verify", str(f)])
490
+ assert result.exit_code == 0
491
+ assert "VERIFIED" in strip_ansi(result.output)
492
+
493
+ def test_verify_receipt_file_with_entries_exits_0(self, tmp_path: Path) -> None:
494
+ f = self._write_receipt(tmp_path, _make_receipt_doc(n_entries=2))
495
+ result = runner.invoke(app, ["verify", str(f)])
496
+ assert result.exit_code == 0
497
+ assert "VERIFIED" in strip_ansi(result.output)
498
+
499
+ def test_verify_receipt_file_shows_merkle_root(self, tmp_path: Path) -> None:
500
+ f = self._write_receipt(tmp_path, _make_receipt_doc(n_entries=2))
501
+ result = runner.invoke(app, ["verify", str(f)])
502
+ assert result.exit_code == 0
503
+ assert "Merkle root:" in strip_ansi(result.output)
504
+
505
+ def test_verify_receipt_file_tampered_count_exits_1(self, tmp_path: Path) -> None:
506
+ f = self._write_receipt(tmp_path, _make_receipt_doc(n_entries=2, tamper_count=True))
507
+ result = runner.invoke(app, ["verify", str(f)])
508
+ assert result.exit_code == 1
509
+ assert "TAMPERED" in strip_ansi(result.output)
510
+
511
+ def test_verify_receipt_file_tampered_root_exits_1(self, tmp_path: Path) -> None:
512
+ f = self._write_receipt(tmp_path, _make_receipt_doc(n_entries=2, tamper_root=True))
513
+ result = runner.invoke(app, ["verify", str(f)])
514
+ assert result.exit_code == 1
515
+ assert "TAMPERED" in strip_ansi(result.output)
516
+
517
+ def test_verify_receipt_file_missing_fields_exits_1(self, tmp_path: Path) -> None:
518
+ f = tmp_path / "partial.json"
519
+ f.write_text('{"session_id": "x", "entries": []}', encoding="utf-8")
520
+ result = runner.invoke(app, ["verify", str(f)])
521
+ assert result.exit_code == 1
522
+
523
+ def test_verify_receipt_file_bad_json_exits_1(self, tmp_path: Path) -> None:
524
+ f = tmp_path / "bad.json"
525
+ f.write_bytes(b"not valid json {{{")
526
+ result = runner.invoke(app, ["verify", str(f)])
527
+ assert result.exit_code == 1
528
+
529
+ def test_verify_dispatches_to_session_mode_for_nonfile(self, tmp_path: Path) -> None:
530
+ result = runner.invoke(
531
+ app, ["verify", "sess-abc-not-a-file", "--state-dir", str(tmp_path)]
532
+ )
533
+ assert result.exit_code == 1
534
+ assert "not found" in strip_ansi(result.output).lower()
535
+
536
+ def test_verify_receipt_roundtrip(self, tmp_path: Path) -> None:
537
+ """Receipt produced by aevum receipt verifies cleanly with aevum verify."""
538
+ db = tmp_path / "aevum.db"
539
+ conn = sqlite3.connect(str(db))
540
+ conn.executescript(_DB_SCHEMA)
541
+ now = datetime.now(UTC).isoformat()
542
+ conn.execute(
543
+ "INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?,?,?,NULL,NULL,NULL,NULL,NULL,NULL)",
544
+ ("roundtrip-01", "complete", "alice", "test", now, now, 0, 0, 0, _EMPTY_MERKLE),
545
+ )
546
+ conn.commit()
547
+ conn.close()
548
+
549
+ export_result = runner.invoke(
550
+ app, ["receipt", "roundtrip-01", "--state-dir", str(tmp_path)]
551
+ )
552
+ assert export_result.exit_code == 0
553
+
554
+ receipt_file = tmp_path / "roundtrip.json"
555
+ receipt_file.write_text(export_result.output, encoding="utf-8")
556
+
557
+ verify_result = runner.invoke(app, ["verify", str(receipt_file)])
558
+ assert verify_result.exit_code == 0
559
+ assert "VERIFIED" in strip_ansi(verify_result.output)
File without changes
File without changes
File without changes