> ## Documentation Index
> Fetch the complete documentation index at: https://docs.open.cx/llms.txt
> Use this file to discover all available pages before exploring further.

# Local test playbook

# Zendesk OAuth local test playbook

This playbook reproduces the local Zendesk OAuth validation used for the OpenCX Zendesk integration. It covers a fresh install, an existing legacy customer switching to OAuth, OAuth-only operation, same-account fallback, cross-account isolation, inbound messaging, replies, media, handoff, Agent Workspace, closing and reopening, uninstall, and reinstall.

Use a Zendesk development account and a disposable OpenCX organization. Do not run destructive lifecycle steps against a customer account.

## What must pass

| Product   | Authorization    | Required behavior                                                                                                                                                                              |
| --------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Ticketing | Legacy           | Existing email, API token, webhook, ticket, and public-reply flows remain unchanged.                                                                                                           |
| Ticketing | OAuth            | Fresh connection creates usable ticketing settings, derives the default agent from the authorizing Zendesk user, and performs ticket/user/group/Guide/webhook operations without an API token. |
| Ticketing | Hybrid           | OAuth is preferred. A revoked OAuth grant is fenced and the failing operation retries once with complete same-account legacy credentials.                                                      |
| Ticketing | Account mismatch | Legacy credentials from a different subdomain are never used as fallback.                                                                                                                      |
| Messaging | Legacy           | Existing Sunshine App key, webhook, switchboard, inbound message, and handoff flows remain unchanged.                                                                                          |
| Messaging | OAuth            | A fresh Marketplace install provisions an encrypted token and signed webhook, then supports inbound messages, deduplication, media, replies, handoff, close, and reopen.                       |
| Messaging | Hybrid           | OAuth is preferred. Revoked OAuth retries the same operation once with complete same-account App credentials and marks OAuth for reconnect.                                                    |
| Messaging | Account mismatch | One Marketplace app is owned by one OpenCX organization; another org or subdomain fails closed.                                                                                                |
| Lifecycle | Both             | State is bound to org, user, and browser session; replay is rejected; disconnect cancels outstanding callbacks; legacy credentials survive OAuth disconnect.                                   |
| Uninstall | Messaging        | A genuine Zendesk uninstall removes the OAuth installation locally, preserves legacy credentials, and permits a clean reinstall.                                                               |

## Prerequisites

* Node.js 24.
* Docker and the services in `backend/docker-compose.yml`.
* `cloudflared` for a public webhook origin.
* Google Chrome for the local full-stack browser test.
* A Zendesk development account with Support, Messaging, Agent Workspace, a Web Widget channel, and admin access.
* An approved OpenCX Marketplace bot for Messaging OAuth.
* An approved Zendesk global OAuth client for Ticketing OAuth. URL-construction tests can run with a test client ID, but successful consent requires the real approved client.
* An OpenCX development user with owner/admin access to a disposable organization.

## Keep credentials out of files

Never paste OAuth secrets, access tokens, callback codes, signed webhook URLs, widget keys, or Zendesk passwords into this document, a shell history entry, a test file, a commit, or CI output.

Store local secrets in macOS Keychain and expose them only to the process that needs them. For example:

```bash theme={"dark"}
security add-generic-password -U \
  -s opencx-zendesk-ticketing-oauth-secret \
  -a "$USER" \
  -w

security add-generic-password -U \
  -s opencx-zendesk-sunshine-oauth-secret \
  -a "$USER" \
  -w
```

Load values without printing them:

```bash theme={"dark"}
export ZENDESK_TICKETING_OAUTH_CLIENT_SECRET="$(security find-generic-password -w -s opencx-zendesk-ticketing-oauth-secret -a "$USER")"
export ZENDESK_SUNSHINE_OAUTH_CLIENT_SECRET="$(security find-generic-password -w -s opencx-zendesk-sunshine-oauth-secret -a "$USER")"
```

The backend uses these variables:

```bash theme={"dark"}
export ZENDESK_TICKETING_OAUTH_CLIENT_ID='<approved-global-client-id>'
export ZENDESK_TICKETING_OAUTH_CLIENT_SECRET='<from-keychain>'
export ZENDESK_SUNSHINE_OAUTH_CLIENT_ID='<approved-marketplace-client-id>'
export ZENDESK_SUNSHINE_OAUTH_CLIENT_SECRET='<from-keychain>'
export ZENDESK_MARKETPLACE_NAME='<marketplace-name>'
export ZENDESK_MARKETPLACE_ORGANIZATION_ID='<marketplace-organization-id>'
export ZENDESK_MARKETPLACE_BOT_ID='<marketplace-bot-id>'
export ZENDESK_OAUTH_REDIRECT_DASHBOARD_BASE_URL='http://localhost:3000'
```

Use literal values in `backend/.env` only when that ignored file is already the team's approved local secret store. Never add it to Git.

## Prepare the worktree

Run every command from the OAuth PR worktree, not a different checkout:

```bash theme={"dark"}
cd /path/to/opencx-oauth-worktree
git branch --show-current
git status --short
node --version
```

The branch must be the Zendesk OAuth PR branch and Node must report `v24.x`.

Install dependencies separately because backend and dashboard pin different pnpm versions:

```bash theme={"dark"}
cd backend
corepack pnpm install --frozen-lockfile

cd ../dashboard
corepack pnpm install --frozen-lockfile
```

If a private `@opencx-labs/*` package returns `401` or `403`, repair the Keychain-backed GitHub Packages token first. Do not share another worktree's `node_modules`; cross-worktree symlinks caused the incorrect local font and incomplete dependency tree seen during the original validation.

## Start local infrastructure

```bash theme={"dark"}
cd backend
docker compose up -d
pnpm dev:prepare
```

The required local ports are:

| Service               | Port    |
| --------------------- | ------- |
| Dashboard             | `3000`  |
| Backend               | `8080`  |
| Zendesk webhook proxy | `18080` |
| Static widget page    | `18081` |
| PostgreSQL            | `5432`  |
| Redis                 | `6379`  |

## Create a narrow public webhook proxy

Do not tunnel the entire local backend. The proxy below exposes only the signed Zendesk webhook route and Marketplace uninstall route, while capturing the latest event for the live test.

Create `/tmp/opencx-zendesk-webhook-proxy.mjs`:

```js theme={"dark"}
import http from "node:http";
import { writeFileSync } from "node:fs";

const webhookPrefix = "/backend/zendesk/webhook/";
const uninstallPath = "/backend/zendesk-sunshine-oauth/uninstall";

http
  .createServer(async (request, response) => {
    if (request.method === "HEAD") {
      response.writeHead(200);
      response.end();
      return;
    }

    const allowed =
      request.method === "POST" &&
      (request.url?.startsWith(webhookPrefix) || request.url === uninstallPath);
    if (!allowed) {
      response.writeHead(404);
      response.end();
      return;
    }

    const chunks = [];
    for await (const chunk of request) chunks.push(chunk);
    const body = Buffer.concat(chunks);
    if (request.url?.startsWith(webhookPrefix)) {
      writeFileSync("/tmp/opencx-last-zendesk-webhook.json", body, { mode: 0o600 });
    }

    const upstream = http.request(
      {
        host: "127.0.0.1",
        port: 8080,
        method: request.method,
        path: request.url,
        headers: request.headers,
      },
      (upstreamResponse) => {
        response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
        upstreamResponse.pipe(response);
      },
    );
    upstream.on("error", () => {
      if (!response.headersSent) response.writeHead(502);
      response.end();
    });
    upstream.end(body);
  })
  .listen(18080, "127.0.0.1");
```

Start it and the tunnel:

```bash theme={"dark"}
node /tmp/opencx-zendesk-webhook-proxy.mjs
cloudflared tunnel --url http://127.0.0.1:18080
```

Copy only the generated HTTPS origin, without a trailing slash:

```bash theme={"dark"}
export SERVER_BASE_URL='https://<generated-host>.trycloudflare.com'
```

Start the backend after `SERVER_BASE_URL` is set. The OAuth callback uses it to provision the real Zendesk webhook target.

```bash theme={"dark"}
cd backend
pnpm ddev
```

Confirm the public proxy is narrow:

```bash theme={"dark"}
curl -I "$SERVER_BASE_URL/"
```

A `HEAD` request returns success for tunnel health. An unrelated `GET` or `POST` path must return `404`.

## Start the dashboard

Set the local backend URL in `dashboard/.env`:

```bash theme={"dark"}
VITE_BACKEND_URL=http://localhost:8080
```

Then start the dashboard:

```bash theme={"dark"}
cd dashboard
pnpm xdev
```

Open `http://localhost:3000/auth`, use **Dev login**, select the disposable org, then open **Settings → Integrations → Zendesk**.

The first page must show exactly two product choices: **Zendesk Ticketing** and **Zendesk Messaging**. Selecting a product must show exactly two authorization choices: **OAuth** and **Legacy credentials**. OAuth carries the **New** and **Recommended** badges.

## Run the full-stack modal test

This test uses the real local backend and system Chrome without downloading a Playwright browser revision:

```bash theme={"dark"}
cd dashboard
PLAYWRIGHT_USE_SYSTEM_CHROME=1 \
E2E_WITH_BACKEND=1 \
E2E_ZENDESK_OAUTH_START=1 \
VITE_BACKEND_URL=http://localhost:8080 \
node_modules/.bin/playwright test \
  e2e/zendesk-oauth-setup.e2e.ts \
  --project=chromium \
  --reporter=list
```

It verifies the two-stage selector, both Legacy forms, both OAuth panels, invalid Ticketing subdomains, real start URL construction, callback-error routing, status isolation, the Ticketing reply webhook, and trigger-instructions link.

## Ticketing OAuth: Zendesk-side setup

1. In OpenCX choose **Zendesk → Ticketing → OAuth**.
2. Enter only the Zendesk subdomain, such as `acme`.
3. Click **Continue to Zendesk**.
4. Confirm the Zendesk hostname matches the entered subdomain.
5. Sign in as an agent or admin and click **Allow**.
6. Confirm OpenCX returns to the OAuth panel with **Connected** and the correct subdomain.
7. Copy the signed **Reply webhook URL** shown in the panel.
8. In Zendesk Admin Center open **Apps and integrations → Webhooks** and create a `POST` JSON webhook with no extra authentication.
9. Open **Objects and rules → Triggers** and create the following trigger:
   * Meet ANY: Ticket is Created; Ticket is Updated.
   * Meet ALL: Comment is Present (public).
   * Action: notify the OpenCX active webhook.
   * Body:

```json theme={"dark"}
{
  "ticketId": "{{ticket.id}}",
  "event_type": "new_comment"
}
```

OAuth authenticates OpenCX's outbound Support API calls. The classic trigger is independently required for inbound public-comment delivery. Never put comment text in the webhook body; OpenCX fetches and filters public comments itself.

If the global client has not yet been approved, stop after the automated start-URL test. A test client ID can prove URL construction but cannot prove consent, token exchange, or refresh rotation.

## Messaging OAuth: Zendesk-side setup

1. In OpenCX choose **Zendesk → Messaging → OAuth** and click **Continue to Zendesk**.
2. Zendesk opens the OpenCX Marketplace bot authorization. Click **Allow** once.
3. For a local backend, Zendesk may redirect to the registered production callback origin. Before that callback is consumed, replace only the origin with `http://localhost:8080`, preserving `/backend/zendesk-sunshine-oauth/callback` and the entire query string byte-for-byte. Do not reload or reuse a callback code.
4. Confirm the local callback returns to `http://localhost:3000/settings/integrations` and shows **Connected**.
5. In Zendesk Admin Center open **AI → AI agents → AI agents → Marketplace bots → OpenCX**.
6. Assign OpenCX to one disposable Web Widget or staging channel and save.
7. In **Channels → Messaging and social → Messaging**, make OpenCX the test channel's default responder.
8. Set **Conversation control** to **Release control** so a closed Agent Workspace conversation returns to OpenCX on the next customer message.

The OAuth callback automatically provisions the integration-scoped webhook. Do not manually create a second Sunshine webhook for the OAuth path.

## Create the local Web Widget page

Get the test channel's Web Widget key from Zendesk Admin Center, then create `/tmp/opencx-zendesk-widget.html` without committing it:

```html theme={"dark"}
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>OpenCX Zendesk OAuth live test</title>
  </head>
  <body>
    <h1>OpenCX Zendesk OAuth live test</h1>
    <script
      id="ze-snippet"
      src="https://static.zdassets.com/ekr/snippet.js?key=<WEB_WIDGET_KEY>"
    ></script>
  </body>
</html>
```

Serve and open it:

```bash theme={"dark"}
python3 -m http.server 18081 --directory /tmp
open http://localhost:18081/opencx-zendesk-widget.html
```

Send a unique message such as `OAuth live inbound <UUID>`. Wait for `/tmp/opencx-last-zendesk-webhook.json`, then extract the live values without printing the whole webhook:

```bash theme={"dark"}
export ZENDESK_SUNSHINE_LIVE_CONVERSATION_ID="$(jq -r '.events[0].payload.conversation.id' /tmp/opencx-last-zendesk-webhook.json)"
export ZENDESK_SUNSHINE_LIVE_INBOUND_TEXT="$(jq -r '.events[0].payload.message.content.text' /tmp/opencx-last-zendesk-webhook.json)"
export ZENDESK_SUNSHINE_LIVE_ORG_ID='<disposable-opencx-org-id>'
```

## Run the real Messaging OAuth lifecycle

`CI_SKIP_FLAKY_TESTS` must be absent. Setting it to `false` still skips tests because any nonempty value is truthy in the test environment.

```bash theme={"dark"}
cd backend
env -u CI_SKIP_FLAKY_TESTS \
  ZENDESK_SUNSHINE_LIVE_ORG_ID="$ZENDESK_SUNSHINE_LIVE_ORG_ID" \
  ZENDESK_SUNSHINE_LIVE_CONVERSATION_ID="$ZENDESK_SUNSHINE_LIVE_CONVERSATION_ID" \
  ZENDESK_SUNSHINE_LIVE_INBOUND_TEXT="$ZENDESK_SUNSHINE_LIVE_INBOUND_TEXT" \
  pnpm test \
    src/zendesk-integration/sunshine/__tests__/sunshine-oauth-marketplace-live-lifecycle.e2e.spec.ts \
    --reporter=verbose
```

The test proves:

* the active auth is a Marketplace bearer;
* the database value is encrypted at rest;
* Zendesk has the expected signed webhook target;
* the real inbound webhook created a contact, session, conversation mapping, and history row;
* replaying the captured webhook does not duplicate the inbound message;
* an OpenCX business reply and image reach the real Zendesk conversation;
* handoff transfers switchboard control to `zd:agentWorkspace`;
* a post-handoff OpenCX agent reply reaches Zendesk and remains one local agent message.

## Manual Agent Workspace and reopen checks

After the live spec:

1. Open the real conversation in Zendesk Agent Workspace.
2. Send a unique public agent reply.
3. Confirm the customer sees it in the Web Widget and OpenCX stores it once as an agent message.
4. Solve and close the Agent Workspace conversation.
5. Confirm the OpenCX session closes and the Zendesk ticket reaches the expected terminal state.
6. Send another unique customer message from the widget.
7. Confirm routing returns to OpenCX, a new OpenCX session is created, and the new lifecycle does not append to the sealed session.

## Legacy, hybrid, and isolation regressions

Run the focused real Zendesk matrix with flaky skipping unset:

```bash theme={"dark"}
cd backend
env -u CI_SKIP_FLAKY_TESTS VITEST_MAX_WORKERS=1 pnpm test \
  src/zendesk-integration-v2/__tests__/oauth-e2e/real-oauth-ticket-lifecycle.e2e.spec.ts \
  src/zendesk-integration-v2/__tests__/oauth-e2e/real-basic-auth-org-unaffected-by-oauth-support.e2e.spec.ts \
  src/zendesk-integration-v2/__tests__/oauth-e2e/real-oauth-revoked-api-request-context-falls-back-to-basic.e2e.spec.ts \
  src/zendesk-integration-v2/__tests__/oauth-e2e/real-oauth-revoked-api-request-context-fails-closed.e2e.spec.ts \
  src/zendesk-integration-v2/__tests__/oauth-e2e/real-oauth-revoked-webhook-provisioning-falls-back-to-basic.e2e.spec.ts \
  src/zendesk-integration/sunshine/__tests__/get-zendesk-ticketing-creds-cross-store-revoked-oauth-falls-back-to-basic.e2e.spec.ts \
  src/livekit-voice-agent/__tests__/contact-enrichment.revoked-oauth.real-zendesk.spec.ts \
  --reporter=verbose
```

Then run the local DB/HTTP defensive matrix:

```bash theme={"dark"}
pnpm test \
  src/zendesk-integration-v2/__tests__/zendesk-ticketing-oauth-callback-rejects-different-user.spec.ts \
  src/zendesk-integration-v2/__tests__/zendesk-ticketing-oauth-callback-rejects-different-user-org.spec.ts \
  src/zendesk-integration-v2/__tests__/zendesk-ticketing-oauth-callback-rejects-different-session.spec.ts \
  src/zendesk-integration-v2/__tests__/zendesk-ticketing-oauth-disconnect-cancels-callback.spec.ts \
  src/zendesk-integration/sunshine/__tests__/sunshine-oauth-concurrent-app-claim-has-one-owner.spec.ts \
  src/zendesk-integration/sunshine/__tests__/sunshine-oauth-lifecycle-generation.spec.ts \
  src/zendesk-integration/sunshine/__tests__/sunshine-oauth-stale-401-does-not-poison-current-token.spec.ts \
  src/zendesk-integration/sunshine/__tests__/sunshine-oauth-expired-hybrid-falls-back-to-legacy.spec.ts \
  --reporter=verbose
```

## Uninstall and reinstall

Zendesk's Marketplace uninstall callback is unauthenticated. OpenCX accepts cleanup only after the stored bearer proves revoked. Therefore:

1. Remove the OpenCX Marketplace bot through Zendesk first.
2. Wait for Zendesk's Remove URL callback.
3. If the saved Remove URL points to production and cannot reach the local backend, replay the same uninstall notification to the local public tunnel only after the real Zendesk removal has revoked the bearer:

```bash theme={"dark"}
curl -X POST "$SERVER_BASE_URL/backend/zendesk-sunshine-oauth/uninstall" \
  -H 'content-type: application/json' \
  --data '{"appId":"<installed-app-id>","integrationId":"<zendesk-integration-id>"}'
```

4. Confirm Messaging OAuth status is disconnected.
5. Confirm any Legacy Sunshine credentials still exist and remain usable.
6. Confirm an OAuth-only org no longer routes through Zendesk when neither Ticketing nor Legacy Sunshine auth remains.
7. Start Messaging OAuth again, click **Allow**, reassign the bot to the staging channel, and confirm a new inbound message works.

Never send the uninstall callback while the bearer is active. OpenCX must retain the connection because an active bearer means the callback is forged or stale.

## Final verification before pushing

```bash theme={"dark"}
cd backend
pnpm tsgo
pnpm voice:test

cd ../dashboard
pnpm type-check

cd ..
git diff --check
```

Also run scoped formatter and linter checks for every changed backend, dashboard, test, and docs file. Review the entire diff, then run an independent PR review before committing.

The completion record should include:

* exact test commands and pass counts;
* proof that `CI_SKIP_FLAKY_TESTS` was unset for live tests;
* the test Zendesk subdomain and disposable OpenCX org, but no secrets;
* confirmation that the installed token was encrypted at rest;
* confirmation that webhook replay deduplicated;
* confirmation that Agent Workspace reply, close, reopen, uninstall, and reinstall worked;
* CI and reviewer status on the pushed PR.

## Troubleshooting

### `Partner in query parameter 'client_id' not found`

The Marketplace client ID is not approved, not published to the selected Zendesk environment, or does not match `ZENDESK_SUNSHINE_OAUTH_CLIENT_ID`. This is a Zendesk Marketplace registration problem, not a customer API-token problem.

### Ticketing authorize URL opens but consent fails

Confirm the global OAuth client is approved for the exact client ID and callback URL. A tenant-local OAuth client cannot behave as the global client used by this integration.

### Callback lands on `api.open.cx` during local testing

Before consuming the one-time callback, replace only the origin with `http://localhost:8080`. Preserve the path, code, state, and encoding. Never paste the callback URL into logs or a ticket.

### Callback says state is invalid or expired

Start a new OAuth flow in the same authenticated browser session. State is intentionally one-time, short-lived, and bound to the exact OpenCX user, org, and session.

### No Messaging webhook arrives

Check all of the following:

* backend started after `SERVER_BASE_URL` was set to the current tunnel;
* the Marketplace bot is assigned to the test channel;
* the channel's default responder is OpenCX;
* the OAuth status is connected and does not require reconnect;
* the public proxy receives only the expected signed webhook path;
* Conversation control is set to Release control for return routing.

### Ticketing agent replies do not return to OpenCX

OAuth does not replace the Zendesk trigger. Confirm the signed Reply webhook URL, `POST` JSON webhook, Created-or-Updated public-comment trigger, and exact `ticketId`/`event_type` body.

### Dashboard font or components look different from production

Verify the dashboard uses branch-local dependencies. Remove a broken `node_modules` symlink and reinstall with the correct dashboard pnpm version and a working private-package token. The expected UI font is Inter.

### A real test is reported as skipped

Run it with `env -u CI_SKIP_FLAKY_TESTS`. Do not set the variable to `false`.

### AI reply assertions fail with an OpenRouter authentication error

Repair the local OpenRouter test credential. That error is independent of Zendesk OAuth and should not be hidden by changing OAuth expectations.

## Cleanup

1. Delete every temporary Zendesk ticket, user, webhook, trigger, and channel assignment created by the run.
2. Leave the Marketplace bot installed only if the disposable org is intended to remain connected; otherwise uninstall it through Zendesk and verify local cleanup.
3. Stop dashboard, backend, widget server, webhook proxy, and `cloudflared`.
4. Remove only these explicit temporary files:

```bash theme={"dark"}
rm /tmp/opencx-zendesk-webhook-proxy.mjs
rm /tmp/opencx-zendesk-widget.html
rm /tmp/opencx-last-zendesk-webhook.json
```

5. Stop Docker services when they are no longer needed:

```bash theme={"dark"}
cd backend
docker compose down
```

Do not delete shared development databases, volumes, credentials, or another worktree's dependencies as part of cleanup.
