Docs/Sandbox emulation (Beta)
#

Sandbox emulation (Beta)

Emulated email, Google OAuth, Jira and Slack inside Sandboxes v2 sandboxes: how interception works and how tests inspect what was sent.

Emulation makes third-party services deterministic and inspectable. Instead of a Sandboxes v2 sandbox's outbound calls to an email provider, Google, Jira or Slack leaving the sandbox, they're intercepted by an in-sandbox emulator that behaves like the real thing well enough for tests, and that lets a test look at what was sent.

01Declaring plugins

Declare a plugin by its id under emulation:, with its config. jira needs a tenant; the other plugins work with {} and take optional keys (below).

yaml
emulation:
  email: {}
  jira: { tenant: acme }    # intercepts acme.atlassian.net
Plugin idWhat it intercepts
emailThe HTTP send APIs of SendGrid, Resend, Postmark, Mailgun and Amazon SES (in us-east-1 unless you configure sesRegions), so outbound mail is captured instead of sent.
google-oauthGoogle's OAuth endpoints, for testing sign-in flows without a real Google account.
jiraThe Jira Cloud REST API of your site, <tenant>.atlassian.net.
slackSlack's API, for flows that post messages.

Plugin config

PluginKeyTypeRequiredMeaning
emailsesRegionslist of stringsNoAWS regions whose SES API host (email.<region>.amazonaws.com) is intercepted. Default: [us-east-1].
emailextraDomainslist of stringsNoExtra hosts to intercept, such as a self-hosted provider's API.
jiratenantstringYesYour Jira site's subdomain: acme for acme.atlassian.net.
google-oauthclientIdstringNoYour app's OAuth client ID, used in the token's aud/azp claims. Taken from the requests when not set.
slackappIdstringNoThe app_id the emulated oauth.v2.access reports.

The email plugin intercepts these hosts: api.sendgrid.com, api.resend.com, api.postmarkapp.com, api.mailgun.net, api.eu.mailgun.net, email.<region>.amazonaws.com for each of sesRegions, and every host in extraDomains.

yaml
emulation:
  email:
    sesRegions: [eu-west-1]
    extraDomains: [mail.internal.example.test]

Only the intercepted hosts are emulated. If your app sends mail through a host the plugin doesn't intercept (SES in a region you didn't list, or a provider host not above), that call leaves the sandbox and real mail may be sent. Check that email.sent-emails captures a test send before relying on it.

cofactor sandbox validate doesn't check plugin config: a jira plugin without tenant passes validation but intercepts no real Jira site.

cofactor sandbox validate rejects an unknown plugin id. cofactor sandbox envs lists each declared plugin's operations.

Declaring any plugin also adds an implicit public emulation port (port 80), which you don't declare and which counts toward the 8-port limit. While emulation is on, ports 80 and 443 are reserved for the emulator, so none of your own ports can use them, and emulation is a reserved port name.

02How interception works

  • Inside the sandbox, the domains a plugin owns (for example email.us-east-1.amazonaws.com for SES, or acme.atlassian.net for a jira plugin with tenant: acme) resolve to the emulator instead of the internet. Other domains are not intercepted.
  • The emulator serves those domains over real TLS, with a certificate authority it generates once, when the ready snapshot is built, so every copy of one version and environment shares it. The CA survives an emulator restart, so a service that reads NODE_EXTRA_CA_CERTS once at startup keeps working.
  • Services with reload_on_prepare: true, prepare commands and actions get NODE_EXTRA_CA_CERTS=/etc/emulation/ca.crt, so Node-based code trusts that CA.
  • A service without reload_on_prepare never gets it, nor any emulation setting, so its calls to an emulated domain fail TLS. Any service that talks to an emulated provider must set reload_on_prepare: true.
  • No passthrough. A path on an emulated domain that no plugin implements returns 501, and the attempt is recorded. A route your app calls that isn't emulated yet fails loudly instead of silently reaching a real third party.
  • Every claim's prepare gives the emulator a fresh configuration and restarts it.

openssl

The emulator needs openssl to generate its certificates.

  • GitHub-delivered: Cofactor installs it before your setup runs.
  • Pushed image: a pushed image that declares emulation must include it (apt-get install -y openssl), or the emulator never turns healthy and prepare fails.

03Google OAuth and third-party login

Use authentication.run for ordinary native flows that need a signed-in persona. Use provider emulation when a flow tests the sign-in journey itself. These exercise different paths: creating a persona's app session does not prove the Google button and callback work.

yaml
emulation:
  google-oauth: {}
params:
  api_url: { port: api }
env:
  GOOGLE_CLIENT_ID: sandbox
  GOOGLE_CLIENT_SECRET: sandbox
  GOOGLE_REDIRECT_URI: ${params.api_url}/auth/callback

This is a fragment to merge into your definition. Declare the public api port and set reload_on_prepare: true on the service that calls Google. Use your app's actual environment variable names and callback path.

Callback configuration and app changes

Each copy has its own API URL. The example supplies the callback through the app's existing configuration, so /auth/callback receives the response in that copy. Apps that already configure their callback URL, or derive it correctly from the request origin, can use this approach without changing auth code. A hardcoded production callback needs to become configurable.

The emulated journey does not require registering each copy's callback in Google Console. Real Google login still follows Google's registration rules. Do not disable token verification or change production auth to make an emulator work. Non-Node clients must trust the sandbox emulator's CA using their runtime's supported mechanism; Node services receive NODE_EXTRA_CA_CERTS as described above.

The browser and server handshake

The browser authorization request is intercepted and returned to the app's callback with an emulated code and the original OAuth state. The sandbox emulator then answers the app's token exchange and user-info requests, and exposes discovery and signing-key endpoints. The app's own callback creates its normal app session.

Beta rollout: Interception of an application's HTTP redirect to Google has been proved in dev and is pending deployment. Do not assume that declaring google-oauth alone proves this browser path is available in your environment; verify the complete journey.

The default identity is Test User (testuser@example.com). Repeated Google sign-ins in a copy return this same identity. Different copies still have separate app users, sessions and data. The Google identity is not selected from the Login node's persona, and the public definition currently has no per-persona Google identity setting.

Google emulation covers direct calls to the supported Google endpoints. If your app authenticates through a hosted intermediary such as Auth0 or Clerk, enabling Google emulation does not emulate that intermediary. Inspect where authorization, token exchange and session creation happen before selecting an approach. Compatibility with each customer's auth library must be proved.

Prove sign-in

  1. Keep the application's Login node to select the app and environment, with performLogin: false, so it does not preload an app session.
  2. Begin signed out and click the app's actual Google sign-in button.
  3. Confirm the callback reaches the current copy, the app reports the fixture identity, and a protected page or API request succeeds with the resulting session.
  4. Confirm no request reaches real Google identity hosts. An intercepted redirect alone does not prove the backend handshake succeeded.

This checks the app's integration and resulting session. It does not check Google's password screen, consent UI or availability.

04Operations

A plugin can offer typed operations: the same shape as an action (a name, a description, a typed input and output). They appear alongside your own actions, named <plugin>.<op>, and run exactly like an action: from the CLI, or as a seed step.

OperationInputOutput
email.sent-emailsnoneemails: every email captured since the sandbox booted or the emulator was last reset

google-oauth, jira and slack have no operations today.

For example, to check that an invite-user action really sent the invitation:

bash
cofactor sandbox run <id> invite-user \
  --input workspace_id=<acme.workspace_id> \
  --input email=new@example.test --input role=member --input as=owner
cofactor sandbox run <id> email.sent-emails

This is why an action that has side effects should go through your app's own API: a raw database insert would skip the send, and there'd be nothing to see.

As a seed step:

yaml
seeds:
  - id: mail
    action: email.sent-emails

emails is a list, so later steps can't reference it with ${mail.emails}; only string, number and boolean outputs can be referenced. See Seeds and actions.