diff --git a/.env.sample b/.env.sample index 6837b9d..75225d3 100644 --- a/.env.sample +++ b/.env.sample @@ -1,3 +1,5 @@ DATABASE_ID= OFFLINE_TOKEN= -EXPIRE_ON= \ No newline at end of file +EXPIRE_ON= +DITTOSH_SERVER_URL=https://xxxx.cloud.dittolive.app/ +DITTOSH_SERVER_API_KEY= diff --git a/AGENTS.md b/AGENTS.md index 5da4b58..8df3a53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,16 +6,19 @@ The Ditto CLI: an npm/Homebrew-installable TypeScript CLI (binary `dittosh` — ## Hard rules (from the spec — do not regress) -- **Offline-only.** Never call `startSync()`. No `--sync`, no user-supplied credentials (`--app-id`/`--license`, config commands). Every install shares one app ID — sync would leak data between users on a LAN. +- **Offline-only local store.** Never call `startSync()`. No `--sync`, no user-supplied SDK credentials (`--app-id`/`--license`, config commands). Every install shares one app ID — sync would leak data between users on a LAN. (The `server` group below is the explicit exception in kind, not in mechanism: it's a plain HTTPS client for the portal's HTTP API with user-provided portal API keys — it never starts sync and never touches the local store or the SDK token.) - **Dev credentials:** repo-root `.env` (`DATABASE_ID`, `OFFLINE_TOKEN`, `EXPIRE_ON`; aliases `DQL_OFFLINE_LICENSE`/`DITTO_APP_ID`), gitignored, honored only in dev builds. Release builds (`RELEASE=true npm run build`) ignore env credentials and use the stamped, obfuscated embedded token (`scripts/stamp-token.ts`, M8). +- **Server credentials (`dittosh server`):** `--url`/`--api-key` flags > shell env `DITTOSH_SERVER_URL`/`DITTOSH_SERVER_API_KEY` (aliases `DITTO_CLOUD_URL`/`DITTO_API_KEY`) > cwd `.env`. Missing → exit 3. The API key is never printed and is redacted from error messages. - **stdout is sacred.** Query results are the only thing on stdout (JSON when piped). Progress, warnings, banners, SDK logs → stderr. Never break this (jq composability is a feature). - One DQL statement per `store.execute` call; no trailing `;`. -- Exit codes: `0` ok · `1` query/DQL error · `2` usage · `3` platform/token · `4` data-dir lock. +- Exit codes: `0` ok · `1` query/DQL/API error · `2` usage · `3` platform/token/server-config/auth/connection · `4` data-dir lock. - Colors off when `NO_COLOR`, `CI`, `--no-color`, or non-TTY. ## Layout -- `src/cli/` — commander entry (`index.ts`), injected version (`version.ts`, tsup `define`), `groups/` per command group (`dql`, later `skills`, `system`) +- `src/cli/` — commander entry (`index.ts`), injected version (`version.ts`, tsup `define`), `groups/` per command group (`dql`, `server`, `skills`, `system`) +- `src/cli/groups/server/` — `dittosh server` wiring: `common.ts` (flags/connect/error mapping/confirm), `store.ts` (execute/remote-execute), `attachments.ts`, `rbac.ts` (roles/users), `webhooks.ts`, `doctor.ts`. Thin glue; logic lives in `src/server/`. The legacy pre-DQL store API (find/findbyid/count/write) is deliberately NOT implemented — `server execute` covers it. +- `src/server/` — portal HTTP API: `config.ts` (flags > shell env > cwd `.env`; URL normalize; sources), `client.ts` (`PortalClient`, injectable `FetchLike`, `PortalApiError`/`PortalConnectionError`, key redaction), `run.ts` (execute/remote-execute rendering through `src/render/`). - `src/config/` — data-dir resolution (`--data-dir` > `DITTOSH_DATA_DIR` > OS default), config dir (`DITTOSH_CONFIG_DIR` > OS default; env-paths caches homedir at module load, so tests must use this override, not `$HOME`), persisted state (one-time warnings, update cache) - `src/identity/` — token loading (dev env / release reassembly), expiry - `src/ditto/session.ts` — the only SDK touchpoint: init/open/close, log taming, lock mapping @@ -47,9 +50,9 @@ scripts/install-release.sh # stamp token → RELEASE=true build → npm i -g . ( ## Testing conventions -- **unit** (`tests/unit`): no SDK. Fast; snapshot-friendly (`FORCE_COLOR=0` in setup). +- **unit** (`tests/unit`): no SDK. Fast; snapshot-friendly (`FORCE_COLOR=0` in setup). Server-group tests inject a mock `FetchLike` via `registerServerGroup(cmd, { fetchImpl })` — no network; they scrub `DITTOSH_SERVER_*`/`DITTO_CLOUD_URL`/`DITTO_API_KEY` and `chdir` to an empty tmpdir because `tests/setup/env.ts` loads the repo `.env` (which may hold REAL portal credentials). - **integration** (`tests/integration`): real offline Ditto in a fresh tmpdir per file. Skip-gated on dev credentials via `tests/helpers/credentials.ts` (`hasDevCredentials`, `NO_CREDENTIALS`). `fileParallelism: false` — the native module holds process-wide state. -- **e2e** (`tests/e2e`): execa spawning `node --import tsx --env-file=.env src/cli/index.ts`. Assert exit codes and both stdout/stderr separately. Each test uses its own tmp `-d` data dir. +- **e2e** (`tests/e2e`): execa spawning `node --import tsx --env-file=.env src/cli/index.ts`. Assert exit codes and both stdout/stderr separately. Each test uses its own tmp `-d` data dir. `server.test.ts` runs a local `node:http` mock Ditto Server; spawns pass an explicit env with execa **`extendEnv: false`** (the v10 name — v9's `extend`) so real `.env` credentials never leak into a test run. - New user-facing command ⇒ e2e coverage. New logic ⇒ unit coverage. New SDK behavior ⇒ integration coverage. - **Coverage is a hard gate: ≥ 85%** statements/branches/functions/lines, enforced by `npm run coverage` (unit + integration projects, thresholds in `vitest.config.ts`) and in CI. `src/cli/index.ts` (process entry) is excluded deliberately — e2e covers it; v8 can't see subprocesses. Keep CLI glue thin: logic lives in injectable, unit-testable modules (see `doctor.ts`, `batch.ts`, `repl-core.ts`, `run.ts`). @@ -59,3 +62,4 @@ scripts/install-release.sh # stamp token → RELEASE=true build → npm i -g . ( - `DittoConfig(appId, { mode: "smallPeersOnly" }, dir)` + `Ditto.open` + `setOfflineOnlyLicenseToken` — verified (Spike A). EXPLAIN → first item `plan`; PROFILE → trailing `~request_profile` item. - The native tracing bootstrap writes ~7 WARN/INFO lines to **stderr** at `sdk.init()` (fd-level, not suppressible from JS). Cosmetic only; stdout is clean. - Two processes on one data dir → "File already locked" → mapped to `LockError` (exit 4). +- Portal HTTP API (verified live on the retail app): Big Peer requires `FROM` in SELECT (`SELECT 1` → 400), so `server doctor` probes with `system:collections`; GET `/auth/roles` answers two wire shapes (bucketed + cursor-paged) — both normalized. diff --git a/README.md b/README.md index bc973d5..889ae46 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ brew install getditto/tap/dittosh # Homebrew (macOS/Linux) The binary is `dittosh`. Requires Node.js ≥ 20 for npm installs. Supported platforms (matching the Ditto Node SDK): **macOS arm64, Linux x64/arm64, Windows x64**. Intel Macs (darwin-x64) are not supported by SDK 5.1.0. -The CLI ships with a built-in offline license and runs entirely locally — no account, no credentials, no sync. `startSync()` is never called. All your data lives in one local directory (see [Data directory](#data-directory)). +The CLI ships with a built-in offline license and runs entirely locally — no account, no credentials, no sync. `startSync()` is never called. All your data lives in one local directory (see [Data directory](#data-directory)). (The [`dittosh server`](#dittosh-server--ditto-server-over-http) group is the one exception in kind: a plain HTTPS client for your Ditto Cloud app's HTTP API with *your* portal API key — it never starts sync and never touches the local store.) ## Quickstart @@ -134,6 +134,48 @@ dittosh dql dataset reset retail --yes # evict the dataset's collecti `dataset run` prints the resolved statement (on stderr, so stdout stays clean), then executes it. Query names resolve across datasets; ambiguous names list the matches. `--setup` applies the entry's index DDL first; write-category catalog queries require `--yes` and clean up after themselves. `--seed ` reproduces a dataset exactly; changing seeds adds new documents (reset first for a clean slate). +### `dittosh server` — Ditto Server over HTTP + +Run DQL against your Ditto Cloud app (the Big Peer behind it) over the portal HTTP API — the same API the portal's DQL editor uses. No local store involved; nothing syncs. + +```bash +# configure once (or use flags every call) +export DITTOSH_SERVER_URL=https://xxxx.cloud.dittolive.app/your-app-id +export DITTOSH_SERVER_API_KEY=your-api-key + +# validate the setup before scripting +dittosh server doctor + +# run DQL on the server +dittosh server execute "SELECT * FROM customers LIMIT 5" +dittosh server execute "SELECT * FROM orders WHERE total > :t" -p t=100 +dittosh server execute "INSERT INTO cars DOCUMENTS (:car)" --args '{"car":{"_id":"c1","make":"Toyota"}}' +cat batch.sql | dittosh server execute # one HTTP call per statement +dittosh server execute "EXPLAIN SELECT * FROM orders WHERE store_id = 's1'" +``` + +Results render exactly like `dittosh dql` (table on TTY, JSON when piped, `-o`/`--format`/`--max-rows`/pager/`--time`). Mutations print `OK` and a `(transactionId … · N documents mutated)` note on stderr. + +**Configuration** is resolved in this order (first hit wins): + +1. **Flags:** `--url ` / `--api-key ` +2. **Shell env:** `DITTOSH_SERVER_URL` / `DITTOSH_SERVER_API_KEY` (aliases from the Ditto docs also work: `DITTO_CLOUD_URL` / `DITTO_API_KEY`) +3. **A `.env` file in the current directory** (never overrides the real environment) + +Find the URL in the portal → your app → "Connecting via HTTP" → **Cloud URL Endpoint** (looks like `xxxx.cloud.dittolive.app/`). Create API keys in the portal → your app → **Auth → New API key**. Prefer env/`.env` over `--api-key` — argv is visible in `ps` and shell history. The URL must be `https://` (cleartext `http://` is rejected for non-local hosts; loopback is exempt for local testing). + +| Command | What it calls | +|---|---| +| `server execute [statement]` (alias `exec`) | `POST /api/v5/store/execute` — any DQL: SELECT/INSERT/UPDATE/DELETE/EXPLAIN/…; `-e`/`-f`/stdin batch, `-p`/`--args`, `--txn-id`, `--api-version v4\|v5`, `--timeout` (default 120s) | +| `server remote-execute ` | `POST /api/v5/sync/remote_execute` — run DQL on connected edge peers (statement must start with `SYNC CONTEXT`) | +| `server attachment upload ` / `get ` | `POST /api/v4/attachments/upload` (multipart) / `GET /api/v4/attachments/{id}` (raw bytes; refuses to dump binary to a terminal — use `-o` or pipe) | +| `server roles list/create/delete` | `/api/v4/auth/roles` — Big Peer RBAC roles (portal-internal API) | +| `server users list/set-roles/delete` | `/api/v4/auth/users` — app users and their role sets (portal-internal API) | +| `server webhook-secrets list/create/rotate/delete` | `/api/v4/auth/webhook/secret` — auth-webhook HMAC secrets (portal-internal API) | +| `server doctor` | config → connection → auth probe; exit 3 on any failure | + +The legacy pre-DQL store API (`find`/`findbyid`/`count`/`write`) is deliberately not supported — `server execute` covers it with full DQL. Every command's `--help` documents its request body and examples (the RBAC/webhook endpoints aren't publicly documented — the help text is the reference). Batch mode: auth/connection failures stop the batch with exit 3 even under `--continue-on-error`; a timeout does *not* mean the statement failed — a mutation may still commit server-side. + ### Global flags `--no-color`, `--quiet` (suppress informational notes), `--no-update-check` (planned; update flow lands in a later milestone). @@ -180,9 +222,9 @@ dittosh dql --advise --apply -y "SELECT …" # apply them | Code | Meaning | |---|---| | 0 | ok | -| 1 | query/DQL error | +| 1 | query/DQL error · server API error · server timeout (`server`) | | 2 | usage error (bad flags, missing file, ambiguous dataset query, …) | -| 3 | platform/token/data-dir problem (unsupported OS/arch, expired license, unwritable dir) | +| 3 | platform/token/data-dir problem (unsupported OS/arch, expired license, unwritable dir) · server config missing/invalid, auth rejected, or unreachable (`server`) | | 4 | data directory locked by another process | ## REPL diff --git a/docs/testing-server.md b/docs/testing-server.md new file mode 100644 index 0000000..bedb96c --- /dev/null +++ b/docs/testing-server.md @@ -0,0 +1,280 @@ +# Manual testing — `dittosh server` (portal HTTP API) + +Checklist for the `server` group: DQL over HTTP against Ditto Server +(execute / remote-execute), attachments, RBAC (roles/users), webhook secrets, +and the config resolution contract (flags > shell env > cwd `.env`). + +The legacy pre-DQL store API (find/findbyid/count/write) is deliberately not +supported — `server execute` covers it all with full DQL. + +Every command is copy-pasteable once the config exists. Run top to bottom; +check off what passes. Note anything off (wrong exit codes, noise on stdout) +as a comment under the failing test. + +Prereq: the release build is installed (`scripts/install-release.sh`) and the +retail dataset is synced to the portal app (collections: `stores`, +`categories`, `products`, `customers`, `inventory`, `orders`, `order_items`). + +Two things that are **not** bugs: + +- Piped stdout is always **JSON**, never the table. +- Progress, notes (`(transactionId …)`), warnings, and errors live on + **stderr** — `dittosh server execute … | jq` stays clean. + +## 1. Setup + +- [ ] **`.env` in the working directory** (what `npm run dev` and the release + build both read): + ```bash + DITTOSH_SERVER_URL=https://xxxx.cloud.dittolive.app/your-app-id + DITTOSH_SERVER_API_KEY=your-api-key + ``` + The `https://` prefix is added when missing; cleartext `http://` is rejected + for non-local hosts (the key would transit unencrypted — loopback is exempt + for local testing). Aliases `DITTO_CLOUD_URL` / `DITTO_API_KEY` also work. + Layers mix per key (a cwd `.env` URL + a shell-env key sends that key to the + `.env`'s host) — `dittosh server doctor` shows where each value came from. + +- [ ] **No config → exit 3 with guidance** (run from a directory with no + `.env`): + ```bash + cd "$(mktemp -d)" && dittosh server execute "SELECT 1"; echo "exit: $?" + ``` + Expect: `No Ditto Server URL configured…` on stderr, `exit: 3`, nothing on + stdout. + +## 2. `server doctor` + +- [ ] **All green** + ```bash + dittosh server doctor; echo "exit: $?" + ``` + Expect: `✓ config` (shows URL + where each credential came from), `✓ + connection`, `✓ auth — API key accepted — probe query ran (transactionId + …)`, `exit: 0`. The API key value must **never** appear. + +- [ ] **Bad key → diagnosis** + ```bash + DITTOSH_SERVER_API_KEY=definitely-wrong-key dittosh server doctor; echo "exit: $?" + ``` + Expect: config ✓, connection ✓, `✗ auth — HTTP 401 … check the API key`, + `exit: 3`. + +- [ ] **Unreachable URL → connection fails** + ```bash + dittosh server doctor --url http://127.0.0.1:1/app; echo "exit: $?" + ``` + Expect: `✗ connection — Cannot reach …`, auth skipped, `exit: 3`. + +## 3. `server execute` (POST /api/v5/store/execute) + +- [ ] **Basic SELECT (piped → JSON)** + ```bash + dittosh server execute "SELECT * FROM customers LIMIT 3" + ``` + Expect: JSON array of 3 customer docs on stdout, `(transactionId …)` on + stderr, exit 0. + +- [ ] **Table on a terminal** (run in a real terminal) + ```bash + dittosh server execute "SELECT first_name, last_name, email FROM customers LIMIT 5" + ``` + Expect: a box table + `5 rows` footer. + +- [ ] **Parameter binding** (`-p` JSON-parses values) + ```bash + dittosh server execute "SELECT count(*) AS n FROM customers WHERE first_name = :name" -p name=Carolyn + dittosh server execute "SELECT * FROM products WHERE price > :p LIMIT 3" --args '{"p":50}' + ``` + Expect: a count for Carolyn; 3 products. + +- [ ] **Aggregates + ordering** + ```bash + dittosh server execute "SELECT city, count(*) AS n FROM stores GROUP BY city ORDER BY n DESC LIMIT 5" + ``` + +- [ ] **EXPLAIN as a plain statement** + ```bash + dittosh server execute "EXPLAIN SELECT * FROM orders WHERE store_id = 'store_seattle'" + ``` + Expect: one JSON row with a `plan`. + +- [ ] **--api-version v4 (strict mode)** + ```bash + dittosh server execute --api-version v4 "SELECT count(*) AS n FROM customers" + ``` + Expect: same count as v5. + +- [ ] **--txn-id consistency header** + ```bash + dittosh server execute "SELECT count(*) AS n FROM customers" --txn-id 1 + ``` + Expect: normal result (txn 1 is long past). + +- [ ] **Batch from stdin** (one HTTP call per statement) + ```bash + printf "SELECT count(*) AS n FROM customers;\nSELECT count(*) AS n FROM orders;\n" | dittosh server execute + ``` + Expect: two JSON arrays on stdout, `2 ok, 0 failed (of 2)` on stderr. + +- [ ] **Batch stops on failure; --continue-on-error doesn't** + ```bash + printf "SELECT 1 FROM stores;\nSELEC broken;\nSELECT 2 FROM stores;\n" | dittosh server execute; echo "exit: $?" + printf "SELECT 1 FROM stores;\nSELEC broken;\nSELECT 2 FROM stores;\n" | dittosh server execute --continue-on-error; echo "exit: $?" + ``` + Expect: first run stops after the error (`exit: 1`); second runs all three + (`2 ok, 1 failed (of 3)`, `exit: 1`). + +- [ ] **DQL error → exit 1, stdout stays clean** + ```bash + out=$(dittosh server execute "SELEC broken"); code=$?; echo "stdout bytes: ${#out}, exit: $code" + ``` + Expect: `stdout bytes: 0, exit: 1`, `Query error: …` on stderr. + +- [ ] **Write round-trip on a scratch collection** + ```bash + dittosh server execute "INSERT INTO dittosh_cli_probe DOCUMENTS ({'_id':'p1','note':'hello'})" + dittosh server execute "SELECT * FROM dittosh_cli_probe WHERE _id = 'p1'" + dittosh server execute "UPDATE dittosh_cli_probe SET note = 'updated' WHERE _id = 'p1'" + dittosh server execute "DELETE FROM dittosh_cli_probe WHERE _id = 'p1'" + dittosh server execute "SELECT count(*) AS n FROM dittosh_cli_probe" + ``` + Expect: `OK` + `1 document mutated` (stderr) per write; the SELECT shows the + doc between INSERT and DELETE; final count `0`. + +- [ ] **-o export** + ```bash + dittosh server execute "SELECT * FROM categories" -o /tmp/categories.json && head -c 200 /tmp/categories.json + ``` + +- [ ] **Usage errors → exit 2, no request made** + ```bash + dittosh server execute "SELECT 1" -e "SELECT 2"; echo "exit: $?" + dittosh server execute "SELECT 1; SELECT 2"; echo "exit: $?" + dittosh server execute "DELETE FROM customers" -o /tmp/x.json; echo "exit: $?" + ``` + (All exit 2; the last one refuses because mutations produce no rows.) + +## 4. `server remote-execute` (POST /api/v5/sync/remote_execute) + +- [ ] **SYNC CONTEXT required (client-side)** + ```bash + dittosh server remote-execute "SELECT 1"; echo "exit: $?" + ``` + Expect: `must start with a SYNC CONTEXT clause`, exit 2. + +- [ ] **Runs against connected peers** (needs at least one small peer online; + otherwise an empty result array) + ```bash + dittosh server remote-execute "SYNC CONTEXT ( PEERS WHERE peerKeyString = '' ) SELECT * FROM system:system_info" + ``` + Expect: JSON array with one entry per responding peer (`peer`, + `elapsedMilliseconds`, `items`). + +## 5. Attachments + +- [ ] **Upload → id/len** + ```bash + printf 'hello attachment' > /tmp/att.txt + dittosh server attachment upload /tmp/att.txt + ``` + Expect: `{"id": "", "len": 16}`. + +- [ ] **Download round-trip** + ```bash + dittosh server attachment get -o /tmp/att-out.txt && diff /tmp/att.txt /tmp/att-out.txt + dittosh server attachment get | cmp - /tmp/att.txt # piped: bytes on stdout + ``` + +- [ ] **Binary on a TTY is refused** (run in a real terminal) + ```bash + dittosh server attachment get ; echo "exit: $?" + ``` + Expect: `Refusing to write binary to the terminal…`, exit 2. + +## 6. RBAC (roles / users) — portal-internal, undocumented + +- [ ] **roles list** + ```bash + dittosh server roles list + ``` + Expect: a row per role (empty list if none) — name, version, description, + collection_permissions, grant_remote_query. + +- [ ] **roles create → list → delete** (destructive; use a throwaway name) + ```bash + dittosh server roles create dittosh-probe --description "CLI test role" --permissions read_only + dittosh server roles list | grep dittosh-probe + dittosh server roles delete dittosh-probe -y + ``` + Expect: created note on stderr; visible in the list; deleted. + +- [ ] **roles delete without -y, piped → exit 2** + ```bash + echo | dittosh server roles delete dittosh-probe; echo "exit: $?" + ``` + +- [ ] **users list** (needs auth/RBAC configured for the app — otherwise + `HTTP 404 … may not support the users endpoint`, exit 1) + ```bash + dittosh server users list --limit 50 + dittosh server users list --user-id "auth0|some-id" + ``` + +- [ ] **users set-roles / delete** (destructive — only against a test user) + ```bash + dittosh server users set-roles "auth0|test-user" dittosh-probe + dittosh server users delete "auth0|test-user" -y + ``` + +## 7. Webhook secrets — portal-internal, undocumented + +**Prerequisite:** the provider must already exist — i.e. an auth webhook must +be configured for the app (portal → app → Auth). This API cannot create +providers: against a nonexistent provider, `list` and `create` both fail with +`HTTP 400 … Provider '' not found` (exit 1). Verified live. + +Destructive — secrets sign your auth webhook traffic. Use a dedicated test +provider, not your production one. + +- [ ] **Nonexistent provider → clean error** + ```bash + dittosh server webhook-secrets list --provider definitely-not-a-provider; echo "exit: $?" + ``` + Expect: `HTTP 400 … Provider 'definitely-not-a-provider' not found`, exit 1 + (a 404 answers `[]` on older deployments). + +- [ ] **list → create → list → rotate → delete** (against an EXISTING test + provider — substitute its real name) + ```bash + PROVIDER=my-test-webhook + dittosh server webhook-secrets list --provider "$PROVIDER" + dittosh server webhook-secrets create --provider "$PROVIDER" --not-after 2027-01-01T00:00:00Z + SECRET=$(dittosh server webhook-secrets list --provider "$PROVIDER" --format json | jq -r '.[0].secret') + dittosh server webhook-secrets rotate --provider "$PROVIDER" --secret "$SECRET" --not-after 2027-06-01T00:00:00Z + dittosh server webhook-secrets delete --provider "$PROVIDER" --secret "$SECRET" -y + ``` + Expect: existing secrets (or `[]`) initially; create prints the new secret + JSON; rotate prints the replacement secret; delete confirms on stderr. + +- [ ] **Validation: bad date → exit 2** + ```bash + dittosh server webhook-secrets create --provider "$PROVIDER" --not-after someday; echo "exit: $?" + ``` + +## 8. Config precedence + +- [ ] **Flags beat env beat .env** + ```bash + dittosh server execute "SELECT count(*) AS n FROM customers" \ + --url "$(grep ^DITTOSH_SERVER_URL= .env | cut -d= -f2-)" \ + --api-key "$(grep ^DITTOSH_SERVER_API_KEY= .env | cut -d= -f2-)" + ``` + Expect: normal result. (`-f2-` keeps `=`-padding in the key.) Note that + `--api-key` on argv is visible in `ps` and your shell history — prefer the + env/.env layers for anything but throwaway shells. + +- [ ] **Bad --api-version → exit 2 before any request** (bad flag value = usage) + ```bash + dittosh server execute "SELECT 1" --api-version v9; echo "exit: $?" + ``` diff --git a/plans/SDKS-4855-implementation-plan.md b/plans/SDKS-4855-implementation-plan.md index 79a6fb5..45f557b 100644 --- a/plans/SDKS-4855-implementation-plan.md +++ b/plans/SDKS-4855-implementation-plan.md @@ -9,7 +9,8 @@ Branch: `aaronlabeau/sdks-4855-dql-cli-tool`. This file is the working checklist - **Adversarial review: 17 rounds, ~161 issues found and fixed, CONVERGED** (final round returned "no issues found"). Every fix carries regression tests; every round verified fixes live (incl. pty-level REPL checks). - Two upstream SDK 5.1.0 bugs found and reported on the Linear ticket (NO_COLOR native panic; retail-joins query hang) — both mitigated CLI-side, see Known issues. - Current: **392/392 tests green · coverage 89.3/86.7/90.6/89.9 (85% hard gate) · lint + typecheck clean.** -- Remaining: M6 (skills), M7 (self-update), M8 (distribution + token stamping). +- M6/M7/M8 landed since; **M9 (`dittosh server`, portal HTTP API) landed on branch `portal-support`** — see the M9 section. +- Remaining: M8 loose ends (release.yml, formula, README). ## Sequencing approach @@ -167,6 +168,34 @@ Branch: `aaronlabeau/sdks-4855-dql-cli-tool`. This file is the working checklist --- +## M9 — `dittosh server` (portal HTTP API) — branch `portal-support` + +**Goal:** full client for the Ditto Server (Big Peer) HTTP RPC API — the API the portal's DQL editor uses. Endpoint inventory from the public docs (`docs.ditto.live/cloud/http-api`, incl. the OpenAPI spec at `/cloud/http-api/api/openapi.json`) and the portal's own client (`cloud-services/portal/core/src/api/rpcClient.ts`). + +- [x] Config resolution (`src/server/config.ts`): flags (`--url`/`--api-key`) > shell env (`DITTOSH_SERVER_URL`/`DITTOSH_SERVER_API_KEY`, aliases `DITTO_CLOUD_URL`/`DITTO_API_KEY`) > cwd `.env` (parsed via `util.parseEnv`, never overrides real env). URL normalized (scheme added, trailing slashes stripped). Missing config → exit 3 with portal guidance. `sources` tracked for `server doctor`. `--api-version v4|v5` (default v5) selects the `/store/execute` API version. +- [x] HTTP client (`src/server/client.ts`): global fetch (injectable for tests), `Authorization: Bearer`, `X-DITTO-TXN-ID`, 30s timeout, JSON-or-text error bodies, API-key redaction in error messages (≥8 chars). Error mapping: 401/403 + connection failures → exit 3; other HTTP rejections → exit 1. +- [x] `server execute` (alias `exec`) — POST `/api/v{4,5}/store/execute`: positional/`-e`/`-f`/stdin batch (one call per statement, `--continue-on-error`), `-p`/`--args` binding, `--txn-id`, all output formats + `-o` + pager + `--max-rows` + `--time` (same render pipeline as local `dql`). DQL errors arrive as `error.description` in a 200/400 body → exit 1. Batch dot-command stripping is local-REPL-only, not applied here. +- [x] `server remote-execute` — POST `/api/v5/sync/remote_execute`; client-side SYNC CONTEXT check (exit 2); per-peer JSON envelope out. +- [x] ~~Legacy store API~~ — **pulled post-review**: find/findbyid/count/write are the legacy pre-DQL API; `server execute` covers all of it with full DQL. (Verified live that legacy `:param` placeholders are rejected by the current deployment despite the OpenAPI text — another reason not to ship them.) +- [x] `server attachment upload|get` — multipart POST / byte GET; get refuses binary on a TTY without `-o`, pipes raw bytes otherwise. +- [x] `server roles list|create|delete` + `server users list|set-roles|delete` — RBAC endpoints the portal uses (undocumented publicly); both GET /roles wire shapes (bucketed + cursor-paged) normalized. Destructive deletes confirm (`-y` / TTY prompt / exit 2 piped). +- [x] `server webhook-secrets list|create|rotate|delete` — auth webhook HMAC secrets; delete/rotate look up the full secret object from `--secret` (server requires it). +- [x] `server doctor` — config (with sources) → connection → auth probe (`SELECT * FROM system:collections LIMIT 1`; "SELECT 1" is invalid DQL — FROM required — observed live). 401/403 → auth ✗; 400 → key accepted (auth happens before query parsing); unreachable → connection ✗. Exit 0/3. +- [x] Rich `--help` on every command: request body shapes, wire formats, and examples (the APIs are mostly undocumented — help text is the documentation). +- [x] Tests: `tests/unit/server-config.test.ts`, `server-client.test.ts`, `server-run.test.ts`, `server-doctor.test.ts`, `cli-server.test.ts`, `cli-server-branches.test.ts`; e2e `tests/e2e/server.test.ts` (node:http mock server, real subprocess; `extendEnv: false` — execa v10 renamed `extend`). +- [x] `docs/testing-server.md` — manual checklist against the retail dataset in the portal. +- [x] Verified against the real portal (retail app): SELECT/params/aggregates, EXPLAIN-as-statement, INSERT→SELECT→UPDATE→DELETE round-trip, doctor with good + bad keys. + +**Findings (verified live, documented in `--help` and `docs/testing-server.md`):** +- Big Peer requires `FROM` in SELECT (`SELECT 1` → 400). Doctor probe uses `system:collections`. +- execa v10: env isolation option is `extendEnv: false` (was `extend` in v9) — e2e spawns rely on it to stay hermetic now that the repo `.env` holds real portal credentials. + +**Not covered (deliberate):** JWT `http_login` exchange (API key is the CLI's model), CDC/Kafka streams (not request/response), no server-side REPL (one-shot + batch only). + +**Adversarial review: 5 rounds, 2 independent reviewers each, CONVERGED (both agreed).** Rounds 1–4 (~25 issues, each with regression tests): batch mode flattened auth/connection failures to exit 1 and retried dead servers (now exit 3 + stop); list commands validated `--format`/`--max-rows` after the network call (now exit 2, zero requests); `remote-execute --args -` hung on a TTY; URLs with userinfo/query/fragment were accepted (credential echo + misrouted paths — now rejected); mid-body timeouts escaped error mapping as raw `TimeoutError`; `roles list` silently truncated the cursor-paged wire shape (now `--cursor` + continuation note); 200-with-error bodies now fail closed (`assertNoErrorBody` everywhere except `execute`, whose errors need statement context); `stripEq` restricted to dual short/long options; `hasError(null)` per-peer crash. Reviewers also caught a false "lint clean" claim (empty `tail` output read as success) — gates are now verified by exit code. + +**Round 5 (fresh reviewers, agree-before-fixing rule — fixes only landed where both agreed after cross-verification):** fail-closed shape validation on ALL 2xx responses (execute/remote-execute require DQL hallmark keys; roles/users require their envelope keys — portal parity; webhook list keeps the portal's deliberate `[]` fallbacks incl. 404→`[]`); non-loopback `http://` rejected (the cloud's 308 made hop 1 cleartext-with-key, hop 2 anonymous → misleading 401); `--timeout` flag (default 120s) + `PortalTimeoutError` with honest "may still be running" message, exit 1 (the portal bounds only login/RBAC writes; DQL is unbounded there); unpasteable `roles create --help` example (single quotes can't nest) fixed to `\"…\"`; `.env` UTF-8 BOM strip; per-key .env hints; batch `stdoutBroken()` check; users-404 "unsupported endpoint" hint; `--permissions` blanket-string whitelist; e2e coverage gaps closed per the house convention (doctor, users, webhook-secrets, roles write, attachment upload, remote-execute happy path); docs/testing-server.md corrected (webhook §7 needs a pre-existing provider — verified live; attachment len 16; portable exit-code capture; `cut -d= -f2-`; ps/history warning for `--api-key`). README gained the full `dittosh server` feature section. + ## Cross-cutting checklist (every milestone) - [ ] Exit codes honored: 0 ok · 1 query/DQL · 2 usage · 3 platform/token · 4 lock diff --git a/src/cli/groups/server/attachments.ts b/src/cli/groups/server/attachments.ts new file mode 100644 index 0000000..f057ada --- /dev/null +++ b/src/cli/groups/server/attachments.ts @@ -0,0 +1,121 @@ +import fs from "node:fs"; +import path from "node:path"; +import chalk from "chalk"; +import type { Command } from "commander"; +import { expandTilde } from "../../../config/paths.js"; +import { note, validateOutPath } from "../dql/run.js"; +import { addServerOpts, connect, type ServerDeps, stripEq, withServerErrors } from "./common.js"; + +/** + * Attachment endpoints: POST /api/v4/attachments/upload (multipart) and + * GET /api/v4/attachments/{id} (raw bytes). The HTTP API caps uploads at 1 MB + * by default (raiseable via Ditto support). + */ + +export function registerAttachmentCommands(server: Command, deps: ServerDeps = {}): void { + const attachment = server + .command("attachment") + .description("Upload and download ATTACHMENT blobs") + .addHelpText( + "after", + ` +Attachments hold binary data referenced from documents via the ATTACHMENT +type. Upload returns an id you store in a document field. + +Uploads are limited to 1 MB by the HTTP API by default. +`, + ); + + addServerOpts( + attachment + .command("upload") + .description("Upload a file (POST /api/v4/attachments/upload, multipart)") + .argument("", "file to upload"), + ) + .addHelpText( + "after", + ` +Multipart form: one "file" part. Response: {"id": "", "len": n} + +Example: + dittosh server attachment upload ./photo.png +`, + ) + .action( + withServerErrors(async (file: string, opts: { url?: string; apiKey?: string }) => { + const filePath = path.resolve(expandTilde(file)); + let buf: Buffer; + try { + buf = fs.readFileSync(filePath); + } catch (err) { + console.error( + chalk.red(`Cannot read file: ${file} (${(err as NodeJS.ErrnoException).message})`), + ); + process.exitCode = 2; + return; + } + const conn = connect(opts, deps); + if (!conn) return; + const form = new FormData(); + form.append("file", new Blob([new Uint8Array(buf)]), path.basename(filePath)); + note(`Uploading ${path.basename(filePath)} (${buf.length.toLocaleString()} bytes)…`); + const res = await conn.client.uploadAttachment(form); + console.log(JSON.stringify({ id: res.id, len: res.len }, null, 2)); + }), + ); + + addServerOpts( + attachment + .command("get") + .description("Download an attachment (GET /api/v4/attachments/{id})") + .argument("", "attachment ID") + .option("-o, --out ", "write to a file (default: raw bytes on stdout when piped)"), + ) + .addHelpText( + "after", + ` +Examples: + dittosh server attachment get RUGMUxzHDRH1x94uH_QcrkzUhV5-j6oFd1c9eAFMxNZDmQ -o photo.png + dittosh server attachment get > photo.png # piped: bytes on stdout +`, + ) + .action( + withServerErrors( + async (id: string, opts: { url?: string; apiKey?: string; out?: string }) => { + // -o is a short option — commander keeps "=" in `-o=x` artifacts. + opts = { ...opts, out: stripEq(opts.out) }; + if (opts.out) { + const outError = validateOutPath(opts.out); + if (outError) { + console.error(chalk.red(outError)); + process.exitCode = 2; + return; + } + } else if (process.stdout.isTTY) { + console.error( + chalk.red("Refusing to write binary to the terminal — pass -o or pipe stdout"), + ); + process.exitCode = 2; + return; + } + const conn = connect(opts, deps); + if (!conn) return; + const bytes = await conn.client.getAttachment(id); + if (opts.out) { + try { + fs.writeFileSync(path.resolve(expandTilde(opts.out)), bytes); + } catch (err) { + console.error( + chalk.red(`Cannot write ${opts.out}: ${(err as NodeJS.ErrnoException).message}`), + ); + process.exitCode = 1; + return; + } + console.log(`Wrote ${bytes.length.toLocaleString()} bytes to ${opts.out}`); + } else { + process.stdout.write(bytes); + } + }, + ), + ); +} diff --git a/src/cli/groups/server/common.ts b/src/cli/groups/server/common.ts new file mode 100644 index 0000000..183649a --- /dev/null +++ b/src/cli/groups/server/common.ts @@ -0,0 +1,131 @@ +import chalk from "chalk"; +import type { Command } from "commander"; +import { ParamError } from "../../../query/params.js"; +import { + type FetchLike, + PortalApiError, + PortalClient, + PortalConnectionError, + PortalTimeoutError, +} from "../../../server/client.js"; +import { + ApiVersionError, + resolveServerConfig, + type ServerConfig, + ServerConfigError, +} from "../../../server/config.js"; + +/** + * Shared wiring for every `dittosh server` subcommand: connection flags, + * config resolution (flags > shell env > cwd .env), and error→exit-code mapping. + */ + +export interface ServerOpts { + url?: string; + apiKey?: string; +} + +/** + * Commander 14 keeps a leading "=" for dual short/long options in several + * forms (`-e=x`, `--execute==x`, even `--execute =x`) — strip exactly one. + * Only apply to dual short/long options (-e/-f/-o/-p). Trade-off: a value + * that legitimately starts with "=" loses it there (never a valid DQL + * statement; pathological for file paths). Long-only options must NOT be + * stripped. + */ +export const stripEq = (v?: string) => v?.replace(/^=/, ""); + +/** Connection flags present on every server subcommand (env vars documented in each --help). */ +export function addServerOpts(cmd: T): T { + return cmd + .option("--url ", "Ditto Server URL (env: DITTOSH_SERVER_URL, or .env)") + .option("--api-key ", "HTTP API key (env: DITTOSH_SERVER_API_KEY, or .env)"); +} + +export interface ServerConnection { + client: PortalClient; + config: ServerConfig; +} + +/** Injectable plumbing for tests (unit tests wire a mock fetch — no network). */ +export interface ServerDeps { + fetchImpl?: FetchLike; +} + +/** Resolve config and build a client; on failure print + set the exit code and return null. */ +export function connect( + opts: ServerOpts & { apiVersion?: string }, + deps: ServerDeps = {}, +): ServerConnection | null { + try { + // No stripEq here: --url/--api-key/--api-version are long-only options. + const config = resolveServerConfig({ + url: opts.url, + apiKey: opts.apiKey, + apiVersion: opts.apiVersion, + }); + return { + client: new PortalClient({ + baseUrl: config.baseUrl, + apiKey: config.apiKey, + fetchImpl: deps.fetchImpl, + }), + config, + }; + } catch (err) { + if (err instanceof ServerConfigError || err instanceof ApiVersionError) { + console.error(chalk.red(err.message)); + process.exitCode = err.exitCode; + return null; + } + throw err; + } +} + +/** Map a client failure to stderr + exit code. Returns true when the error was handled. */ +export function reportServerError(err: unknown): boolean { + if ( + err instanceof PortalApiError || + err instanceof PortalConnectionError || + err instanceof PortalTimeoutError + ) { + console.error(chalk.red(err.message)); + process.exitCode = err.exitCode; + return true; + } + return false; +} + +/** Wrap a subcommand action with the standard server error mapping. */ +export function withServerErrors( + fn: (...args: A) => Promise, +): (...args: A) => Promise { + return async (...args: A) => { + try { + await fn(...args); + } catch (err) { + if (!reportServerError(err)) throw err; + } + }; +} + +/** Parse a JSON flag value (inline, @file, or "-" handled upstream); usage error on garbage. */ +export function parseJsonFlag(raw: string, flag: string): unknown { + try { + return JSON.parse(raw); + } catch { + throw new ParamError(`${flag} must be valid JSON, got: ${raw.slice(0, 80)}`); + } +} + +/** Destructive server writes: -y skips; a TTY prompts; piped without -y is a usage error. */ +export async function confirmDestructive(message: string, yes?: boolean): Promise { + if (yes) return true; + if (!(process.stdin.isTTY && process.stderr.isTTY)) { + console.error(chalk.red(`${message} — pass -y/--yes to confirm (non-interactive)`)); + process.exitCode = 2; + return false; + } + const { confirm } = await import("@inquirer/prompts"); + return confirm({ message, default: false }, { input: process.stdin, output: process.stderr }); +} diff --git a/src/cli/groups/server/doctor.ts b/src/cli/groups/server/doctor.ts new file mode 100644 index 0000000..dc6d7de --- /dev/null +++ b/src/cli/groups/server/doctor.ts @@ -0,0 +1,141 @@ +import { + type FetchLike, + PortalApiError, + PortalClient, + PortalConnectionError, + PortalTimeoutError, +} from "../../../server/client.js"; +import { + type ConfigSource, + resolveServerConfig, + type ServerConfig, + ServerConfigError, +} from "../../../server/config.js"; + +/** + * `dittosh server doctor` — validate that the CLI has a working Ditto Server + * configuration BEFORE a script depends on it: config resolvable, URL sane, + * server reachable, API key accepted. Logic lives here (injectable) so unit + * tests need no network; the command only renders. + */ + +export interface ServerDoctorCheck { + ok: boolean; + label: string; + detail: string; +} + +export interface ServerDoctorOptions { + url?: string; + apiKey?: string; + apiVersion?: string; + /** Injectable for tests (no network). */ + fetchImpl?: FetchLike; + /** Injectable for tests. */ + env?: NodeJS.ProcessEnv; + /** Injectable for tests (cwd .env lookup). */ + cwd?: string; +} + +const SOURCE_LABELS: Record = { + flag: "flag", + env: "shell env", + dotenv: "cwd .env", +}; + +function skipped(label: string, why: string): ServerDoctorCheck { + return { ok: false, label, detail: `skipped — ${why}` }; +} + +/** Run every check, collecting results; never throws for config/network/auth failures. */ +export async function collectServerDoctorChecks( + opts: ServerDoctorOptions = {}, +): Promise { + let config: ServerConfig; + try { + config = resolveServerConfig( + { url: opts.url, apiKey: opts.apiKey, apiVersion: opts.apiVersion }, + opts.env, + opts.cwd, + ); + } catch (err) { + if (err instanceof ServerConfigError) { + return [ + { ok: false, label: "config", detail: err.message }, + skipped("connection", "no configuration"), + skipped("auth", "no configuration"), + ]; + } + throw err; + } + + const checks: ServerDoctorCheck[] = [ + { + ok: true, + label: "config", + detail: + `url ${config.baseUrl} (${SOURCE_LABELS[config.sources.url]}) · ` + + `api key set (${SOURCE_LABELS[config.sources.apiKey]}) · api ${config.apiVersion}`, + }, + ]; + + // One probe answers both remaining checks. The statement must be valid DQL + // (the Big Peer rejects "SELECT 1" — FROM is required) and cheap: + // system:collections exists on every Ditto store. + // Network failure → connection ✗. 401/403 → connection ✓, auth ✗. + // Anything the server ANSWERED (even 400) proves the key was evaluated. + const client = new PortalClient({ + baseUrl: config.baseUrl, + apiKey: config.apiKey, + fetchImpl: opts.fetchImpl, + }); + const PROBE = "SELECT * FROM system:collections LIMIT 1"; + try { + const res = await client.execute(PROBE, undefined, { version: config.apiVersion }); + checks.push({ ok: true, label: "connection", detail: `reached ${config.baseUrl}` }); + const dqlError = res.error?.description; + checks.push({ + ok: true, + label: "auth", + detail: dqlError + ? `API key accepted (probe returned a query note: ${dqlError})` + : `API key accepted — probe query ran (transactionId ${res.transactionId ?? "?"})`, + }); + } catch (err) { + if (err instanceof PortalConnectionError || err instanceof PortalTimeoutError) { + checks.push({ ok: false, label: "connection", detail: err.message }); + checks.push( + skipped( + "auth", + err instanceof PortalTimeoutError ? "probe timed out" : "server unreachable", + ), + ); + } else if (err instanceof PortalApiError) { + checks.push({ ok: true, label: "connection", detail: `reached ${config.baseUrl}` }); + if (err.status === 401 || err.status === 403) { + checks.push({ + ok: false, + label: "auth", + detail: `${err.message} — check the API key and its permissions (portal → app → Auth)`, + }); + } else if (err.status === 400) { + // A 400 means the request was authenticated and parsed — the key works. + checks.push({ + ok: true, + label: "auth", + detail: `API key accepted (server rejected the probe statement itself: ${err.message})`, + }); + } else { + checks.push({ + ok: false, + label: "auth", + detail: `${err.message} — server error; the key itself was not evaluated`, + }); + } + } else { + throw err; + } + } + + return checks; +} diff --git a/src/cli/groups/server/index.ts b/src/cli/groups/server/index.ts new file mode 100644 index 0000000..fe9039c --- /dev/null +++ b/src/cli/groups/server/index.ts @@ -0,0 +1,102 @@ +import chalk from "chalk"; +import type { Command } from "commander"; +import { ApiVersionError } from "../../../server/config.js"; +import { registerAttachmentCommands } from "./attachments.js"; +import type { ServerDeps } from "./common.js"; +import { addServerOpts } from "./common.js"; +import { collectServerDoctorChecks } from "./doctor.js"; +import { registerRbacCommands } from "./rbac.js"; +import { registerStoreCommands } from "./store.js"; +import { registerWebhookCommands } from "./webhooks.js"; + +const GROUP_HELP = ` +Query and manage Ditto Server (the Big Peer behind your app) over its HTTP +RPC API — the same API the portal's DQL editor uses. + +Configuration (checked in this order — first hit wins): + 1. flags: --url / --api-key + 2. shell env: DITTOSH_SERVER_URL / DITTOSH_SERVER_API_KEY + 3. .env in cwd: DITTOSH_SERVER_URL=… and DITTOSH_SERVER_API_KEY=… + (aliases from the Ditto docs also work: DITTO_CLOUD_URL / DITTO_API_KEY) + +Find the URL in the portal: your app → "Connecting via HTTP" → Cloud URL +Endpoint (looks like xxxx.cloud.dittolive.app/). Create API keys under +your app → Auth → New API key. The URL must be https:// (cleartext http is +rejected for non-local hosts — the key would transit unencrypted). + +Prefer the env/.env layers over --api-key: argv is visible in ps and your +shell history. Layers mix per key: a cwd .env URL + a shell-env API key sends +that key to the .env's host. Run "dittosh server doctor" to see where each +value came from. + +Endpoints covered (public docs + portal client): + execute / remote-execute DQL against the server / connected peers + attachment upload/get ATTACHMENT blobs + roles / users RBAC (undocumented; portal wire shapes) + webhook-secrets auth webhook HMAC secrets (undocumented) + doctor validate URL + API key before scripting + +(The legacy pre-DQL store API — find/findbyid/count/write — is deliberately +not supported; 'server execute' runs full DQL, including INSERT/UPDATE/DELETE.) + +Exit codes: 0 ok · 1 query/API error · 2 usage · 3 config/auth/connection.`; + +export function registerServerGroup(server: Command, deps: ServerDeps = {}): void { + server + .description("Query and manage Ditto Server over the portal HTTP API") + .addHelpText("after", GROUP_HELP) + .action(() => { + server.help(); + }); + + registerStoreCommands(server, deps); + registerAttachmentCommands(server, deps); + registerRbacCommands(server, deps); + registerWebhookCommands(server, deps); + + addServerOpts( + server + .command("doctor") + .description("Validate the server URL and API key (probes with a trivial DQL query)") + .option("--api-version ", "v5 (default) or v4 — the probe uses this API version"), + ) + .addHelpText( + "after", + ` +Checks, in order: + config URL + API key resolvable (and where they came from) + connection the server answers at all + auth the API key is accepted (probes with SELECT * FROM system:collections LIMIT 1) + +Exit codes: 0 all checks pass · 3 any check failed. + +Example: + dittosh server doctor + dittosh server doctor --url xxxx.cloud.dittolive.app/ --api-key … +`, + ) + .action(async (opts: { url?: string; apiKey?: string; apiVersion?: string }) => { + let checks: Awaited>; + try { + checks = await collectServerDoctorChecks({ + url: opts.url, + apiKey: opts.apiKey, + apiVersion: opts.apiVersion, + fetchImpl: deps.fetchImpl, + }); + } catch (err) { + // A bad flag value (--api-version) is a usage error, not a config report. + if (err instanceof ApiVersionError) { + console.error(chalk.red(err.message)); + process.exitCode = err.exitCode; + return; + } + throw err; + } + for (const c of checks) { + console.log(`${c.ok ? chalk.green("✓") : chalk.red("✗")} ${c.label} — ${c.detail}`); + } + const failures = checks.filter((c) => !c.ok).length; + process.exitCode = failures === 0 ? 0 : 3; + }); +} diff --git a/src/cli/groups/server/rbac.ts b/src/cli/groups/server/rbac.ts new file mode 100644 index 0000000..8a2b0f6 --- /dev/null +++ b/src/cli/groups/server/rbac.ts @@ -0,0 +1,451 @@ +import chalk from "chalk"; +import type { Command } from "commander"; +import { ParamError, parsePositiveInt, resolveArgsSource } from "../../../query/params.js"; +import { FormatError, resolveFormat } from "../../../render/output.js"; +import { PortalApiError, type PortalUser, type RoleDoc } from "../../../server/client.js"; +import { emitRows } from "../../../server/run.js"; +import { note } from "../dql/run.js"; +import { + addServerOpts, + confirmDestructive, + connect, + parseJsonFlag, + type ServerDeps, + withServerErrors, +} from "./common.js"; + +/** + * RBAC endpoints used by the portal (undocumented publicly — shapes from + * cloud-services/portal/core/src/api/rpcClient.ts): + * GET/POST /api/v4/auth/roles, DELETE /api/v4/auth/roles/{name} + * GET /api/v4/auth/users, PATCH/DELETE /api/v4/auth/users/{userId} + */ + +interface ServerOnlyOpts { + url?: string; + apiKey?: string; +} + +export interface RolesPage { + rows: Record[]; + hasMore: boolean; + cursor?: string; +} + +/** GET /roles answers two shapes: bucketed {roles:{name:[docs]}} or paged {roles:[docs],hasMore,cursor}. */ +export function normalizeRolesPage(data: unknown): RolesPage { + if (typeof data !== "object" || data === null) return { rows: [], hasMore: false }; + const envelope = data as { roles?: unknown; hasMore?: unknown; cursor?: unknown }; + const roles = envelope.roles; + const cursor = + typeof envelope.cursor === "string" && envelope.cursor ? envelope.cursor : undefined; + let docs: RoleDoc[] = []; + if (Array.isArray(roles)) { + docs = roles as RoleDoc[]; + // The paged shape is the only one that can be truncated — surface it. + const rows = docs.map(roleRow).sort((a, b) => String(a.name).localeCompare(String(b.name))); + return { rows, hasMore: envelope.hasMore === true, cursor }; + } + if (typeof roles === "object" && roles !== null) { + // Bucketed: keep the newest version. UUIDv7s compare in creation order by + // codepoint (matching the portal) — not localeCompare (locale-dependent). + docs = Object.values(roles) + .map((versions) => + Array.isArray(versions) && versions.length > 0 + ? ([...versions] + .sort((a, b) => { + const av = String(a?._id?.version ?? ""); + const bv = String(b?._id?.version ?? ""); + return av < bv ? -1 : av > bv ? 1 : 0; + }) + .at(-1) as RoleDoc) + : undefined, + ) + .filter((d): d is RoleDoc => d !== undefined); + } + const rows = docs.map(roleRow).sort((a, b) => String(a.name).localeCompare(String(b.name))); + return { rows, hasMore: false }; +} + +function roleRow(d: RoleDoc): Record { + return { + name: d?._id?.name, + version: d?._id?.version, + description: d?.description ?? "", + collection_permissions: d?.collection_permissions ?? "none", + grant_remote_query: d?.grant_remote_query ?? false, + }; +} + +/** Kept for direct unit tests — rows only. */ +export function normalizeRoles(data: unknown): Record[] { + return normalizeRolesPage(data).rows; +} + +/** roles stay a real array — JSON consumers must not get a comma-joined string. */ +function normalizeUsers(users: PortalUser[] | undefined): Record[] { + return (users ?? []).map((u) => ({ + userId: u.userId, + roles: u.roles ?? [], + identityVersion: u.identityVersion ?? "", + })); +} + +async function readStdinText(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString("utf8"); +} + +export function registerRbacCommands(server: Command, deps: ServerDeps = {}): void { + // ---- roles --------------------------------------------------------------- + + const roles = server + .command("roles") + .description("Manage Big Peer RBAC roles (portal API, undocumented)") + .addHelpText( + "after", + ` +Roles gate what authenticated users may read/write. Wire shapes here come from +the portal's own client — the public docs don't cover these endpoints. +`, + ); + + addServerOpts( + roles + .command("list") + .description("List roles (GET /api/v4/auth/roles)") + .option("--cursor ", "continue from a previous page (cursor-paged deployments)") + .option("--format ", "table | json | csv | markdown | html | vertical") + .option("--max-rows ", "maximum rows to display", "10000") + .option("--no-pager", "never pipe results through $PAGER/less"), + ) + .addHelpText( + "after", + ` +Examples: + dittosh server roles list + dittosh server roles list --cursor +`, + ) + .action( + withServerErrors( + async ( + opts: ServerOnlyOpts & { + cursor?: string; + format?: string; + maxRows?: string; + pager?: boolean; + }, + ) => { + // Usage validation BEFORE any network I/O (exit 2, no request). + let maxRows: number; + try { + if (opts.format !== undefined) resolveFormat(opts.format); + maxRows = parsePositiveInt(opts.maxRows, "--max-rows", 10_000); + } catch (err) { + if (err instanceof ParamError || err instanceof FormatError) { + console.error(chalk.red(err.message)); + process.exitCode = err.exitCode; + return; + } + throw err; + } + const conn = connect(opts, deps); + if (!conn) return; + const page = normalizeRolesPage(await conn.client.listRoles({ cursor: opts.cursor })); + const r = emitRows( + page.rows, + { format: opts.format, maxRows, maxRowsExplicit: false, pager: opts.pager }, + 0, + ); + if (!r.ok) process.exitCode = 1; + if (page.hasMore && page.cursor) { + note(`(more pages — continue with --cursor ${page.cursor})`); + } + }, + ), + ); + + addServerOpts( + roles + .command("create") + .description("Create or replace a role (POST /api/v4/auth/roles)") + .argument("", "role name") + .option("--description ", "human-readable description", "") + .option( + "--permissions ", + "blanket ('none'|'read_only'|'write_only'|'read_and_write') or a per-collection map ('@file' reads a file)", + ) + .option("--grant-remote-query", "allow members to run remote queries", false), + ) + .addHelpText( + "after", + ` +Request body (server assigns the role version): + { "name": "", + "doc": { "roles_version": "v1-preview", "description": "...", + "collection_permissions": , "grant_remote_query": bool } } + +POST creates OR REPLACES the role. Omitted flags send explicit defaults +("none" permissions, no remote query) — matching the portal's behavior. + +--permissions is either a blanket string or a map of collection → read/write +sides, each side true | false | a list of DQL WHERE clauses (any match grants): + --permissions read_only + --permissions '{"cars": {"read": true, "write": ["_id == "car-1""]}}' + --permissions @role.json + (single-quoted shell strings can't contain escaped single quotes — write + DQL string literals with "double quotes" inside the JSON, as above) + +Examples: + dittosh server roles create staff --description "Store staff" --permissions read_only + dittosh server roles create ops --permissions @ops-role.json --grant-remote-query +`, + ) + .action( + withServerErrors( + async ( + name: string, + opts: ServerOnlyOpts & { + description?: string; + permissions?: string; + grantRemoteQuery?: boolean; + }, + ) => { + let permissions: unknown; + try { + if (opts.permissions !== undefined) { + const BLANKETS = ["none", "read_only", "write_only", "read_and_write"]; + const raw = opts.permissions.startsWith("@") + ? await resolveArgsSource(opts.permissions, readStdinText, "--permissions") + : opts.permissions; + // Bare blanket strings are accepted as-is; anything else must be a JSON map. + const parsed = BLANKETS.includes(raw!.trim()) + ? raw!.trim() + : parseJsonFlag(raw!, "--permissions"); + const isBlanket = typeof parsed === "string" && BLANKETS.includes(parsed); + if ( + !isBlanket && + (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) + ) { + throw new ParamError( + "--permissions must be 'none'|'read_only'|'write_only'|'read_and_write' or a JSON object map", + ); + } + permissions = parsed; + } + } catch (err) { + if (err instanceof ParamError) { + console.error(chalk.red(err.message)); + process.exitCode = err.exitCode; + return; + } + throw err; + } + const conn = connect(opts, deps); + if (!conn) return; + await conn.client.createRole({ + name, + description: opts.description, + collectionPermissions: permissions, + grantRemoteQuery: opts.grantRemoteQuery ? true : undefined, + }); + console.error(chalk.dim(`Role "${name}" created`)); + }, + ), + ); + + addServerOpts( + roles + .command("delete") + .description("Delete every version of a role (DELETE /api/v4/auth/roles/{name})") + .argument("", "role name") + .option("-y, --yes", "confirm without prompting", false), + ) + .addHelpText( + "after", + ` +Example: + dittosh server roles delete staff -y +`, + ) + .action( + withServerErrors(async (name: string, opts: ServerOnlyOpts & { yes?: boolean }) => { + const conn = connect(opts, deps); + if (!conn) return; + // Confirm only after config resolves — no point prompting when the + // command can't act anyway. + if (!(await confirmDestructive(`Delete role "${name}"?`, opts.yes))) return; + await conn.client.deleteRole(name); + console.error(chalk.dim(`Role "${name}" deleted`)); + }), + ); + + // ---- users --------------------------------------------------------------- + + const users = server + .command("users") + .description("Manage app users and their roles (portal API, undocumented)") + .addHelpText( + "after", + ` +A user is an identity-provider subject (e.g. auth0|1234) provisioned via the +auth webhook. These endpoints list users and replace their role sets. +`, + ); + + addServerOpts( + users + .command("list") + .description("List users (GET /api/v4/auth/users)") + .option("--user-id ", "filter to one user") + .option("--limit ", "page size") + .option("--cursor ", "continue from a previous page") + .option("--format ", "table | json | csv | markdown | html | vertical") + .option("--max-rows ", "maximum rows to display", "10000") + .option("--no-pager", "never pipe results through $PAGER/less"), + ) + .addHelpText( + "after", + ` +Query params: userId (filter), cursor, limit. +Response: { "users": [ {"userId", "roles": […], "identityVersion"} ], "hasMore": bool, "cursor" } + +Example: + dittosh server users list --limit 50 + dittosh server users list --cursor +`, + ) + .action( + withServerErrors( + async ( + opts: ServerOnlyOpts & { + userId?: string; + limit?: string; + cursor?: string; + format?: string; + maxRows?: string; + pager?: boolean; + }, + ) => { + // Usage validation BEFORE any network I/O (exit 2, no request). + let limit: number | undefined; + let maxRows: number; + try { + limit = + opts.limit === undefined + ? undefined + : parsePositiveInt(opts.limit, "--limit", 0, { min: 0 }); + if (opts.format !== undefined) resolveFormat(opts.format); + maxRows = parsePositiveInt(opts.maxRows, "--max-rows", 10_000); + } catch (err) { + if (err instanceof ParamError || err instanceof FormatError) { + console.error(chalk.red(err.message)); + process.exitCode = err.exitCode; + return; + } + throw err; + } + const conn = connect(opts, deps); + if (!conn) return; + let page: Awaited>; + try { + page = await conn.client.listUsers({ + userId: opts.userId, + cursor: opts.cursor, + limit, + }); + } catch (err) { + // The portal treats 404 on these endpoints as "unsupported" — the + // route is absent when auth/RBAC isn't configured for the app. + if (err instanceof PortalApiError && err.status === 404) { + console.error( + chalk.red( + `${err.message} — this deployment may not support the users endpoint (auth/RBAC not configured for the app)`, + ), + ); + process.exitCode = 1; + return; + } + throw err; + } + const r = emitRows( + normalizeUsers(page.users), + { + format: opts.format, + maxRows, + maxRowsExplicit: false, + pager: opts.pager, + }, + 0, + ); + if (!r.ok) process.exitCode = 1; + if (page.hasMore && page.cursor) { + note(`(more pages — continue with --cursor ${page.cursor})`); + } + }, + ), + ); + + addServerOpts( + users + .command("set-roles") + .description("Replace a user's entire role set (PATCH /api/v4/auth/users/{userId})") + .argument("", "user ID (IdP subject, e.g. auth0|1234)") + .argument("[roles...]", "role names; none clears the user's roles"), + ) + .addHelpText( + "after", + ` +Request body: { "roles": ["", …] } — REPLACES the user's whole set. + +Examples: + dittosh server users set-roles "auth0|1234" staff ops + dittosh server users set-roles "auth0|1234" # clears all roles +`, + ) + .action( + withServerErrors(async (userId: string, rolesList: string[], opts: ServerOnlyOpts) => { + const conn = connect(opts, deps); + if (!conn) return; + const res = await conn.client.setUserRoles(userId, rolesList); + console.log( + JSON.stringify( + { + userId, + roles: rolesList, + identityVersion: res.identityVersion, + transactionId: res.transactionId, + }, + null, + 2, + ), + ); + }), + ); + + addServerOpts( + users + .command("delete") + .description("Remove a user from the app (DELETE /api/v4/auth/users/{userId})") + .argument("", "user ID (IdP subject, e.g. auth0|1234)") + .option("-y, --yes", "confirm without prompting", false), + ) + .addHelpText( + "after", + ` +Example: + dittosh server users delete "auth0|1234" -y +`, + ) + .action( + withServerErrors(async (userId: string, opts: ServerOnlyOpts & { yes?: boolean }) => { + const conn = connect(opts, deps); + if (!conn) return; + if (!(await confirmDestructive(`Remove user "${userId}" from the app?`, opts.yes))) return; + await conn.client.deleteUser(userId); + console.error(chalk.dim(`User "${userId}" removed`)); + }), + ); +} diff --git a/src/cli/groups/server/store.ts b/src/cli/groups/server/store.ts new file mode 100644 index 0000000..7c6ff19 --- /dev/null +++ b/src/cli/groups/server/store.ts @@ -0,0 +1,440 @@ +import fs from "node:fs"; +import chalk from "chalk"; +import type { Command } from "commander"; +import { classify, stripLeadingTrivia } from "../../../query/execute.js"; +import { + ParamError, + parseParams, + parsePositiveInt, + resolveArgsSource, +} from "../../../query/params.js"; +import { isBlankOrComments, splitComplete, splitStatements } from "../../../query/split.js"; +import { FormatError, resolveFormat } from "../../../render/output.js"; +import { PortalApiError, PortalConnectionError } from "../../../server/client.js"; +import type { ServerRunOptions } from "../../../server/run.js"; +import { runServerExecute, runServerRemoteExecute } from "../../../server/run.js"; +import { stdoutBroken } from "../../streams.js"; +import { validateOutPath } from "../dql/run.js"; +import { + addServerOpts, + connect, + reportServerError, + type ServerDeps, + stripEq, + withServerErrors, +} from "./common.js"; + +/** + * Store data plane: DQL execute against Ditto Server, plus remote_execute for + * connected edge peers. Mirrors `dittosh dql exec` semantics where they make + * sense over HTTP (batch, params, formats, -o, pager). + */ + +interface ExecOpts { + url?: string; + apiKey?: string; + apiVersion?: string; + txnId?: string; + format?: string; + maxRows?: string; + out?: string; + file?: string; + param?: string[]; + args?: string; + continueOnError?: boolean; + pager?: boolean; + time?: boolean; + execute?: string; + timeout?: string; +} + +function collectParam(value: string, previous: string[]): string[] { + return [...previous, value]; +} + +async function readStdinText(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString("utf8"); +} + +function parseTxnId(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined; + return parsePositiveInt(raw, "--txn-id", 0, { min: 0 }); // 0 is a valid txn id +} + +/** Validate flags and build run options; throws ParamError/FormatError (exit 2). */ +function execRunOpts( + opts: ExecOpts, + maxRowsExplicit: boolean, +): ServerRunOptions & { + apiVersion?: "v4" | "v5"; + txnId?: number; +} { + const format = opts.format === undefined ? undefined : resolveFormat(opts.format); + return { + format, + maxRows: parsePositiveInt(opts.maxRows, "--max-rows", 10_000), + maxRowsExplicit, + out: opts.out, + params: parseParams(opts.param, opts.args), + pager: opts.pager, + time: opts.time, + txnId: parseTxnId(opts.txnId), + timeoutMs: parsePositiveInt(opts.timeout, "--timeout", 120) * 1000, + }; +} + +const EXECUTE_HELP = ` +Request body (POST {url}/api/v5/store/execute): + { "statement": "", "args": { "name": value } } + +Connection is resolved from --url/--api-key, then DITTOSH_SERVER_URL / +DITTOSH_SERVER_API_KEY from the shell environment, then a .env file in the +current directory (shell always wins over .env). + +Any DQL works, including EXPLAIN / PROFILE / ADVISE as statements: + dittosh server execute "SELECT * FROM customers LIMIT 5" + dittosh server execute "EXPLAIN SELECT * FROM customers WHERE tier = :t" -p t=gold + dittosh server execute "INSERT INTO customers DOCUMENTS (:doc)" \\ + --args '{"doc":{"_id":"c1","name":"Ada"}}' + dittosh server execute --api-version v4 "SELECT * FROM customers" # strict mode + cat batch.sql | dittosh server execute # one HTTP call per statement + dittosh server execute -f batch.sql --continue-on-error + +Notes: + - -o/--out is for row-producing statements (SELECT/EXPLAIN/PROFILE/ADVISE). + INSERT/UPDATE/DELETE … RETURNING does emit rows but is refused anyway + (classified by its first keyword) — run it without -o. + - In batch mode, auth/connection failures stop the batch with exit 3 even + under --continue-on-error (a dead server won't heal mid-file). + - Statements get 120s by default (--timeout). A timeout does NOT mean the + statement failed — a mutation may still commit server-side. + +Exit codes: 0 ok · 1 DQL/API error · 2 usage · 3 config/auth/connection.`; + +export function registerStoreCommands(server: Command, deps: ServerDeps = {}): void { + addServerOpts( + server + .command("execute") + .alias("exec") + .description("Run a DQL statement, file, or piped input against Ditto Server") + .argument("[statement]", "DQL statement to run") + .option("-e, --execute ", "explicit statement form (alternative to the positional)") + .option("-f, --file ", "run statements from a file") + .option( + "-p, --param ", + "bind :name parameters (repeatable; values JSON-parsed, string fallback)", + collectParam, + [] as string[], + ) + .option( + "--args ", + "bind parameters from a JSON object ('-' reads stdin, '@file' reads a file)", + ) + .option("--api-version ", "v5 (default) or v4 (legacy strict mode)") + .option("--txn-id ", "X-DITTO-TXN-ID: wait until the server reaches this transaction") + .option("--timeout ", "per-statement timeout (default 120; DQL can run long)") + .option("-o, --out ", "write results to a file (format from extension or --format)") + .option("--format ", "table | json | csv | markdown | html | vertical") + .option("--max-rows ", "maximum rows to display", "10000") + .option("--no-pager", "never pipe results through $PAGER/less") + .option("--continue-on-error", "keep running statements after a failure (-f/stdin)", false) + .option("--time", "print timing after the results", false), + ) + .addHelpText("after", EXECUTE_HELP) + .action( + withServerErrors(async (positional: string | undefined, opts: ExecOpts, command: Command) => { + // stripEq only for SHORT-form options — commander keeps "=" in `-e=x` + // artifacts; long options never carry it, and stripping would corrupt + // legit values that start with "=" (e.g. --api-key "=abc"). + opts = { + ...opts, + file: stripEq(opts.file), + execute: stripEq(opts.execute), + out: stripEq(opts.out), + param: opts.param?.map((p) => p.replace(/^=/, "")), + }; + + // ---- usage validation (exit 2) before any network I/O ---- + let runOpts: ReturnType; + try { + if (positional && opts.execute) { + throw new ParamError( + "pass the statement either positionally or via -e/--execute, not both", + ); + } + if (opts.file && (positional || opts.execute)) { + throw new ParamError("-f/--file cannot be combined with a statement argument"); + } + if (opts.args === "-") { + if (process.stdin.isTTY) { + throw new ParamError( + "--args - reads a JSON object from stdin, but stdin is a terminal", + ); + } + if (!positional && !opts.execute && !opts.file) { + throw new ParamError( + "--args - consumes stdin — pass the statement positionally, via -e, or via -f", + ); + } + } + const argsJson = await resolveArgsSource(opts.args, readStdinText); + runOpts = execRunOpts( + { ...opts, args: argsJson }, + command.getOptionValueSource("maxRows") === "cli", + ); + } catch (err) { + if (err instanceof ParamError || err instanceof FormatError) { + console.error(chalk.red(err.message)); + process.exitCode = err.exitCode; + return; + } + throw err; + } + + let statement = positional ?? opts.execute; + if (statement !== undefined) { + const { statements, rest } = splitComplete(statement); + if (statements.length > 1) { + console.error( + chalk.red("multiple statements in one invocation — use -f or pipe via stdin"), + ); + process.exitCode = 2; + return; + } + if (statements.length === 1) { + if (!isBlankOrComments(rest)) { + console.error( + chalk.red( + `trailing text after the statement is not executable: "${rest.trim()}" — use -f for multiple statements`, + ), + ); + process.exitCode = 2; + return; + } + statement = statements[0]!; + } else if (isBlankOrComments(statement)) { + console.error( + chalk.red( + 'No statement given (input was only whitespace/comments). Usage: dittosh server execute "SELECT ..."', + ), + ); + process.exitCode = 2; + return; + } + } + if (runOpts.out) { + const outError = validateOutPath(runOpts.out); + if (outError) { + console.error(chalk.red(outError)); + process.exitCode = 2; + return; + } + // -o writes row data — mutations/DDL produce none (nothing to write). + if (statement && ["mutation", "ddl", "other"].includes(classify(statement))) { + console.error( + chalk.red( + "-o/--out only applies to row-producing statements (SELECT/EXPLAIN/PROFILE/ADVISE)", + ), + ); + process.exitCode = 2; + return; + } + } + + if (opts.file !== undefined && opts.file.trim() === "") { + console.error(chalk.red("-f/--file requires a path")); + process.exitCode = 2; + return; + } + + const stdinPiped = !process.stdin.isTTY; + + // Read the batch source before connecting (usage beats network). + let batchText: string | undefined; + let batchSource = "stdin"; + if (opts.file) { + try { + batchText = fs.readFileSync(opts.file, "utf8"); + } catch { + console.error(chalk.red(`Cannot read file: ${opts.file}`)); + process.exitCode = 2; + return; + } + batchSource = opts.file; + } else if (!statement) { + if (!stdinPiped) { + console.error( + 'No statement given. Usage: dittosh server execute "SELECT ..." (see --help)', + ); + process.exitCode = 2; + return; + } + batchText = await readStdinText(); + } + + if (batchText !== undefined) { + const statements = splitStatements(batchText); + if (statements.length === 0) { + console.error(chalk.red(`No statements in ${batchSource}.`)); + process.exitCode = 2; + return; + } + if (runOpts.out) { + if (statements.length > 1) { + console.error( + chalk.red( + "--out is only supported for a single statement (batch results would overwrite each other).", + ), + ); + process.exitCode = 2; + return; + } + // A one-statement -f batch is still subject to the -o row-data rule. + if (["mutation", "ddl", "other"].includes(classify(statements[0]!))) { + console.error( + chalk.red( + "-o/--out only applies to row-producing statements (SELECT/EXPLAIN/PROFILE/ADVISE)", + ), + ); + process.exitCode = 2; + return; + } + } + } + + const conn = connect(opts, deps); + if (!conn) return; + + if (batchText !== undefined) { + let okCount = 0; + let failed = 0; + let fatal = false; // auth/connection: won't heal mid-batch — stop regardless of --continue-on-error + const statements = splitStatements(batchText); + for (const stmt of statements) { + if (stdoutBroken()) break; // reader went away mid-batch (| head) — stop quietly + try { + const r = await runServerExecute(conn.client, stmt, { + ...runOpts, + apiVersion: conn.config.apiVersion, + }); + if (r.ok) okCount++; + else failed++; + } catch (err) { + failed++; + // Auth/connection failures (exit-3 class) won't heal mid-batch: + // report once and stop, keeping exit 3 (even with --continue-on-error). + if ( + err instanceof PortalConnectionError || + (err instanceof PortalApiError && err.exitCode === 3) + ) { + reportServerError(err); + fatal = true; + break; + } + // Query-class failures (400, 500, …) print and follow --continue-on-error. + console.error(chalk.red(err instanceof Error ? err.message : String(err))); + } + if (failed > 0 && !opts.continueOnError) break; + } + if (statements.length > 1) { + console.error(chalk.dim(`${okCount} ok, ${failed} failed (of ${statements.length})`)); + } + if (fatal) process.exitCode = 3; + else if (failed > 0) process.exitCode = 1; + return; + } + + const r = await runServerExecute(conn.client, statement!, { + ...runOpts, + apiVersion: conn.config.apiVersion, + }); + if (!r.ok) process.exitCode = 1; + }), + ); + + // ---- remote-execute ------------------------------------------------------ + + addServerOpts( + server + .command("remote-execute") + .description("Run a DQL statement on connected edge peers (POST /api/v5/sync/remote_execute)") + .argument("", "DQL statement; must include a SYNC CONTEXT clause selecting peers") + .option( + "-p, --param ", + "bind :name parameters (repeatable; values JSON-parsed, string fallback)", + collectParam, + [] as string[], + ) + .option( + "--args ", + "bind parameters from a JSON object ('-' reads stdin, '@file' reads a file)", + ) + .option("--time", "print timing after the results", false) + .option("--timeout ", "timeout for the remote fan-out (default 120)") + .option("--no-pager", "never pipe results through $PAGER/less"), + ) + .addHelpText( + "after", + ` +The statement MUST start with a SYNC CONTEXT clause naming the target peers +(undocumented in the public API reference — taken from the portal client): + SYNC CONTEXT ( PEERS WHERE peerKeyString = '' ) SELECT * FROM cars + +Leading comments/whitespace are fine; a statement starting with "--" needs the +-- separator so it's not parsed as a flag: + dittosh server remote-execute -- "-- comment\nSYNC CONTEXT (…) SELECT …" + +Output is always a JSON array, one entry per responding peer: + [ { "peer": …, "elapsedMilliseconds": n, "items": [ …rows… ] } ] + +Example: + dittosh server remote-execute "SYNC CONTEXT ( PEERS WHERE peerKeyString = 'pkAg' ) SELECT * FROM cars LIMIT 5" +`, + ) + .action( + withServerErrors(async (statement: string, opts: ExecOpts) => { + opts = { + ...opts, + param: opts.param?.map((p) => p.replace(/^=/, "")), + }; + let params: Record | undefined; + let timeoutMs: number; + try { + if (!/^SYNC\s+CONTEXT/i.test(stripLeadingTrivia(statement))) { + throw new ParamError( + "remote-execute statements must start with a SYNC CONTEXT clause, e.g.\n" + + " SYNC CONTEXT ( PEERS WHERE peerKeyString = '' ) SELECT ...", + ); + } + if (opts.args === "-" && process.stdin.isTTY) { + throw new ParamError( + "--args - reads a JSON object from stdin, but stdin is a terminal", + ); + } + const argsJson = await resolveArgsSource(opts.args, readStdinText); + params = parseParams(opts.param, argsJson); + timeoutMs = parsePositiveInt(opts.timeout, "--timeout", 120) * 1000; + } catch (err) { + if (err instanceof ParamError) { + console.error(chalk.red(err.message)); + process.exitCode = err.exitCode; + return; + } + throw err; + } + const conn = connect(opts, deps); + if (!conn) return; + const r = await runServerRemoteExecute(conn.client, statement, { + maxRows: 10_000, + maxRowsExplicit: false, + params, + pager: opts.pager, + time: opts.time, + timeoutMs, + }); + if (!r.ok) process.exitCode = 1; + }), + ); +} diff --git a/src/cli/groups/server/webhooks.ts b/src/cli/groups/server/webhooks.ts new file mode 100644 index 0000000..c22507b --- /dev/null +++ b/src/cli/groups/server/webhooks.ts @@ -0,0 +1,287 @@ +import chalk from "chalk"; +import type { Command } from "commander"; +import { ParamError, parsePositiveInt } from "../../../query/params.js"; +import { FormatError, resolveFormat } from "../../../render/output.js"; +import type { WebhookSecret } from "../../../server/client.js"; +import { emitRows } from "../../../server/run.js"; +import { + addServerOpts, + confirmDestructive, + connect, + type ServerDeps, + withServerErrors, +} from "./common.js"; + +/** + * Auth webhook secrets (HMAC-SHA256 signature verification for auth webhooks) — + * GET/POST/PATCH/DELETE /api/v4/auth/webhook/secret. Undocumented publicly; + * shapes from the portal client. DELETE and PATCH need the full secret object, + * so the CLI looks it up by --secret before writing. + */ + +interface ServerOnlyOpts { + url?: string; + apiKey?: string; +} + +function requireProvider(raw: string | undefined): string { + const provider = raw?.trim(); + if (!provider) throw new ParamError("--provider is required (the auth webhook provider name)"); + return provider; +} + +function requireIsoDate(raw: string | undefined, flag: string): string { + const value = raw?.trim(); + if (!value) throw new ParamError(`${flag} is required (ISO 8601, e.g. 2026-12-31T00:00:00Z)`); + if (Number.isNaN(Date.parse(value))) { + throw new ParamError(`${flag} must be an ISO 8601 date, got: "${value}"`); + } + return value; +} + +function requireSecret(raw: string | undefined): string { + const secret = raw?.trim(); + if (!secret) throw new ParamError("--secret is required (the base64 secret to operate on)"); + return secret; +} + +/** Look up one secret object by value; null (caller sets exit 1) when absent. */ +function findSecret( + secrets: WebhookSecret[], + provider: string, + secret: string, +): WebhookSecret | null { + const matches = secrets.filter((s) => s.secret === secret); + if (matches.length === 0) { + console.error( + chalk.red(`No webhook secret matching --secret for provider "${provider}" — list them first`), + ); + return null; + } + return matches[0]!; +} + +function printUsageError(err: unknown): boolean { + if (err instanceof ParamError) { + console.error(chalk.red(err.message)); + process.exitCode = err.exitCode; + return true; + } + return false; +} + +export function registerWebhookCommands(server: Command, deps: ServerDeps = {}): void { + const webhooks = server + .command("webhook-secrets") + .description("Manage auth webhook HMAC secrets (portal API, undocumented)") + .addHelpText( + "after", + ` +Secrets sign requests to your auth webhook (HMAC-SHA256). Each secret has a +validity window [notBefore, notAfter]; rotation issues a new secret while the +old one stays valid until its notAfter. +`, + ); + + addServerOpts( + webhooks + .command("list") + .description("List a provider's secrets (GET /api/v4/auth/webhook/secret?provider=…)") + .requiredOption("--provider ", "auth webhook provider name") + .option("--format ", "table | json | csv | markdown | html | vertical") + .option("--max-rows ", "maximum rows to display", "10000") + .option("--no-pager", "never pipe results through $PAGER/less"), + ) + .addHelpText( + "after", + ` +Response entries: { "secret": "", "notBefore": "", + "notAfter": "", "rotated": "" } + +Example: + dittosh server webhook-secrets list --provider my-auth-webhook +`, + ) + .action( + withServerErrors( + async ( + opts: ServerOnlyOpts & { + provider?: string; + format?: string; + maxRows?: string; + pager?: boolean; + }, + ) => { + let provider: string; + let maxRows: number; + try { + provider = requireProvider(opts.provider); + if (opts.format !== undefined) resolveFormat(opts.format); + maxRows = parsePositiveInt(opts.maxRows, "--max-rows", 10_000); + } catch (err) { + if (printUsageError(err)) return; + if (err instanceof FormatError) { + console.error(chalk.red(err.message)); + process.exitCode = err.exitCode; + return; + } + throw err; + } + const conn = connect(opts, deps); + if (!conn) return; + const secrets = await conn.client.listWebhookSecrets(provider); + const r = emitRows( + secrets.map((s) => ({ + secret: s.secret, + notBefore: s.notBefore, + notAfter: s.notAfter, + rotated: s.rotated ?? "", + })), + { format: opts.format, maxRows, maxRowsExplicit: false, pager: opts.pager }, + 0, + ); + if (!r.ok) process.exitCode = 1; + }, + ), + ); + + addServerOpts( + webhooks + .command("create") + .description("Generate a new secret (POST /api/v4/auth/webhook/secret)") + .requiredOption("--provider ", "auth webhook provider name") + .requiredOption("--not-after ", "expiry, ISO 8601 (e.g. 2026-12-31T00:00:00Z)"), + ) + .addHelpText( + "after", + ` +Request body: { "provider": "", "notBefore": , "notAfter": "" } +Response: { "secret": "", "notBefore": …, "notAfter": … } + +Example: + dittosh server webhook-secrets create --provider my-auth-webhook --not-after 2027-01-01T00:00:00Z +`, + ) + .action( + withServerErrors(async (opts: ServerOnlyOpts & { provider?: string; notAfter?: string }) => { + let provider: string; + let notAfter: string; + try { + provider = requireProvider(opts.provider); + notAfter = requireIsoDate(opts.notAfter, "--not-after"); + } catch (err) { + if (printUsageError(err)) return; + throw err; + } + const conn = connect(opts, deps); + if (!conn) return; + const secret = await conn.client.createWebhookSecret(provider, notAfter); + console.log(JSON.stringify(secret, null, 2)); + }), + ); + + addServerOpts( + webhooks + .command("rotate") + .description("Rotate a secret: mark the old one rotated, issue a new one (PATCH)") + .requiredOption("--provider ", "auth webhook provider name") + .requiredOption("--secret ", "the existing secret to rotate") + .requiredOption("--not-after ", "expiry for the NEW secret, ISO 8601"), + ) + .addHelpText( + "after", + ` +Request body: { "provider": "", + "rotate": { "secret", "notBefore", "notAfter" }, // existing secret + "new": { "notBefore": , "notAfter": "" } } + +The CLI fetches the existing secret's validity window for you — pass only its value: + dittosh server webhook-secrets rotate --provider my-auth-webhook \\ + --secret "$(…)" --not-after 2027-01-01T00:00:00Z +`, + ) + .action( + withServerErrors( + async ( + opts: ServerOnlyOpts & { provider?: string; secret?: string; notAfter?: string }, + ) => { + let provider: string; + let secretValue: string; + let notAfter: string; + try { + provider = requireProvider(opts.provider); + secretValue = requireSecret(opts.secret); + notAfter = requireIsoDate(opts.notAfter, "--not-after"); + } catch (err) { + if (printUsageError(err)) return; + throw err; + } + const conn = connect(opts, deps); + if (!conn) return; + const existing = findSecret( + await conn.client.listWebhookSecrets(provider), + provider, + secretValue, + ); + if (!existing) { + process.exitCode = 1; + return; + } + const rotated = await conn.client.rotateWebhookSecret(provider, existing, notAfter); + console.log(JSON.stringify(rotated, null, 2)); + }, + ), + ); + + addServerOpts( + webhooks + .command("delete") + .description("Delete a secret (DELETE /api/v4/auth/webhook/secret)") + .requiredOption("--provider ", "auth webhook provider name") + .requiredOption("--secret ", "the secret to delete") + .option("-y, --yes", "confirm without prompting", false), + ) + .addHelpText( + "after", + ` +DELETE requires the full secret object — the CLI looks it up from --secret. + +Example: + dittosh server webhook-secrets delete --provider my-auth-webhook --secret "$(…)" -y +`, + ) + .action( + withServerErrors( + async (opts: ServerOnlyOpts & { provider?: string; secret?: string; yes?: boolean }) => { + let provider: string; + let secretValue: string; + try { + provider = requireProvider(opts.provider); + secretValue = requireSecret(opts.secret); + } catch (err) { + if (printUsageError(err)) return; + throw err; + } + const conn = connect(opts, deps); + if (!conn) return; + const existing = findSecret( + await conn.client.listWebhookSecrets(provider), + provider, + secretValue, + ); + if (!existing) { + process.exitCode = 1; + return; + } + // Confirm only once we know the secret exists and we can act on it. + if ( + !(await confirmDestructive(`Delete this webhook secret for "${provider}"?`, opts.yes)) + ) { + return; + } + await conn.client.deleteWebhookSecret({ ...existing, provider }); + console.error(chalk.dim("Webhook secret deleted")); + }, + ), + ); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 3f99ceb..44a7387 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,6 +2,7 @@ import chalk from "chalk"; import { Command, CommanderError } from "commander"; import { rewriteDefaultSubcommand } from "./default-command.js"; import { registerDqlGroup } from "./groups/dql/index.js"; +import { registerServerGroup } from "./groups/server/index.js"; import { registerSkillsGroup } from "./groups/skills/index.js"; import { registerSystemGroup } from "./groups/system/index.js"; import { installStdoutGuard } from "./streams.js"; @@ -29,6 +30,9 @@ program const dql = program.command("dql"); registerDqlGroup(dql); +const server = program.command("server"); +registerServerGroup(server); + const skills = program.command("skills"); registerSkillsGroup(skills); diff --git a/src/query/execute.ts b/src/query/execute.ts index cd89b07..452e649 100644 --- a/src/query/execute.ts +++ b/src/query/execute.ts @@ -10,7 +10,7 @@ export type StatementKind = | "other"; /** Strip leading whitespace and comments (loop-based — no backtracking regex). */ -function stripLeadingTrivia(s: string): string { +export function stripLeadingTrivia(s: string): string { let rest = s; for (;;) { const trimmed = rest.replace(/^\s+/, ""); diff --git a/src/query/params.ts b/src/query/params.ts index 588950c..079f23a 100644 --- a/src/query/params.ts +++ b/src/query/params.ts @@ -16,22 +16,25 @@ export class ParamError extends Error { * - `--args -` read from stdin (the jq pipeline form) * - `--args @file.json` read from a file (curl-style) * The result is validated by parseParams (must be a JSON object). + * `flag` names the calling flag in error messages (the server group's + * --permissions reuses this). */ export async function resolveArgsSource( value: string | undefined, readStdin: () => Promise, + flag = "--args", ): Promise { if (value === undefined) return undefined; if (value === "-") return readStdin(); if (value.startsWith("@")) { const file = value.slice(1).trim(); if (!file) { - throw new ParamError("--args @ requires a file path (e.g. --args @params.json)"); + throw new ParamError(`${flag} @ requires a file path (e.g. ${flag} @params.json)`); } try { return fs.readFileSync(expandTilde(file), "utf8"); } catch (err) { - throw new ParamError(`--args: cannot read ${file}: ${(err as Error).message}`); + throw new ParamError(`${flag}: cannot read ${file}: ${(err as Error).message}`); } } return value; diff --git a/src/server/client.ts b/src/server/client.ts new file mode 100644 index 0000000..aa283f4 --- /dev/null +++ b/src/server/client.ts @@ -0,0 +1,519 @@ +import type { ApiVersion } from "./config.js"; + +/** + * Minimal client for the Ditto Server (Big Peer) HTTP RPC API — the API the + * portal's DQL editor talks to. Endpoint inventory from the public docs + * (docs.ditto.live/cloud/http-api) and the portal's own client + * (cloud-services/portal/core/src/api/rpcClient.ts). + * + * fetch is injectable for tests; nothing here touches the network by default + * beyond the one request per method call. + */ + +export type FetchLike = ( + url: string, + init: { + method: string; + headers: Record; + body?: string | FormData; + signal?: AbortSignal; + }, +) => Promise<{ + status: number; + statusText: string; + headers: { get(name: string): string | null }; + text(): Promise; + /** Present on real fetch Responses; needed for binary downloads. */ + arrayBuffer?(): Promise; +}>; + +/** The server answered, but the request failed (bad DQL, auth, …). */ +export class PortalApiError extends Error { + readonly status: number; + readonly exitCode: number; + constructor(status: number, message: string) { + super(message); + this.name = "PortalApiError"; + this.status = status; + // 401/403 → auth/config family (3, like token errors). Everything else the + // server actively rejected → query/API error (1). + this.exitCode = status === 401 || status === 403 ? 3 : 1; + } +} + +/** No answer at all: DNS, refused, TLS. Treated as platform (exit 3). */ +export class PortalConnectionError extends Error { + readonly exitCode = 3; + constructor(message: string) { + super(message); + this.name = "PortalConnectionError"; + } +} + +/** + * The request's own timeout fired. NOT a connection error (exit 3): the server + * was reached and a mutation may still commit — the user must not assume + * failure. Query/API class (exit 1) with an honest message. + */ +export class PortalTimeoutError extends Error { + readonly exitCode = 1; + constructor(baseUrl: string, timeoutMs: number) { + super( + `No response within ${Math.round(timeoutMs / 1000)}s from ${baseUrl} — ` + + "the server may still be running the statement (raise with --timeout)", + ); + this.name = "PortalTimeoutError"; + } +} + +export interface PortalClientOptions { + baseUrl: string; + apiKey: string; + fetchImpl?: FetchLike; + /** Per-request timeout (default 30s, matching the portal's token exchange bound). */ + timeoutMs?: number; +} + +export interface ExecuteResponse { + transactionId?: number; + queryType?: string; + items?: unknown[]; + mutatedDocumentIds?: unknown[]; + error?: { description?: string }; + warnings?: { description: string; _id?: unknown }[]; + totalWarningsCount?: number; +} + +export interface RemoteExecutePeerResult { + peer?: unknown; + items?: unknown[]; + elapsedMilliseconds?: unknown; + error?: { description?: string }; + warnings?: unknown[]; + totalWarningsCount?: unknown; +} + +export interface RemoteExecuteResponse { + result?: RemoteExecutePeerResult[]; + error?: { description?: string }; +} + +export interface RoleDoc { + _id: { name: string; version: string }; + roles_version?: string; + description?: string; + collection_permissions?: unknown; + grant_remote_query?: boolean; +} + +export interface PortalUser { + userId: string; + roles?: string[]; + identityVersion?: string; +} + +export interface WebhookSecret { + secret: string; + notBefore: string; + notAfter: string; + rotated?: string; +} + +const DEFAULT_TIMEOUT_MS = 30_000; + +export class PortalClient { + private readonly baseUrl: string; + private readonly apiKey: string; + private readonly fetchImpl: FetchLike; + private readonly timeoutMs: number; + + constructor(opts: PortalClientOptions) { + this.baseUrl = opts.baseUrl.replace(/\/+$/, ""); + this.apiKey = opts.apiKey; + this.fetchImpl = opts.fetchImpl ?? (fetch as unknown as FetchLike); + this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + } + + /** Redact the key if it ever leaks into a server/transport message (long keys only — short ones mangle English words). */ + private redact(message: string): string { + if (this.apiKey.length < 8) return message; + return message.split(this.apiKey).join("***"); + } + + /** Best-effort reason from a fetch failure: undici puts it in `cause` ("fetch failed" alone is useless); ECONNREFUSED on multi-address hosts yields an AggregateError with an empty message — dig one level. */ + private static failureReason(err: unknown): string { + const e = err as Error & { cause?: unknown }; + const cause = e.cause as (Error & { errors?: Error[]; code?: string }) | undefined; + return cause?.message || cause?.errors?.[0]?.message || cause?.code || e.message; + } + + /** Our AbortSignal is the only abort source — an AbortError/TimeoutError IS the timeout. */ + private static isTimeout(err: unknown): boolean { + const name = (err as Error).name; + return name === "TimeoutError" || name === "AbortError"; + } + + /** A 2xx body can still carry a DQL-style error envelope — check before trusting data. */ + private static assertNoErrorBody(status: number, data: unknown): void { + const desc = (data as { error?: { description?: unknown } } | undefined)?.error?.description; + if (typeof desc === "string" && desc) throw new PortalApiError(status, desc); + } + + /** + * Fail closed on a 2xx whose body isn't the shape the endpoint contract + * promises (SSO/captive-portal HTML page, proxy error page, shape drift). + * The portal client does the same via its isDQLHTTPResponse/isGetRolesResponse + * guards. Only called where the response MUST be a JSON object. + */ + private static assertResponseShape( + status: number, + data: unknown, + requirement: string, + ok: (obj: Record) => boolean, + ): Record { + if (typeof data !== "object" || data === null || !ok(data as Record)) { + throw new PortalApiError( + status, + `Invalid response from Ditto Server (expected ${requirement}) — ` + + "if this URL goes through a proxy/SSO, it may have answered instead", + ); + } + return data as Record; + } + + private async request( + method: string, + path: string, + opts: { + body?: unknown; + form?: FormData; + query?: Record; + txnId?: number; + /** Per-call timeout override (DQL statements legitimately run long). */ + timeoutMs?: number; + } = {}, + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + ): Promise<{ status: number; data: unknown; text: string }> { + const url = new URL(`${this.baseUrl}${path}`); + for (const [k, v] of Object.entries(opts.query ?? {})) { + if (v !== undefined && v !== "") url.searchParams.set(k, String(v)); + } + + const headers: Record = { Authorization: `Bearer ${this.apiKey}` }; + if (opts.txnId !== undefined) headers["X-DITTO-TXN-ID"] = String(opts.txnId); + let body: string | FormData | undefined; + if (opts.form) { + body = opts.form; // fetch sets the multipart boundary itself + } else if (opts.body !== undefined) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(opts.body); + } + + const timeoutMs = opts.timeoutMs ?? this.timeoutMs; + let res: Awaited>; + try { + res = await this.fetchImpl(url.toString(), { + method, + headers, + body, + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (err) { + if (PortalClient.isTimeout(err)) throw new PortalTimeoutError(this.baseUrl, timeoutMs); + throw new PortalConnectionError( + this.redact(`Cannot reach ${this.baseUrl} — ${PortalClient.failureReason(err)}`), + ); + } + + // The body read can ALSO fail (mid-body timeout, connection reset while + // streaming) — same mapping. + let text: string; + try { + text = await res.text(); + } catch (err) { + if (PortalClient.isTimeout(err)) throw new PortalTimeoutError(this.baseUrl, timeoutMs); + throw new PortalConnectionError( + this.redact(`Cannot reach ${this.baseUrl} — ${PortalClient.failureReason(err)}`), + ); + } + let data: unknown; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = undefined; + } + + if (res.status < 200 || res.status >= 300) { + const message = + (data as { message?: string })?.message ?? + (data as { error?: { description?: string } })?.error?.description ?? + text.trim(); + throw new PortalApiError( + res.status, + this.redact(`HTTP ${res.status} from Ditto Server${message ? `: ${message}` : ""}`), + ); + } + return { status: res.status, data, text }; + } + + // ---- DQL --------------------------------------------------------------- + + /** POST /api/{v4,v5}/store/execute — the primary DQL endpoint. Fails closed on a non-DQL 2xx body. */ + async execute( + statement: string, + args?: Record, + opts: { version?: ApiVersion; txnId?: number; timeoutMs?: number } = {}, + ): Promise { + const { status, data } = await this.request( + "POST", + `/api/${opts.version ?? "v5"}/store/execute`, + { + body: { statement, ...(args ? { args } : {}) }, + txnId: opts.txnId, + timeoutMs: opts.timeoutMs, + }, + ); + // A DQL response always carries at least one of these (portal: isDQLHTTPResponse). + const obj = PortalClient.assertResponseShape(status, data, "a DQL execute response", (o) => + ["queryType", "items", "mutatedDocumentIds", "error", "warnings"].some((k) => k in o), + ); + if (obj.items !== undefined && !Array.isArray(obj.items)) { + throw new PortalApiError( + status, + "Invalid response from Ditto Server (items is not an array)", + ); + } + return obj as ExecuteResponse; + } + + /** POST /api/v5/sync/remote_execute — run a DQL statement on connected peers (needs SYNC CONTEXT). */ + async remoteExecute( + statement: string, + args?: Record, + opts: { timeoutMs?: number } = {}, + ): Promise { + const { status, data } = await this.request("POST", "/api/v5/sync/remote_execute", { + body: { statement, ...(args ? { args } : {}) }, + timeoutMs: opts.timeoutMs, + }); + const obj = PortalClient.assertResponseShape( + status, + data, + "a remote_execute response (result array or error)", + (o) => Array.isArray(o.result) || o.error !== undefined, + ); + return obj as RemoteExecuteResponse; + } + + // ---- Attachments (v4) ---------------------------------------------------- + + /** POST /api/v4/attachments/upload — multipart; returns {id, len}. */ + async uploadAttachment(form: FormData): Promise<{ id?: string; len?: number }> { + const { status, data } = await this.request("POST", "/api/v4/attachments/upload", { form }); + PortalClient.assertNoErrorBody(status, data); + const obj = PortalClient.assertResponseShape( + status, + data, + "an attachment upload response ({id, len})", + (o) => typeof o.id === "string", + ); + return obj as { id?: string; len?: number }; + } + + /** GET /api/v4/attachments/{id} — raw bytes (arrayBuffer path; never text-decode binary). */ + async getAttachment(id: string): Promise { + const url = `${this.baseUrl}/api/v4/attachments/${encodeURIComponent(id)}`; + let res: Awaited>; + try { + res = await this.fetchImpl(url, { + method: "GET", + headers: { Authorization: `Bearer ${this.apiKey}` }, + signal: AbortSignal.timeout(this.timeoutMs), + }); + } catch (err) { + if (PortalClient.isTimeout(err)) throw new PortalTimeoutError(this.baseUrl, this.timeoutMs); + throw new PortalConnectionError( + this.redact(`Cannot reach ${this.baseUrl} — ${PortalClient.failureReason(err)}`), + ); + } + let errText: string; + let bytes: Buffer | undefined; + try { + if (res.status < 200 || res.status >= 300) { + errText = await res.text(); + } else if (res.arrayBuffer) { + bytes = Buffer.from(await res.arrayBuffer()); + } else { + bytes = Buffer.from(await res.text(), "binary"); // test doubles without arrayBuffer + } + } catch (err) { + if (PortalClient.isTimeout(err)) throw new PortalTimeoutError(this.baseUrl, this.timeoutMs); + throw new PortalConnectionError( + this.redact(`Cannot reach ${this.baseUrl} — ${PortalClient.failureReason(err)}`), + ); + } + if (res.status < 200 || res.status >= 300) { + const text = errText!; + let message = text.trim(); + try { + message = (JSON.parse(text) as { message?: string })?.message ?? message; + } catch { + /* plain-text error body */ + } + throw new PortalApiError( + res.status, + this.redact(`HTTP ${res.status} from Ditto Server${message ? `: ${message}` : ""}`), + ); + } + return bytes!; + } + + // ---- RBAC: roles & users (v4, as used by the portal) --------------------- + + /** GET /api/v4/auth/roles — both known wire shapes share the `roles` key; `cursor` continues a paged listing. */ + async listRoles(opts: { cursor?: string } = {}): Promise { + const { status, data } = await this.request("GET", "/api/v4/auth/roles", { + query: { cursor: opts.cursor }, + }); + PortalClient.assertNoErrorBody(status, data); + // Portal parity: the reference throws on a roles-less envelope. + PortalClient.assertResponseShape(status, data, "a roles response ({roles: …})", (o) => + Object.hasOwn(o, "roles"), + ); + return data; + } + + /** POST /api/v4/auth/roles — create OR REPLACE a role (server assigns the version). */ + async createRole(input: { + name: string; + description?: string; + collectionPermissions?: unknown; + grantRemoteQuery?: boolean; + }): Promise { + const { status, data } = await this.request("POST", "/api/v4/auth/roles", { + body: { + name: input.name, + doc: { + roles_version: "v1-preview", + description: input.description ?? "", + // The portal always sends both (and POST replaces the role) — explicit + // defaults make a bare `roles create` predictably "no permissions". + collection_permissions: input.collectionPermissions ?? "none", + grant_remote_query: input.grantRemoteQuery ?? false, + }, + }, + }); + PortalClient.assertNoErrorBody(status, data); + return data; + } + + async deleteRole(name: string): Promise { + const { status, data } = await this.request( + "DELETE", + `/api/v4/auth/roles/${encodeURIComponent(name)}`, + ); + PortalClient.assertNoErrorBody(status, data); + } + + /** GET /api/v4/auth/users?userId&cursor&limit */ + async listUsers( + opts: { userId?: string; cursor?: string; limit?: number } = {}, + ): Promise<{ users?: PortalUser[]; hasMore?: boolean; cursor?: string }> { + const { status, data } = await this.request("GET", "/api/v4/auth/users", { + query: { userId: opts.userId, cursor: opts.cursor, limit: opts.limit }, + }); + PortalClient.assertNoErrorBody(status, data); + // Portal parity: normalizeUsersResponse throws on a malformed envelope. + const obj = PortalClient.assertResponseShape( + status, + data, + "a users response ({users: [...], hasMore: bool})", + (o) => Array.isArray(o.users) && typeof o.hasMore === "boolean", + ); + return obj as { users?: PortalUser[]; hasMore?: boolean; cursor?: string }; + } + + /** PATCH /api/v4/auth/users/{id} — replace the user's whole role set. */ + async setUserRoles( + userId: string, + roles: string[], + ): Promise<{ identityVersion?: string; transactionId?: number }> { + const { status, data } = await this.request( + "PATCH", + `/api/v4/auth/users/${encodeURIComponent(userId)}`, + { body: { roles } }, + ); + PortalClient.assertNoErrorBody(status, data); + return (data ?? {}) as { identityVersion?: string; transactionId?: number }; + } + + async deleteUser(userId: string): Promise { + const { status, data } = await this.request( + "DELETE", + `/api/v4/auth/users/${encodeURIComponent(userId)}`, + ); + PortalClient.assertNoErrorBody(status, data); + } + + // ---- Auth webhook secrets (v4, as used by the portal) --------------------- + + /** GET /api/v4/auth/webhook/secret?provider= — returns [] when none exist (incl. 404 deployments, matching the portal). */ + async listWebhookSecrets(provider: string): Promise { + let status: number; + let data: unknown; + try { + ({ status, data } = await this.request("GET", "/api/v4/auth/webhook/secret", { + query: { provider }, + })); + } catch (err) { + // The portal maps 404 → "no secrets" (rpcClient getWebhookSecrets). + if (err instanceof PortalApiError && err.status === 404) return []; + throw err; + } + PortalClient.assertNoErrorBody(status, data); + // The backend's shape varies: {} when empty, {secret: [...]}, a bare array, + // or a single secret object (portal client normalizes the same way). + if (!data || typeof data !== "object") return []; + if (Array.isArray(data)) return data as WebhookSecret[]; + const obj = data as Record; + if (Object.keys(obj).length === 0) return []; + if (Array.isArray(obj.secret)) return obj.secret as WebhookSecret[]; + if (typeof obj.secret === "string") return [obj as unknown as WebhookSecret]; + return []; + } + + /** POST /api/v4/auth/webhook/secret — generate a secret valid [now, notAfter]. */ + async createWebhookSecret(provider: string, notAfter: string): Promise { + const { status, data } = await this.request("POST", "/api/v4/auth/webhook/secret", { + body: { provider, notBefore: new Date().toISOString(), notAfter }, + }); + PortalClient.assertNoErrorBody(status, data); + return (data ?? {}) as WebhookSecret; + } + + /** PATCH /api/v4/auth/webhook/secret — mark `rotate` rotated, issue a new secret. */ + async rotateWebhookSecret( + provider: string, + rotate: { secret: string; notBefore: string; notAfter: string }, + notAfter: string, + ): Promise { + const { status, data } = await this.request("PATCH", "/api/v4/auth/webhook/secret", { + body: { provider, rotate, new: { notBefore: new Date().toISOString(), notAfter } }, + }); + PortalClient.assertNoErrorBody(status, data); + return (data ?? {}) as WebhookSecret; + } + + /** DELETE /api/v4/auth/webhook/secret — the server wants exactly these four fields. */ + async deleteWebhookSecret(secret: WebhookSecret & { provider: string }): Promise { + const { status, data } = await this.request("DELETE", "/api/v4/auth/webhook/secret", { + body: { + provider: secret.provider, + secret: secret.secret, + notBefore: secret.notBefore, + notAfter: secret.notAfter, + }, + }); + PortalClient.assertNoErrorBody(status, data); + } +} diff --git a/src/server/config.ts b/src/server/config.ts new file mode 100644 index 0000000..3e2b22c --- /dev/null +++ b/src/server/config.ts @@ -0,0 +1,196 @@ +import fs from "node:fs"; +import path from "node:path"; +import { parseEnv } from "node:util"; + +/** + * Configuration for `dittosh server` (Ditto Server / portal HTTP API). + * + * Precedence (highest wins): + * 1. CLI flags (--url / --api-key) + * 2. Shell environment (DITTOSH_SERVER_URL / DITTOSH_SERVER_API_KEY) + * 3. `.env` in the current working directory (never overrides real env) + * + * Aliases (for users coming from the Ditto docs): DITTO_CLOUD_URL, DITTO_API_KEY. + */ + +export class ServerConfigError extends Error { + readonly exitCode = 3; + constructor(message: string) { + super(message); + this.name = "ServerConfigError"; + } +} + +/** A bad flag VALUE (e.g. --api-version v9) — usage error, not a config absence. */ +export class ApiVersionError extends Error { + readonly exitCode = 2; + constructor(message: string) { + super(message); + this.name = "ApiVersionError"; + } +} + +/** Loopback hosts where cleartext http is fine (local dev servers, e2e mocks). */ +function isLoopback(hostname: string): boolean { + const h = hostname.toLowerCase(); + return ( + h === "localhost" || + h === "[::1]" || + h === "::1" || + h === "0:0:0:0:0:0:0:1" || + h.startsWith("127.") || + h.endsWith(".localhost") + ); +} + +export type ApiVersion = "v4" | "v5"; + +export type ConfigSource = "flag" | "env" | "dotenv"; + +export interface ServerConfig { + /** Base URL with scheme, no trailing slash — e.g. https://abc.cloud.dittolive.app/ */ + baseUrl: string; + /** API key (Bearer token). Never printed. */ + apiKey: string; + /** API version for /store/execute (v5 default; v4 = legacy strict mode). */ + apiVersion: ApiVersion; + /** Which layer each credential came from (reported by `server doctor`; never the values). */ + sources: { url: ConfigSource; apiKey: ConfigSource }; +} + +export interface ServerConfigFlags { + url?: string; + apiKey?: string; + apiVersion?: string; +} + +/** Parse a cwd `.env` file without touching process.env. Missing/unreadable → {}. */ +export function readDotEnv(cwd: string = process.cwd()): Record { + try { + const file = path.join(cwd, ".env"); + if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return {}; + // Strip a UTF-8 BOM — parseEnv would otherwise glue it onto the FIRST key + // ("\uFEFFDITTOSH_SERVER_URL"), silently losing that variable. + const parsed = parseEnv(fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "")); + return Object.fromEntries( + Object.entries(parsed).filter((e): e is [string, string] => e[1] !== undefined), + ); + } catch { + return {}; + } +} + +/** First non-empty value from a list of candidate keys across flag/env/dotenv layers. */ +function pick( + flag: string | undefined, + names: string[], + env: NodeJS.ProcessEnv, + dotEnv: Record, +): { value: string; source: ConfigSource } | undefined { + if (flag?.trim()) return { value: flag.trim(), source: "flag" }; + for (const name of names) { + const v = env[name]?.trim(); + if (v) return { value: v, source: "env" }; + } + for (const name of names) { + const v = dotEnv[name]?.trim(); + if (v) return { value: v, source: "dotenv" }; + } + return undefined; +} + +/** Normalize the cloud URL endpoint: add https:// when scheme-less, drop trailing slashes, validate. */ +export function normalizeBaseUrl(raw: string): string { + const withScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw) ? raw : `https://${raw}`; + let url: URL; + try { + url = new URL(withScheme); + } catch { + throw new ServerConfigError( + `Invalid server URL "${raw}" — expected something like xxxx.cloud.dittolive.app/`, + ); + } + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new ServerConfigError(`Invalid server URL "${raw}" — only http(s) URLs are supported`); + } + // Cleartext http is only legitimate for loopback (local dev/test servers): + // the real cloud 308-redirects http→https, and the FIRST request would carry + // the API key unencrypted (the redirect then strips it → a misleading 401). + if (url.protocol === "http:" && !isLoopback(url.hostname)) { + throw new ServerConfigError( + `Refusing cleartext http:// for a non-local host (${url.hostname}) — the API key would be sent unencrypted. ` + + "Use https:// (loopback addresses are exempt for local testing).", + ); + } + // Reject anything that would corrupt request paths or leak secrets into + // printed URLs: userinfo, query strings, fragments. + if (url.username || url.password) { + throw new ServerConfigError( + "Invalid server URL — credentials must not be embedded in the URL (use --api-key / DITTOSH_SERVER_API_KEY)", + ); + } + if (url.search || url.hash) { + throw new ServerConfigError( + "Invalid server URL — expected just the Cloud URL Endpoint (host + app id), no query string or fragment", + ); + } + // A bare trailing "?" or "#" parses as empty search/hash but survives in the + // serialized URL and would misroute every request — strip unconditionally. + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/+$/, ""); +} + +export function resolveApiVersion(raw: string | undefined): ApiVersion { + if (raw === undefined) return "v5"; + if (raw === "v4" || raw === "v5") return raw; + throw new ApiVersionError(`--api-version must be v4 or v5 — got "${raw}"`); +} + +/** + * Resolve the effective server config or throw ServerConfigError (exit 3) + * with a message that names exactly what to set. + */ +export function resolveServerConfig( + flags: ServerConfigFlags, + env: NodeJS.ProcessEnv = process.env, + cwd: string = process.cwd(), +): ServerConfig { + // Usage errors (bad flag VALUES) beat config-absence errors. + const apiVersion = resolveApiVersion(flags.apiVersion); + + const dotEnv = readDotEnv(cwd); + + // Hint when a .env exists but the missing value wasn't in it — otherwise + // "no config" is confusing when the file is right there (unreadable, or + // wrong var names). Per-key: only claim uselessness for the MISSING one. + const dotEnvExists = fs.existsSync(path.join(cwd, ".env")); + const dotEnvHint = (missing: string) => + dotEnvExists + ? `\n(Note: a .env exists in the current directory but provided no usable ${missing} — unreadable, or wrong variable name.)` + : ""; + + const rawUrl = pick(flags.url, ["DITTOSH_SERVER_URL", "DITTO_CLOUD_URL"], env, dotEnv); + if (!rawUrl) { + throw new ServerConfigError( + "No Ditto Server URL configured. Set DITTOSH_SERVER_URL (shell or .env) or pass --url.\n" + + 'Find it in the portal: your app → "Connecting via HTTP" → Cloud URL Endpoint ' + + `(looks like xxxx.cloud.dittolive.app/).${dotEnvHint("DITTOSH_SERVER_URL")}`, + ); + } + + const apiKey = pick(flags.apiKey, ["DITTOSH_SERVER_API_KEY", "DITTO_API_KEY"], env, dotEnv); + if (!apiKey) { + throw new ServerConfigError( + "No Ditto Server API key configured. Set DITTOSH_SERVER_API_KEY (shell or .env) or pass --api-key.\n" + + `Create one in the portal: your app → Auth → New API key.${dotEnvHint("DITTOSH_SERVER_API_KEY")}`, + ); + } + + return { + baseUrl: normalizeBaseUrl(rawUrl.value), + apiKey: apiKey.value, + apiVersion, + sources: { url: rawUrl.source, apiKey: apiKey.source }, + }; +} diff --git a/src/server/run.ts b/src/server/run.ts new file mode 100644 index 0000000..8e53764 --- /dev/null +++ b/src/server/run.ts @@ -0,0 +1,218 @@ +import fs from "node:fs"; +import chalk from "chalk"; +import { note } from "../cli/groups/dql/run.js"; +import { expandTilde } from "../config/paths.js"; +import { capRows, classify } from "../query/execute.js"; +import { formatForOutFile, renderRows, resolveFormat } from "../render/output.js"; +import { type PageOptions, pageIfLong } from "../render/pager.js"; +import type { ExecuteResponse, PortalClient } from "./client.js"; + +/** + * Run DQL against Ditto Server over HTTP and render with the same pipeline as + * local execution (table on TTY, JSON when piped, -o export, pager, caps). + */ + +export interface ServerRunOptions { + format?: string; + maxRows: number; + maxRowsExplicit: boolean; + out?: string; + params?: Record; + time?: boolean; + pager?: boolean; + /** Per-request timeout override (ms) — DQL legitimately runs long. */ + timeoutMs?: number; + /** Injectable "stdout is a TTY" for tests. */ + stdoutIsTTY?: boolean; + /** Injectable pager for tests. */ + page?: (text: string, opts?: PageOptions) => boolean; +} + +export interface ServerRunResult { + ok: boolean; + rows: number; + elapsedMs: number; +} + +/** Items arrive as plain JSON values; table/csv/etc. need objects. */ +export function normalizeItems(items: unknown[]): Record[] { + return items.map((item) => { + if (item === null || item === undefined) return {}; + if (typeof item === "object" && !Array.isArray(item)) return item as Record; + return { value: item }; + }); +} + +/** Print the response's warnings on stderr (never stdout). */ +export function printWarnings(res: ExecuteResponse): void { + for (const w of res.warnings ?? []) { + // Off-contract warnings may lack `description` — never print "undefined". + console.error(chalk.yellow(`warning: ${w.description ?? JSON.stringify(w)}`)); + } + const extra = (res.totalWarningsCount ?? 0) - (res.warnings?.length ?? 0); + if (extra > 0) console.error(chalk.yellow(`…and ${extra} more warning(s)`)); +} + +/** Shared tail of every row-producing command: render rows / write -o / page. */ +export function emitRows( + rows: Record[], + opts: ServerRunOptions, + elapsedMs: number, +): { ok: boolean; rows: number } { + const { rows: shown, truncated, total } = capRows(rows, opts.maxRows); + const rowsForFile = opts.maxRowsExplicit ? shown : rows; + const format = opts.out ? formatForOutFile(opts.out, opts.format) : resolveFormat(opts.format); + if (format === "json") process.env.DITTOSH_JSON_OUT = "1"; // keep the update banner off JSON stdout + + if (opts.out) { + const prevLevel = chalk.level; + chalk.level = 0; // files never get ANSI escapes + let rendered: string; + try { + rendered = `${renderRows(rowsForFile, format)}\n`; + } finally { + chalk.level = prevLevel; + } + try { + fs.writeFileSync(expandTilde(opts.out), rendered, "utf8"); + } catch (err) { + console.error( + chalk.red(`Cannot write ${opts.out}: ${(err as NodeJS.ErrnoException).message}`), + ); + return { ok: false, rows: 0 }; + } + const cappedNote = + opts.maxRowsExplicit && rowsForFile.length < rows.length + ? ` (first ${rowsForFile.length} of ${rows.length} — --max-rows)` + : ""; + console.log( + `Wrote ${rowsForFile.length.toLocaleString()} row${rowsForFile.length === 1 ? "" : "s"} to ${opts.out} (${format})${cappedNote}`, + ); + } else { + const tty = opts.stdoutIsTTY ?? process.stdout.isTTY; + const rendered = renderRows(shown, format, { + maxWidth: tty ? process.stdout.columns || undefined : undefined, + }); + const page = opts.page ?? pageIfLong; + if (!page(rendered, { disabled: opts.pager === false })) console.log(rendered); + } + + if (truncated && !opts.out) { + console.error( + chalk.yellow(`showing first ${shown.length} of ${total} rows — add a LIMIT clause`), + ); + } + if (opts.time) console.error(chalk.dim(`Time: ${elapsedMs.toFixed(1)} ms`)); + return { ok: true, rows: shown.length }; +} + +/** + * Execute one DQL statement via POST /store/execute. A DQL-level error arrives + * as HTTP 200/400 with `error.description` in the body — ok:false, exit 1. + */ +export async function runServerExecute( + client: PortalClient, + statement: string, + opts: ServerRunOptions & { apiVersion?: "v4" | "v5"; txnId?: number }, +): Promise { + // PortalApiError/PortalConnectionError/PortalTimeoutError propagate — the + // command layer maps them to exit codes (3 auth/connection, 1 query/API/timeout). + const started = performance.now(); + const res = await client.execute(statement, opts.params, { + version: opts.apiVersion, + txnId: opts.txnId, + timeoutMs: opts.timeoutMs, + }); + const elapsedMs = performance.now() - started; + + printWarnings(res); + + // error.description is documented, but treat ANY truthy error object as a failure. + if ( + res.error && + (typeof res.error.description === "string" || Object.keys(res.error).length > 0) + ) { + const description = + typeof res.error.description === "string" ? res.error.description : JSON.stringify(res.error); + console.error(chalk.red(`Query error: ${description}`)); + console.error(chalk.dim(` in: ${statement}`)); + return { ok: false, rows: 0, elapsedMs }; + } + + const kind = classify(statement); + const rows = normalizeItems(res.items ?? []); + + // Mutations/DDL: acknowledge, and report what changed (stderr — stdout is data). + if (rows.length === 0 && kind !== "select") { + const mutated = res.mutatedDocumentIds?.length ?? 0; + if (opts.stdoutIsTTY ?? process.stdout.isTTY) console.log("OK"); + else console.error("OK"); + const bits = [ + res.transactionId !== undefined ? `transactionId ${res.transactionId}` : undefined, + mutated > 0 ? `${mutated} document${mutated === 1 ? "" : "s"} mutated` : undefined, + ].filter(Boolean); + if (bits.length) note(`(${bits.join(" · ")})`); + if (opts.time) console.error(chalk.dim(`Time: ${elapsedMs.toFixed(1)} ms`)); + return { ok: true, rows: 0, elapsedMs }; + } + + const r = emitRows(rows, opts, elapsedMs); + if (res.transactionId !== undefined) note(`(transactionId ${res.transactionId})`); + return { ...r, elapsedMs }; +} + +/** Remote execute: per-peer sections; rows when there's exactly one peer with rows. */ +export async function runServerRemoteExecute( + client: PortalClient, + statement: string, + opts: ServerRunOptions, +): Promise { + const started = performance.now(); + const res = await client.remoteExecute(statement, opts.params, { timeoutMs: opts.timeoutMs }); + const elapsedMs = performance.now() - started; + + // Same predicate as runServerExecute: ANY non-empty error object is a failure. + // Loose != covers both undefined and explicit JSON null (serializers emit it). + const hasError = (e?: { description?: string } | null) => + e != null && (typeof e.description === "string" || Object.keys(e).length > 0); + + if (res.error && hasError(res.error)) { + const description = + typeof res.error.description === "string" ? res.error.description : JSON.stringify(res.error); + console.error(chalk.red(`Remote query error: ${description}`)); + return { ok: false, rows: 0, elapsedMs }; + } + + const results = res.result ?? []; + let failures = 0; + const perPeer = results.map((r) => { + if (hasError(r.error)) failures++; + return { + peer: r.peer, + elapsedMilliseconds: r.elapsedMilliseconds, + ...(hasError(r.error) + ? { + error: + typeof r.error?.description === "string" + ? r.error.description + : JSON.stringify(r.error), + } + : {}), + items: r.items ?? [], + ...(Array.isArray(r.warnings) && r.warnings.length > 0 ? { warnings: r.warnings } : {}), + ...(r.totalWarningsCount !== undefined ? { totalWarningsCount: r.totalWarningsCount } : {}), + }; + }); + + // Piped/JSON: the full per-peer envelope is the data. TTY: same JSON — peer + // results don't flatten into one table without lying about provenance. + process.env.DITTOSH_JSON_OUT = "1"; // always-JSON stdout: keep the update banner off it + const rendered = JSON.stringify(perPeer, null, 2); + const page = opts.page ?? pageIfLong; + if (!page(rendered, { disabled: opts.pager === false })) console.log(rendered); + if (failures > 0) { + console.error(chalk.yellow(`${failures} of ${results.length} peer(s) returned an error`)); + } + if (opts.time) console.error(chalk.dim(`Time: ${elapsedMs.toFixed(1)} ms`)); + return { ok: failures === 0, rows: results.length, elapsedMs }; +} diff --git a/tests/e2e/server.test.ts b/tests/e2e/server.test.ts new file mode 100644 index 0000000..9aacaaa --- /dev/null +++ b/tests/e2e/server.test.ts @@ -0,0 +1,606 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import path from "node:path"; +import { execa } from "execa"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { rmrf, tmpDataDir } from "../helpers/credentials.js"; + +/** + * e2e for `dittosh server` against a local mock Ditto Server (node:http). + * The spawned CLI is the real entrypoint; only the network is fake. + * + * Env hygiene: tests/setup/env.ts loads the repo .env (which may contain REAL + * portal credentials) into this process, and execa inherits by default. Every + * spawn below therefore passes an explicit env — either overriding + * DITTOSH_SERVER_* at the mock, or extending nothing (missing-config tests + * also run from an empty tmp cwd so no .env is found). + */ + +const ROOT = path.resolve(import.meta.dirname, "../.."); +const TSX = path.join(ROOT, "node_modules", "tsx", "dist", "loader.mjs"); + +interface CapturedRequest { + method: string; + url: string; + authorization?: string; + txnId?: string; + body: string; +} + +let server: http.Server; +let port: number; +let requests: CapturedRequest[]; +/** Per-test response override; default: empty successful execute response. */ +let responder: (req: CapturedRequest, res: http.ServerResponse) => void; + +function defaultResponder(req: CapturedRequest, res: http.ServerResponse) { + if (req.url.includes("/store/execute")) { + const statement = (JSON.parse(req.body) as { statement: string }).statement; + if (statement.startsWith("BROKEN")) { + res.writeHead(400, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + queryType: "unknown", + items: [], + mutatedDocumentIds: [], + error: { description: "syntax error near BROKEN" }, + warnings: [], + totalWarningsCount: 0, + }), + ); + return; + } + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + transactionId: 42, + queryType: "select", + items: [{ _id: "c1", name: "Ada" }], + mutatedDocumentIds: [], + warnings: [], + totalWarningsCount: 0, + }), + ); + return; + } + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({})); +} + +beforeAll(async () => { + requests = []; + responder = defaultResponder; + server = http.createServer((req, res) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + const captured: CapturedRequest = { + method: req.method ?? "", + url: req.url ?? "", + authorization: req.headers.authorization, + txnId: req.headers["x-ditto-txn-id"] as string | undefined, + body, + }; + requests.push(captured); + responder(captured, res); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + port = (server.address() as AddressInfo).port; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); +}); + +interface RunResult { + exitCode: number; + stdout: string; + stderr: string; +} + +function cli( + args: string[], + opts: { env?: Record; cwd?: string; input?: string; extend?: boolean } = {}, +) { + const env: Record = { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? "", + NO_COLOR: "1", + DITTOSH_NO_UPDATE_CHECK: "1", + ...(opts.extend === false + ? {} + : { + DITTOSH_SERVER_URL: `http://127.0.0.1:${port}/app-id`, + DITTOSH_SERVER_API_KEY: "e2e-key", + }), + ...opts.env, + }; + // Default cwd is a fresh tmpdir, NOT the repo root — the repo .env may hold + // real portal credentials and the spawned process would read them via the + // cwd-.env fallback (env vars set below always win over it anyway). + return execa(process.execPath, ["--import", TSX, path.join(ROOT, "src/cli/index.ts"), ...args], { + cwd: opts.cwd ?? tmpDataDir("dittosh-e2e-cwd-"), + reject: false, + env, + extendEnv: false, + input: opts.input, + }) as unknown as Promise; +} + +describe("e2e: dittosh server execute", () => { + it("runs a SELECT against the mock server — JSON on stdout, auth header sent", async () => { + const r = await cli(["server", "execute", "SELECT * FROM customers LIMIT 1"]); + expect(r.exitCode).toBe(0); + expect(JSON.parse(r.stdout)).toEqual([{ _id: "c1", name: "Ada" }]); + const req = requests.at(-1)!; + expect(req.url).toBe("/app-id/api/v5/store/execute"); + expect(req.authorization).toBe("Bearer e2e-key"); + expect(JSON.parse(req.body)).toEqual({ statement: "SELECT * FROM customers LIMIT 1" }); + }); + + it("sends X-DITTO-TXN-ID when --txn-id is passed", async () => { + const r = await cli(["server", "execute", "SELECT 1", "--txn-id", "17"]); + expect(r.exitCode).toBe(0); + expect(requests.at(-1)!.txnId).toBe("17"); + }); + + it("uses v4 when --api-version v4", async () => { + const r = await cli(["server", "execute", "SELECT 1", "--api-version", "v4"]); + expect(r.exitCode).toBe(0); + expect(requests.at(-1)!.url).toContain("/api/v4/store/execute"); + }); + + it("DQL error from the server → exit 1, error on stderr, stdout clean", async () => { + const r = await cli(["server", "execute", "BROKEN"]); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain("syntax error near BROKEN"); + expect(r.stdout).toBe(""); + }); + + it("HTTP 401 → exit 3", async () => { + const prev = responder; + responder = (_req, res) => { + res.writeHead(401, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: "invalid API key" })); + }; + try { + const r = await cli(["server", "execute", "SELECT 1"]); + expect(r.exitCode).toBe(3); + expect(r.stderr).toContain("invalid API key"); + expect(r.stdout).toBe(""); + } finally { + responder = prev; + } + }); + + it("unreachable server → exit 3 with a connection error", async () => { + const r = await cli(["server", "execute", "SELECT 1"], { + env: { DITTOSH_SERVER_URL: "http://127.0.0.1:1/app", DITTOSH_SERVER_API_KEY: "k" }, + }); + expect(r.exitCode).toBe(3); + expect(r.stderr).toContain("Cannot reach"); + }); + + it("piped stdin batch runs one call per statement", async () => { + const before = requests.length; + const r = await cli(["server", "execute"], { input: "SELECT 1;\nSELECT 2;\n" }); + expect(r.exitCode).toBe(0); + expect(requests.length - before).toBe(2); + expect(r.stderr).toContain("2 ok, 0 failed (of 2)"); + }); + + it("usage error: statement + -e together → exit 2, no request", async () => { + const before = requests.length; + const r = await cli(["server", "execute", "SELECT 1", "-e", "SELECT 2"]); + expect(r.exitCode).toBe(2); + expect(requests.length).toBe(before); + }); +}); + +describe("e2e: dittosh server config resolution", () => { + it("missing URL/key → exit 3 with guidance (empty cwd, scrubbed env)", async () => { + const cwd = tmpDataDir("dittosh-e2e-nocfg-"); + try { + const r = await cli(["server", "execute", "SELECT 1"], { cwd, extend: false }); + expect(r.exitCode).toBe(3); + expect(r.stderr).toContain("DITTOSH_SERVER_URL"); + expect(r.stderr).toContain("--url"); + } finally { + rmrf(cwd); + } + }); + + it(".env in the cwd provides config", async () => { + const fs = await import("node:fs"); + const cwd = tmpDataDir("dittosh-e2e-dotenv-"); + try { + fs.writeFileSync( + path.join(cwd, ".env"), + `DITTOSH_SERVER_URL=http://127.0.0.1:${port}/from-dotenv\nDITTOSH_SERVER_API_KEY=dotenv-key\n`, + ); + const r = await cli(["server", "execute", "SELECT 1"], { cwd, extend: false }); + expect(r.exitCode).toBe(0); + const req = requests.at(-1)!; + expect(req.url).toBe("/from-dotenv/api/v5/store/execute"); + expect(req.authorization).toBe("Bearer dotenv-key"); + } finally { + rmrf(cwd); + } + }); + + it("--url/--api-key flags beat everything", async () => { + const r = await cli( + [ + "server", + "execute", + "SELECT 1", + "--url", + `http://127.0.0.1:${port}/flag-app`, + "--api-key", + "flag-key", + ], + { extend: false }, + ); + expect(r.exitCode).toBe(0); + expect(requests.at(-1)!.url).toBe("/flag-app/api/v5/store/execute"); + expect(requests.at(-1)!.authorization).toBe("Bearer flag-key"); + }); + + it("bad URL → exit 3 with a clear message", async () => { + const r = await cli( + ["server", "execute", "SELECT 1", "--url", "ht tp://bad", "--api-key", "k"], + { + extend: false, + }, + ); + expect(r.exitCode).toBe(3); + expect(r.stderr).toContain("Invalid server URL"); + }); +}); + +describe("e2e: dittosh server help documents itself", () => { + it("group help lists commands and the config story", async () => { + const r = await cli(["server", "--help"], { extend: false }); + expect(r.exitCode).toBe(0); + for (const needle of [ + "execute", + "remote-execute", + "attachment", + "roles", + "users", + "webhook-secrets", + "doctor", + "DITTOSH_SERVER_URL", + "DITTOSH_SERVER_API_KEY", + ]) { + expect(r.stdout).toContain(needle); + } + }); + + it("execute --help documents the request body and examples", async () => { + const r = await cli(["server", "execute", "--help"], { extend: false }); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("store/execute"); + expect(r.stdout).toContain("statement"); + expect(r.stdout).toContain("args"); + expect(r.stdout).toContain("--api-version"); + }); + + it("roles create --help documents the permissions shape", async () => { + const r = await cli(["server", "roles", "create", "--help"], { extend: false }); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("collection_permissions"); + expect(r.stdout).toContain("read_only"); + }); + + it("remote-execute without SYNC CONTEXT → exit 2 with guidance", async () => { + const r = await cli(["server", "remote-execute", "SELECT 1"]); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("SYNC CONTEXT"); + }); +}); + +describe("e2e: dittosh server admin commands against the mock", () => { + it("roles list renders rows", async () => { + const prev = responder; + responder = (_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + roles: { + staff: [ + { + _id: { name: "staff", version: "v1" }, + roles_version: "v1-preview", + description: "Store staff", + collection_permissions: "read_only", + grant_remote_query: false, + }, + ], + }, + }), + ); + }; + try { + const r = await cli(["server", "roles", "list"]); + expect(r.exitCode).toBe(0); + const rows = JSON.parse(r.stdout); + expect(rows).toEqual([ + { + name: "staff", + version: "v1", + description: "Store staff", + collection_permissions: "read_only", + grant_remote_query: false, + }, + ]); + } finally { + responder = prev; + } + }); + + it("attachment get refuses binary on a TTY… (piped here) writes bytes to stdout", async () => { + const prev = responder; + responder = (_req, res) => { + res.writeHead(200, { "content-type": "application/octet-stream" }); + res.end(Buffer.from([0x89, 0x50, 0x4e, 0x47])); + }; + try { + const r = await execa( + process.execPath, + [ + "--import", + TSX, + path.join(ROOT, "src/cli/index.ts"), + "server", + "attachment", + "get", + "att-1", + ], + { + cwd: ROOT, + reject: false, + encoding: "buffer", + env: { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? "", + NO_COLOR: "1", + DITTOSH_NO_UPDATE_CHECK: "1", + DITTOSH_SERVER_URL: `http://127.0.0.1:${port}/app-id`, + DITTOSH_SERVER_API_KEY: "e2e-key", + }, + extendEnv: false, + }, + ); + expect(r.exitCode).toBe(0); + expect(Buffer.from(r.stdout as unknown as Uint8Array).subarray(0, 4)).toEqual( + Buffer.from([0x89, 0x50, 0x4e, 0x47]), + ); + } finally { + responder = prev; + } + }); +}); + +describe("e2e: dittosh server doctor", () => { + it("all checks green against the mock", async () => { + const r = await cli(["server", "doctor"]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("✓ config"); + expect(r.stdout).toContain("✓ connection"); + expect(r.stdout).toContain("✓ auth"); + expect(requests.at(-1)!.url).toBe("/app-id/api/v5/store/execute"); + expect(JSON.parse(requests.at(-1)!.body)).toEqual({ + statement: "SELECT * FROM system:collections LIMIT 1", + }); + }); + + it("401 → exit 3, auth fails", async () => { + const prev = responder; + responder = (_req, res) => { + res.writeHead(401, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: "unauthorized" })); + }; + try { + const r = await cli(["server", "doctor"]); + expect(r.exitCode).toBe(3); + expect(r.stdout).toContain("✗ auth"); + } finally { + responder = prev; + } + }); +}); + +describe("e2e: dittosh server RBAC + webhook writes against the mock", () => { + it("roles create → delete round trip", async () => { + const prev = responder; + responder = (_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({})); + }; + try { + const c = await cli([ + "server", + "roles", + "create", + "staff", + "--description", + "Store staff", + "--permissions", + "read_only", + ]); + expect(c.exitCode).toBe(0); + const createReq = requests.at(-1)!; + expect(createReq.method).toBe("POST"); + expect(JSON.parse(createReq.body)).toEqual({ + name: "staff", + doc: { + roles_version: "v1-preview", + description: "Store staff", + collection_permissions: "read_only", + grant_remote_query: false, + }, + }); + + const d = await cli(["server", "roles", "delete", "staff", "-y"]); + expect(d.exitCode).toBe(0); + expect(requests.at(-1)!.method).toBe("DELETE"); + expect(requests.at(-1)!.url).toBe("/app-id/api/v4/auth/roles/staff"); + } finally { + responder = prev; + } + }); + + it("users list / set-roles / delete", async () => { + const prev = responder; + responder = (req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + if (req.url.includes("/auth/users") && req.method === "GET") { + res.end( + JSON.stringify({ + users: [{ userId: "auth0|1", roles: ["staff"], identityVersion: "v1" }], + hasMore: false, + }), + ); + } else if (req.method === "PATCH") { + res.end(JSON.stringify({ identityVersion: "v2", transactionId: 12 })); + } else { + res.end(JSON.stringify({})); + } + }; + try { + const l = await cli(["server", "users", "list"]); + expect(l.exitCode).toBe(0); + expect(JSON.parse(l.stdout)).toEqual([ + { userId: "auth0|1", roles: ["staff"], identityVersion: "v1" }, + ]); + + const s = await cli(["server", "users", "set-roles", "auth0|1", "staff", "ops"]); + expect(s.exitCode).toBe(0); + expect(JSON.parse(requests.at(-1)!.body)).toEqual({ roles: ["staff", "ops"] }); + expect(JSON.parse(s.stdout).transactionId).toBe(12); + + const d = await cli(["server", "users", "delete", "auth0|1", "-y"]); + expect(d.exitCode).toBe(0); + expect(requests.at(-1)!.method).toBe("DELETE"); + } finally { + responder = prev; + } + }); + + it("webhook-secrets list → create → rotate → delete against the mock", async () => { + const existing = { secret: "s1", notBefore: "a", notAfter: "b" }; + const prev = responder; + responder = (req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + if (req.method === "GET") { + res.end(JSON.stringify({ secret: [existing] })); + } else if (req.method === "POST") { + res.end(JSON.stringify({ secret: "created-secret", notBefore: "x", notAfter: "y" })); + } else if (req.method === "PATCH") { + res.end(JSON.stringify({ secret: "rotated-secret", notBefore: "x", notAfter: "z" })); + } else { + res.end(JSON.stringify({})); + } + }; + try { + const l = await cli(["server", "webhook-secrets", "list", "--provider", "p1"]); + expect(l.exitCode).toBe(0); + expect(JSON.parse(l.stdout)[0].secret).toBe("s1"); + + const c = await cli([ + "server", + "webhook-secrets", + "create", + "--provider", + "p1", + "--not-after", + "2027-01-01T00:00:00Z", + ]); + expect(c.exitCode).toBe(0); + expect(JSON.parse(c.stdout).secret).toBe("created-secret"); + + const ro = await cli([ + "server", + "webhook-secrets", + "rotate", + "--provider", + "p1", + "--secret", + "s1", + "--not-after", + "2027-06-01T00:00:00Z", + ]); + expect(ro.exitCode).toBe(0); + expect(JSON.parse(ro.stdout).secret).toBe("rotated-secret"); + const patch = requests.at(-1)!; + expect(JSON.parse(patch.body).rotate).toEqual(existing); + + const d = await cli([ + "server", + "webhook-secrets", + "delete", + "--provider", + "p1", + "--secret", + "s1", + "-y", + ]); + expect(d.exitCode).toBe(0); + const del = requests.at(-1)!; + expect(del.method).toBe("DELETE"); + expect(JSON.parse(del.body)).toEqual({ provider: "p1", ...existing }); + } finally { + responder = prev; + } + }); + + it("attachment upload posts multipart and prints {id, len}", async () => { + const fs = await import("node:fs"); + const os = await import("node:os"); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dittosh-e2e-att-")); + const file = path.join(dir, "blob.bin"); + fs.writeFileSync(file, Buffer.from([1, 2, 3, 4])); + const prev = responder; + responder = (_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ id: "att-9", len: 4 })); + }; + try { + const r = await cli(["server", "attachment", "upload", file]); + expect(r.exitCode).toBe(0); + expect(JSON.parse(r.stdout)).toEqual({ id: "att-9", len: 4 }); + expect(requests.at(-1)!.url).toBe("/app-id/api/v4/attachments/upload"); + } finally { + responder = prev; + rmrf(dir); + } + }); + + it("remote-execute happy path renders the per-peer envelope", async () => { + const prev = responder; + responder = (_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + result: [{ peer: { peerKeyString: "pk1" }, elapsedMilliseconds: 5, items: [{ a: 1 }] }], + }), + ); + }; + try { + const r = await cli([ + "server", + "remote-execute", + "SYNC CONTEXT ( PEERS WHERE peerKeyString = 'pk1' ) SELECT * FROM cars LIMIT 5", + ]); + expect(r.exitCode).toBe(0); + const parsed = JSON.parse(r.stdout); + expect(parsed[0].peer.peerKeyString).toBe("pk1"); + expect(parsed[0].items).toEqual([{ a: 1 }]); + expect(requests.at(-1)!.url).toBe("/app-id/api/v5/sync/remote_execute"); + } finally { + responder = prev; + } + }); +}); diff --git a/tests/unit/cli-server-branches.test.ts b/tests/unit/cli-server-branches.test.ts new file mode 100644 index 0000000..fdefcc6 --- /dev/null +++ b/tests/unit/cli-server-branches.test.ts @@ -0,0 +1,881 @@ +import fs from "node:fs"; +import path from "node:path"; +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerServerGroup } from "../../src/cli/groups/server/index.js"; +import type { FetchLike } from "../../src/server/client.js"; +import { rmrf, tmpDataDir } from "../helpers/credentials.js"; + +/** + * Branch-coverage companion to cli-server.test.ts: the usage-validation paths + * (exit 2 before any network) and the per-command happy paths that file + * doesn't already exercise. + */ + +const SERVER_ENV_VARS = [ + "DITTOSH_SERVER_URL", + "DITTOSH_SERVER_API_KEY", + "DITTO_CLOUD_URL", + "DITTO_API_KEY", +]; + +let outSpy: ReturnType; +let errSpy: ReturnType; +let writeSpy: ReturnType; +let savedEnv: Record; +let savedCwd: string; +let workDir: string; + +beforeEach(() => { + outSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + process.exitCode = undefined; + savedEnv = Object.fromEntries(SERVER_ENV_VARS.map((k) => [k, process.env[k]])); + for (const k of SERVER_ENV_VARS) delete process.env[k]; + workDir = tmpDataDir("dittosh-cli-server2-"); + savedCwd = process.cwd(); + process.chdir(workDir); +}); + +afterEach(() => { + outSpy.mockRestore(); + errSpy.mockRestore(); + writeSpy.mockRestore(); + process.exitCode = undefined; + process.chdir(savedCwd); + rmrf(workDir); + for (const k of SERVER_ENV_VARS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } +}); + +const stdout = () => outSpy.mock.calls.flat().join("\n"); +const stderr = () => errSpy.mock.calls.flat().join("\n"); + +const ENV = { DITTOSH_SERVER_URL: "https://mock.example/app", DITTOSH_SERVER_API_KEY: "key" }; + +interface CannedReply { + status?: number; + body?: unknown; + text?: string; +} + +function buildProgram(handler: (url: string, body?: unknown) => CannedReply = () => ({})) { + const calls: { url: string; method: string; body?: unknown; authorization?: string }[] = []; + const fetchImpl: FetchLike = async (url, init) => { + const body = + typeof init.body === "string" + ? (() => { + try { + return JSON.parse(init.body as string); + } catch { + return init.body; + } + })() + : undefined; + calls.push({ url, method: init.method, body, authorization: init.headers.Authorization }); + const reply = handler(url, body); + return { + status: reply.status ?? 200, + statusText: "", + headers: { get: () => "application/json" }, + text: async () => reply.text ?? JSON.stringify(reply.body ?? {}), + }; + }; + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeOut: () => {}, writeErr: () => {} }); + registerServerGroup(program.command("server"), { fetchImpl }); + return { program, calls }; +} + +async function run(program: Command, args: string[]) { + try { + await program.parseAsync(["node", "dittosh", ...args]); + } catch (err) { + if (err instanceof Error && "exitCode" in err) { + process.exitCode = (err as { exitCode: number }).exitCode === 0 ? 0 : 2; + } else throw err; + } +} + +function withStdinTTY(isTTY: boolean, fn: () => Promise) { + return async () => { + const orig = process.stdin.isTTY; + Object.defineProperty(process.stdin, "isTTY", { value: isTTY, configurable: true }); + try { + await fn(); + } finally { + Object.defineProperty(process.stdin, "isTTY", { value: orig, configurable: true }); + } + }; +} + +describe("server: bare group prints help", () => { + it("dittosh server → help, exit 0", async () => { + const { program } = buildProgram(); + await run(program, ["server"]); + expect(process.exitCode ?? 0).toBe(0); + }); +}); + +describe("server execute: more usage validation", () => { + beforeEach(() => { + Object.assign(process.env, ENV); + }); + + it("--args - with a TTY stdin → exit 2", async () => { + const { program, calls } = buildProgram(); + await withStdinTTY(true, async () => { + await run(program, ["server", "execute", "SELECT 1", "--args", "-"]); + })(); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("stdin is a terminal"); + expect(calls).toHaveLength(0); + }); + + it("--args - without a statement → exit 2", async () => { + const { program, calls } = buildProgram(); + const orig = process.stdin.isTTY; + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + try { + await run(program, ["server", "execute", "--args", "-"]); + } finally { + Object.defineProperty(process.stdin, "isTTY", { value: orig, configurable: true }); + } + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("consumes stdin"); + expect(calls).toHaveLength(0); + }); + + it("-f with an unreadable file → exit 2", async () => { + const { program, calls } = buildProgram(); + await run(program, ["server", "execute", "-f", path.join(workDir, "nope.sql")]); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("Cannot read file"); + expect(calls).toHaveLength(0); + }); + + it('-f "" → exit 2', async () => { + const { program, calls } = buildProgram(); + await run(program, ["server", "execute", "-f", " "]); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("-f/--file requires a path"); + expect(calls).toHaveLength(0); + }); + + it("-f with an empty file → exit 2", async () => { + const { program, calls } = buildProgram(); + const file = path.join(workDir, "empty.sql"); + fs.writeFileSync(file, "-- only a comment\n"); + await run(program, ["server", "execute", "-f", file]); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("No statements in"); + expect(calls).toHaveLength(0); + }); + + it("-f with multiple statements + -o → exit 2", async () => { + const { program, calls } = buildProgram(); + const file = path.join(workDir, "two.sql"); + fs.writeFileSync(file, "SELECT 1;\nSELECT 2;\n"); + await run(program, ["server", "execute", "-f", file, "-o", "out.json"]); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("--out is only supported for a single statement"); + expect(calls).toHaveLength(0); + }); + + it("-o to a bogus path → exit 2", async () => { + const { program, calls } = buildProgram(); + await run(program, [ + "server", + "execute", + "SELECT 1", + "-o", + path.join(workDir, "nope", "x.json"), + ]); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("Cannot write"); + expect(calls).toHaveLength(0); + }); + + it("unterminated single statement is sent as-is (no trailing-; rule over HTTP)", async () => { + const { program, calls } = buildProgram(() => ({ + body: { queryType: "unknown", items: [], mutatedDocumentIds: [], error: {}, warnings: [] }, + })); + await run(program, ["server", "execute", "SELECT 1 garbage here"]); + expect(process.exitCode ?? 0).toBe(0); // single unterminated statement is sent as-is + expect(calls).toHaveLength(1); + }); + + it("whitespace-only statement → exit 2", async () => { + const { program, calls } = buildProgram(); + await run(program, ["server", "execute", " -- just a comment"]); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("No statement given"); + expect(calls).toHaveLength(0); + }); + + it("bad --format → exit 2", async () => { + const { program, calls } = buildProgram(); + await run(program, ["server", "execute", "SELECT 1", "--format", "yaml"]); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("--format must be one of"); + expect(calls).toHaveLength(0); + }); + + it("batch --continue-on-error runs all statements", async () => { + const file = path.join(workDir, "b.sql"); + fs.writeFileSync(file, "SELECT 1;\nBROKEN;\nSELECT 3;\n"); + const { program, calls } = buildProgram((_url, body) => { + const stmt = (body as { statement?: string })?.statement ?? ""; + if (stmt === "BROKEN") { + return { + body: { + queryType: "unknown", + items: [], + mutatedDocumentIds: [], + error: { description: "bad" }, + warnings: [], + }, + }; + } + return { body: { queryType: "select", items: [], mutatedDocumentIds: [] } }; + }); + await run(program, ["server", "execute", "-f", file, "--continue-on-error"]); + expect(calls).toHaveLength(3); + expect(process.exitCode).toBe(1); + expect(stderr()).toContain("2 ok, 1 failed (of 3)"); + }); + + it("batch: a thrown HTTP error counts as failed and stops", async () => { + const file = path.join(workDir, "b.sql"); + fs.writeFileSync(file, "SELECT 1;\nSELECT 2;\n"); + const { program, calls } = buildProgram(() => ({ status: 500, body: { message: "boom" } })); + await run(program, ["server", "execute", "-f", file]); + expect(calls).toHaveLength(1); + expect(process.exitCode).toBe(1); + expect(stderr()).toContain("0 ok, 1 failed (of 2)"); + }); + + it("the exec alias works", async () => { + const { program, calls } = buildProgram(() => ({ + body: { queryType: "select", items: [], mutatedDocumentIds: [] }, + })); + await run(program, ["server", "exec", "SELECT 1"]); + expect(process.exitCode ?? 0).toBe(0); + expect(calls).toHaveLength(1); + }); +}); + +describe("server remote-execute: usage validation", () => { + beforeEach(() => { + Object.assign(process.env, ENV); + }); + + it("bad --args → exit 2", async () => { + const { program, calls } = buildProgram(); + await run(program, [ + "server", + "remote-execute", + "SYNC CONTEXT ( PEERS WHERE peerKeyString = 'x' ) SELECT 1", + "--args", + "{no", + ]); + expect(process.exitCode).toBe(2); + expect(calls).toHaveLength(0); + }); +}); + +describe("server attachment commands", () => { + beforeEach(() => { + Object.assign(process.env, ENV); + }); + + it("upload: missing file → exit 2", async () => { + const { program, calls } = buildProgram(); + await run(program, ["server", "attachment", "upload", "no-such-file.bin"]); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("Cannot read file"); + expect(calls).toHaveLength(0); + }); + + it("upload posts multipart and prints {id, len}", async () => { + const file = path.join(workDir, "blob.bin"); + fs.writeFileSync(file, Buffer.from([1, 2, 3, 4])); + const { program, calls } = buildProgram(() => ({ body: { id: "att-9", len: 4 } })); + await run(program, ["server", "attachment", "upload", file]); + expect(process.exitCode ?? 0).toBe(0); + expect(calls[0]!.url).toContain("/api/v4/attachments/upload"); + expect(JSON.parse(stdout())).toEqual({ id: "att-9", len: 4 }); + }); + + it("get -o writes the bytes to a file", async () => { + const { program } = buildProgram(() => ({ text: "BINARY" })); + const out = path.join(workDir, "att.bin"); + await run(program, ["server", "attachment", "get", "att-1", "-o", out]); + expect(process.exitCode ?? 0).toBe(0); + expect(fs.readFileSync(out).toString("binary")).toBe("BINARY"); + expect(stdout()).toContain("Wrote 6 bytes"); + }); + + it("get piped writes raw bytes to stdout", async () => { + const { program } = buildProgram(() => ({ text: "RAWBYTES" })); + const origTty = process.stdout.isTTY; + Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true }); + try { + await run(program, ["server", "attachment", "get", "att-1"]); + } finally { + Object.defineProperty(process.stdout, "isTTY", { value: origTty, configurable: true }); + } + expect(process.exitCode ?? 0).toBe(0); + const written = writeSpy.mock.calls.map((c: unknown[]) => c[0]); + expect( + written.some((c: unknown) => Buffer.isBuffer(c) && c.toString("binary") === "RAWBYTES"), + ).toBe(true); + }); + + it("get -o to a bogus path → exit 2", async () => { + const { program, calls } = buildProgram(); + await run(program, [ + "server", + "attachment", + "get", + "att-1", + "-o", + path.join(workDir, "nope", "x.bin"), + ]); + expect(process.exitCode).toBe(2); + expect(calls).toHaveLength(0); + }); +}); + +describe("server rbac: extra branches", () => { + beforeEach(() => { + Object.assign(process.env, ENV); + }); + + it("roles list: garbage body → invalid-response error, exit 1 (fail closed)", async () => { + const { program } = buildProgram(() => ({ body: "not-an-object" })); + await run(program, ["server", "roles", "list"]); + expect(process.exitCode).toBe(1); + expect(stderr()).toContain("Invalid response from Ditto Server"); + }); + + it("roles create --permissions @file", async () => { + const file = path.join(workDir, "perms.json"); + fs.writeFileSync(file, JSON.stringify({ cars: { read: true, write: ["_id == 'c1'"] } })); + const { program, calls } = buildProgram(); + await run(program, ["server", "roles", "create", "ops", "--permissions", `@${file}`]); + expect(process.exitCode ?? 0).toBe(0); + expect( + (calls[0]!.body as { doc: { collection_permissions: unknown } }).doc.collection_permissions, + ).toEqual({ + cars: { read: true, write: ["_id == 'c1'"] }, + }); + }); + + it("roles create with no permissions sends explicit defaults (POST replaces)", async () => { + const { program, calls } = buildProgram(); + await run(program, ["server", "roles", "create", "empty"]); + expect(process.exitCode ?? 0).toBe(0); + expect(calls[0]!.body).toEqual({ + name: "empty", + doc: { + roles_version: "v1-preview", + description: "", + collection_permissions: "none", + grant_remote_query: false, + }, + }); + }); + + it("roles create: server 403 → exit 3", async () => { + const { program } = buildProgram(() => ({ status: 403, body: { message: "admin required" } })); + await run(program, ["server", "roles", "create", "staff"]); + expect(process.exitCode).toBe(3); + }); + + it("users list: bad --limit → exit 2", async () => { + const { program, calls } = buildProgram(); + await run(program, ["server", "users", "list", "--limit", "nope"]); + expect(process.exitCode).toBe(2); + expect(calls).toHaveLength(0); + }); + + it("users list: --user-id filter is sent", async () => { + const { program, calls } = buildProgram(() => ({ body: { users: [], hasMore: false } })); + await run(program, ["server", "users", "list", "--user-id", "auth0|9"]); + expect(process.exitCode ?? 0).toBe(0); + expect(calls[0]!.url).toContain("userId=auth0%7C9"); + }); + + it("users delete without -y, non-interactive → exit 2, no request", async () => { + const { program, calls } = buildProgram(); + const origIn = process.stdin.isTTY; + const origErr = process.stderr.isTTY; + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + Object.defineProperty(process.stderr, "isTTY", { value: false, configurable: true }); + try { + await run(program, ["server", "users", "delete", "auth0|1"]); + } finally { + Object.defineProperty(process.stdin, "isTTY", { value: origIn, configurable: true }); + Object.defineProperty(process.stderr, "isTTY", { value: origErr, configurable: true }); + } + expect(process.exitCode).toBe(2); + expect(calls).toHaveLength(0); + }); +}); + +describe("server doctor via the CLI", () => { + beforeEach(() => { + Object.assign(process.env, ENV); + }); + + it("all checks green → exit 0", async () => { + const { program } = buildProgram(() => ({ + body: { transactionId: 5, queryType: "select", items: [] }, + })); + await run(program, ["server", "doctor"]); + expect(process.exitCode ?? 0).toBe(0); + expect(stdout()).toContain("✓ config"); + expect(stdout()).toContain("✓ connection"); + expect(stdout()).toContain("✓ auth"); + }); + + it("401 → exit 3 with guidance", async () => { + const { program } = buildProgram(() => ({ status: 401, body: { message: "nope" } })); + await run(program, ["server", "doctor"]); + expect(process.exitCode).toBe(3); + expect(stdout()).toContain("✗ auth"); + }); + + it("missing config → exit 3, probe checks skipped", async () => { + for (const k of SERVER_ENV_VARS) delete process.env[k]; + const { program } = buildProgram(); + await run(program, ["server", "doctor"]); + expect(process.exitCode).toBe(3); + expect(stdout()).toContain("✗ config"); + expect(stdout()).toContain("skipped"); + }); + + it("honors --api-version v4 in the probe URL", async () => { + const { program, calls } = buildProgram(() => ({ + body: { transactionId: 5, queryType: "select", items: [] }, + })); + await run(program, ["server", "doctor", "--api-version", "v4"]); + expect(calls[0]!.url).toContain("/api/v4/store/execute"); + }); +}); + +describe("server: flags reach the wire on every command family", () => { + it("roles list with --url/--api-key flags", async () => { + const { program, calls } = buildProgram(() => ({ body: { roles: [] } })); + await run(program, [ + "server", + "roles", + "list", + "--url", + "flags.example/app", + "--api-key", + "flag-key", + ]); + expect(process.exitCode ?? 0).toBe(0); + expect(calls[0]!.url).toBe("https://flags.example/app/api/v4/auth/roles"); + }); + + it("remote-execute binds -p and --args together", async () => { + Object.assign(process.env, ENV); + const { program, calls } = buildProgram(() => ({ body: { result: [] } })); + await run(program, [ + "server", + "remote-execute", + "SYNC CONTEXT ( PEERS WHERE peerKeyString = :pk ) SELECT * FROM c WHERE x = :x", + "-p", + "x=1", + "--args", + '{"pk":"abc"}', + ]); + expect(process.exitCode ?? 0).toBe(0); + expect(calls[0]!.body).toMatchObject({ args: { pk: "abc", x: 1 } }); + }); + + it("users list sends --limit and --cursor", async () => { + Object.assign(process.env, ENV); + const { program, calls } = buildProgram(() => ({ body: { users: [], hasMore: false } })); + await run(program, ["server", "users", "list", "--limit", "10", "--cursor", "abc"]); + expect(calls[0]!.url).toContain("limit=10"); + expect(calls[0]!.url).toContain("cursor=abc"); + }); + + it("webhook-secrets list with --format json prints rows", async () => { + Object.assign(process.env, ENV); + const { program } = buildProgram(() => ({ + body: { secret: [{ secret: "s1", notBefore: "a", notAfter: "b", rotated: "r" }] }, + })); + await run(program, [ + "server", + "webhook-secrets", + "list", + "--provider", + "p1", + "--format", + "json", + ]); + expect(process.exitCode ?? 0).toBe(0); + expect(JSON.parse(stdout())).toEqual([ + { secret: "s1", notBefore: "a", notAfter: "b", rotated: "r" }, + ]); + }); +}); + +describe("server: interactive confirm path (mocked @inquirer/prompts)", () => { + it("roles delete without -y prompts on a TTY and proceeds when confirmed", async () => { + Object.assign(process.env, ENV); + vi.doMock("@inquirer/prompts", () => ({ confirm: vi.fn(async () => true) })); + vi.resetModules(); + const { registerServerGroup: registerFresh } = await import( + "../../src/cli/groups/server/index.js" + ); + const calls: { url: string; method: string }[] = []; + const fetchImpl: FetchLike = async (url, init) => { + calls.push({ url, method: init.method }); + return { + status: 200, + statusText: "", + headers: { get: () => null }, + text: async () => "{}", + }; + }; + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeOut: () => {}, writeErr: () => {} }); + registerFresh(program.command("server"), { fetchImpl }); + + const origIn = process.stdin.isTTY; + const origErr = process.stderr.isTTY; + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + try { + await run(program, ["server", "roles", "delete", "staff"]); + } finally { + Object.defineProperty(process.stdin, "isTTY", { value: origIn, configurable: true }); + Object.defineProperty(process.stderr, "isTTY", { value: origErr, configurable: true }); + vi.doUnmock("@inquirer/prompts"); + vi.resetModules(); + } + expect(process.exitCode ?? 0).toBe(0); + expect(calls.some((c) => c.method === "DELETE")).toBe(true); + }); +}); + +describe("server webhook-secrets: extra branches", () => { + beforeEach(() => { + Object.assign(process.env, ENV); + }); + + it("create without --not-after → exit 2", async () => { + const { program, calls } = buildProgram(); + try { + await run(program, ["server", "webhook-secrets", "create", "--provider", "p1"]); + } catch { + // commander requiredOption throws through exitOverride — either path is exit 2 + } + expect(process.exitCode).toBe(2); + expect(calls).toHaveLength(0); + }); + + it("delete without -y, non-interactive → exit 2 after the lookup, no DELETE", async () => { + // The confirm gate now runs AFTER connect + lookup — the secret must exist + // for the gate to be reached; only the GET may hit the wire. + const { program, calls } = buildProgram(() => ({ + body: { secret: [{ secret: "s1", notBefore: "a", notAfter: "b" }] }, + })); + const origIn = process.stdin.isTTY; + const origErr = process.stderr.isTTY; + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + Object.defineProperty(process.stderr, "isTTY", { value: false, configurable: true }); + try { + await run(program, [ + "server", + "webhook-secrets", + "delete", + "--provider", + "p1", + "--secret", + "s1", + ]); + } finally { + Object.defineProperty(process.stdin, "isTTY", { value: origIn, configurable: true }); + Object.defineProperty(process.stderr, "isTTY", { value: origErr, configurable: true }); + } + expect(process.exitCode).toBe(2); + expect(calls.filter((c) => c.method === "DELETE")).toHaveLength(0); + expect(calls.filter((c) => c.method === "GET")).toHaveLength(1); + }); + + it("delete: --secret that doesn't exist → exit 1", async () => { + const { program } = buildProgram(() => ({ body: { secret: [] } })); + await run(program, [ + "server", + "webhook-secrets", + "delete", + "--provider", + "p1", + "--secret", + "nope", + "-y", + ]); + expect(process.exitCode).toBe(1); + expect(stderr()).toContain("No webhook secret matching"); + }); +}); + +describe("normalizeRoles shape handling (direct)", () => { + it("handles non-objects, empty buckets, and missing fields", async () => { + const { normalizeRoles } = await import("../../src/cli/groups/server/rbac.js"); + expect(normalizeRoles(undefined)).toEqual([]); + expect(normalizeRoles(null)).toEqual([]); + expect(normalizeRoles("nope")).toEqual([]); + expect(normalizeRoles({ roles: {} })).toEqual([]); + // bucket with an empty version array is dropped; missing fields default + expect( + normalizeRoles({ + roles: { gone: [], bare: [{ _id: { name: "bare", version: "v1" } }] }, + }), + ).toEqual([ + { + name: "bare", + version: "v1", + description: "", + collection_permissions: "none", + grant_remote_query: false, + }, + ]); + // paged entries with a malformed doc don't crash the row build + expect(normalizeRoles({ roles: [null, undefined] })).toEqual([ + { + name: undefined, + version: undefined, + description: "", + collection_permissions: "none", + grant_remote_query: false, + }, + { + name: undefined, + version: undefined, + description: "", + collection_permissions: "none", + grant_remote_query: false, + }, + ]); + }); +}); + +describe("regression: adversarial review", () => { + beforeEach(() => { + Object.assign(process.env, ENV); + }); + + it("batch: a 401 mid-batch is exit 3 (not flattened to 1) and stops the batch", async () => { + const file = path.join(workDir, "auth.sql"); + fs.writeFileSync(file, "SELECT 1;\nSELECT 2;\nSELECT 3;\n"); + let n = 0; + const { program, calls } = buildProgram(() => { + n++; + if (n === 1) return { body: { queryType: "select", items: [], mutatedDocumentIds: [] } }; + return { status: 401, body: { message: "expired key" } }; + }); + await run(program, ["server", "execute", "-f", file, "--continue-on-error"]); + expect(process.exitCode).toBe(3); + expect(stderr()).toContain("expired key"); + expect(calls.length).toBe(2); // stopped — auth won't heal mid-batch + expect(stderr()).toContain("1 ok, 1 failed (of 3)"); + }); + + it("batch: a 500 stays exit 1 and honors stop-on-first-error", async () => { + const file = path.join(workDir, "boom.sql"); + fs.writeFileSync(file, "SELECT 1;\nSELECT 2;\nSELECT 3;\n"); + const { program, calls } = buildProgram(() => ({ status: 500, body: { message: "boom" } })); + await run(program, ["server", "execute", "-f", file]); + expect(process.exitCode).toBe(1); + expect(calls).toHaveLength(1); + }); + + it("single-statement -f prints no batch summary", async () => { + const file = path.join(workDir, "one.sql"); + fs.writeFileSync(file, "SELECT 1;\n"); + const { program } = buildProgram(() => ({ + body: { queryType: "select", items: [{ a: 1 }], mutatedDocumentIds: [] }, + })); + await run(program, ["server", "execute", "-f", file]); + expect(process.exitCode ?? 0).toBe(0); + expect(stderr()).not.toContain("ok,"); + expect(stderr()).not.toContain("failed"); + }); + + it("remote-execute --args - on a TTY → exit 2 (no hang)", async () => { + const { program, calls } = buildProgram(); + await withStdinTTY(true, async () => { + await run(program, [ + "server", + "remote-execute", + "SYNC CONTEXT ( PEERS WHERE peerKeyString = 'x' ) SELECT 1", + "--args", + "-", + ]); + })(); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("stdin is a terminal"); + expect(calls).toHaveLength(0); + }); + + it("-f single-mutation + -o → exit 2 (same rule as the positional form)", async () => { + const file = path.join(workDir, "mut.sql"); + fs.writeFileSync(file, "DELETE FROM cars WHERE year < 1990;\n"); + const { program, calls } = buildProgram(); + await run(program, ["server", "execute", "-f", file, "-o", path.join(workDir, "x.json")]); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("row-producing"); + expect(calls).toHaveLength(0); + }); + + it("roles list --format yaml → exit 2 BEFORE any request", async () => { + const { program, calls } = buildProgram(); + await run(program, ["server", "roles", "list", "--format", "yaml"]); + expect(process.exitCode).toBe(2); + expect(calls).toHaveLength(0); + }); + + it("users list --max-rows abc → exit 2 BEFORE any request", async () => { + const { program, calls } = buildProgram(); + await run(program, ["server", "users", "list", "--max-rows", "abc"]); + expect(process.exitCode).toBe(2); + expect(calls).toHaveLength(0); + }); + + it("webhook-secrets list --format yaml → exit 2 BEFORE any request", async () => { + const { program, calls } = buildProgram(); + await run(program, [ + "server", + "webhook-secrets", + "list", + "--provider", + "p", + "--format", + "yaml", + ]); + expect(process.exitCode).toBe(2); + expect(calls).toHaveLength(0); + }); + + it("roles list surfaces the paged shape's cursor", async () => { + const { program } = buildProgram(() => ({ + body: { + roles: [{ _id: { name: "staff", version: "v1" } }], + hasMore: true, + cursor: "page-2", + }, + })); + await run(program, ["server", "roles", "list"]); + expect(process.exitCode ?? 0).toBe(0); + expect(stderr()).toContain("--cursor page-2"); + }); + + it("roles list --cursor is sent", async () => { + const { program, calls } = buildProgram(() => ({ body: { roles: [], hasMore: false } })); + await run(program, ["server", "roles", "list", "--cursor", "page-2"]); + expect(calls[0]!.url).toContain("cursor=page-2"); + }); + + it("--api-key value starting with '=' is not corrupted", async () => { + const { program, calls } = buildProgram(() => ({ + body: { queryType: "select", items: [], mutatedDocumentIds: [] }, + })); + await run(program, [ + "server", + "execute", + "SELECT 1", + "--url", + "mock.example/app", + "--api-key", + "=abc", + ]); + expect(process.exitCode ?? 0).toBe(0); + expect(calls[0]!.url).toContain("mock.example"); + expect(calls[0]!.authorization).toBe("Bearer =abc"); + }); + + it("doctor --api-version v9 → exit 2", async () => { + const { program, calls } = buildProgram(); + await run(program, ["server", "doctor", "--api-version", "v9"]); + expect(process.exitCode).toBe(2); + expect(calls).toHaveLength(0); + }); +}); + +describe("regression: round-3 agreed minors", () => { + beforeEach(() => { + Object.assign(process.env, ENV); + }); + + it("roles create --permissions with a non-blanket JSON string → exit 2 (no request)", async () => { + const { program, calls } = buildProgram(); + await run(program, ["server", "roles", "create", "staff", "--permissions", '"not-a-blanket"']); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("--permissions must be"); + expect(calls).toHaveLength(0); + }); + + it("roles create --permissions accepts a quoted blanket string", async () => { + const { program, calls } = buildProgram(); + await run(program, ["server", "roles", "create", "staff", "--permissions", '"read_only"']); + expect(process.exitCode ?? 0).toBe(0); + expect( + (calls[0]!.body as { doc: { collection_permissions: string } }).doc.collection_permissions, + ).toBe("read_only"); + }); + + it("execute --timeout abc → exit 2", async () => { + const { program, calls } = buildProgram(); + await run(program, ["server", "execute", "SELECT 1", "--timeout", "abc"]); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("--timeout must be an integer"); + expect(calls).toHaveLength(0); + }); + + it("remote-execute accepts SYNC CONTEXT behind a leading comment (after --)", async () => { + const { program, calls } = buildProgram(() => ({ body: { result: [] } })); + // A statement starting with "--" needs the -- separator so commander + // doesn't read it as an option (same rule as the dql group). + await run(program, [ + "server", + "remote-execute", + "--", + "-- probe\nSYNC CONTEXT ( PEERS WHERE peerKeyString = 'x' ) SELECT 1", + ]); + expect(process.exitCode ?? 0).toBe(0); + expect(calls).toHaveLength(1); + }); + + it("remote-execute --timeout abc → exit 2", async () => { + const { program, calls } = buildProgram(); + await run(program, [ + "server", + "remote-execute", + "SYNC CONTEXT ( PEERS WHERE peerKeyString = 'x' ) SELECT 1", + "--timeout", + "abc", + ]); + expect(process.exitCode).toBe(2); + expect(calls).toHaveLength(0); + }); + + it("users list 404 → exit 1 with an unsupported-endpoint hint", async () => { + const { program } = buildProgram(() => ({ status: 404, body: { message: "Not Found" } })); + await run(program, ["server", "users", "list"]); + expect(process.exitCode).toBe(1); + expect(stderr()).toContain("may not support the users endpoint"); + }); +}); diff --git a/tests/unit/cli-server.test.ts b/tests/unit/cli-server.test.ts new file mode 100644 index 0000000..19acbea --- /dev/null +++ b/tests/unit/cli-server.test.ts @@ -0,0 +1,632 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerServerGroup } from "../../src/cli/groups/server/index.js"; +import type { FetchLike } from "../../src/server/client.js"; + +/** + * Commander-level tests for `dittosh server`. A mock fetch is injected via the + * group's deps so nothing touches the network; the DITTOSH_SERVER_* env vars + * are scrubbed because tests/setup/env.ts loads the repo .env, which may hold + * REAL credentials — these tests must stay hermetic either way. + */ + +const SERVER_ENV_VARS = [ + "DITTOSH_SERVER_URL", + "DITTOSH_SERVER_API_KEY", + "DITTO_CLOUD_URL", + "DITTO_API_KEY", +]; + +let outSpy: ReturnType; +let errSpy: ReturnType; +let savedEnv: Record; +let savedCwd: string; +let workDir: string; + +beforeEach(async () => { + outSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + process.exitCode = undefined; + savedEnv = Object.fromEntries(SERVER_ENV_VARS.map((k) => [k, process.env[k]])); + for (const k of SERVER_ENV_VARS) delete process.env[k]; + // server config reads a .env in the CWD — the repo root has one with real + // credentials, so every test runs from an empty tmpdir to stay hermetic. + const { tmpDataDir } = await import("../helpers/credentials.js"); + workDir = tmpDataDir("dittosh-cli-server-"); + savedCwd = process.cwd(); + process.chdir(workDir); +}); + +afterEach(async () => { + outSpy.mockRestore(); + errSpy.mockRestore(); + process.exitCode = undefined; + process.chdir(savedCwd); + const { rmrf } = await import("../helpers/credentials.js"); + rmrf(workDir); + for (const k of SERVER_ENV_VARS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } +}); + +const stdout = () => outSpy.mock.calls.flat().join("\n"); +const stderr = () => errSpy.mock.calls.flat().join("\n"); + +interface CannedReply { + status?: number; + body?: unknown; + text?: string; +} + +function buildProgram(handler: (url: string, body?: unknown) => CannedReply = () => ({})) { + const calls: { url: string; method: string; body?: unknown }[] = []; + const fetchImpl: FetchLike = async (url, init) => { + const body = + typeof init.body === "string" + ? (() => { + try { + return JSON.parse(init.body as string); + } catch { + return init.body; + } + })() + : undefined; + calls.push({ url, method: init.method, body }); + const reply = handler(url, body); + return { + status: reply.status ?? 200, + statusText: "", + headers: { get: () => "application/json" }, + text: async () => reply.text ?? JSON.stringify(reply.body ?? {}), + }; + }; + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeOut: () => {}, writeErr: () => {} }); + registerServerGroup(program.command("server"), { fetchImpl }); + return { program, calls }; +} + +const ENV = { DITTOSH_SERVER_URL: "https://mock.example/app", DITTOSH_SERVER_API_KEY: "key" }; + +async function run(program: Command, args: string[]) { + try { + await program.parseAsync(["node", "dittosh", ...args]); + } catch (err) { + // commander throws CommanderError for help/usage with exitOverride + if (err instanceof Error && "exitCode" in err) { + process.exitCode = (err as { exitCode: number }).exitCode === 0 ? 0 : 2; + } else throw err; + } +} + +describe("server: config resolution through the CLI", () => { + it("missing config → exit 3 with guidance", async () => { + const { program } = buildProgram(); + await run(program, ["server", "execute", "SELECT 1"]); + expect(process.exitCode).toBe(3); + expect(stderr()).toContain("DITTOSH_SERVER_URL"); + expect(stdout()).toBe(""); + }); + + it("--url/--api-key flags are honored", async () => { + const { program, calls } = buildProgram(() => ({ + body: { queryType: "select", items: [{ n: 1 }], mutatedDocumentIds: [] }, + })); + await run(program, [ + "server", + "execute", + "SELECT 1", + "--url", + "flag.example/app", + "--api-key", + "flag-key", + ]); + expect(process.exitCode ?? 0).toBe(0); + expect(calls[0]!.url).toBe("https://flag.example/app/api/v5/store/execute"); + }); +}); + +describe("server execute: usage errors (exit 2, no network)", () => { + it("rejects positional + -e together", async () => { + const { program, calls } = buildProgram(); + await run(program, ["server", "execute", "SELECT 1", "-e", "SELECT 2"]); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("not both"); + expect(calls).toHaveLength(0); + }); + + it("rejects -f with a statement", async () => { + const { program, calls } = buildProgram(); + await run(program, ["server", "execute", "SELECT 1", "-f", "x.sql"]); + expect(process.exitCode).toBe(2); + expect(calls).toHaveLength(0); + }); + + it("rejects multiple statements in one argv", async () => { + const { program, calls } = buildProgram(); + await run(program, ["server", "execute", "SELECT 1; SELECT 2"]); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("multiple statements"); + expect(calls).toHaveLength(0); + }); + + it("rejects garbage --args JSON", async () => { + const { program } = buildProgram(); + await run(program, ["server", "execute", "SELECT 1", "--args", "{nope"]); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("--args must be a JSON object"); + }); + + it("rejects -o with a mutation", async () => { + const { program } = buildProgram(); + Object.assign(process.env, ENV); + await run(program, ["server", "execute", "DELETE FROM c", "-o", "x.json"]); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("row-producing"); + }); + + it("rejects a bad --api-version (exit 2 — a bad flag VALUE is usage)", async () => { + const { program, calls } = buildProgram(); + Object.assign(process.env, ENV); + await run(program, ["server", "execute", "SELECT 1", "--api-version", "v9"]); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("--api-version must be v4 or v5"); + expect(calls).toHaveLength(0); + }); + + it("rejects a bad --txn-id", async () => { + const { program } = buildProgram(); + Object.assign(process.env, ENV); + await run(program, ["server", "execute", "SELECT 1", "--txn-id", "abc"]); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("--txn-id must be an integer"); + }); + + it("no statement and TTY stdin → usage error", async () => { + const { program, calls } = buildProgram(); + const origIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + try { + await run(program, ["server", "execute"]); + } finally { + Object.defineProperty(process.stdin, "isTTY", { value: origIsTTY, configurable: true }); + } + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("No statement given"); + expect(calls).toHaveLength(0); + }); +}); + +describe("server execute: happy paths over mock HTTP", () => { + beforeEach(() => { + Object.assign(process.env, ENV); + }); + + it("runs a SELECT and prints JSON rows when piped", async () => { + const { program, calls } = buildProgram(() => ({ + body: { + transactionId: 42, + queryType: "select", + items: [{ _id: "c1", name: "Ada" }], + mutatedDocumentIds: [], + warnings: [], + }, + })); + await run(program, ["server", "execute", "SELECT * FROM customers LIMIT 1"]); + expect(process.exitCode ?? 0).toBe(0); + expect(JSON.parse(stdout())).toEqual([{ _id: "c1", name: "Ada" }]); + expect(calls[0]!.body).toEqual({ statement: "SELECT * FROM customers LIMIT 1" }); + }); + + it("binds -p/--args parameters", async () => { + const { program, calls } = buildProgram(() => ({ + body: { queryType: "select", items: [], mutatedDocumentIds: [] }, + })); + await run(program, [ + "server", + "execute", + "SELECT * FROM c WHERE x = :x AND y = :y", + "-p", + "x=1", + "--args", + '{"y":"two"}', + ]); + expect(calls[0]!.body).toEqual({ + statement: "SELECT * FROM c WHERE x = :x AND y = :y", + args: { y: "two", x: 1 }, + }); + }); + + it("strips a trailing semicolon", async () => { + const { program, calls } = buildProgram(() => ({ + body: { queryType: "select", items: [], mutatedDocumentIds: [] }, + })); + await run(program, ["server", "execute", "SELECT 1;"]); + expect(calls[0]!.body).toEqual({ statement: "SELECT 1" }); + }); + + it("DQL error in the response → exit 1, message on stderr", async () => { + const { program } = buildProgram(() => ({ + body: { + queryType: "unknown", + items: [], + mutatedDocumentIds: [], + error: { description: "syntax error" }, + warnings: [], + }, + })); + await run(program, ["server", "execute", "SELEC"]); + expect(process.exitCode).toBe(1); + expect(stderr()).toContain("syntax error"); + }); + + it("HTTP 401 → exit 3", async () => { + const { program } = buildProgram(() => ({ status: 401, body: { message: "unauthorized" } })); + await run(program, ["server", "execute", "SELECT 1"]); + expect(process.exitCode).toBe(3); + expect(stderr()).toContain("unauthorized"); + }); + + it("server HTTP 500 → exit 1", async () => { + const { program } = buildProgram(() => ({ status: 500, body: { message: "boom" } })); + await run(program, ["server", "execute", "SELECT 1"]); + expect(process.exitCode).toBe(1); + expect(stderr()).toContain("boom"); + }); + + it("runs a -f batch, one call per statement, with a summary", async () => { + const fs = await import("node:fs"); + const path = await import("node:path"); + const os = await import("node:os"); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dittosh-batch-")); + const file = path.join(dir, "b.sql"); + fs.writeFileSync(file, "SELECT 1;\nSELECT 2;\n"); + try { + const { program, calls } = buildProgram(() => ({ + body: { queryType: "select", items: [], mutatedDocumentIds: [] }, + })); + await run(program, ["server", "execute", "-f", file]); + expect(calls).toHaveLength(2); + expect(process.exitCode ?? 0).toBe(0); + expect(stderr()).toContain("2 ok, 0 failed (of 2)"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("batch stops on first failure without --continue-on-error", async () => { + const fs = await import("node:fs"); + const path = await import("node:path"); + const os = await import("node:os"); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dittosh-batch-")); + const file = path.join(dir, "b.sql"); + fs.writeFileSync(file, "SELECT 1;\nBROKEN;\nSELECT 3;\n"); + try { + const { program, calls } = buildProgram((_url, body) => { + const stmt = (body as { statement?: string })?.statement ?? ""; + if (stmt.startsWith("BROKEN")) { + return { + body: { + queryType: "unknown", + items: [], + mutatedDocumentIds: [], + error: { description: "bad" }, + warnings: [], + }, + }; + } + return { body: { queryType: "select", items: [], mutatedDocumentIds: [] } }; + }); + await run(program, ["server", "execute", "-f", file]); + expect(calls).toHaveLength(2); + expect(process.exitCode).toBe(1); + expect(stderr()).toContain("1 ok, 1 failed (of 3)"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("server remote-execute", () => { + beforeEach(() => { + Object.assign(process.env, ENV); + }); + + it("requires SYNC CONTEXT (usage error without it)", async () => { + const { program, calls } = buildProgram(); + await run(program, ["server", "remote-execute", "SELECT 1"]); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("SYNC CONTEXT"); + expect(calls).toHaveLength(0); + }); + + it("posts to /api/v5/sync/remote_execute", async () => { + const { program, calls } = buildProgram(() => ({ + body: { result: [{ peer: "pk", items: [{ a: 1 }] }] }, + })); + await run(program, [ + "server", + "remote-execute", + "SYNC CONTEXT ( PEERS WHERE peerKeyString = 'pk' ) SELECT 1", + ]); + expect(process.exitCode ?? 0).toBe(0); + expect(calls[0]!.url).toContain("/api/v5/sync/remote_execute"); + }); +}); + +describe("server roles", () => { + beforeEach(() => { + Object.assign(process.env, ENV); + }); + + it("roles list normalizes the bucketed shape", async () => { + const { program } = buildProgram(() => ({ + body: { + roles: { + staff: [ + { + _id: { name: "staff", version: "v1" }, + description: "old", + collection_permissions: "none", + }, + { + _id: { name: "staff", version: "v2" }, + description: "new", + collection_permissions: "read_only", + }, + ], + }, + }, + })); + await run(program, ["server", "roles", "list"]); + expect(process.exitCode ?? 0).toBe(0); + const rows = JSON.parse(stdout()); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ name: "staff", version: "v2", description: "new" }); + }); + + it("roles list normalizes the paged shape", async () => { + const { program } = buildProgram(() => ({ + body: { + roles: [{ _id: { name: "b", version: "v1" } }, { _id: { name: "a", version: "v1" } }], + hasMore: false, + }, + })); + await run(program, ["server", "roles", "list"]); + const rows = JSON.parse(stdout()); + expect(rows.map((r: { name: string }) => r.name)).toEqual(["a", "b"]); + }); + + it("roles create validates --permissions", async () => { + const { program, calls } = buildProgram(); + await run(program, ["server", "roles", "create", "staff", "--permissions", "[1,2]"]); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("--permissions"); + expect(calls).toHaveLength(0); + }); + + it("roles create posts the doc envelope", async () => { + const { program, calls } = buildProgram(); + await run(program, [ + "server", + "roles", + "create", + "staff", + "--description", + "Store staff", + "--permissions", + "read_only", + "--grant-remote-query", + ]); + expect(process.exitCode ?? 0).toBe(0); + expect(calls[0]!.body).toEqual({ + name: "staff", + doc: { + roles_version: "v1-preview", + description: "Store staff", + collection_permissions: "read_only", + grant_remote_query: true, + }, + }); + }); + + it("roles delete requires -y when non-interactive", async () => { + const { program, calls } = buildProgram(); + const origIn = process.stdin.isTTY; + const origErr = process.stderr.isTTY; + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + Object.defineProperty(process.stderr, "isTTY", { value: false, configurable: true }); + try { + await run(program, ["server", "roles", "delete", "staff"]); + } finally { + Object.defineProperty(process.stdin, "isTTY", { value: origIn, configurable: true }); + Object.defineProperty(process.stderr, "isTTY", { value: origErr, configurable: true }); + } + expect(process.exitCode).toBe(2); + expect(calls).toHaveLength(0); + }); + + it("roles delete -y issues the DELETE", async () => { + const { program, calls } = buildProgram(); + await run(program, ["server", "roles", "delete", "staff", "-y"]); + expect(process.exitCode ?? 0).toBe(0); + expect(calls[0]!.method).toBe("DELETE"); + expect(calls[0]!.url).toContain("/api/v4/auth/roles/staff"); + }); +}); + +describe("server users", () => { + beforeEach(() => { + Object.assign(process.env, ENV); + }); + + it("users list prints rows and a cursor hint", async () => { + const { program } = buildProgram(() => ({ + body: { + users: [{ userId: "auth0|1", roles: ["staff"], identityVersion: "v1" }], + hasMore: true, + cursor: "next-cursor", + }, + })); + await run(program, ["server", "users", "list"]); + expect(process.exitCode ?? 0).toBe(0); + expect(JSON.parse(stdout())).toEqual([ + { userId: "auth0|1", roles: ["staff"], identityVersion: "v1" }, + ]); + expect(stderr()).toContain("--cursor next-cursor"); + }); + + it("users set-roles patches and reports", async () => { + const { program, calls } = buildProgram(() => ({ + body: { identityVersion: "v2", transactionId: 12 }, + })); + await run(program, ["server", "users", "set-roles", "auth0|1", "staff", "ops"]); + expect(process.exitCode ?? 0).toBe(0); + expect(calls[0]!.method).toBe("PATCH"); + expect(calls[0]!.body).toEqual({ roles: ["staff", "ops"] }); + expect(JSON.parse(stdout())).toMatchObject({ userId: "auth0|1", transactionId: 12 }); + }); + + it("users delete -y issues the DELETE", async () => { + const { program, calls } = buildProgram(); + await run(program, ["server", "users", "delete", "auth0|1", "-y"]); + expect(process.exitCode ?? 0).toBe(0); + expect(calls[0]!.method).toBe("DELETE"); + expect(calls[0]!.url).toContain("/api/v4/auth/users/auth0%7C1"); + }); +}); + +describe("server webhook-secrets", () => { + beforeEach(() => { + Object.assign(process.env, ENV); + }); + + it("list prints secrets; empty object → empty table", async () => { + const { program } = buildProgram(() => ({ + body: { secret: [{ secret: "s1", notBefore: "a", notAfter: "b" }] }, + })); + await run(program, ["server", "webhook-secrets", "list", "--provider", "p1"]); + expect(process.exitCode ?? 0).toBe(0); + expect(JSON.parse(stdout())).toEqual([ + { secret: "s1", notBefore: "a", notAfter: "b", rotated: "" }, + ]); + }); + + it("create requires --provider and a parseable --not-after", async () => { + const { program, calls } = buildProgram(); + await run(program, [ + "server", + "webhook-secrets", + "create", + "--provider", + "p1", + "--not-after", + "not-a-date", + ]); + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("--not-after must be an ISO 8601 date"); + expect(calls).toHaveLength(0); + }); + + it("create posts and prints the new secret", async () => { + const { program, calls } = buildProgram(() => ({ + body: { secret: "newsecret", notBefore: "nb", notAfter: "2027-01-01T00:00:00Z" }, + })); + await run(program, [ + "server", + "webhook-secrets", + "create", + "--provider", + "p1", + "--not-after", + "2027-01-01T00:00:00Z", + ]); + expect(process.exitCode ?? 0).toBe(0); + expect(calls[0]!.method).toBe("POST"); + expect(JSON.parse(stdout()).secret).toBe("newsecret"); + }); + + it("rotate fails when --secret doesn't match an existing secret", async () => { + const { program } = buildProgram(() => ({ body: {} })); // list → [] + await run(program, [ + "server", + "webhook-secrets", + "rotate", + "--provider", + "p1", + "--secret", + "nope", + "--not-after", + "2027-01-01T00:00:00Z", + ]); + expect(process.exitCode).toBe(1); + expect(stderr()).toContain("No webhook secret matching"); + }); + + it("rotate patches with the looked-up secret object", async () => { + const existing = { secret: "s1", notBefore: "a", notAfter: "b" }; + const { program, calls } = buildProgram((_url, body) => { + if (body === undefined) return { body: { secret: [existing] } }; // GET list + return { body: { secret: "new", notBefore: "x", notAfter: "y" } }; // PATCH + }); + await run(program, [ + "server", + "webhook-secrets", + "rotate", + "--provider", + "p1", + "--secret", + "s1", + "--not-after", + "2027-01-01T00:00:00Z", + ]); + expect(process.exitCode ?? 0).toBe(0); + const patch = calls.find((c) => c.method === "PATCH"); + expect(patch).toBeDefined(); + expect((patch!.body as { rotate: unknown }).rotate).toEqual(existing); + }); + + it("delete -y removes the looked-up secret", async () => { + const existing = { secret: "s1", notBefore: "a", notAfter: "b" }; + const { program, calls } = buildProgram((_url, body) => { + if (body === undefined) return { body: { secret: [existing] } }; // GET list + return { body: {} }; // DELETE + }); + await run(program, [ + "server", + "webhook-secrets", + "delete", + "--provider", + "p1", + "--secret", + "s1", + "-y", + ]); + expect(process.exitCode ?? 0).toBe(0); + const del = calls.find((c) => c.method === "DELETE"); + expect((del!.body as { secret: string }).secret).toBe("s1"); + expect((del!.body as { provider: string }).provider).toBe("p1"); + }); +}); + +describe("server attachment", () => { + beforeEach(() => { + Object.assign(process.env, ENV); + }); + + it("get refuses binary on a TTY without -o", async () => { + const { program, calls } = buildProgram(); + const origTty = process.stdout.isTTY; + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + try { + await run(program, ["server", "attachment", "get", "att1"]); + } finally { + Object.defineProperty(process.stdout, "isTTY", { value: origTty, configurable: true }); + } + expect(process.exitCode).toBe(2); + expect(stderr()).toContain("Refusing to write binary"); + expect(calls).toHaveLength(0); + }); +}); diff --git a/tests/unit/server-client.test.ts b/tests/unit/server-client.test.ts new file mode 100644 index 0000000..151b097 --- /dev/null +++ b/tests/unit/server-client.test.ts @@ -0,0 +1,573 @@ +import { describe, expect, it } from "vitest"; +import { + type FetchLike, + PortalApiError, + PortalClient, + PortalConnectionError, + PortalTimeoutError, +} from "../../src/server/client.js"; + +interface CapturedCall { + url: string; + method: string; + headers: Record; + body?: string | FormData; +} + +/** Build a fetch stub that records calls and replies with the given status/body. */ +function mockFetch( + handler: (call: CapturedCall) => { status?: number; body?: unknown; text?: string } = () => ({}), +): { fetchImpl: FetchLike; calls: CapturedCall[] } { + const calls: CapturedCall[] = []; + const fetchImpl: FetchLike = async (url, init) => { + const call: CapturedCall = { url, method: init.method, headers: init.headers, body: init.body }; + calls.push(call); + const reply = handler(call); + const text = reply.text ?? (reply.body !== undefined ? JSON.stringify(reply.body) : ""); + return { + status: reply.status ?? 200, + statusText: "", + headers: { get: () => "application/json" }, + text: async () => text, + }; + }; + return { fetchImpl, calls }; +} + +function makeClient(fetchImpl: FetchLike) { + return new PortalClient({ + baseUrl: "https://abc.cloud.dittolive.app/app-id", + apiKey: "sekret-key", + fetchImpl, + }); +} + +describe("PortalClient request plumbing", () => { + it("sends the bearer token and JSON body to the right URL", async () => { + const { fetchImpl, calls } = mockFetch(() => ({ + body: { transactionId: 7, queryType: "select", items: [{ a: 1 }] }, + })); + const client = makeClient(fetchImpl); + const res = await client.execute("SELECT * FROM cars", undefined, {}); + expect(calls[0]!.url).toBe("https://abc.cloud.dittolive.app/app-id/api/v5/store/execute"); + expect(calls[0]!.method).toBe("POST"); + expect(calls[0]!.headers.Authorization).toBe("Bearer sekret-key"); + expect(calls[0]!.headers["Content-Type"]).toBe("application/json"); + expect(JSON.parse(calls[0]!.body as string)).toEqual({ statement: "SELECT * FROM cars" }); + expect(res.transactionId).toBe(7); + expect(res.items).toEqual([{ a: 1 }]); + }); + + it("includes args only when provided", async () => { + const { fetchImpl, calls } = mockFetch(() => ({ body: { queryType: "select", items: [] } })); + const client = makeClient(fetchImpl); + await client.execute("SELECT * FROM cars WHERE color = :c", { c: "blue" }); + expect(JSON.parse(calls[0]!.body as string)).toEqual({ + statement: "SELECT * FROM cars WHERE color = :c", + args: { c: "blue" }, + }); + }); + + it("honors the v4 API version", async () => { + const { fetchImpl, calls } = mockFetch(() => ({ body: { queryType: "select", items: [] } })); + const client = makeClient(fetchImpl); + await client.execute("SELECT 1", undefined, { version: "v4" }); + expect(calls[0]!.url).toContain("/api/v4/store/execute"); + }); + + it("sets X-DITTO-TXN-ID when asked", async () => { + const { fetchImpl, calls } = mockFetch(() => ({ body: { queryType: "select", items: [] } })); + const client = makeClient(fetchImpl); + await client.execute("SELECT 1", undefined, { txnId: 17 }); + expect(calls[0]!.headers["X-DITTO-TXN-ID"]).toBe("17"); + }); + + it("maps 400 to PortalApiError with exit 1 and the server's message", async () => { + const { fetchImpl } = mockFetch(() => ({ + status: 400, + body: { message: "Invalid query" }, + })); + const client = makeClient(fetchImpl); + const err = await client.execute("SELEC broken").catch((e: unknown) => e); + expect(err).toBeInstanceOf(PortalApiError); + expect((err as PortalApiError).status).toBe(400); + expect((err as PortalApiError).exitCode).toBe(1); + expect((err as PortalApiError).message).toContain("Invalid query"); + }); + + it("reads error.description when message is absent (execute error shape)", async () => { + const { fetchImpl } = mockFetch(() => ({ + status: 400, + body: { error: { description: "syntax error near SELEC" } }, + })); + const client = makeClient(fetchImpl); + const err = await client.execute("SELEC").catch((e: unknown) => e); + expect((err as PortalApiError).message).toContain("syntax error near SELEC"); + }); + + it("maps 401/403 to exit 3 (auth)", async () => { + for (const status of [401, 403]) { + const { fetchImpl } = mockFetch(() => ({ status, body: { message: "denied" } })); + const client = makeClient(fetchImpl); + const err = await client.execute("SELECT 1").catch((e: unknown) => e); + expect((err as PortalApiError).exitCode).toBe(3); + } + }); + + it("handles plain-text error bodies", async () => { + const { fetchImpl } = mockFetch(() => ({ status: 502, text: "Bad Gateway" })); + const client = makeClient(fetchImpl); + const err = await client.execute("SELECT 1").catch((e: unknown) => e); + expect((err as PortalApiError).message).toContain("HTTP 502"); + expect((err as PortalApiError).message).toContain("Bad Gateway"); + }); + + it("throws PortalConnectionError (exit 3) when the network fails", async () => { + const fetchImpl: FetchLike = async () => { + throw new Error("getaddrinfo ENOTFOUND nope.invalid"); + }; + const client = makeClient(fetchImpl); + const err = await client.execute("SELECT 1").catch((e: unknown) => e); + expect(err).toBeInstanceOf(PortalConnectionError); + expect((err as PortalConnectionError).exitCode).toBe(3); + expect((err as PortalConnectionError).message).toContain("Cannot reach"); + }); + + it("redacts the API key if it ever appears in an error message", async () => { + const fetchImpl: FetchLike = async () => { + throw new Error("auth failed for sekret-key"); + }; + const client = makeClient(fetchImpl); + const err = await client.execute("SELECT 1").catch((e: unknown) => e); + expect((err as Error).message).not.toContain("sekret-key"); + expect((err as Error).message).toContain("***"); + }); +}); + +describe("PortalClient endpoints", () => { + it("remoteExecute posts to /api/v5/sync/remote_execute", async () => { + const { fetchImpl, calls } = mockFetch(() => ({ body: { result: [] } })); + const client = makeClient(fetchImpl); + await client.remoteExecute("SYNC CONTEXT ( PEERS WHERE peerKeyString = 'x' ) SELECT 1", { + a: 1, + }); + expect(calls[0]!.url).toContain("/api/v5/sync/remote_execute"); + expect(JSON.parse(calls[0]!.body as string)).toEqual({ + statement: "SYNC CONTEXT ( PEERS WHERE peerKeyString = 'x' ) SELECT 1", + args: { a: 1 }, + }); + }); + + it("uploadAttachment posts multipart form data without a JSON content-type", async () => { + const { fetchImpl, calls } = mockFetch(() => ({ body: { id: "att-1", len: 3 } })); + const client = makeClient(fetchImpl); + const form = new FormData(); + form.append("file", new Blob([new Uint8Array([1, 2, 3])]), "x.bin"); + const res = await client.uploadAttachment(form); + expect(calls[0]!.url).toContain("/api/v4/attachments/upload"); + expect(calls[0]!.body).toBeInstanceOf(FormData); + expect(calls[0]!.headers["Content-Type"]).toBeUndefined(); + expect(res.id).toBe("att-1"); + }); + + it("getAttachment GETs /api/v4/attachments/{id} and returns bytes", async () => { + const { fetchImpl, calls } = mockFetch(() => ({ text: "PNGDATA" })); + const client = makeClient(fetchImpl); + const bytes = await client.getAttachment("att 1"); // space exercises encoding + expect(calls[0]!.url).toContain("/api/v4/attachments/att%201"); + expect(calls[0]!.method).toBe("GET"); + expect(bytes.toString("binary")).toBe("PNGDATA"); + }); + + it("getAttachment maps HTTP errors", async () => { + const { fetchImpl } = mockFetch(() => ({ status: 404, body: { message: "no attachment" } })); + const client = makeClient(fetchImpl); + const err = await client.getAttachment("x").catch((e: unknown) => e); + expect(err).toBeInstanceOf(PortalApiError); + expect((err as PortalApiError).message).toContain("no attachment"); + }); + + it("getAttachment surfaces connection failures", async () => { + const fetchImpl: FetchLike = async () => { + throw new Error("ECONNREFUSED"); + }; + const client = makeClient(fetchImpl); + const err = await client.getAttachment("x").catch((e: unknown) => e); + expect(err).toBeInstanceOf(PortalConnectionError); + }); +}); + +describe("PortalClient RBAC", () => { + it("listRoles GETs /api/v4/auth/roles", async () => { + const { fetchImpl, calls } = mockFetch(() => ({ body: { roles: {} } })); + const client = makeClient(fetchImpl); + await client.listRoles(); + expect(calls[0]!.url).toContain("/api/v4/auth/roles"); + expect(calls[0]!.method).toBe("GET"); + }); + + it("createRole posts the v1-preview doc envelope", async () => { + const { fetchImpl, calls } = mockFetch(); + const client = makeClient(fetchImpl); + await client.createRole({ + name: "staff", + description: "Store staff", + collectionPermissions: "read_only", + grantRemoteQuery: true, + }); + expect(JSON.parse(calls[0]!.body as string)).toEqual({ + name: "staff", + doc: { + roles_version: "v1-preview", + description: "Store staff", + collection_permissions: "read_only", + grant_remote_query: true, + }, + }); + }); + + it("createRole sends explicit defaults for unset fields (portal parity: POST replaces)", async () => { + const { fetchImpl, calls } = mockFetch(); + const client = makeClient(fetchImpl); + await client.createRole({ name: "staff" }); + expect(JSON.parse(calls[0]!.body as string)).toEqual({ + name: "staff", + doc: { + roles_version: "v1-preview", + description: "", + collection_permissions: "none", + grant_remote_query: false, + }, + }); + }); + + it("deleteRole DELETEs the named role, URL-encoded", async () => { + const { fetchImpl, calls } = mockFetch(); + const client = makeClient(fetchImpl); + await client.deleteRole("ops/team lead"); + expect(calls[0]!.method).toBe("DELETE"); + expect(calls[0]!.url).toContain("/api/v4/auth/roles/ops%2Fteam%20lead"); + }); + + it("listUsers passes query params", async () => { + const { fetchImpl, calls } = mockFetch(() => ({ + body: { users: [], hasMore: true, cursor: "c2" }, + })); + const client = makeClient(fetchImpl); + const res = await client.listUsers({ userId: "auth0|1", cursor: "c1", limit: 25 }); + const url = new URL(calls[0]!.url); + expect(url.searchParams.get("userId")).toBe("auth0|1"); + expect(url.searchParams.get("cursor")).toBe("c1"); + expect(url.searchParams.get("limit")).toBe("25"); + expect(res.hasMore).toBe(true); + }); + + it("listUsers omits empty params", async () => { + const { fetchImpl, calls } = mockFetch(() => ({ body: { users: [], hasMore: false } })); + const client = makeClient(fetchImpl); + await client.listUsers(); + expect(calls[0]!.url).not.toContain("?"); + }); + + it("setUserRoles PATCHes the roles array", async () => { + const { fetchImpl, calls } = mockFetch(() => ({ + body: { identityVersion: "v9", transactionId: 12 }, + })); + const client = makeClient(fetchImpl); + await client.setUserRoles("auth0|1234", ["staff", "ops"]); + expect(calls[0]!.method).toBe("PATCH"); + expect(calls[0]!.url).toContain("/api/v4/auth/users/auth0%7C1234"); + expect(JSON.parse(calls[0]!.body as string)).toEqual({ roles: ["staff", "ops"] }); + }); + + it("deleteUser DELETEs the user", async () => { + const { fetchImpl, calls } = mockFetch(); + const client = makeClient(fetchImpl); + await client.deleteUser("oidc|team lead#1"); + expect(calls[0]!.method).toBe("DELETE"); + expect(calls[0]!.url).toContain("/api/v4/auth/users/oidc%7Cteam%20lead%231"); + }); +}); + +describe("PortalClient webhook secrets", () => { + it("list normalizes {} to []", async () => { + const { fetchImpl } = mockFetch(() => ({ body: {} })); + expect(await makeClient(fetchImpl).listWebhookSecrets("p")).toEqual([]); + }); + + it("list normalizes {secret: [...]}", async () => { + const secret = { secret: "s1", notBefore: "a", notAfter: "b" }; + const { fetchImpl } = mockFetch(() => ({ body: { secret: [secret] } })); + expect(await makeClient(fetchImpl).listWebhookSecrets("p")).toEqual([secret]); + }); + + it("list normalizes a bare array", async () => { + const secret = { secret: "s1", notBefore: "a", notAfter: "b" }; + const { fetchImpl } = mockFetch(() => ({ body: [secret] })); + expect(await makeClient(fetchImpl).listWebhookSecrets("p")).toEqual([secret]); + }); + + it("list normalizes a single secret object", async () => { + const secret = { secret: "s1", notBefore: "a", notAfter: "b" }; + const { fetchImpl } = mockFetch(() => ({ body: secret })); + expect(await makeClient(fetchImpl).listWebhookSecrets("p")).toEqual([secret]); + }); + + it("list returns [] for garbage", async () => { + const { fetchImpl } = mockFetch(() => ({ body: { unexpected: 1 } })); + expect(await makeClient(fetchImpl).listWebhookSecrets("p")).toEqual([]); + }); + + it("create posts provider + validity window", async () => { + const { fetchImpl, calls } = mockFetch(() => ({ + body: { secret: "s", notBefore: "nb", notAfter: "2027-01-01T00:00:00Z" }, + })); + await makeClient(fetchImpl).createWebhookSecret("prov", "2027-01-01T00:00:00Z"); + const body = JSON.parse(calls[0]!.body as string); + expect(body.provider).toBe("prov"); + expect(body.notAfter).toBe("2027-01-01T00:00:00Z"); + expect(typeof body.notBefore).toBe("string"); + }); + + it("rotate patches with rotate + new windows", async () => { + const { fetchImpl, calls } = mockFetch(() => ({ + body: { secret: "new", notBefore: "x", notAfter: "y" }, + })); + const existing = { secret: "old", notBefore: "a", notAfter: "b" }; + await makeClient(fetchImpl).rotateWebhookSecret("prov", existing, "2027-06-01T00:00:00Z"); + expect(calls[0]!.method).toBe("PATCH"); + const body = JSON.parse(calls[0]!.body as string); + expect(body.provider).toBe("prov"); + expect(body.rotate).toEqual(existing); + expect(body.new.notAfter).toBe("2027-06-01T00:00:00Z"); + }); + + it("delete sends exactly the four fields the server wants (no extras like `rotated`)", async () => { + const { fetchImpl, calls } = mockFetch(); + const existing = { provider: "prov", secret: "s", notBefore: "a", notAfter: "b", rotated: "r" }; + await makeClient(fetchImpl).deleteWebhookSecret(existing); + expect(calls[0]!.method).toBe("DELETE"); + expect(JSON.parse(calls[0]!.body as string)).toEqual({ + provider: "prov", + secret: "s", + notBefore: "a", + notAfter: "b", + }); + }); +}); + +describe("regression: adversarial review fixes", () => { + it("mid-body timeout (text() rejects) maps to PortalTimeoutError exit 1 with an honest message", async () => { + const fetchImpl: FetchLike = async () => ({ + status: 200, + statusText: "", + headers: { get: () => null }, + text: async () => { + const err = new Error("The operation was aborted due to timeout"); + err.name = "TimeoutError"; + throw err; + }, + }); + const client = makeClient(fetchImpl); + const err = await client.execute("SELECT 1").catch((e: unknown) => e); + expect(err).toBeInstanceOf(PortalTimeoutError); + expect((err as PortalTimeoutError).exitCode).toBe(1); + // honest: NOT "cannot reach" — the server was reached and may still be running + expect((err as Error).message).toContain("may still be running"); + expect((err as Error).message).not.toContain("Cannot reach"); + }); + + it("a fetch-level AbortError (our AbortSignal) is also PortalTimeoutError", async () => { + const fetchImpl: FetchLike = async () => { + const err = new Error("This operation was aborted"); + err.name = "AbortError"; + throw err; + }; + const client = makeClient(fetchImpl); + const err = await client.execute("SELECT 1").catch((e: unknown) => e); + expect(err).toBeInstanceOf(PortalTimeoutError); + }); + + it("undici's 'fetch failed' surfaces the cause reason (ENOTFOUND etc.)", async () => { + const fetchImpl: FetchLike = async () => { + throw new TypeError("fetch failed", { + cause: new Error("getaddrinfo ENOTFOUND nope.invalid"), + }); + }; + const client = makeClient(fetchImpl); + const err = await client.execute("SELECT 1").catch((e: unknown) => e); + expect((err as Error).message).toContain("ENOTFOUND"); + }); + + it("getAttachment body-read failure maps to PortalConnectionError", async () => { + const fetchImpl: FetchLike = async () => ({ + status: 200, + statusText: "", + headers: { get: () => null }, + text: async () => "", + arrayBuffer: async () => { + throw new Error("socket hang up"); + }, + }); + const client = makeClient(fetchImpl); + const err = await client.getAttachment("a1").catch((e: unknown) => e); + expect(err).toBeInstanceOf(PortalConnectionError); + expect((err as Error).message).toContain("socket hang up"); + }); + + it("a 200 with an error.description body fails write-ish endpoints", async () => { + const { fetchImpl } = mockFetch(() => ({ + body: { error: { description: "quota exceeded" } }, + })); + const client = makeClient(fetchImpl); + const form = new FormData(); + form.append("file", new Blob([new Uint8Array([1])]), "x.bin"); + const err = await client.uploadAttachment(form).catch((e: unknown) => e); + expect(err).toBeInstanceOf(PortalApiError); + expect((err as PortalApiError).message).toContain("quota exceeded"); + expect((err as PortalApiError).exitCode).toBe(1); + + await expect(client.createRole({ name: "r" })).rejects.toBeInstanceOf(PortalApiError); + await expect(client.setUserRoles("u", [])).rejects.toBeInstanceOf(PortalApiError); + await expect(client.createWebhookSecret("p", "2027-01-01T00:00:00Z")).rejects.toBeInstanceOf( + PortalApiError, + ); + await expect( + client.rotateWebhookSecret("p", { secret: "s", notBefore: "a", notAfter: "b" }, "c"), + ).rejects.toBeInstanceOf(PortalApiError); + }); + + it("listRoles passes the cursor query param", async () => { + const { fetchImpl, calls } = mockFetch(() => ({ body: { roles: [], hasMore: false } })); + await makeClient(fetchImpl).listRoles({ cursor: "abc123" }); + expect(calls[0]!.url).toContain("cursor=abc123"); + }); + + it("listRoles without a cursor sends no query string", async () => { + const { fetchImpl, calls } = mockFetch(() => ({ body: { roles: {} } })); + await makeClient(fetchImpl).listRoles(); + expect(calls[0]!.url).not.toContain("?"); + }); +}); + +describe("regression: AggregateError cause (ECONNREFUSED on localhost)", () => { + it("digs into cause.errors[0] when the cause message is empty", async () => { + const fetchImpl: FetchLike = async () => { + throw new TypeError("fetch failed", { + cause: new AggregateError([new Error("connect ECONNREFUSED 127.0.0.1:8080")], ""), + }); + }; + const client = makeClient(fetchImpl); + const err = await client.execute("SELECT 1").catch((e: unknown) => e); + expect((err as Error).message).toContain("ECONNREFUSED"); + }); + + it("list/delete endpoints fail on a 200-with-error body", async () => { + const { fetchImpl } = mockFetch(() => ({ + body: { error: { description: "rbac backend down" } }, + })); + const client = makeClient(fetchImpl); + await expect(client.listRoles()).rejects.toBeInstanceOf(PortalApiError); + await expect(client.listUsers()).rejects.toBeInstanceOf(PortalApiError); + await expect(client.listWebhookSecrets("p")).rejects.toBeInstanceOf(PortalApiError); + await expect(client.deleteRole("r")).rejects.toBeInstanceOf(PortalApiError); + await expect(client.deleteUser("u")).rejects.toBeInstanceOf(PortalApiError); + await expect( + client.deleteWebhookSecret({ provider: "p", secret: "s", notBefore: "a", notAfter: "b" }), + ).rejects.toBeInstanceOf(PortalApiError); + }); +}); + +describe("regression: fail-closed 2xx shape validation (round 3 agreed major)", () => { + it("execute: a 200 with no DQL hallmark keys → PortalApiError, not silent []", async () => { + const { fetchImpl } = mockFetch(() => ({ body: { foo: 1 } })); + const err = await makeClient(fetchImpl) + .execute("SELECT 1") + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(PortalApiError); + expect((err as Error).message).toContain("Invalid response from Ditto Server"); + }); + + it("execute: a 200 HTML page (proxy/SSO interstitial) → PortalApiError", async () => { + const { fetchImpl } = mockFetch(() => ({ text: "login" })); + const err = await makeClient(fetchImpl) + .execute("SELECT * FROM customers") + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(PortalApiError); + expect((err as Error).message).toContain("proxy/SSO"); + }); + + it("execute: 200 with items of the wrong type → PortalApiError (not a raw TypeError)", async () => { + const { fetchImpl } = mockFetch(() => ({ body: { items: "not-an-array" } })); + const err = await makeClient(fetchImpl) + .execute("SELECT 1") + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(PortalApiError); + expect((err as Error).message).toContain("items is not an array"); + }); + + it("execute: error-only envelopes are valid (DQL error body)", async () => { + const { fetchImpl } = mockFetch(() => ({ + body: { + queryType: "unknown", + items: [], + mutatedDocumentIds: [], + error: { description: "x" }, + warnings: [], + }, + })); + const res = await makeClient(fetchImpl).execute("SELEC"); + expect(res.error?.description).toBe("x"); + }); + + it("remoteExecute: requires a result array or an error", async () => { + const { fetchImpl } = mockFetch(() => ({ body: { nope: true } })); + const err = await makeClient(fetchImpl) + .remoteExecute("SYNC CONTEXT ( PEERS WHERE peerKeyString = 'x' ) SELECT 1") + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(PortalApiError); + + const ok = mockFetch(() => ({ body: { result: [] } })); + await expect( + makeClient(ok.fetchImpl).remoteExecute("SYNC CONTEXT (…) SELECT 1"), + ).resolves.toEqual({ result: [] }); + + const errBody = mockFetch(() => ({ body: { error: { description: "no peers" } } })); + await expect( + makeClient(errBody.fetchImpl).remoteExecute("SYNC CONTEXT (…) SELECT 1"), + ).resolves.toEqual({ error: { description: "no peers" } }); + }); + + it("listRoles: a roles-less body → PortalApiError (portal parity)", async () => { + const { fetchImpl } = mockFetch(() => ({ body: { unexpected: 1 } })); + const err = await makeClient(fetchImpl) + .listRoles() + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(PortalApiError); + expect((err as Error).message).toContain("roles response"); + }); + + it("listUsers: a malformed envelope → PortalApiError (portal parity)", async () => { + const { fetchImpl } = mockFetch(() => ({ body: { users: [] } })); // no hasMore + const err = await makeClient(fetchImpl) + .listUsers() + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(PortalApiError); + expect((err as Error).message).toContain("users response"); + }); + + it("uploadAttachment: a 200 without an id → PortalApiError (no false { } success)", async () => { + const { fetchImpl } = mockFetch(() => ({ body: { unexpected: true } })); + const form = new FormData(); + form.append("file", new Blob([new Uint8Array([1])]), "x.bin"); + const err = await makeClient(fetchImpl) + .uploadAttachment(form) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(PortalApiError); + }); + + it("listWebhookSecrets: 404 → [] (portal parity)", async () => { + const { fetchImpl } = mockFetch(() => ({ status: 404, body: { message: "not found" } })); + await expect(makeClient(fetchImpl).listWebhookSecrets("p")).resolves.toEqual([]); + }); +}); diff --git a/tests/unit/server-config.test.ts b/tests/unit/server-config.test.ts new file mode 100644 index 0000000..3672eb3 --- /dev/null +++ b/tests/unit/server-config.test.ts @@ -0,0 +1,306 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + normalizeBaseUrl, + readDotEnv, + resolveApiVersion, + resolveServerConfig, + ServerConfigError, +} from "../../src/server/config.js"; +import { rmrf, tmpDataDir } from "../helpers/credentials.js"; + +let dir: string; + +beforeEach(() => { + dir = tmpDataDir("dittosh-server-config-"); +}); + +afterEach(() => { + rmrf(dir); +}); + +function writeEnv(contents: string, cwd: string = dir) { + fs.writeFileSync(path.join(cwd, ".env"), contents); +} + +describe("readDotEnv", () => { + it("returns {} when no .env exists", () => { + expect(readDotEnv(dir)).toEqual({}); + }); + + it("parses KEY=value pairs", () => { + writeEnv("DITTOSH_SERVER_URL=https://x.example/app\nDITTOSH_SERVER_API_KEY=k3y\n"); + expect(readDotEnv(dir)).toEqual({ + DITTOSH_SERVER_URL: "https://x.example/app", + DITTOSH_SERVER_API_KEY: "k3y", + }); + }); + + it("returns {} on malformed content instead of throwing", () => { + writeEnv("NO EQUALS HERE BUT UNPARSEABLE \u0000 BYTES"); + expect(readDotEnv(dir)).toEqual({}); + }); + + it("returns {} when .env is a directory", () => { + fs.mkdirSync(path.join(dir, ".env")); + expect(readDotEnv(dir)).toEqual({}); + }); +}); + +describe("normalizeBaseUrl", () => { + it("adds https:// to scheme-less endpoints", () => { + expect(normalizeBaseUrl("abc.cloud.dittolive.app/app-id")).toBe( + "https://abc.cloud.dittolive.app/app-id", + ); + }); + + it("keeps an explicit scheme and strips trailing slashes", () => { + expect(normalizeBaseUrl("http://localhost:8080/app-id/")).toBe("http://localhost:8080/app-id"); + expect(normalizeBaseUrl("https://x.example/app///")).toBe("https://x.example/app"); + }); + + it("rejects garbage URLs", () => { + expect(() => normalizeBaseUrl("ht tp://not a url")).toThrow(ServerConfigError); + }); + + it("rejects non-http(s) schemes", () => { + expect(() => normalizeBaseUrl("ftp://x.example/app")).toThrow(/only http\(s\)/); + }); +}); + +describe("resolveApiVersion", () => { + it("defaults to v5", () => { + expect(resolveApiVersion(undefined)).toBe("v5"); + }); + it("accepts v4", () => { + expect(resolveApiVersion("v4")).toBe("v4"); + }); + it("rejects anything else", () => { + expect(() => resolveApiVersion("v3")).toThrow(/--api-version must be v4 or v5/); + }); +}); + +describe("resolveServerConfig", () => { + it("fails with exit 3 and guidance when the URL is missing", () => { + try { + resolveServerConfig({}, {}, dir); + expect.unreachable(); + } catch (err) { + expect(err).toBeInstanceOf(ServerConfigError); + expect((err as ServerConfigError).exitCode).toBe(3); + expect((err as Error).message).toContain("DITTOSH_SERVER_URL"); + } + }); + + it("fails with exit 3 and guidance when the API key is missing", () => { + try { + resolveServerConfig({}, { DITTOSH_SERVER_URL: "x.example/app" }, dir); + expect.unreachable(); + } catch (err) { + expect((err as Error).message).toContain("DITTOSH_SERVER_API_KEY"); + expect((err as ServerConfigError).exitCode).toBe(3); + } + }); + + it("resolves from the shell environment", () => { + const cfg = resolveServerConfig( + {}, + { DITTOSH_SERVER_URL: "x.example/app", DITTOSH_SERVER_API_KEY: "key" }, + dir, + ); + expect(cfg).toEqual({ + baseUrl: "https://x.example/app", + apiKey: "key", + apiVersion: "v5", + sources: { url: "env", apiKey: "env" }, + }); + }); + + it("resolves from a cwd .env when the shell has nothing", () => { + writeEnv("DITTOSH_SERVER_URL=dotenv.example/app\nDITTOSH_SERVER_API_KEY=dotenv-key"); + const cfg = resolveServerConfig({}, {}, dir); + expect(cfg.baseUrl).toBe("https://dotenv.example/app"); + expect(cfg.apiKey).toBe("dotenv-key"); + expect(cfg.sources).toEqual({ url: "dotenv", apiKey: "dotenv" }); + }); + + it("shell env wins over .env", () => { + writeEnv("DITTOSH_SERVER_URL=dotenv.example/app\nDITTOSH_SERVER_API_KEY=dotenv-key"); + const cfg = resolveServerConfig( + {}, + { DITTOSH_SERVER_URL: "shell.example/app", DITTOSH_SERVER_API_KEY: "shell-key" }, + dir, + ); + expect(cfg.baseUrl).toBe("https://shell.example/app"); + expect(cfg.apiKey).toBe("shell-key"); + }); + + it("flags win over everything", () => { + writeEnv("DITTOSH_SERVER_URL=dotenv.example/app\nDITTOSH_SERVER_API_KEY=dotenv-key"); + const cfg = resolveServerConfig( + { url: "flag.example/app", apiKey: "flag-key", apiVersion: "v4" }, + { DITTOSH_SERVER_URL: "shell.example/app", DITTOSH_SERVER_API_KEY: "shell-key" }, + dir, + ); + expect(cfg).toEqual({ + baseUrl: "https://flag.example/app", + apiKey: "flag-key", + apiVersion: "v4", + sources: { url: "flag", apiKey: "flag" }, + }); + }); + + it("supports the DITTO_CLOUD_URL / DITTO_API_KEY aliases", () => { + const cfg = resolveServerConfig( + {}, + { DITTO_CLOUD_URL: "alias.example/app", DITTO_API_KEY: "alias-key" }, + dir, + ); + expect(cfg.baseUrl).toBe("https://alias.example/app"); + expect(cfg.apiKey).toBe("alias-key"); + }); + + it("primary names win over aliases", () => { + const cfg = resolveServerConfig( + {}, + { + DITTOSH_SERVER_URL: "primary.example/app", + DITTO_CLOUD_URL: "alias.example/app", + DITTOSH_SERVER_API_KEY: "primary-key", + DITTO_API_KEY: "alias-key", + }, + dir, + ); + expect(cfg.baseUrl).toBe("https://primary.example/app"); + expect(cfg.apiKey).toBe("primary-key"); + }); + + it("ignores blank/whitespace values and falls through", () => { + writeEnv("DITTOSH_SERVER_URL=dotenv.example/app\nDITTOSH_SERVER_API_KEY=dotenv-key"); + const cfg = resolveServerConfig( + { url: " " }, + { DITTOSH_SERVER_URL: " ", DITTOSH_SERVER_API_KEY: "dotenv-key-shell" }, + dir, + ); + expect(cfg.baseUrl).toBe("https://dotenv.example/app"); + expect(cfg.apiKey).toBe("dotenv-key-shell"); + }); + + it("rejects a bad --api-version before any network use", () => { + expect(() => + resolveServerConfig({ url: "x.example/app", apiKey: "k", apiVersion: "v9" }, {}, dir), + ).toThrow(/--api-version must be v4 or v5/); + }); +}); + +describe("regression: URL hardening", () => { + it("rejects URLs with embedded credentials (would leak into printed errors)", () => { + expect(() => normalizeBaseUrl("https://user:pass@host.example/app")).toThrow( + /credentials must not be embedded/, + ); + }); + + it("rejects URLs with query strings (paths would land inside the query)", () => { + expect(() => normalizeBaseUrl("https://host.example/app?foo=bar")).toThrow(/query string/); + }); + + it("rejects URLs with fragments", () => { + expect(() => normalizeBaseUrl("https://host.example/app#frag")).toThrow( + /query string|fragment/, + ); + }); + + it("still accepts scheme-less host:port with a path", () => { + expect(normalizeBaseUrl("localhost:8080/app-id")).toBe("https://localhost:8080/app-id"); + }); +}); + +describe("regression: bad --api-version is a usage error", () => { + it("throws ApiVersionError with exitCode 2", () => { + try { + resolveApiVersion("v9"); + expect.unreachable(); + } catch (err) { + expect((err as Error).name).toBe("ApiVersionError"); + expect((err as { exitCode: number }).exitCode).toBe(2); + } + }); +}); + +describe("regression: .env present but unhelpful is called out", () => { + it("missing-config error notes the .env file exists", () => { + writeEnv("UNRELATED=value\n"); + try { + resolveServerConfig({}, {}, dir); + expect.unreachable(); + } catch (err) { + expect((err as Error).message).toContain(".env exists in the current directory"); + } + }); + + it("no .env → no note", () => { + try { + resolveServerConfig({}, {}, dir); + expect.unreachable(); + } catch (err) { + expect((err as Error).message).not.toContain(".env exists"); + } + }); +}); + +describe("regression: bare ? / # are stripped, not just rejected", () => { + it("--url https://host/app? normalizes cleanly", () => { + expect(normalizeBaseUrl("https://host.example/app?")).toBe("https://host.example/app"); + }); + it("--url https://host/app# normalizes cleanly", () => { + expect(normalizeBaseUrl("https://host.example/app#")).toBe("https://host.example/app"); + }); +}); + +describe("regression: cleartext http rejected off-loopback (round 3 agreed major)", () => { + it("rejects http:// for a remote host", () => { + expect(() => normalizeBaseUrl("http://6b1b5999.cloud.dittolive.app/app")).toThrow( + /cleartext http/, + ); + }); + + it("allows http:// for loopback (local dev, e2e mocks)", () => { + expect(normalizeBaseUrl("http://localhost:8080/app")).toBe("http://localhost:8080/app"); + expect(normalizeBaseUrl("http://127.0.0.1:8080/app")).toBe("http://127.0.0.1:8080/app"); + expect(normalizeBaseUrl("http://[::1]:8080/app")).toBe("http://[::1]:8080/app"); + }); +}); + +describe("regression: .env BOM and per-key hints (round 3 agreed minors)", () => { + it("a UTF-8 BOM doesn't swallow the first variable", () => { + fs.writeFileSync( + path.join(dir, ".env"), + "DITTOSH_SERVER_URL=bom.example/app\nDITTOSH_SERVER_API_KEY=bom-key\n", + ); + const cfg = resolveServerConfig({}, {}, dir); + expect(cfg.baseUrl).toBe("https://bom.example/app"); + expect(cfg.apiKey).toBe("bom-key"); + }); + + it("the .env hint names only the MISSING key", () => { + writeEnv("DITTOSH_SERVER_URL=dotenv.example/app\n"); + try { + resolveServerConfig({}, {}, dir); + expect.unreachable(); + } catch (err) { + expect((err as Error).message).toContain("no usable DITTOSH_SERVER_API_KEY"); + expect((err as Error).message).not.toContain("no usable DITTOSH_SERVER_URL"); + } + }); + + it("a bad --api-version beats missing config (usage errors first)", () => { + try { + resolveServerConfig({ apiVersion: "v9" }, {}, dir); + expect.unreachable(); + } catch (err) { + expect((err as Error).name).toBe("ApiVersionError"); + expect((err as { exitCode: number }).exitCode).toBe(2); + } + }); +}); diff --git a/tests/unit/server-doctor.test.ts b/tests/unit/server-doctor.test.ts new file mode 100644 index 0000000..561ea6e --- /dev/null +++ b/tests/unit/server-doctor.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from "vitest"; +import { collectServerDoctorChecks } from "../../src/cli/groups/server/doctor.js"; +import type { FetchLike } from "../../src/server/client.js"; +import { rmrf, tmpDataDir } from "../helpers/credentials.js"; + +/** Doctor checks with an injected fetch — no network, no env leakage. */ + +function fetchReply(status: number, body: unknown): FetchLike { + return async () => ({ + status, + statusText: "", + headers: { get: () => "application/json" }, + text: async () => JSON.stringify(body), + }); +} + +const FETCH_FAILS: FetchLike = async () => { + throw new Error("ENOTFOUND"); +}; + +const EXECUTE_OK = fetchReply(200, { + transactionId: 123, + queryType: "select", + items: [1], + mutatedDocumentIds: [], +}); + +const ENV = { DITTOSH_SERVER_URL: "x.example/app", DITTOSH_SERVER_API_KEY: "key" }; + +function emptyCwd(): string { + return tmpDataDir("dittosh-doctor-"); +} + +describe("server doctor", () => { + it("all green: config (with sources), connection, auth", async () => { + const cwd = emptyCwd(); + try { + const checks = await collectServerDoctorChecks({ + env: ENV, + cwd, + fetchImpl: EXECUTE_OK, + }); + expect(checks.map((c) => c.label)).toEqual(["config", "connection", "auth"]); + expect(checks.every((c) => c.ok)).toBe(true); + expect(checks[0]!.detail).toContain("https://x.example/app"); + expect(checks[0]!.detail).toContain("shell env"); + expect(checks[2]!.detail).toContain("transactionId 123"); + // The key value must never appear in any check detail. + expect(JSON.stringify(checks)).not.toContain('"key"'); + } finally { + rmrf(cwd); + } + }); + + it("flags are reported as the source", async () => { + const cwd = emptyCwd(); + try { + const checks = await collectServerDoctorChecks({ + url: "flag.example/app", + apiKey: "flag-key", + env: {}, + cwd, + fetchImpl: EXECUTE_OK, + }); + expect(checks[0]!.detail).toContain("(flag)"); + } finally { + rmrf(cwd); + } + }); + + it("missing config → config fails, connection/auth skipped", async () => { + const cwd = emptyCwd(); + try { + const checks = await collectServerDoctorChecks({ env: {}, cwd, fetchImpl: EXECUTE_OK }); + expect(checks[0]).toMatchObject({ ok: false, label: "config" }); + expect(checks[0]!.detail).toContain("DITTOSH_SERVER_URL"); + expect(checks[1]!.detail).toContain("skipped"); + expect(checks[2]!.detail).toContain("skipped"); + } finally { + rmrf(cwd); + } + }); + + it("unreachable server → connection fails, auth skipped", async () => { + const cwd = emptyCwd(); + try { + const checks = await collectServerDoctorChecks({ + env: ENV, + cwd, + fetchImpl: FETCH_FAILS, + }); + expect(checks[0]!.ok).toBe(true); + expect(checks[1]).toMatchObject({ ok: false, label: "connection" }); + expect(checks[1]!.detail).toContain("Cannot reach"); + expect(checks[2]!.detail).toContain("skipped"); + } finally { + rmrf(cwd); + } + }); + + it("401 → connection ok, auth fails with portal guidance", async () => { + const cwd = emptyCwd(); + try { + const checks = await collectServerDoctorChecks({ + env: ENV, + cwd, + fetchImpl: fetchReply(401, { message: "invalid API key" }), + }); + expect(checks[1]!.ok).toBe(true); + expect(checks[2]).toMatchObject({ ok: false, label: "auth" }); + expect(checks[2]!.detail).toContain("invalid API key"); + expect(checks[2]!.detail).toContain("Auth"); + } finally { + rmrf(cwd); + } + }); + + it("500 → connection ok, auth reports the HTTP status", async () => { + const cwd = emptyCwd(); + try { + const checks = await collectServerDoctorChecks({ + env: ENV, + cwd, + fetchImpl: fetchReply(500, { message: "boom" }), + }); + expect(checks[1]!.ok).toBe(true); + expect(checks[2]!.ok).toBe(false); + expect(checks[2]!.detail).toContain("HTTP 500"); + } finally { + rmrf(cwd); + } + }); + + it("200 with a DQL error body → key was accepted; note in the detail", async () => { + const cwd = emptyCwd(); + try { + const checks = await collectServerDoctorChecks({ + env: ENV, + cwd, + fetchImpl: fetchReply(200, { queryType: "unknown", error: { description: "odd" } }), + }); + expect(checks[2]!.ok).toBe(true); + expect(checks[2]!.detail).toContain("odd"); + } finally { + rmrf(cwd); + } + }); + + it("400 → key was accepted (auth happens before query parsing)", async () => { + const cwd = emptyCwd(); + try { + const checks = await collectServerDoctorChecks({ + env: ENV, + cwd, + fetchImpl: fetchReply(400, { message: "Invalid query" }), + }); + expect(checks[2]!.ok).toBe(true); + expect(checks[2]!.detail).toContain("API key accepted"); + } finally { + rmrf(cwd); + } + }); + + it("honors --api-version for the probe", async () => { + const cwd = emptyCwd(); + let seenUrl = ""; + const fetchImpl: FetchLike = async (url) => { + seenUrl = url; + return { + status: 200, + statusText: "", + headers: { get: () => null }, + text: async () => JSON.stringify({ transactionId: 1, queryType: "select", items: [] }), + }; + }; + try { + await collectServerDoctorChecks({ env: ENV, cwd, apiVersion: "v4", fetchImpl }); + expect(seenUrl).toContain("/api/v4/store/execute"); + } finally { + rmrf(cwd); + } + }); +}); + +describe("regression: fail-closed probe (round 3 agreed major)", () => { + it("a 200 non-DQL body (proxy/SSO page) → auth NOT green", async () => { + const cwd = emptyCwd(); + try { + const fetchImpl: FetchLike = async () => ({ + status: 200, + statusText: "", + headers: { get: () => "text/html" }, + text: async () => "login", + }); + const checks = await collectServerDoctorChecks({ env: ENV, cwd, fetchImpl }); + expect(checks[1]!.ok).toBe(true); // connection — something answered + expect(checks[2]!.ok).toBe(false); // auth must NOT claim success + expect(checks[2]!.detail).toContain("Invalid response from Ditto Server"); + } finally { + rmrf(cwd); + } + }); + + it("a probe timeout → connection ✗, auth skipped", async () => { + const cwd = emptyCwd(); + try { + const fetchImpl: FetchLike = async () => { + const err = new Error("The operation was aborted due to timeout"); + err.name = "TimeoutError"; + throw err; + }; + const checks = await collectServerDoctorChecks({ env: ENV, cwd, fetchImpl }); + expect(checks[1]).toMatchObject({ ok: false, label: "connection" }); + expect(checks[1]!.detail).toContain("may still be running"); + expect(checks[2]!.detail).toContain("skipped"); + } finally { + rmrf(cwd); + } + }); +}); diff --git a/tests/unit/server-run.test.ts b/tests/unit/server-run.test.ts new file mode 100644 index 0000000..c37d9f9 --- /dev/null +++ b/tests/unit/server-run.test.ts @@ -0,0 +1,417 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + type ExecuteResponse, + type FetchLike, + PortalApiError, + PortalClient, + PortalConnectionError, + type RemoteExecuteResponse, +} from "../../src/server/client.js"; +import { + normalizeItems, + printWarnings, + runServerExecute, + runServerRemoteExecute, + type ServerRunOptions, +} from "../../src/server/run.js"; +import { rmrf, tmpDataDir } from "../helpers/credentials.js"; + +let outSpy: ReturnType; +let errSpy: ReturnType; +let dir: string; + +beforeEach(() => { + outSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + dir = tmpDataDir("dittosh-server-run-"); +}); + +afterEach(() => { + outSpy.mockRestore(); + errSpy.mockRestore(); + rmrf(dir); + delete process.env.DITTOSH_JSON_OUT; +}); + +const stdout = () => outSpy.mock.calls.flat().join("\n"); +const stderr = () => errSpy.mock.calls.flat().join("\n"); + +/** A PortalClient whose execute/remoteExecute return canned responses (no HTTP). */ +function cannedClient(handlers: { + execute?: ExecuteResponse | (() => ExecuteResponse); + remoteExecute?: RemoteExecuteResponse; +}): PortalClient { + const fetchImpl: FetchLike = async (url) => { + const isRemote = url.includes("remote_execute"); + const body = isRemote + ? (handlers.remoteExecute ?? { result: [] }) + : typeof handlers.execute === "function" + ? handlers.execute() + : (handlers.execute ?? {}); + return { + status: 200, + statusText: "", + headers: { get: () => "application/json" }, + text: async () => JSON.stringify(body), + }; + }; + return new PortalClient({ baseUrl: "https://x.example/app", apiKey: "k", fetchImpl }); +} + +function opts(over: Partial = {}): ServerRunOptions { + return { + maxRows: 10_000, + maxRowsExplicit: false, + stdoutIsTTY: false, + page: () => false, // never spawn a pager in tests + ...over, + }; +} + +describe("normalizeItems", () => { + it("passes objects through, wraps scalars, and null-safes", () => { + expect(normalizeItems([{ a: 1 }, 5, "s", null, undefined, [1, 2]])).toEqual([ + { a: 1 }, + { value: 5 }, + { value: "s" }, + {}, + {}, + { value: [1, 2] }, + ]); + }); +}); + +describe("printWarnings", () => { + it("prints each warning and the overflow count to stderr", () => { + printWarnings({ + warnings: [{ description: "w1" }, { description: "w2" }], + totalWarningsCount: 5, + }); + const err = stderr(); + expect(err).toContain("warning: w1"); + expect(err).toContain("warning: w2"); + expect(err).toContain("3 more warning(s)"); + }); + + it("stays quiet with no warnings", () => { + printWarnings({}); + printWarnings({ warnings: [] }); + expect(stderr()).toBe(""); + }); +}); + +describe("runServerExecute", () => { + it("renders SELECT items as JSON when piped", async () => { + const client = cannedClient({ + execute: { + transactionId: 42, + queryType: "select", + items: [{ _id: "c1", name: "Ada" }], + mutatedDocumentIds: [], + }, + }); + const r = await runServerExecute(client, "SELECT * FROM customers", opts()); + expect(r.ok).toBe(true); + expect(JSON.parse(stdout())).toEqual([{ _id: "c1", name: "Ada" }]); + expect(stderr()).toContain("transactionId 42"); + }); + + it("renders a table when asked", async () => { + const client = cannedClient({ + execute: { queryType: "select", items: [{ n: 3 }], mutatedDocumentIds: [] }, + }); + const r = await runServerExecute(client, "SELECT count(*) AS n FROM customers", { + ...opts(), + format: "table", + stdoutIsTTY: true, + }); + expect(r.ok).toBe(true); + expect(stdout()).toContain("│ n "); + }); + + it("acknowledges mutations on stdout (TTY) with details on stderr", async () => { + const client = cannedClient({ + execute: { + transactionId: 77, + queryType: "insert", + items: [], + mutatedDocumentIds: ["c1", "c2"], + }, + }); + const r = await runServerExecute(client, "INSERT INTO customers DOCUMENTS (:d)", { + ...opts(), + stdoutIsTTY: true, + }); + expect(r.ok).toBe(true); + expect(stdout()).toContain("OK"); + expect(stderr()).toContain("transactionId 77"); + expect(stderr()).toContain("2 documents mutated"); + }); + + it("mutation OK goes to stderr when piped", async () => { + const client = cannedClient({ + execute: { queryType: "delete", items: [], mutatedDocumentIds: [] }, + }); + const r = await runServerExecute(client, "DELETE FROM customers", opts()); + expect(r.ok).toBe(true); + expect(stdout()).toBe(""); + expect(stderr()).toContain("OK"); + }); + + it("reports a DQL error from the response body as ok:false", async () => { + const client = cannedClient({ + execute: { + queryType: "unknown", + items: [], + mutatedDocumentIds: [], + error: { description: "syntax error near SELEC" }, + }, + }); + const r = await runServerExecute(client, "SELEC broken", opts()); + expect(r.ok).toBe(false); + expect(stderr()).toContain("syntax error near SELEC"); + expect(stderr()).toContain("in: SELEC broken"); + expect(stdout()).toBe(""); + }); + + it("prints warnings from the response", async () => { + const client = cannedClient({ + execute: { + queryType: "select", + items: [{ a: 1 }], + mutatedDocumentIds: [], + warnings: [{ description: "index missing" }], + totalWarningsCount: 1, + }, + }); + const r = await runServerExecute(client, "SELECT * FROM c", opts()); + expect(r.ok).toBe(true); + expect(stderr()).toContain("warning: index missing"); + }); + + it("caps rows with --max-rows and notes truncation", async () => { + const client = cannedClient({ + execute: { + queryType: "select", + items: [{ n: 1 }, { n: 2 }, { n: 3 }], + mutatedDocumentIds: [], + }, + }); + const r = await runServerExecute(client, "SELECT * FROM c", { + ...opts(), + maxRows: 2, + maxRowsExplicit: true, + }); + expect(r.ok).toBe(true); + expect(JSON.parse(stdout())).toHaveLength(2); + expect(stderr()).toContain("showing first 2 of 3 rows"); + }); + + it("writes -o files uncapped, without ANSI", async () => { + const client = cannedClient({ + execute: { + queryType: "select", + items: [{ n: 1 }, { n: 2 }], + mutatedDocumentIds: [], + }, + }); + const out = path.join(dir, "out.json"); + const r = await runServerExecute(client, "SELECT * FROM c", { ...opts(), out }); + expect(r.ok).toBe(true); + expect(JSON.parse(fs.readFileSync(out, "utf8"))).toEqual([{ n: 1 }, { n: 2 }]); + expect(stdout()).toContain("Wrote 2 rows"); + }); + + it("surfaces -o write failures as ok:false", async () => { + const client = cannedClient({ + execute: { queryType: "select", items: [{ n: 1 }], mutatedDocumentIds: [] }, + }); + const r = await runServerExecute(client, "SELECT * FROM c", { + ...opts(), + out: path.join(dir, "missing", "out.json"), + }); + expect(r.ok).toBe(false); + expect(stderr()).toContain("Cannot write"); + }); + + it("--time prints a timing footer", async () => { + const client = cannedClient({ + execute: { queryType: "select", items: [{ n: 1 }], mutatedDocumentIds: [] }, + }); + await runServerExecute(client, "SELECT * FROM c", { ...opts(), time: true }); + expect(stderr()).toMatch(/Time: \d+\.\d ms/); + }); + + it("propagates PortalApiError (HTTP layer) to the caller", async () => { + const fetchImpl: FetchLike = async () => ({ + status: 401, + statusText: "", + headers: { get: () => null }, + text: async () => JSON.stringify({ message: "bad key" }), + }); + const client = new PortalClient({ baseUrl: "https://x.example/app", apiKey: "k", fetchImpl }); + await expect(runServerExecute(client, "SELECT 1", opts())).rejects.toBeInstanceOf( + PortalApiError, + ); + }); + + it("propagates PortalConnectionError to the caller", async () => { + const fetchImpl: FetchLike = async () => { + throw new Error("ECONNREFUSED"); + }; + const client = new PortalClient({ baseUrl: "https://x.example/app", apiKey: "k", fetchImpl }); + await expect(runServerExecute(client, "SELECT 1", opts())).rejects.toBeInstanceOf( + PortalConnectionError, + ); + }); +}); + +describe("runServerRemoteExecute", () => { + it("renders the per-peer envelope as JSON", async () => { + const client = cannedClient({ + remoteExecute: { + result: [ + { peer: { peerKeyString: "pk1" }, elapsedMilliseconds: 5, items: [{ a: 1 }] }, + { peer: { peerKeyString: "pk2" }, elapsedMilliseconds: 8, items: [] }, + ], + }, + }); + const r = await runServerRemoteExecute(client, "SYNC CONTEXT (… ) SELECT 1", opts()); + expect(r.ok).toBe(true); + const parsed = JSON.parse(stdout()); + expect(parsed).toHaveLength(2); + expect(parsed[0].peer.peerKeyString).toBe("pk1"); + }); + + it("counts peer errors and reports them on stderr", async () => { + const client = cannedClient({ + remoteExecute: { + result: [ + { peer: "pk1", items: [{ a: 1 }] }, + { peer: "pk2", error: { description: "boom" }, items: [] }, + ], + }, + }); + const r = await runServerRemoteExecute(client, "SYNC CONTEXT (… ) SELECT 1", opts()); + expect(r.ok).toBe(false); + expect(stderr()).toContain("1 of 2 peer(s) returned an error"); + expect(stdout()).toContain("boom"); + }); + + it("handles a top-level error", async () => { + const client = cannedClient({ remoteExecute: { error: { description: "no peers" } } }); + const r = await runServerRemoteExecute(client, "SYNC CONTEXT (… ) SELECT 1", opts()); + expect(r.ok).toBe(false); + expect(stderr()).toContain("no peers"); + }); + + it("empty result set is ok", async () => { + const client = cannedClient({ remoteExecute: { result: [] } }); + const r = await runServerRemoteExecute(client, "SYNC CONTEXT (… ) SELECT 1", opts()); + expect(r.ok).toBe(true); + expect(JSON.parse(stdout())).toEqual([]); + }); +}); + +describe("regression: adversarial review round 2", () => { + it("a non-empty error object WITHOUT a string description still fails", async () => { + const client = cannedClient({ + execute: { + queryType: "unknown", + items: [], + mutatedDocumentIds: [], + error: {} as { description?: string }, // cast: shape guard, see next line + }, + }); + // {} alone is the documented "no error" sentinel — must PASS + let r = await runServerExecute(client, "SELECT * FROM c", opts()); + expect(r.ok).toBe(true); + + const failing = cannedClient({ + execute: { + queryType: "unknown", + items: [], + mutatedDocumentIds: [], + error: { code: 42 } as unknown as { description: string }, + }, + }); + r = await runServerExecute(failing, "SELECT * FROM c", opts()); + expect(r.ok).toBe(false); + expect(stderr()).toContain('{"code":42}'); + }); + + it("remote-execute: description-less error objects fail (top-level and per-peer)", async () => { + const topLevel = cannedClient({ + remoteExecute: { error: { code: "x" } as unknown as { description: string } }, + }); + let r = await runServerRemoteExecute(topLevel, "SYNC CONTEXT (…) SELECT 1", opts()); + expect(r.ok).toBe(false); + expect(stderr()).toContain('{"code":"x"}'); + + errSpy.mockClear(); + const perPeer = cannedClient({ + remoteExecute: { + result: [ + { peer: "pk", error: { code: 7 } as unknown as { description: string }, items: [] }, + ], + }, + }); + r = await runServerRemoteExecute(perPeer, "SYNC CONTEXT (…) SELECT 1", opts()); + expect(r.ok).toBe(false); + expect(stderr()).toContain("1 of 1 peer(s) returned an error"); + expect(JSON.parse(stdout())[0].error).toBe('{"code":7}'); + }); + + it("remote-execute passes per-peer warnings through", async () => { + const client = cannedClient({ + remoteExecute: { + result: [ + { + peer: "pk1", + items: [], + warnings: [{ description: "slow" }], + totalWarningsCount: 1, + }, + ], + }, + }); + const r = await runServerRemoteExecute(client, "SYNC CONTEXT (…) SELECT 1", opts()); + expect(r.ok).toBe(true); + const parsed = JSON.parse(stdout()); + expect(parsed[0].warnings).toEqual([{ description: "slow" }]); + expect(parsed[0].totalWarningsCount).toBe(1); + }); + + it("-o + explicit --max-rows prints the capped note", async () => { + const client = cannedClient({ + execute: { + queryType: "select", + items: [{ n: 1 }, { n: 2 }, { n: 3 }], + mutatedDocumentIds: [], + }, + }); + const out = path.join(dir, "capped.json"); + const r = await runServerExecute(client, "SELECT * FROM c", { + ...opts(), + out, + maxRows: 2, + maxRowsExplicit: true, + }); + expect(r.ok).toBe(true); + expect(stdout()).toContain("first 2 of 3 — --max-rows"); + expect(JSON.parse(fs.readFileSync(out, "utf8"))).toHaveLength(2); + }); +}); + +it("remote-execute: an explicit null per-peer error is not a failure (regression R3-1)", async () => { + const client = cannedClient({ + remoteExecute: { + result: [{ peer: "pk", error: null as never, items: [{ a: 1 }] }], + }, + }); + const r = await runServerRemoteExecute(client, "SYNC CONTEXT (…) SELECT 1", opts()); + expect(r.ok).toBe(true); + expect(JSON.parse(stdout())[0].items).toEqual([{ a: 1 }]); +});