Testing
Test site keys and secrets for automated tests and CI.
AgentGate is built to slow down automated browsers, and your end-to-end tests are automated browsers. Selenium, Playwright, Cypress and Puppeteer set navigator.webdriver, run headless and move no pointer, so on a real site key they will often be asked for the press-and-hold check, and in enforce mode they may be declined.
Use the testing keys below instead. They are fixed, public values that behave the same way every time and never run detection:
- the widget and
AgentGate.execute()work unchanged on any page, includinghttp://localhostand CI hosts; - no proof of work, collectors or instrumentation run, so a test run finishes in milliseconds;
/v1/siteverifyanswers with a fixed result chosen by the secret, so you can test your backend's success and failure paths.
Warning
Testing keys never protect anything. Anyone can use them, every test token passes a test secret, and a test token is rejected everywhere else. Never ship them to production: use them only in test and CI environments and switch keys with an environment variable (see CI).
When to use test keys
| Situation | Keys |
|---|---|
| Unit and integration tests of your backend's siteverify handling | a test secret, with any test token |
| End-to-end tests (Playwright, Cypress, Selenium) of pages that embed the widget | a test site key and a test secret |
| Testing how your UI handles a declined verification | site_test_always_fail |
| Testing the page with the visible check shown (layout, focus, accessibility) | site_test_force_interactive |
| Staging that should behave like production | a real site with its own origins, in monitor mode |
Test site keys
Use these as data-sitekey or in AgentGate.init({siteKey}). They work from any origin: no allowed-origins list applies.
| Site key | Widget behaviour |
|---|---|
site_test_always_pass | Verifies at once, without the check, and yields a test token. |
site_test_always_fail | Fails: errorCallback (or the execute() rejection) gets an error whose code is challenge_failed, and no token is issued. |
site_test_force_interactive | Always shows the press-and-hold check (in every widget mode). Completing it, by holding or with "Verify another way", yields a test token. |
Test secrets
Use these as the Authorization: Bearer credential (or the secret field) of POST /v1/siteverify.
| Secret | Result for a test token |
|---|---|
ags_test_always_pass | 200, success: true |
ags_test_always_fail | 422, success: false, error: "invalid_token" |
ags_test_token_spent | 409, success: false, error: "token_used" |
The rules that keep test and real traffic apart:
- Test tokens start with
agr_test_. Treat them as opaque strings anyway. - A test secret accepts only test tokens. A real token (or anything else) sent with a test secret gets
422 invalid_tokenand is not consumed. - A real secret rejects test tokens with
422 invalid_token, and so does the gateway (/v1/gateway/check). A test token never unlocks a real site. - Test secrets are stateless:
ags_test_always_passaccepts the same test token again and again. Test single use withags_test_token_spent. - Request validation is the same as for real secrets:
tokenandexpectedActionare required (400 invalid_requestotherwise). - Every response to test keys carries
"test": true, so your code and logs can tell them apart. - Nothing about test traffic is stored: it creates no sites, credentials, receipts or decisions and never appears in your dashboard or metrics. The normal per-IP rate limits and CORS rules still apply.
Client example
<form method="post" action="/signup">
<input name="email" type="email" required>
<div class="agentgate" data-sitekey="site_test_always_pass" data-action="signup"
data-mode="managed"></div>
<button>Sign up</button>
</form>
<script src="https://agentgate.example/sdk/v1/agentgate.js"></script>The widget fills the hidden agentgate_token field with a test token when the form is submitted. With AgentGate.execute():
AgentGate.init({ siteKey: "site_test_always_pass", endpoint: "https://agentgate.example" });
const { token } = await AgentGate.execute({ action: "signup" });
// token starts with "agr_test_"Server examples
Send the token to /v1/siteverify exactly as in production; only the secret changes.
curl
curl -s https://agentgate.example/v1/siteverify \
-H "Authorization: Bearer ags_test_always_pass" \
-H "Content-Type: application/json" \
-d '{"token":"agr_test_…","expectedAction":"signup"}'Node
const r = await fetch("https://agentgate.example/v1/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + process.env.AGENTGATE_SECRET },
body: JSON.stringify({ token, expectedAction: "signup" }),
});
const v = await r.json();
if (!v.success) throw new Error(v.error); // invalid_token, token_used, …Python
import os, requests
r = requests.post("https://agentgate.example/v1/siteverify",
headers={"Authorization": "Bearer " + os.environ["AGENTGATE_SECRET"]},
json={"token": token, "expectedAction": "signup"}, timeout=5)
v = r.json()
if not v["success"]:
raise PermissionError(v["error"])Go
body, _ := json.Marshal(map[string]string{"token": token, "expectedAction": "signup"})
req, _ := http.NewRequest("POST", "https://agentgate.example/v1/siteverify", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+os.Getenv("AGENTGATE_SECRET"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var v struct {
Success bool `json:"success"`
Error string `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&v); err != nil || !v.Success {
return fmt.Errorf("agentgate: %s", v.Error)
}A success with ags_test_always_pass looks like:
{"success":true,"decision":"allow","decisionId":"test","action":"signup","issuedAt":1790000000000,"test":true}CI
Read the site key and the secret from environment variables everywhere, and set them per environment. Your code then never names a test key:
# .env.test / CI job
AGENTGATE_SITE_KEY=site_test_always_pass
AGENTGATE_SECRET=ags_test_always_pass
# production: the real values, from your secret store
AGENTGATE_SITE_KEY=site_…
AGENTGATE_SECRET=ags_…Render the page with data-sitekey="${AGENTGATE_SITE_KEY}" and send Authorization: Bearer ${AGENTGATE_SECRET} from the backend. To cover the failure paths, run the suite (or a few tests) again with AGENTGATE_SITE_KEY=site_test_always_fail for the page, and with AGENTGATE_SECRET=ags_test_always_fail or ags_test_token_spent for the backend.
A production guard costs a few lines and catches a misconfigured deploy:
const keys = [process.env.AGENTGATE_SITE_KEY, process.env.AGENTGATE_SECRET];
if (process.env.NODE_ENV === "production" && keys.some((k) => /^(site|ags)_test_/.test(k || ""))) {
throw new Error("AgentGate testing keys in production");
}The example in examples/browser-siteverify/ runs end to end in Chrome with site_test_always_pass and ags_test_always_pass as part of its run.mjs.
End-to-end: onboarding a customer
The AgentGate repository has a Playwright suite, web/e2e-pw/, that tests AgentGate itself the way a new customer meets it. It uses real keys and a real browser, not the testing keys above. Each run creates a brand-new account and shows that the account's traffic is tracked:
- Widget (
onboarding-widget.spec.ts): the operator invites the account; the owner opens the invite link, sets a password and adds a site with the wizard (invisible widget), stores the backend secret from the show-once step and copies the HTML snippet from the snippet tab. A small "customer website" on another origin serves that snippet in a form, and its backend calls/v1/siteverifywith the secret. A visitor sends the form. The console then shows the install check live, the Get started checklist advanced, no "not verifying tokens" warning, the challenge and the validated token in Analytics, and the decision in Decisions. A form without a token and a replayed token are refused, and the replay is counted as a failed validation. After the switch to enforce (through the confirmation dialog), a scripted client that runs no browser check gets no token and the form is refused. - Gateway (
onboarding-gateway.spec.ts): the site's actions and routes are set in the console (site page, Configuration, Edit) and the site is switched to enforce. The customer website's protected API asksGET /v1/gateway/checkbefore each request, as nginx'sauth_requestdoes, using the site's secret. A browser with a token is allowed, a plain script is challenged (receipt_required), and a burst trips the rate rule (rate_limited). Decisions shows each outcome with the rule that matched, and the Rules page shows those rules and their actions.
The operator invite is the only step that does not use the UI. At the end, each spec deletes its account with the operator API.
Run it locally
cd web
npm ci
npm run test:pwThe suite starts its own AgentGate on a free port, with a temporary state directory, a random operator key, PUBLIC_BASE_URL set to its own address and GATEWAY_IP_PER_MINUTE=20, so the rate test needs only a few requests. It runs $AGENTGATE_BIN (a path relative to the repository root), or builds the binary with go build first when that is unset. It uses the Google Chrome installed on the machine, or the browser at $CHROME, so no download is needed. Without Chrome, run npx playwright install chromium once and set PW_CHROMIUM=1.
A run takes about 15 seconds. On a failure the HTML report (web/e2e-pw/playwright-report/) keeps the trace and a screenshot of each failed step. It also keeps a video when Playwright's ffmpeg is installed (npx playwright install ffmpeg). The server's log is in web/e2e-pw/server-logs/agentgate.log. The server's temporary directory (its state, and the binary when the suite built one) is removed after a passing run. After a failed or interrupted run it is kept, and the run prints its path. make release-check runs the suite as the step "Playwright onboarding (real Chrome)". Without Chrome the step is skipped loudly, and with RELEASE_CHECK_STRICT=1, as in CI, the skip is a failure.
Run it against a running instance
BASE_URL=https://agentgate.example \
AGENTGATE_E2E_ALLOW_REMOTE=1 \
AGENTGATE_ADMIN_KEY="$AGENTGATE_ADMIN_KEY" \
npm run test:pw- A
BASE_URLthat is notlocalhostor127.0.0.1is refused unlessAGENTGATE_E2E_ALLOW_REMOTE=1is set. The suite creates real accounts there, and deletes them when it ends. AGENTGATE_ADMIN_KEYis the instance's operator key. Take it from the environment or your secret store, never from a file in the repository. When the admin API is reached another way, for example through an SSH tunnel, setADMIN_BASE_URLto that address.- The rate test sends
GATEWAY_IP_PER_MINUTE + 5requests (605 at the default of 600). If the instance uses another value, setAGENTGATE_E2E_GATEWAY_IP_PER_MINUTEto it. - The customer website runs on the machine that runs the tests, at
http://127.0.0.1:<port>. The browser loads the SDK from the instance, and the website's backend calls the instance's siteverify and gateway check. - The enforce step needs the rule the scripted client trips (
instrument_missing) to enforce on the test site. A fresh install's default rules enforce it, and so does the shippeddeploy/rules.count-mode.jsonsinceprod-1.1.0-count; the test then logs that no override was needed. On an instance whose rule set still counts it, the test sets a site override tochallengeon the Rules page first, as a customer would.