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 throughgrantor_verify::verify_jwt,key_idmatches the verify crate's own thumbprint computation, JWKS is well-formed and carries the rightkid,pairwise_subis 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 fourCachedIsActiveavailability cases (fresh hit, TTL refresh, serve-stale-within-bound, fail-closed past the bound) plus a cold-failure case, using a fakeIsActiveSourceand an injectableClockso no anvil is needed.gate.rs—ChainGatefails 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 thatDebugredactssigning_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 thatAppState::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 PoemTestClient: 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 bothGET /authorizeandPOST /authorize/callbackwith noLocationheader on failure; an inactive tenant gets 403.http/token.rs,http/token_code.rs,http/assertion.rs— Grant C over aTestClient: 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/assetsstatic 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 (setTierConfig → createTenant → topUp → drawPeriod), 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 /tokenwith the PKCE verifier → JWT, thengrantor_verify::verify_jwtagainst the issuer's real public key, asserting the OIDCnonceechoes,tidis correct, andsubis 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 /tokenwithgrant_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.