# 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](#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_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()`:

```js
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.

```sh title="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"}'
```

```js title="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 title="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 title="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:

```sh
# .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:

```js
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

```sh
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

```sh
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.
