Contributing
Workflow, conventions and the just ci gate are described in AGENTS.md in the repository.
This page covers the end-to-end tests.
Pull request titles must read DNSY-{n}: {emoji} {Sentence} (several stories: DNSY-12, DNSY-13: ...).
The pr-title workflow (.github/workflows/pr-title.yml) checks this on every open, edit and push, and
prints the expected shape when the title does not match.
Release profile and dependencies
[profile.release] in the root Cargo.toml sets lto = "fat", codegen-units = 1,
strip = true, overflow-checks = true and keeps panic = "unwind" (the server has no
catch_unwind; abort would turn any panic reachable from hostile input into an outage of the
whole resolver). Measured on the release build with the UI embedded:
| before | after | |
|---|---|---|
dnsy binary | 28.99 MB | 17.22 MB |
| clean release build | 80 s | 180 s |
cargo tree --workspace unique crates | 316 | 279 |
cargo tree -p dnsy (normal) | 277 | 240 |
UI dist text assets | 1.11 MB | 0.28 MB brotli (0.32 MB gzip) |
libsqlite3-sys at opt-level = 2 cuts its clean build from about 65 s to 30 s, but writer
throughput on the shared development host could not be shown within 5 % of the default (the
numbers varied by more than 2x between identical runs), so it is not enabled. Revisit with a
quiet machine.
Dependencies are declared once in [workspace.dependencies] with only the features the code uses
(tokio, clap, tracing-subscriber, proptest, insta). Keep it that way: check
unused dependencies with cargo machete before adding a crate, and do not add x509-parser,
time or tower-http back for a convenience.
web/scripts/compress-dist.mjs runs as postbuild after pnpm build and writes .br and .gz
next to each text asset under web/dist/dnsy/browser.
End-to-end tests
The crate tests/e2e (dnsy-e2e) starts the real dnsy binary with a temporary configuration
and talks to it over the wire, the way a client would. It is excluded from just test and
just cov-rust (coverage measures the crates, and these tests need the built binary) and has its own
recipe, which just ci runs last:
just e2e # build target/debug/dnsy, run everything in parallel
cargo nextest run -p dnsy-e2e --test transports mtls # a subset, once the binary is built
The harness looks for the binary next to the test executables (<target>/debug/dnsy); it builds
it with cargo build -p dnsy when it is missing, and DNSY_BIN points it at another binary. After
changing server code rebuild first (cargo build -p dnsy), or use just e2e, which always does.
What Harness::start(opts) does
- Creates a temp directory with the configuration, zone files, a blocklist file and a throwaway
PKI (
rcgen): a server CA and certificate (valid for127.0.0.1), a client CA, and three client certificates: valid, expired and signed by an untrusted CA. - Starts a scriptable fake upstream (
FakeUpstream) whenopts.upstreamis set. - Creates an admin user and two tokens (
dns:queryfor DoH, admin for the API) with the CLI. - Runs
dnsy serve --ready-file ...with every listener on port 0 and waits (polling, with a deadline) for the ready file, which holds the bound addresses. - With mTLS on, registers the valid client certificate’s fingerprint through the API.
Everything is per test: its own temp directory, its own ports and database, so tests run in
parallel (nextest runs each test in its own process). Dropping the Harness kills the server;
terminate() sends SIGTERM and waits for a clean exit. signal("HUP"), logs(),
write_zone(), api_send() and new_token() cover the rest of what a test usually needs.
Clients and fixtures
The clients live in the crate and use dnsy-proto as the codec: udp_exchange / udp_raw,
Framed for TCP and DoT (Framed::connect_dot with or without a client certificate), and
DohClient (HTTP/2 or HTTP/1.1; GET, POST, bearer or path tokens, arbitrary requests).
FakeUpstream::on(name, type, Reply) scripts answers, NXDOMAIN, NODATA, rcodes, drops, delays,
garbage and wrong-id responses and counts what it was asked; serve_signed_root serves a signed
root zone whose DS you pass as a trust anchor. zones::example_zone() is the zone most tests use.
Writing a test
#[tokio::test]
async fn my_case() {
let h = Harness::start(Opts::default().zone("example.test.", example_zone())).await;
let r = udp_exchange(h.udp, &query("www.example.test.", RecordType::A)).await;
assert_eq!(r.header.rcode, Rcode::NoError);
}
- Be deterministic: never sleep for a fixed time to wait for the server. Poll with
eventually(deadline, "what", || async { ... }), or usewait_for_log. The only fixed waits are the short silence windows of negative checks (a dropped UDP query, a log line that must not repeat). - Bind nothing to a fixed port. Use the addresses on the harness.
- The product has no clock hook, so TTL tests use 1 second TTLs and poll for the effect.
- Tools such as
digandkdigare optional: skip with a message when they are missing (tests/interop.rs). - Put secrets in neither assertions’ messages nor logs: tests that look for leaked tokens assert on the server log and on the query log.
Testing the REST API and the cache
h.rest() returns an Api client for the management API. Every request states its credential
explicitly (Auth::None, Bearer, Session, and session variants without or with a wrong CSRF
header), so tests can assert 401/403 mappings; Resp::problem(status) checks the RFC 9457 shape
and Resp::ok(status) returns the JSON body. Api::login, create_user and token_for build
sessions, users and role-limited tokens (the harness admin is e2e / e2e-password-1234, its
token is h.api_token). Req::stream() and Stream::next_event read server-sent events.
Opts::no_users starts a server without users for the bootstrap flow (h.bootstrap_code()), and
Opts::prefetch turns cache prefetch on.
- A change made through the API is asserted on the DNS path too, with
eventually: the zone answer, the REFUSED, the NXDOMAIN with its extended error. - Cache tests use 1 to 3 second TTLs (there is no clock hook) and observe the cache through
GET /cachecounters and the upstream’s query counts; clamps show on cached answers, not on the first (miss) answer. - Query log and stats are written asynchronously: poll them until the expected entries appear.
Suites: protocol (UDP, TCP, EDNS, malformed input, hot reload), transports (DoT, mTLS, DoH),
policy (ACL, rate limit, blocklists), resolver (forwarding, cache, failures, serve-stale,
validation smoke test), lifecycle (SIGHUP, SIGTERM) and interop; the REST suites rest_auth,
rest_errors, rest_zones, rest_policy and rest_observe, and the cache suites cache_api,
cache_ttl and cache_stale.
The DNSSEC suites are dnssec_validation (forwarding path: modes, AD/CD/DO, EDE codes, negative
trust anchors, mode changes), dnssec_signing (signing through the REST API, answers over UDP,
TCP, DoT and DoH, denial, wildcards, DS export, BIND export) and dnssec_lifecycle (a second
instance validating the first, key rollovers, the clock-sanity guard, delv/dig interop and an
ignored live test against the real root). They use dnsy_e2e::dnssec: Tree::build creates a
signed root with signed or unsigned zones below it (NSEC, NSEC3, opt-out, expired or future
signatures, missing, unsupported or mismatching DS, colliding keys) and tree.serve(&upstream)
makes the fake upstream answer from it; Tree::answer returns a response you can forge and script
with upstream.on. validate_answer runs the dnsy-dnssec validator on a server’s own answer.
The clock guard test patches the build timestamp in a copy of the binary (Opts::binary), since a
child process cannot be given another clock.
Benchmarks
The crate tests/bench (dnsy-bench, not published, excluded from just test and the coverage
gate like dnsy-e2e) holds the performance tools:
just bench: the scenario runner (benchbinary). It starts the releasednsy(throughDNSY_BIN) with the e2e harness and a scripted upstream and prints QPS, p50 and p99 for UDP hit and miss, TCP and DoH. It lifts the per-client connection and UDP in-flight limits because all load clients share one source address; every row must report 0 errors.loadgen: a closed-loop load generator for any server over UDP or TCP (--clients,--pipeline,--countnames cycled for cache hits,--uniquefor misses).fakeup: a fast multi-task UDP upstream answering every name (nx*gets NXDOMAIN).just bench-micro: criterion micro-benchmarks (benches/micro.rs): codec, single-thread cache hit andcache/hit_16_threads_320k(16 threads, 320 000 lookups per iteration; ops/s = 320 000 / time).just allocs: allocations per cache hit (allocsbinary).unsafe_codeis forbidden, so the repo has no counting allocator of its own; the binary installs thedhatcrate’s as the global allocator (bench crate only) and reads its cumulative block count around 2 000 runs, counting every thread. It reportsCache::getalone,Handler::handleon a warm cache and a datagram through the UDP listener and back (engine with an event sink that drops events). Targets: cache hit at most 2, UDP hit at most 30; the recipe fails above them. Measured on this tree: cache hit 2.00, engine hit 2.00, UDP hit 9.00. The two cache-hit allocations are the owned copy of the cached message (its question and answer vectors) thatCache::getreturns with TTLs decremented; decrementing at encode time would remove that copy but needs the codec and the engine pipeline to carry the cached message by reference, which the numbers do not call for.
Performance stories paste the just bench table into their PR. Run it on an otherwise idle
machine and pin CPUs with taskset when comparing before and after.
Browser end-to-end tests
The Playwright suite in web/e2e drives the real UI in Chromium against a real dnsy (built with
--features embed-ui, started with a temporary configuration, free ports and a fake UDP upstream
written in the test support code). It covers sign-in and first-admin setup, the throttle, CSRF and
session expiry, the no-WebGL and reduced-motion login, navigation, 404 and the auth guard, the
dashboard after real DNS queries, zones and records (validation, ETag conflict, BIND export),
the query log (filters, live tail), filtering and ACL rules changing DNS answers, upstream groups,
client certificates, settings, cache flush, users and roles, tokens (secret shown once), password
change, the DNSSEC tab, mascot states (simulated with route interception), responsive checks at
360 and 1280 px, keyboard use and an axe scan (no serious or critical violations). Any console
CSP violation fails the test.
just e2e-ui # build UI and binary, install Chromium if missing, run everything
cd web && DNSY_BIN=... pnpm e2e # run again without rebuilding
cd web && pnpm exec playwright test zones --headed # a subset, with a visible browser
cd web && pnpm exec playwright show-report # HTML report of the last run
Chromium is installed once into the Playwright cache (pnpm exec playwright install chromium);
the recipe does it for you. Only Chromium runs: WebKit needs system libraries not present on
every machine and Firefox lacks the clipboard permissions and console messages two tests use.
DNSY_BIN points at another binary (it must include the embedded UI); without it the suite uses
$CARGO_TARGET_DIR/debug/dnsy. A debug build reads web/dist from disk, so rebuild the web app
(pnpm build) after changing it.
How it is organised:
- A global setup starts one shared server with the users
admin,operatorandviewer(passworde2e-password-1234) and stores their signed-in sessions, so most tests start signed in. Pick a role withtest.use({ storageState: roleState('viewer') }). - Tests that change global state (settings, ACL, filtering, cache, counts) start their own server
with the
freshServerfixture and sign in withsessionFor; zones use unique names, so tests run in parallel. Never log an API client in with the page’s cookie: logging in with a session cookie ends that session (Api.loginuses an empty jar for that reason). support/dns.tssends hand-built UDP queries;support/upstream.tsanswers everyAquery with192.0.2.7(names startingnx.get NXDOMAIN) and counts the queries it saw.- No fixed sleeps: locators auto-wait and
expect.poll/toPasswait for server-side effects. - Traces and screenshots are kept only for failing tests (
web/test-results, not committed).
Documentation screenshots
The screenshots in docs/book/src/images/ come from a seeded demo, not from hand-made captures:
just build # or cargo build -p dnsy --features embed-ui
scripts/demo/run.sh /tmp/dnsy-demo target/release/dnsy & # fake upstream, zones, rules, a signed zone, traffic
node scripts/demo/capture.mjs /tmp/dnsy-shots # Playwright at 1280 px (needs `pnpm exec playwright install chromium` in web/)
python3 scripts/demo/optimize.py /tmp/dnsy-shots docs/book/src/images
Keep each image to a few hundred KB at most, write outputs outside the repository, and never commit
logs. just docs checks the book (mdbook test), and the config_reference_lists_all_keys test in
dnsy-config fails when the configuration reference and the schema disagree.
Fuzzing
Two targets cover untrusted input. Neither is part of just ci.
| Target | Crate | What it feeds |
|---|---|---|
fuzz_decode | crates/dnsy-proto/fuzz | Message::formerr_for, decode, encode at five limits, encode_padded, set_ede, canonical RDATA, presentation form and the decode-encode-decode round trip |
fuzz_validate | crates/dnsy-dnssec/fuzz | Validator::validate on a mutated response with mutated DS and DNSKEY answers from the fetcher, against a trust anchor that signs the seeds |
just fuzz # fuzz_decode, one hour, libFuzzer (nightly + cargo-fuzz)
just fuzz fuzz_validate 600 # the validator target for ten minutes
just fuzz-stable fuzz_decode 600 # pinned stable toolchain: std-only mutation fuzzer
just fuzz needs rustup toolchain install nightly and cargo install cargo-fuzz; nothing else
in the repository needs nightly. just fuzz-stable runs the same functions through a small
mutation engine (bit flips, interesting values, splices, truncation, 16-bit field nudges, random
buffers) with overflow-checks on and no coverage feedback; it is what runs where nightly is not
installed. The fuzz crates are not workspace members (their Cargo.lock stays out of the main
one) and dnsy-proto itself keeps zero dependencies. A crashing input is written to the fuzz
directory’s crashes/ (stable) or artifacts/ (libFuzzer); fix it, and add the input as a unit
test next to the code it broke. The committed starting corpora are in fuzz/corpus/<target>/.
Recorded runs
| Date | Toolchain | Target | Iterations | Panics |
|---|---|---|---|---|
| 2026-10-21 | stable 1.99.1, fuzz-stable, 3 processes x 25 min | fuzz_decode | 2 658 869 248 | 0 |
| 2026-10-21 | stable 1.99.1, fuzz-stable, 2 processes x 25 min | fuzz_validate | 315 691 008 | 0 |
The libFuzzer one-hour runs need a nightly toolchain, which the build machines do not have; the
rows above are the stable mutation runs, shorter and without coverage guidance. Add a row here
after each just fuzz run.
Code coverage
Coverage is a merge gate. just ci runs just cov-rust and the web gate (inside just web).
just cov # Rust gate, then web gate
just cov-rust # cargo llvm-cov via nextest, per-crate table, HTML report
just cov-web # ng test --coverage (Vitest, v8)
What is measured
- Rust: line coverage from
cargo llvm-cov nextest --workspace --exclude dnsy-e2e --exclude dnsy-bench, that is unit and integration tests. The end-to-end suites (just e2e,just e2e-ui) are not counted: they spawn the real binary, so the instrumentation would have to follow child processes, and a gate that depends on timing-sensitive wire tests would be flaky. The e2e suites are additional safety, not a way to reach the numbers. - Web:
ng test --coverageoverweb/src. The thresholds live inweb/angular.json. - Exclusions: none. No
#[cfg(coverage)], no ignore regions and no ignore-filename regex;main.rsglue and thegen-openapibinary are measured like everything else.
Gates
| Scope | Metric | Floor |
|---|---|---|
| Rust workspace | lines | 80% |
| every crate | lines | 80% |
dnsy-proto, dnsy-dnssec | lines | 90% |
| web | lines, statements, functions | 80% |
| web | branches | 70% |
The floors are set in the justfile (cov-rust) and enforced by scripts/cov-crate-gates.sh,
which also prints the table. Percentages are exact, so 89.99% fails a 90% gate. Raise a floor in
the same change that earns it; never lower one to merge.
Reading the report
just cov-rust ends with one row per crate (lines, covered, percent, min, ok/FAIL) and
a workspace total. For the per-file view open target/llvm-cov/html/index.html, or list the
uncovered lines of a file from the last run:
cargo llvm-cov report --show-missing-lines | grep dnsy-server
The web table is printed by Vitest (% Stmts, % Branch, % Funcs, % Lines, uncovered line
numbers per file).
Filling gaps
Look at uncovered lines first, then ask what behaviour they encode: error paths, limits, parsers
fed malformed input, auth, throttles, ACLs, redaction, the DNSSEC validator. Write a test that
asserts that behaviour (the right error, nothing added, memory bounded), not one that merely calls
the function. Put new tests in their own *_tests.rs module or tests/ file. Untestable code
(process glue) stays measured and is justified in the PR, not hidden.
Continuous integration
Two workflows run on every pull request; both call the same just
recipes as the local gate, so a green just ci predicts a green pipeline.
| Workflow / job | What it runs |
|---|---|
ci / rust (linux) | just check (fmt, clippy, cargo-deny, advisory cargo-audit, OpenAPI drift), just cov-rust (full nextest suite plus the coverage gates) |
ci / rust (embed-ui) | builds web/dist, then clippy and the dnsy-api tests with --features embed-ui, which the default build compiles out (ui_serves_brotli_when_accepted). just ci does not run these |
ci / web | just web (frozen install, dedupe check, ng lint, ng test --coverage, ng build, API types drift) plus an advisory pnpm audit |
e2e / e2e (rust) | just e2e |
e2e / e2e (browser) | the just e2e-ui steps, with playwright install --with-deps chromium; the report is uploaded on failure |
Slower, platform-specific checks do not run on every pull request. The release-pre-check workflow
holds them: build-and-test (macos-latest, windows-latest) (cargo build --workspace --all-targets
and the nextest suite, without coverage, deny or e2e) and the pkg (macos arm64) and
msi (windows x64) jobs, which build the cargo-dist installers and install them on a real runner.
It never runs on pull requests: start it by hand (Actions, release-pre-check, Run workflow, on main
or on a branch that changes packaging, dist-workspace.toml, the build scripts or platform-specific
code) and it runs weekly on main. It is not a required check.
The jobs named ci and e2e only aggregate the others, so branch protection needs just those two
(plus pr-title) as required checks. Every third-party action is pinned by commit SHA with its
version in a comment; Dependabot (.github/dependabot.yml) bumps them weekly together with the
cargo and web/ npm dependencies (minor and patch grouped, majors individual). Workflows use
permissions: contents: read, never pull_request_target, and pass no untrusted input to run:.
Dependabot commit and PR titles use the prefix DNSY-115: ⬆️ (cargo, npm) or DNSY-115: 👷
(github-actions), where DNSY-115 is the tracking story for dependency updates, so they satisfy the
pr-title check, e.g. DNSY-115: ⬆️ Bump tokio from 1.40.0 to 1.41.0 in the tokio-stack group.
No include: scope is set, which would insert (deps) after the prefix.