docs / internal

view as .md

Testing#

Grantor is built TDD, chunk by chunk (see .superpowers/sdd/progress.md for the full ledger): a failing test is written first, then the minimal code to pass it, then a subagent-driven review pass before moving on. This page describes the resulting test strategy and how to run it.

Layers#

1. Contract (contracts/, Foundry)#

contracts/test/GrantorRegistry.t.sol — unit tests covering deployment immutables, tenant creation, multiwallet admin (add/remove, non-admin rejection), tier configuration (owner-only), billing (topUp, drawPeriod charging + activating, rejecting a draw while still active, rejecting on insufficient balance), the derived status() state machine (active → grace → inactive), withdrawBalance (admin-only, returns unused funds), registerIssuerKey/isActive (including "requires active tenant" and grace-vs-inactive behavior), deregisterIssuerKey (stops trust), app/agent registration counters, pause blocking createTenant, setGraceWindow (owner-only), the fee-snapshot anti-drain guarantee (test_owner_cannot_drain_tenant_via_fee_raise), and the autoRenew gate (on by default; turning it off blocks drawPeriod and allows a safe withdrawal).

contracts/test/GrantorRegistry.invariant.t.sol — a Foundry invariant test. A Handler contract drives two independent tenants through randomized topUp/draw (warps 31 days, tolerates reverts)/withdraw sequences; the invariant asserted after every run is usdc.balanceOf(registry) == sum of all tenants' balances — this is where money-handling correctness lives, so it gets the heaviest fuzz budget of any component in the repo.

Run:

cd contracts
forge test

2. grantor-verify (Rust)#

Unit tests in crates/grantor-verify/src/lib.rs: ES256 JWT verification (verify_jwt), the RFC 7638 JWK thumbprint (issuer_key_id) that must equal the on-chain keyId, and the TrustSource trait + verify() composition (token cryptographically valid and the trust source reports the key active).

3. grantor-issuer (Rust, unit + Poem endpoint tests)#

Unit tests live alongside the code they test, #[cfg(test)]-gated in the same file:

  • token.rs — JWT minting round-trips through grantor_verify::verify_jwt, key_id matches the verify crate's own thumbprint computation, JWKS is well-formed and carries the right kid, pairwise_sub is per-salt and case-insensitive, PEM loading rejects junk and accepts a real key.
  • login.rs — EIP-191 recovery: recovers the true signer, rejects a wrong expected address, and a tampered message recovers to a different address than the real signer.
  • cache.rs — the four CachedIsActive availability cases (fresh hit, TTL refresh, serve-stale-within-bound, fail-closed past the bound) plus a cold-failure case, using a fake IsActiveSource and an injectable Clock so no anvil is needed.
  • gate.rsChainGate fails closed when the RPC is unreachable and the cache is cold.
  • config.rs — env parsing (IssuerConfig::from_map) succeeds on a full env and errors on a missing required var; a dedicated assertion that Debug redacts signing_key_pem/tenant_salt.
  • clients.rs — exact-match redirect/audience lookups (including the trailing-slash / different-path / unknown-client negative cases), TOML parsing of a full registry, a parse-error path, and an empty registry rejecting everything (fail-closed default).
  • state.rs — single-use + expiring auth codes, single-use + expiring nonces, and that AppState::for_test() seeds a usable client store.
  • http/discovery.rs, http/page.rs — OIDC discovery document shape and the connect-and-sign HTML page (including an HTML-escaping regression test for the reflected query parameters — a CRITICAL finding from an earlier review, fixed by escaping before interpolation).
  • http/authorize.rs — Grant A over a Poem TestClient: valid SIWE issues a code and 302s; PKCE is required with S256 only; bad SIWE signature, domain mismatch, and nonce replay are all rejected; unregistered client/redirect is rejected at both GET /authorize and POST /authorize/callback with no Location header on failure; an inactive tenant gets 403.
  • http/token.rs, http/token_code.rs, http/assertion.rs — Grant C over a TestClient: valid assertion mints for a registered, active agent key; inactive tenant → 403; non-agent key → 400; unregistered audience → 400. Grant A code exchange: full SIWE→code→/token→verify round trip, single-use code enforcement, and a wrong-PKCE-verifier → 400 case.
  • http/mod.rs — router wiring (/healthz, discovery + jwks mount, the /assets static endpoint serving the login script with a JS content type).

Poem's TestClient (poem::test::TestClient, gated behind the test feature in [dev-dependencies]) drives these as real HTTP requests against the router, not direct function calls — so route wiring, form/query extraction, and status codes are exercised, not just the handler logic.

4. Live-anvil end-to-end tests#

crates/grantor-issuer/tests/grant_a_e2e.rs and crates/grantor-issuer/tests/grant_c_e2e.rs are the two full-stack tests: each spawns a real anvil node, deploys MockUSDC + GrantorRegistry from their Foundry build artifacts (contracts/out/*/*.json, referenced via alloy::sol!), runs a full tenant lifecycle (setTierConfigcreateTenanttopUpdrawPeriod), registers the issuer key's RFC 7638 thumbprint on-chain, wires up the real AppState/ChainGate/FileClientStore pointed at the anvil endpoint, and drives the actual HTTP router:

  • grant_a_e2e.rs — a user wallet signs a SIWE message, POST /authorize/callback → 302 with a code, POST /token with the PKCE verifier → JWT, then grantor_verify::verify_jwt against the issuer's real public key, asserting the OIDC nonce echoes, tid is correct, and sub is pairwise (never equal to the raw wallet address).
  • grant_c_e2e.rs — additionally registers an on-chain agent key, signs the canonical client-credentials assertion with it, POST /token with grant_type=client_credentials → JWT, then verifies it the same way.

These are genuine end-to-end tests against a live chain, not a mock: an earlier review confirmed removing the on-chain registration step turns the happy path into a 403, so the test is not vacuous.

Running everything#

# workspace unit + Poem endpoint tests + both live-anvil e2e tests
# (anvil must be on PATH — see the env note below)
cargo test --workspace

# contract unit + invariant/fuzz tests
cd contracts && forge test

# lint (must be clean before a review pass)
cargo clippy --workspace --all-targets

Environment needed for the Rust side (Foundry's anvil/forge/cast and Cargo's build output are not on the default PATH/location on this box):

export PATH="$HOME/.foundry/bin:$HOME/.cargo/bin:$PATH"
export CARGO_TARGET_DIR=~/.cargo-target

Without anvil on PATH, the two tests/grant_*_e2e.rs tests fail fast with an explicit "anvil must be installed" panic rather than a confusing timeout.

Process#

Implementation followed the plans under docs/superpowers/plans/ chunk by chunk: write a failing test for the next bite-sized behavior, implement just enough to pass it, then hand the diff to a subagent-driven review pass before moving to the next chunk (see .superpowers/sdd/progress.md for the per-task review outcomes — most chunks were "Approved, no issues," a few surfaced Important/Critical findings that were fixed before merge, e.g. the reflected-XSS fix in http/page.rs and the signature-before-nonce-burn ordering fix in both http/assertion.rs and http/authorize.rs). A final whole-branch review preceded each merge-readiness verdict.

This page is also served as Markdown — agents should read that. The whole tree is indexed for machines in llms.txt.