devnomads-cli 0.9.0__tar.gz → 0.10.0__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.
Files changed (20) hide show
  1. {devnomads_cli-0.9.0 → devnomads_cli-0.10.0}/PKG-INFO +17 -3
  2. {devnomads_cli-0.9.0 → devnomads_cli-0.10.0}/README.md +16 -2
  3. {devnomads_cli-0.9.0 → devnomads_cli-0.10.0}/devnomads_cli.egg-info/PKG-INFO +17 -3
  4. {devnomads_cli-0.9.0 → devnomads_cli-0.10.0}/dncli.py +116 -8
  5. {devnomads_cli-0.9.0 → devnomads_cli-0.10.0}/pyproject.toml +1 -1
  6. {devnomads_cli-0.9.0 → devnomads_cli-0.10.0}/tests/test_cert.py +62 -0
  7. {devnomads_cli-0.9.0 → devnomads_cli-0.10.0}/tests/test_cli.py +86 -0
  8. {devnomads_cli-0.9.0 → devnomads_cli-0.10.0}/LICENSE +0 -0
  9. {devnomads_cli-0.9.0 → devnomads_cli-0.10.0}/devnomads_cli.egg-info/SOURCES.txt +0 -0
  10. {devnomads_cli-0.9.0 → devnomads_cli-0.10.0}/devnomads_cli.egg-info/dependency_links.txt +0 -0
  11. {devnomads_cli-0.9.0 → devnomads_cli-0.10.0}/devnomads_cli.egg-info/entry_points.txt +0 -0
  12. {devnomads_cli-0.9.0 → devnomads_cli-0.10.0}/devnomads_cli.egg-info/requires.txt +0 -0
  13. {devnomads_cli-0.9.0 → devnomads_cli-0.10.0}/devnomads_cli.egg-info/top_level.txt +0 -0
  14. {devnomads_cli-0.9.0 → devnomads_cli-0.10.0}/setup.cfg +0 -0
  15. {devnomads_cli-0.9.0 → devnomads_cli-0.10.0}/tests/test_config.py +0 -0
  16. {devnomads_cli-0.9.0 → devnomads_cli-0.10.0}/tests/test_generate.py +0 -0
  17. {devnomads_cli-0.9.0 → devnomads_cli-0.10.0}/tests/test_generated_cli.py +0 -0
  18. {devnomads_cli-0.9.0 → devnomads_cli-0.10.0}/tests/test_helpers.py +0 -0
  19. {devnomads_cli-0.9.0 → devnomads_cli-0.10.0}/tests/test_hook.py +0 -0
  20. {devnomads_cli-0.9.0 → devnomads_cli-0.10.0}/tests/test_transfer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: Manage your DevNomads services from the command line
5
5
  Author-email: DevNomads <support@devnomads.nl>
6
6
  License: MIT
@@ -81,6 +81,18 @@ dncli dns records set example.com www A 192.0.2.1 --ttl 3600
81
81
  dncli dns records delete example.com www A
82
82
  ```
83
83
 
84
+ Anywhere a zone is expected you may pass the service id of a domain
85
+ instead of its name, so the ids from `dncli domains list` work here
86
+ too:
87
+
88
+ ```sh
89
+ dncli dns records list 42
90
+ ```
91
+
92
+ An id always means that domain's own zone. A delegated subzone such
93
+ as `foo.example.com` has no domain service of its own, so address it
94
+ by name.
95
+
84
96
  ## Moving a zone from another provider
85
97
 
86
98
  `dncli dns transfer` copies a zone from your current DNS provider
@@ -210,8 +222,10 @@ The `.pfx` is unencrypted unless you pass `--passphrase` (alias
210
222
  certificate, and the issuing intermediate.
211
223
 
212
224
  `dncli certs delete <domain>` removes a stored certificate and its
213
- private key from disk (after confirmation; `--yes` skips it). It only
214
- deletes the local files - the certificate itself is not revoked.
225
+ private key from disk (after confirmation; `--yes` skips it). By
226
+ default it only deletes the local files; add `--revoke` to also revoke
227
+ the certificate at the CA that issued it (the files are kept if
228
+ revocation fails).
215
229
 
216
230
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
217
231
 
@@ -65,6 +65,18 @@ dncli dns records set example.com www A 192.0.2.1 --ttl 3600
65
65
  dncli dns records delete example.com www A
66
66
  ```
67
67
 
68
+ Anywhere a zone is expected you may pass the service id of a domain
69
+ instead of its name, so the ids from `dncli domains list` work here
70
+ too:
71
+
72
+ ```sh
73
+ dncli dns records list 42
74
+ ```
75
+
76
+ An id always means that domain's own zone. A delegated subzone such
77
+ as `foo.example.com` has no domain service of its own, so address it
78
+ by name.
79
+
68
80
  ## Moving a zone from another provider
69
81
 
70
82
  `dncli dns transfer` copies a zone from your current DNS provider
@@ -194,8 +206,10 @@ The `.pfx` is unencrypted unless you pass `--passphrase` (alias
194
206
  certificate, and the issuing intermediate.
195
207
 
196
208
  `dncli certs delete <domain>` removes a stored certificate and its
197
- private key from disk (after confirmation; `--yes` skips it). It only
198
- deletes the local files - the certificate itself is not revoked.
209
+ private key from disk (after confirmation; `--yes` skips it). By
210
+ default it only deletes the local files; add `--revoke` to also revoke
211
+ the certificate at the CA that issued it (the files are kept if
212
+ revocation fails).
199
213
 
200
214
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
201
215
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: Manage your DevNomads services from the command line
5
5
  Author-email: DevNomads <support@devnomads.nl>
6
6
  License: MIT
@@ -81,6 +81,18 @@ dncli dns records set example.com www A 192.0.2.1 --ttl 3600
81
81
  dncli dns records delete example.com www A
82
82
  ```
83
83
 
84
+ Anywhere a zone is expected you may pass the service id of a domain
85
+ instead of its name, so the ids from `dncli domains list` work here
86
+ too:
87
+
88
+ ```sh
89
+ dncli dns records list 42
90
+ ```
91
+
92
+ An id always means that domain's own zone. A delegated subzone such
93
+ as `foo.example.com` has no domain service of its own, so address it
94
+ by name.
95
+
84
96
  ## Moving a zone from another provider
85
97
 
86
98
  `dncli dns transfer` copies a zone from your current DNS provider
@@ -210,8 +222,10 @@ The `.pfx` is unencrypted unless you pass `--passphrase` (alias
210
222
  certificate, and the issuing intermediate.
211
223
 
212
224
  `dncli certs delete <domain>` removes a stored certificate and its
213
- private key from disk (after confirmation; `--yes` skips it). It only
214
- deletes the local files - the certificate itself is not revoked.
225
+ private key from disk (after confirmation; `--yes` skips it). By
226
+ default it only deletes the local files; add `--revoke` to also revoke
227
+ the certificate at the CA that issued it (the files are kept if
228
+ revocation fails).
215
229
 
216
230
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
217
231
 
@@ -53,7 +53,7 @@ from rich.markup import escape
53
53
  from rich.table import Table
54
54
  from typer.core import TyperGroup
55
55
 
56
- __version__ = "0.9.0"
56
+ __version__ = "0.10.0"
57
57
 
58
58
  # Distribution names, for reporting versions via `dncli version`.
59
59
  CLI_PACKAGE = "devnomads-cli"
@@ -673,6 +673,45 @@ def zone_id(zone: str) -> str:
673
673
  return zone if zone.endswith(".") else f"{zone}."
674
674
 
675
675
 
676
+ def resolve_zone(state: AppState, zone: str) -> str:
677
+ """Map an all-digit domain service id to that domain's zone name; any
678
+ other value is taken to be a zone name already and passed through.
679
+
680
+ An id always means the domain product's own zone. A delegated subzone
681
+ such as foo.example.com has no domain service of its own, so it can
682
+ only be addressed by name.
683
+ """
684
+
685
+ text = str(zone)
686
+ if not text.isdigit():
687
+ return text.rstrip(".")
688
+ client = get_client(state)
689
+ rows = client.request("GET", "/services/domains")
690
+ if isinstance(rows, dict):
691
+ rows = rows.get("data", [])
692
+ match = next(
693
+ (
694
+ row
695
+ for row in (rows or [])
696
+ if isinstance(row, dict) and str(row.get("service_id")) == text
697
+ ),
698
+ None,
699
+ )
700
+ if match is None:
701
+ raise CliError(f"no domain service with id {text}")
702
+ name = str(match.get("entity") or "").rstrip(".")
703
+ if not name:
704
+ raise CliError(f"domain service {text} has no domain name")
705
+ zones = client.request("GET", "/services/dns/zones")
706
+ known = {str(item.get("name", "")).rstrip(".").lower() for item in zones or []}
707
+ if name.lower() not in known:
708
+ raise CliError(
709
+ f"{name} (service {text}) is not hosted on DevNomads DNS; "
710
+ "dncli dns zones list shows your zones"
711
+ )
712
+ return name
713
+
714
+
676
715
  def fqdn(name: str, zone: str) -> str:
677
716
  """Absolute record name with trailing dot, PowerDNS style."""
678
717
 
@@ -1438,13 +1477,17 @@ def zones_list(
1438
1477
  @zones_app.command("show")
1439
1478
  def zones_show(
1440
1479
  ctx: typer.Context,
1441
- zone: Annotated[str, typer.Argument(help="Zone name, e.g. example.com.")],
1480
+ zone: Annotated[
1481
+ str,
1482
+ typer.Argument(help="Zone name (example.com) or the service id of a domain."),
1483
+ ],
1442
1484
  output: OutputOption = None,
1443
1485
  columns: ColumnsOption = None,
1444
1486
  ) -> None:
1445
1487
  """Show a DNS zone including its records."""
1446
1488
 
1447
1489
  state = state_from(ctx, output, columns)
1490
+ zone = resolve_zone(state, zone)
1448
1491
  data = get_client(state).request("GET", f"/services/dns/zones/{zone_id(zone)}")
1449
1492
  if resolve_format(state) is not OutputFormat.table or not isinstance(data, dict):
1450
1493
  render(state, data)
@@ -1465,7 +1508,10 @@ def zones_show(
1465
1508
  @records_app.command("list")
1466
1509
  def records_list(
1467
1510
  ctx: typer.Context,
1468
- zone: Annotated[str, typer.Argument(help="Zone name, e.g. example.com.")],
1511
+ zone: Annotated[
1512
+ str,
1513
+ typer.Argument(help="Zone name (example.com) or the service id of a domain."),
1514
+ ],
1469
1515
  sort: SortOption = None,
1470
1516
  output: OutputOption = None,
1471
1517
  columns: ColumnsOption = None,
@@ -1473,6 +1519,7 @@ def records_list(
1473
1519
  """List the records in a zone, one row per value."""
1474
1520
 
1475
1521
  state = state_from(ctx, output, columns)
1522
+ zone = resolve_zone(state, zone)
1476
1523
  data = get_client(state).request("GET", f"/services/dns/zones/{zone_id(zone)}")
1477
1524
  rrsets = data.get("rrsets", []) if isinstance(data, dict) else []
1478
1525
  render(
@@ -1486,7 +1533,10 @@ def records_list(
1486
1533
  @records_app.command("set")
1487
1534
  def records_set(
1488
1535
  ctx: typer.Context,
1489
- zone: Annotated[str, typer.Argument(help="Zone name, e.g. example.com.")],
1536
+ zone: Annotated[
1537
+ str,
1538
+ typer.Argument(help="Zone name (example.com) or the service id of a domain."),
1539
+ ],
1490
1540
  name: Annotated[
1491
1541
  str, typer.Argument(help="Record name relative to the zone; @ for the apex.")
1492
1542
  ],
@@ -1504,6 +1554,7 @@ def records_set(
1504
1554
  """Create or replace a record set (PowerDNS REPLACE semantics)."""
1505
1555
 
1506
1556
  state = state_from(ctx)
1557
+ zone = resolve_zone(state, zone)
1507
1558
  rrset = build_rrset(
1508
1559
  zone, name, rtype, changetype="REPLACE", ttl=ttl, contents=content
1509
1560
  )
@@ -1520,7 +1571,10 @@ def records_set(
1520
1571
  @records_app.command("delete")
1521
1572
  def records_delete(
1522
1573
  ctx: typer.Context,
1523
- zone: Annotated[str, typer.Argument(help="Zone name, e.g. example.com.")],
1574
+ zone: Annotated[
1575
+ str,
1576
+ typer.Argument(help="Zone name (example.com) or the service id of a domain."),
1577
+ ],
1524
1578
  name: Annotated[
1525
1579
  str, typer.Argument(help="Record name relative to the zone; @ for the apex.")
1526
1580
  ],
@@ -1534,6 +1588,7 @@ def records_delete(
1534
1588
  """Delete a whole record set (all values for name + type)."""
1535
1589
 
1536
1590
  state = state_from(ctx)
1591
+ zone = resolve_zone(state, zone)
1537
1592
  rrset = build_rrset(zone, name, rtype, changetype="DELETE")
1538
1593
  _confirm(f"Delete all {rrset['type']} records for {rrset['name']}?", yes)
1539
1594
  payload = {"rrsets": [rrset]}
@@ -1555,7 +1610,12 @@ def dns_transfer(
1555
1610
  ),
1556
1611
  ],
1557
1612
  zone: Annotated[
1558
- str, typer.Option("--zone", help="Zone name on both sides, e.g. example.com.")
1613
+ str,
1614
+ typer.Option(
1615
+ "--zone",
1616
+ help="Zone name on both sides (example.com), or the service id "
1617
+ "of a DevNomads domain.",
1618
+ ),
1559
1619
  ],
1560
1620
  source_profile: Annotated[
1561
1621
  str | None,
@@ -1583,6 +1643,7 @@ def dns_transfer(
1583
1643
 
1584
1644
  state = state_from(ctx, output, columns)
1585
1645
  client = get_client(state) # resolve DevNomads credentials first: fail fast
1646
+ zone = resolve_zone(state, zone)
1586
1647
  source = build_source(from_, source_profile)
1587
1648
  zones = client.request("GET", "/services/dns/zones")
1588
1649
  known = {str(item.get("name", "")).rstrip(".") for item in zones or []}
@@ -2479,24 +2540,71 @@ def cert_export(
2479
2540
  sys.stdout.write(_combined_pem(key_text, leaf_text, chain_text))
2480
2541
 
2481
2542
 
2543
+ def _revoke_certificate(domain: str, cert_dir: Path) -> None:
2544
+ """Revoke the stored leaf for ``domain`` at the CA that issued it."""
2545
+
2546
+ from cryptography import x509
2547
+
2548
+ acme = _load_acme()
2549
+ cert_path = cert_dir / "cert.pem"
2550
+ try:
2551
+ cert = x509.load_pem_x509_certificate(cert_path.read_bytes())
2552
+ except (OSError, ValueError) as exc:
2553
+ raise CliError(f"could not read {cert_path}: {exc}") from exc
2554
+ provider = _cert_provider(cert_path, cert)
2555
+ directory_url = acme.CA_DIRECTORIES.get(provider)
2556
+ if directory_url is None:
2557
+ raise CliError(
2558
+ f"cannot revoke {domain}: unknown issuing CA"
2559
+ + (f" {provider!r}" if provider else "")
2560
+ )
2561
+ # AcmeClient exposes no revoke of its own; drive the underlying ACME v2
2562
+ # client directly, with the same account key that issued the certificate.
2563
+ wrapper = acme.AcmeClient(
2564
+ str(_account_key_path(provider)), directory_url=directory_url
2565
+ )
2566
+ try:
2567
+ client, _ = wrapper._init_client()
2568
+ client.revoke(cert, 0) # reason 0: unspecified
2569
+ except acme.AcmeError as exc:
2570
+ raise CliError(f"revocation failed: {exc}") from exc
2571
+ except Exception as exc:
2572
+ if getattr(exc, "code", None) == "alreadyRevoked":
2573
+ err_console.print(f"{domain}: certificate was already revoked")
2574
+ return
2575
+ raise CliError(f"revocation failed: {exc}") from exc
2576
+ err_console.print(f"revoked {domain} at {provider}")
2577
+
2578
+
2482
2579
  @cert_app.command("delete")
2483
2580
  def cert_delete(
2484
2581
  domain: Annotated[
2485
2582
  str, typer.Argument(help="Domain of a previously issued certificate.")
2486
2583
  ],
2584
+ revoke: Annotated[
2585
+ bool,
2586
+ typer.Option(
2587
+ "--revoke",
2588
+ help="Also revoke the certificate at the issuing CA before deleting.",
2589
+ ),
2590
+ ] = False,
2487
2591
  yes: Annotated[
2488
2592
  bool, typer.Option("--yes", "-y", help="Do not ask for confirmation.")
2489
2593
  ] = False,
2490
2594
  ) -> None:
2491
2595
  """Delete a stored certificate and its private key from this machine.
2492
2596
 
2493
- This only removes the local files; the certificate itself is not revoked.
2597
+ This only removes the local files; pass --revoke to also revoke the
2598
+ certificate at the CA that issued it.
2494
2599
  """
2495
2600
 
2496
2601
  cert_dir = _cert_dir(domain)
2497
2602
  if not cert_dir.is_dir():
2498
2603
  raise CliError(f"no certificate for {domain} in {cert_dir}")
2499
- _confirm(f"Delete the certificate and private key for {domain}?", yes)
2604
+ action = "Revoke and delete" if revoke else "Delete"
2605
+ _confirm(f"{action} the certificate and private key for {domain}?", yes)
2606
+ if revoke:
2607
+ _revoke_certificate(domain, cert_dir)
2500
2608
  shutil.rmtree(cert_dir)
2501
2609
  err_console.print(f"deleted {cert_dir}")
2502
2610
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devnomads-cli"
3
- version = "0.9.0"
3
+ version = "0.10.0"
4
4
  description = "Manage your DevNomads services from the command line"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -775,6 +775,68 @@ def test_cert_delete_missing(isolated_config):
775
775
  assert "no certificate for example.com" in result.output
776
776
 
777
777
 
778
+ def _fake_revocation(monkeypatch, revoke):
779
+ """Replace the ACME account/network setup with a stub whose ``revoke``
780
+ is the given callable."""
781
+
782
+ import devnomads.acme as acme
783
+
784
+ class FakeClient:
785
+ def revoke(self, cert, rsn):
786
+ revoke(cert, rsn)
787
+
788
+ monkeypatch.setattr(
789
+ acme.AcmeClient, "_init_client", lambda self: (FakeClient(), None)
790
+ )
791
+
792
+
793
+ def test_cert_delete_revoke_revokes_then_deletes(isolated_config, monkeypatch):
794
+ cert_dir = dncli.config_dir() / "certs" / "example.com"
795
+ _write_cert(cert_dir, days=45, with_key_chain=True)
796
+ (cert_dir / "provider").write_text("letsencrypt\n")
797
+ calls = {}
798
+
799
+ def revoke(cert, rsn):
800
+ calls["subject"] = cert.subject.rfc4514_string()
801
+ calls["rsn"] = rsn
802
+
803
+ _fake_revocation(monkeypatch, revoke)
804
+ result = runner.invoke(app, ["certs", "delete", "example.com", "--revoke", "--yes"])
805
+ assert result.exit_code == 0, result.output
806
+ assert calls["rsn"] == 0
807
+ assert "CN=example.com" in calls["subject"]
808
+ assert "revoked example.com at letsencrypt" in result.output
809
+ assert not cert_dir.exists()
810
+
811
+
812
+ def test_cert_delete_revoke_failure_keeps_files(isolated_config, monkeypatch):
813
+ import devnomads.acme as acme
814
+
815
+ cert_dir = dncli.config_dir() / "certs" / "example.com"
816
+ _write_cert(cert_dir, days=45, with_key_chain=True)
817
+ (cert_dir / "provider").write_text("letsencrypt\n")
818
+
819
+ def revoke(cert, rsn):
820
+ raise acme.AcmeError("boom")
821
+
822
+ _fake_revocation(monkeypatch, revoke)
823
+ result = runner.invoke(app, ["certs", "delete", "example.com", "--revoke", "--yes"])
824
+ assert result.exit_code != 0
825
+ assert "revocation failed" in result.output
826
+ assert (cert_dir / "privkey.pem").is_file()
827
+
828
+
829
+ def test_cert_delete_revoke_unknown_provider(isolated_config):
830
+ # the test cert is self-signed and has no provider marker, so the
831
+ # issuing CA cannot be determined and revocation must refuse early
832
+ cert_dir = dncli.config_dir() / "certs" / "example.com"
833
+ _write_cert(cert_dir, days=45, with_key_chain=True)
834
+ result = runner.invoke(app, ["certs", "delete", "example.com", "--revoke", "--yes"])
835
+ assert result.exit_code != 0
836
+ assert "unknown issuing CA" in result.output
837
+ assert (cert_dir / "privkey.pem").is_file()
838
+
839
+
778
840
  def test_cert_delete_requires_confirmation(isolated_config):
779
841
  # without --yes and without a TTY the command must refuse and keep the files
780
842
  cert_dir = dncli.config_dir() / "certs" / "example.com"
@@ -337,6 +337,92 @@ def test_records_delete_with_yes(configured):
337
337
  assert payload["rrsets"][0]["changetype"] == "DELETE"
338
338
 
339
339
 
340
+ # --- service id -> zone name resolution --------------------------------------
341
+
342
+ DOMAINS = [
343
+ {"service_id": 42, "type": "domain", "entity": "example.com"},
344
+ {"service_id": 43, "type": "domain", "entity": "elsewhere.com"},
345
+ ]
346
+
347
+
348
+ def mock_domains(zones=("example.com.",)):
349
+ respx.get(f"{API}/services/domains").mock(
350
+ return_value=Response(200, json={"data": DOMAINS})
351
+ )
352
+ respx.get(f"{API}/services/dns/zones").mock(
353
+ return_value=Response(200, json=[{"id": z, "name": z} for z in zones])
354
+ )
355
+
356
+
357
+ @respx.mock
358
+ def test_records_list_accepts_a_domain_service_id(configured):
359
+ mock_domains()
360
+ respx.get(f"{API}/services/dns/zones/example.com.").mock(
361
+ return_value=Response(200, json=ZONE)
362
+ )
363
+ result = runner.invoke(app, ["--out", "json", "dns", "records", "list", "42"])
364
+ assert result.exit_code == 0, all_output(result)
365
+ assert json.loads(result.output)[0]["name"] == "www.example.com."
366
+
367
+
368
+ @respx.mock
369
+ def test_records_set_by_service_id_builds_names_from_the_zone(configured):
370
+ mock_domains()
371
+ route = respx.patch(f"{API}/services/dns/zones/example.com.").mock(
372
+ return_value=Response(204)
373
+ )
374
+ result = runner.invoke(
375
+ app, ["dns", "records", "set", "42", "www", "A", "192.0.2.1"]
376
+ )
377
+ assert result.exit_code == 0, all_output(result)
378
+ payload = json.loads(route.calls.last.request.content)
379
+ assert payload["rrsets"][0]["name"] == "www.example.com."
380
+
381
+
382
+ @respx.mock
383
+ def test_zones_show_by_service_id(configured):
384
+ mock_domains()
385
+ route = respx.get(f"{API}/services/dns/zones/example.com.").mock(
386
+ return_value=Response(200, json=ZONE)
387
+ )
388
+ result = runner.invoke(app, ["--out", "json", "dns", "zones", "show", "42"])
389
+ assert result.exit_code == 0, all_output(result)
390
+ assert route.called
391
+
392
+
393
+ @respx.mock
394
+ def test_unknown_service_id_fails_clearly(configured):
395
+ mock_domains()
396
+ result = runner.invoke(app, ["dns", "records", "list", "999"])
397
+ assert result.exit_code == 1
398
+ assert "no domain service with id 999" in all_output(result)
399
+
400
+
401
+ @respx.mock
402
+ def test_domain_without_a_devnomads_zone_fails_clearly(configured):
403
+ # 43 is elsewhere.com: registered with us, DNS hosted somewhere else.
404
+ mock_domains()
405
+ result = runner.invoke(app, ["dns", "records", "list", "43"])
406
+ assert result.exit_code == 1
407
+ output = all_output(result)
408
+ assert "elsewhere.com (service 43) is not hosted on DevNomads DNS" in output
409
+
410
+
411
+ @respx.mock
412
+ def test_zone_names_do_not_trigger_a_lookup(configured):
413
+ domains = respx.get(f"{API}/services/domains").mock(
414
+ return_value=Response(200, json={"data": DOMAINS})
415
+ )
416
+ respx.get(f"{API}/services/dns/zones/example.com.").mock(
417
+ return_value=Response(200, json=ZONE)
418
+ )
419
+ result = runner.invoke(
420
+ app, ["--out", "json", "dns", "records", "list", "example.com"]
421
+ )
422
+ assert result.exit_code == 0, all_output(result)
423
+ assert not domains.called
424
+
425
+
340
426
  @respx.mock
341
427
  def test_api_error_message_and_exit_code(configured):
342
428
  respx.get(f"{API}/services").mock(
File without changes
File without changes