add tests and CI

This commit is contained in:
Alain Brenzikofer
2026-08-31 11:25:40 +02:00
parent 685c3ff264
commit a40fdb8b36
4 changed files with 373 additions and 4 deletions
+28
View File
@@ -301,3 +301,31 @@ jobs:
echo "All "$attempts" attempts failed."
exit 1
fi
# =============================
# Resolver test job
# =============================
# The SNRC resolver is Python and stdlib-only apart from keccak, so it needs
# none of the Haskell toolchain above and runs independently of it.
resolver-test:
name: "resolver (python)"
runs-on: ubuntu-latest
steps:
- name: Clone project
uses: actions/checkout@v3
- name: Set up Python
# Matches the runtime stage of scripts/resolver/service/Dockerfile.
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install resolver dependencies
# The only runtime dependency; declared in
# scripts/resolver/service/pyproject.toml.
run: python -m pip install "eth-hash[pycryptodome]>=0.7"
- name: Test
run: python -m unittest discover -s scripts/resolver/service -v
+17 -2
View File
@@ -152,7 +152,7 @@ unknown.
| `grace` | lapsed, but only the previous owner may renew it, until `graceEnds` |
| `expired` | lapsed and past grace — anyone may register it now |
| `unregistered` | never registered, and free to take |
| `reserved` | not registered, and held for a brand — registration will be refused |
| `reserved` | not registered, and held back — registration will be refused; the body carries a `reason` |
| `noResolver` | registered, but points nowhere |
| `unknown` | no `SNRC_REGISTRAR_<TLD>` configured, so status could not be read |
@@ -205,6 +205,21 @@ Every non-2xx body carries a stable `error` code to branch on and a human
"status": "unregistered", "expires": null, "graceEnds": null}
```
A `reserved` body carries one extra field, `reason`, explaining why the name is
held back:
```jsonc
{"name": "support.testing", "error": "reserved",
"message": "this name is reserved and cannot be registered",
"reason": "reserved for a brand or public interest",
"status": "reserved", "expires": null, "graceEnds": null}
```
The contract records only that a name is reserved, not why, so today every
reserved name gets that same sentence; a per-name lookup is expected to replace
it. `reason` appears on no other status, so its presence is the signal that one
is known — render it rather than matching on its text.
Codes: `tldNotConfigured`, `notFullyQualified`, `unregistered`, `reserved`,
`grace`, `expired`, `noResolver`, `badAddress`, `badOffset`,
`noRegistrarConfigured`, `unauthorized`, `noSuchRoute`, `upstreamError`. For a name whose registration
@@ -330,7 +345,7 @@ any lookup, and carry none of the three.
| Lapsed, still in grace | 410 | `grace` | `expires` (when it lapsed), `graceEnds` (last moment its owner can renew) |
| Lapsed, past grace | 410 | `expired` | same fields; anyone may register it now |
| Never registered | 404 | `unregistered` | `expires` and `graceEnds` are `null` |
| Reserved for a brand | 404 | `reserved` | not registered and not registrable; overrides `unregistered` and `expired` |
| Reserved | 404 | `reserved` | not registered and not registrable; adds `reason`; overrides `unregistered` and `expired` |
| Queried by labelhash (`0x…64hex.testing`) | as the label | as the label | identical answer; the label is never sent |
| TLD has no registry configured | 400 | — | `error: tldNotConfigured`, plus `configuredTlds` |
| TLD has no *registrar* configured | 200 / 404 | `unknown` | resolves as it otherwise would; expiry cannot be read, so `expires` and `graceEnds` are `null` |
+13 -2
View File
@@ -100,6 +100,12 @@ CONTROLLERS = {
"simplex": os.environ.get("SNRC_CONTROLLER_SIMPLEX", ""), # not deployed yet
}
# Why a name is reserved. `reservedNames` stores only the fact, so every
# reserved name gets this same sentence; a per-name lookup (table or REST) is
# the intended replacement. Callers should render whatever this field holds
# rather than matching on its text.
RESERVED_REASON = "reserved for a brand or public interest"
# Shared secret the caller must present. Unset means no check - correct for a
# loopback deployment, and the reason the check exists at all is that the
# Haskell client has always been able to send `Authorization` and nothing here
@@ -547,18 +553,23 @@ def resolve(name: str):
# anyone, and one that is held but not pointed anywhere.
reg = name_status(name)
if reg["status"] in ("unregistered", "reserved"):
return 404, {
body = {
"name": name,
"status": reg["status"],
"expires": reg["expires"],
"graceEnds": reg["graceEnds"],
"error": reg["status"],
"message": (
"this name is held for its trademark owner and cannot be registered"
"this name is reserved and cannot be registered"
if reg["status"] == "reserved"
else "this name has never been registered"
),
}
# Only reserved names carry a reason, so its presence is the signal
# that one is known.
if reg["status"] == "reserved":
body["reason"] = RESERVED_REASON
return 404, body
if reg["status"] in ("grace", "expired"):
return 410, {
"name": name,
@@ -5,9 +5,14 @@ Run with `python3 -m unittest scripts/resolver/service/test_snrc_resolve.py`.
"""
import importlib.util
import json
import os
import threading
import time
import unittest
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer
# snrc-resolve.py has a hyphen, so import it via importlib instead of `import`.
_HERE = os.path.dirname(os.path.abspath(__file__))
@@ -315,6 +320,77 @@ class ReservedTests(unittest.TestCase):
self.assertEqual(snrc.name_status(h + ".testing")["status"], "reserved")
class ReservedReasonTests(unittest.TestCase):
"""Why a name is reserved travels in its own field, so a client can show it
without parsing the message, and so a per-name reason can replace the fixed
one without moving anything."""
REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b"
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714"
def setUp(self):
self._saved = (
snrc.REGISTRIES,
snrc.REGISTRARS,
snrc.CONTROLLERS,
snrc.eth_call,
)
snrc.REGISTRIES = {"testing": self.REGISTRY}
snrc.REGISTRARS = {"testing": self.REGISTRAR}
snrc.CONTROLLERS = {"testing": self.CONTROLLER}
def tearDown(self):
(
snrc.REGISTRIES,
snrc.REGISTRARS,
snrc.CONTROLLERS,
snrc.eth_call,
) = self._saved
def _chain(self, expires, reserved):
def eth_call(to, data):
if data.startswith(snrc.selector("reservedNames(bytes32)")):
return "0x" + snrc.encode_uint(1 if reserved else 0)
if data.startswith(snrc.selector("GRACE_PERIOD()")):
return "0x" + snrc.encode_uint(90 * 86400)
return "0x" + snrc.encode_uint(expires)
return eth_call
def test_a_reserved_name_carries_the_reason(self):
snrc.eth_call = self._chain(0, True)
status, body = snrc.resolve("acme.testing")
self.assertEqual(status, 404)
self.assertEqual(body["status"], "reserved")
self.assertEqual(body["reason"], "reserved for a brand or public interest")
def test_the_message_does_not_claim_a_trademark(self):
snrc.eth_call = self._chain(0, True)
_, body = snrc.resolve("acme.testing")
self.assertNotIn("trademark", body["message"])
def test_an_unregistered_name_has_no_reason(self):
snrc.eth_call = self._chain(0, False)
status, body = snrc.resolve("acme.testing")
self.assertEqual(status, 404)
self.assertEqual(body["status"], "unregistered")
self.assertNotIn("reason", body)
def test_an_expired_name_has_no_reason(self):
snrc.eth_call = self._chain(1, False)
status, body = snrc.resolve("acme.testing")
self.assertEqual(status, 410)
self.assertEqual(body["status"], "expired")
self.assertNotIn("reason", body)
def test_a_hashed_query_gets_the_reason_too(self):
snrc.eth_call = self._chain(0, True)
h = "0x" + snrc.keccak(b"acme").hex()
_, body = snrc.resolve(h + ".testing")
self.assertEqual(body["reason"], "reserved for a brand or public interest")
class NameStatusTests(unittest.TestCase):
"""simplexmq#1821: unresolvable has three causes and a caller has to tell
them apart. Names expire lazily, so the chain still holds the answer."""
@@ -554,5 +630,244 @@ class SplitLinksTests(unittest.TestCase):
)
class HandlerTests(unittest.TestCase):
"""The HTTP layer: routing, auth, query parsing, and the mapping from a
(status, body) pair to a response.
These go over a real socket because that is the only way to reach them
every branch here lives in `do_GET`, which no function-level test calls.
"""
REGISTRY = "0x58fc46996d975c57883564648bda5206d1a0102b"
REGISTRAR = "0xef47eb4384b46c89e4482a677c2cbcbd2a6fd85a"
CONTROLLER = "0x281ca41311c2aa808c917c4674639d7567b75714"
RESOLVER = "0x1111111111111111111111111111111111111111"
OWNER = "0x69a6000000000000000000000000000000002d32"
FUTURE = 4102444800 # 2100-01-01
def setUp(self):
self._saved = {
k: getattr(snrc, k)
for k in (
"REGISTRIES",
"REGISTRARS",
"CONTROLLERS",
"AUTH_BEARER",
"AUTH_BASIC",
"eth_call",
"text",
"addr_multicoin",
)
}
snrc.REGISTRIES = {"testing": self.REGISTRY}
snrc.REGISTRARS = {"testing": self.REGISTRAR}
snrc.CONTROLLERS = {"testing": self.CONTROLLER}
snrc.AUTH_BEARER = ""
snrc.AUTH_BASIC = ""
self.chain(expires=self.FUTURE)
class Quiet(snrc.Handler):
def log_message(self, fmt, *args):
pass
self.srv = ThreadingHTTPServer(("127.0.0.1", 0), Quiet)
# Default poll_interval is 0.5s and shutdown() waits for it, which
# would cost half a second per test in this class alone.
threading.Thread(
target=self.srv.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True
).start()
self.base = "http://127.0.0.1:%d" % self.srv.server_address[1]
def tearDown(self):
self.srv.shutdown()
self.srv.server_close()
for k, v in self._saved.items():
setattr(snrc, k, v)
# -- fixtures ---------------------------------------------------------
def chain(self, expires, reserved=False, resolver=None, raises=None):
"""Install a fake chain. `raises` makes every call fail, which is how
the 502 path is reached."""
resolver = self.RESOLVER if resolver is None else resolver
sel = snrc.selector
def eth_call(to, data):
if raises is not None:
raise raises
if data.startswith(sel("reservedNames(bytes32)")):
return "0x" + snrc.encode_uint(1 if reserved else 0)
if data.startswith(sel("GRACE_PERIOD()")):
return "0x" + snrc.encode_uint(90 * 86400)
if data.startswith(sel("nameExpires(uint256)")):
return "0x" + snrc.encode_uint(expires)
if data.startswith(sel("resolver(bytes32)")):
return "0x" + snrc.encode_uint(int(resolver, 16))
if data.startswith(sel("owner(bytes32)")):
return "0x" + snrc.encode_uint(int(self.OWNER, 16))
if data.startswith(sel("balanceOf(address)")):
return "0x" + snrc.encode_uint(1)
if data.startswith(sel("tokenOfOwnerByIndex(address,uint256)")):
return "0x" + snrc.encode_uint(int.from_bytes(snrc.keccak(b"acme"), "big"))
if data.startswith(sel("labelOf(uint256)")):
label = b"acme"
head = (32).to_bytes(32, "big") + len(label).to_bytes(32, "big")
return "0x" + (head + label + b"\x00" * 28).hex()
raise AssertionError("unexpected call " + data[:10])
snrc.eth_call = eth_call
snrc.text = lambda r, node, key: {"name": "Acme", "url": "https://acme.example"}.get(key, "")
snrc.addr_multicoin = lambda r, node, coin: (
self.OWNER if coin == snrc.COIN_ETH else None
)
def get(self, path, auth=None):
req = urllib.request.Request(self.base + path)
if auth is not None:
req.add_header("Authorization", auth)
try:
with urllib.request.urlopen(req, timeout=5) as r:
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
with e:
return e.code, json.loads(e.read())
# -- routing ----------------------------------------------------------
def test_health_reports_the_version_and_the_registrars(self):
status, body = self.get("/health")
self.assertEqual(status, 200)
self.assertTrue(body["ok"])
self.assertEqual(body["version"], snrc.API_VERSION)
# Present so an operator can see why status would read "unknown".
self.assertEqual(body["registrars"], {"testing": self.REGISTRAR})
def test_an_unknown_route_names_the_routes_that_exist(self):
status, body = self.get("/nope")
self.assertEqual(status, 404)
self.assertEqual(body["error"], "noSuchRoute")
self.assertIn("/resolve/<name>", body["routes"])
def test_the_root_path_is_not_a_route(self):
status, body = self.get("/")
self.assertEqual(status, 404)
self.assertEqual(body["error"], "noSuchRoute")
# -- auth -------------------------------------------------------------
def test_no_auth_configured_means_no_header_is_needed(self):
self.assertEqual(self.get("/health")[0], 200)
def test_a_configured_token_is_required(self):
snrc.AUTH_BEARER = "s3cret"
status, body = self.get("/health")
self.assertEqual(status, 401)
self.assertEqual(body["error"], "unauthorized")
def test_the_right_token_is_accepted(self):
snrc.AUTH_BEARER = "s3cret"
self.assertEqual(self.get("/health", auth="Bearer s3cret")[0], 200)
def test_a_wrong_token_is_refused(self):
snrc.AUTH_BEARER = "s3cret"
self.assertEqual(self.get("/health", auth="Bearer nope")[0], 401)
def test_auth_is_checked_before_the_route_exists(self):
# An unauthenticated caller learns nothing about which routes exist.
snrc.AUTH_BEARER = "s3cret"
status, body = self.get("/nope")
self.assertEqual(status, 401)
self.assertNotIn("routes", body)
# -- /resolve ---------------------------------------------------------
def test_a_live_name_returns_its_record(self):
status, body = self.get("/resolve/acme.testing")
self.assertEqual(status, 200)
self.assertEqual(body["name"], "acme.testing")
self.assertEqual(body["nickname"], "Acme")
self.assertEqual(body["website"], "https://acme.example")
self.assertEqual(body["owner"], self.OWNER)
self.assertEqual(body["status"], "registered")
self.assertEqual(body["expires"], self.FUTURE)
def test_a_bare_label_is_rejected_before_any_rpc(self):
self.chain(expires=0, raises=AssertionError("must not reach the chain"))
status, body = self.get("/resolve/acme")
self.assertEqual(status, 400)
self.assertEqual(body["error"], "notFullyQualified")
def test_a_name_is_lowercased(self):
status, body = self.get("/resolve/ACME.TESTING")
self.assertEqual(status, 200)
self.assertEqual(body["name"], "acme.testing")
def test_a_reserved_name_is_404_with_its_reason(self):
self.chain(expires=0, reserved=True)
status, body = self.get("/resolve/acme.testing")
self.assertEqual(status, 404)
self.assertEqual(body["status"], "reserved")
self.assertEqual(body["reason"], "reserved for a brand or public interest")
def test_an_expired_name_is_410(self):
self.chain(expires=1)
status, body = self.get("/resolve/acme.testing")
self.assertEqual(status, 410)
self.assertEqual(body["status"], "expired")
def test_a_name_with_no_resolver_is_404(self):
self.chain(expires=self.FUTURE, resolver=snrc.ZERO_ADDR)
status, body = self.get("/resolve/acme.testing")
self.assertEqual(status, 404)
self.assertEqual(body["error"], "noResolver")
def test_an_unconfigured_tld_is_400(self):
status, body = self.get("/resolve/acme.example")
self.assertEqual(status, 400)
self.assertEqual(body["error"], "tldNotConfigured")
self.assertEqual(body["configuredTlds"], ["testing"])
# -- /owned-by --------------------------------------------------------
def test_owned_by_lists_the_names_held(self):
status, body = self.get("/owned-by/" + self.OWNER)
self.assertEqual(status, 200)
self.assertEqual([n["name"] for n in body["names"]], ["acme.testing"])
self.assertEqual(body["offset"], 0)
def test_a_negative_offset_is_rejected(self):
status, body = self.get("/owned-by/%s?offset=-1" % self.OWNER)
self.assertEqual(status, 400)
self.assertEqual(body["error"], "badOffset")
def test_a_non_numeric_offset_is_rejected(self):
status, body = self.get("/owned-by/%s?offset=abc" % self.OWNER)
self.assertEqual(status, 400)
self.assertEqual(body["error"], "badOffset")
def test_a_bad_address_is_rejected(self):
status, body = self.get("/owned-by/not-an-address")
self.assertEqual(status, 400)
self.assertEqual(body["error"], "badAddress")
# -- upstream failure -------------------------------------------------
def test_an_rpc_failure_is_502_and_does_not_leak_the_rpc_url(self):
# SNRC_RPC can carry a key, and urlopen puts the URL it failed on into
# the exception message, so the body must not quote the exception.
self.chain(expires=0, raises=RuntimeError("failed on http://user:key@rpc.internal:8545"))
status, body = self.get("/resolve/acme.testing")
self.assertEqual(status, 502)
self.assertEqual(body["error"], "upstreamError")
self.assertNotIn("rpc.internal", json.dumps(body))
self.assertNotIn("key", json.dumps(body))
def test_an_rpc_failure_on_owned_by_is_also_502(self):
self.chain(expires=0, raises=RuntimeError("boom"))
status, body = self.get("/owned-by/" + self.OWNER)
self.assertEqual(status, 502)
self.assertEqual(body["error"], "upstreamError")
if __name__ == "__main__":
unittest.main()