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, including http://localhost and CI hosts;
  • no proof of work, collectors or instrumentation run, so a test run finishes in milliseconds;
  • /v1/siteverify answers 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

SituationKeys
Unit and integration tests of your backend's siteverify handlinga test secret, with any test token
End-to-end tests (Playwright, Cypress, Selenium) of pages that embed the widgeta test site key and a test secret
Testing how your UI handles a declined verificationsite_test_always_fail
Testing the page with the visible check shown (layout, focus, accessibility)site_test_force_interactive
Staging that should behave like productiona 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 keyWidget behaviour
site_test_always_passVerifies at once, without the check, and yields a test token.
site_test_always_failFails: errorCallback (or the execute() rejection) gets an error whose code is challenge_failed, and no token is issued.
site_test_force_interactiveAlways 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.

SecretResult for a test token
ags_test_always_pass200, success: true
ags_test_always_fail422, success: false, error: "invalid_token"
ags_test_token_spent409, 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_token and 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_pass accepts the same test token again and again. Test single use with ags_test_token_spent.
  • Request validation is the same as for real secrets: token and expectedAction are required (400 invalid_request otherwise).
  • 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

HTML
<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():

JavaScript
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

Shell
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

JavaScript
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

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

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:

JSON
{"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:

Shell
# .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:

JavaScript
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/siteverify with 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 asks GET /v1/gateway/check before each request, as nginx's auth_request does, 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

Shell
cd web
npm ci
npm run test:pw

The 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

Shell
BASE_URL=https://agentgate.example \
AGENTGATE_E2E_ALLOW_REMOTE=1 \
AGENTGATE_ADMIN_KEY="$AGENTGATE_ADMIN_KEY" \
npm run test:pw
  • A BASE_URL that is not localhost or 127.0.0.1 is refused unless AGENTGATE_E2E_ALLOW_REMOTE=1 is set. The suite creates real accounts there, and deletes them when it ends.
  • AGENTGATE_ADMIN_KEY is 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, set ADMIN_BASE_URL to that address.
  • The rate test sends GATEWAY_IP_PER_MINUTE + 5 requests (605 at the default of 600). If the instance uses another value, set AGENTGATE_E2E_GATEWAY_IP_PER_MINUTE to 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 shipped deploy/rules.count-mode.json since prod-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 to challenge on the Rules page first, as a customer would.

View as Markdown