Testing & CI
Run the local gates, the vitest suite, and what CI checks.
Testing and CI
This page explains how HostSSH's tests are organized, how to run the local quality gate, and what the GitHub Actions CI does. For step-by-step operator runbooks (the build loop, deploy mechanics, incident triage) see the skill references linked throughout — this doc covers the what and why for a human engineer rather than terse ops steps.
The control plane is a Next.js 16 standalone app under control-plane/web. The Go agent lives under agent/. Both are exercised by CI, but the bulk of the test suite today is the control plane's Vitest suite.
The local quality gate
Before committing, run the same four checks CI runs on the control plane. From control-plane/web:
npm run lint # eslint .
npm run typecheck # tsc --noEmit
npm test # vitest run (CI mode, no watch)
npm run build # next build (prebuild also rebuilds the chat index)
These map directly to the scripts in control-plane/web/package.json. npm test runs vitest run (single pass, non-interactive); use npm run test:watch for an interactive watch loop while developing.
In practice the tight inner loop is npm run lint && npm test, with typecheck and build run before pushing. The build step matters because prebuild runs scripts/build-chat-index.mjs, so a build catches index-generation breakage as well as type/compile errors that tsc --noEmit would miss in Next-specific code.
The condensed pre-commit gate and the wider build loop are documented in the operator skill — see operations and process. For how a passing build actually reaches production (it deploys independently of CI), see deploy and CD.
Vitest setup
The Vitest config is intentionally minimal (vitest.config.ts):
- Environment:
node— these are unit/logic tests, not jsdom/DOM tests. - Includes:
lib/**/*.test.tsandapp/**/*.test.ts. - Excludes:
node_modules,.next,.next/standalone. - Path aliases:
vite-tsconfig-pathsresolves the@/import alias fromtsconfig.json, so tests import internal modules the same way app code does (e.g.@/lib/types).
There is no global setup file. The normal local suite runs in-process; DB contract tests
use describe.skipIf and activate only when explicit test database URLs are supplied.
The in-memory backend
The data-layer stores are written to work with or without Postgres. Each store checks DATABASE_URL at runtime: when it is set they talk to the production Postgres (pgvector on the WireGuard bridge — see database & migrations); when it is unset, they fall back to an in-memory backend. npm test runs with DATABASE_URL unset, so every store test exercises the in-memory path — no database fixture, container, or migration is required to run the suite.
This is why stores like the audit log, the slot board, and the fleet/agents map ship a seeded in-memory implementation: it keeps local dev and demo functional with zero DB, and it makes the stores unit-testable. The in-memory state is held on globalThis (e.g. the audit store binds its array to globalThis.__hsAudit once at import), so tests reset between cases by truncating that array in place in a beforeEach rather than reassigning it — reassigning would orphan the module's binding. See lib/audit/store.test.ts for the canonical example of this pattern.
Test inventory
The current suites under control-plane/web/lib, with roughly what each covers:
| Suite | File | Covers |
|---|---|---|
| RBAC | lib/rbac.test.ts | effectiveAdminRole (root collapses to super_admin), userHasPermission (super-only permissions are never grantable to a sub-admin even if listed; role none is denied everything), and isAdmin. |
| Rate limit | lib/ratelimit.test.ts | rateLimit window/bucket behavior (allows up to the limit then blocks, separate buckets per key, resets after the window), and clientIp (first x-forwarded-for hop, fallback to x-real-ip then unknown). |
| SEO | lib/seo.test.ts | pageMeta — canonical URL set to the page path, OpenGraph title/description/url + social image, and a summary_large_image Twitter card. |
| Jobs | lib/platform/jobs.test.ts | validateJobSpec — accepts a minimal valid spec; rejects empty app, out-of-range port, invalid builder / image builder without a tag, and networks with invalid characters. |
| Licenses | lib/licenses/store.test.ts | maskKey — keeps the prefix and last 4, hides the middle, leaves very short strings untouched (so license keys never leak in logs/UI). |
| Fleet / agents | lib/fleet/agents.test.ts | licenseIdForKey (deterministic, prefixed, leaks no key material), bearer token extraction (case-insensitive), and validateLicenseKey against the HOSTSSH_LICENSE_KEYS env allow-list — including fails closed in production with no allow-list and no DB. |
| Session token | lib/access/session-token.test.ts | The web-SSH session token: mint/verify round-trip, rejects tampered payload/signature and expired tokens, and clamps TTL into the 30s–1h band. |
| Keygen | lib/access/keygen.test.ts | generateSshKeypair — valid OpenSSH ed25519 public line, standard SHA256 fingerprint, unencrypted OpenSSH private key, and a fresh key per call. |
| Audit | lib/audit/store.test.ts | The in-memory audit store: record/list newest-first, default fields, limit handling; and the audit() convenience that derives actor + launch mode from the session, records onBehalfOf when impersonating, and never throws when headers() is unavailable outside a request. |
| Migrations core | lib/migrations/core.test.ts | The migration runner internals: checksum (deterministic, content-sensitive, invariant under LF↔CRLF and re-indentation — the drift footgun), planMigrations (pending detection, flags drift without re-running a changed migration), statusFrom (reports the last contiguous applied id), validateMigrations (rejects out-of-order/duplicate ids and self-managed transactions, no false positive on END inside a function body or CASE), and a check that the bundled migration list passes its own validation. |
| API schemas | lib/api/schemas.test.ts | The Zod request-body schemas: activateBody, heartbeatBody, claimBody, jobStatusBody, jobLogsBody (including a DoS guard rejecting absurdly large log batches), and chatBody. Verifies type rejection, unknown-key stripping, and required-field enforcement. |
| API validate | lib/api/validate.test.ts | parseBody — returns typed data on a valid body, 400s on malformed JSON, and 400s with field details on a schema mismatch. |
| Slots | lib/slots/store.test.ts | The Slots model: recommendedSlotCapacity (sizing heuristic — a 4 vCPU/8 GB box ≈ 20 burstable slots, an 8/16 box ≈ 50; reserved far sparser; never negative), assembleBoard (per-rack used/free + fleet summary, clamps overfilled racks, orders by slot index), and getSlotBoard against the seeded in-memory board. |
For the data model and product framing behind the last few suites, see Slots, the API reference, and the migrations and DB roles skill reference.
A note on the migration suite: it tests the pure planning/validation logic (checksums, ordering, drift detection) without touching a database. The runner applies migrations forward-only at boot against the privileged hostssh_migrator role (MIGRATIONS_DATABASE_URL), while the app's DML runs as the hostssh role — that role split is enforced operationally, not in these unit tests. See database migrations and roles.
Active CI jobs
The active CI workflow runs on every pull request and
push to main, with same-ref runs cancelled when superseded. It has four jobs:
- Repository hygiene rejects tracked environment/key/PEM/decrypted-secret files.
- Control plane runs Node 22
npm ci, lint, typecheck, the complete Vitest suite, and the production Next build. - Go agent runs Go 1.24 build, vet, and race-enabled tests.
- Fresh PostgreSQL + provisioning contract starts pgvector PostgreSQL 16, applies every baseline schema, proves the complete numbered migration graph, exercises concurrent provisioning workers through the full readiness state machine, and proves provider credential ciphertext can be decrypted server-side with truthful escrow/DEK custody.
Legacy deploy, release, security, and scheduler workflows remain deliberately parked in
.github/workflows-disabled; they are not active gates and this document does not count
them as such.
CI is not a deploy gate
Production deploys are driven by Coolify's GitHub auto-deploy: a push to main triggers a rebuild independently of CI status. A red CI run does not block a deploy, and a green one does not trigger it. CI is a safety net (lint, types, tests, license/secret hygiene) you are expected to keep green, not a release valve. The deploy path, the secondary webhook workflow, and why the two are decoupled are documented in deploy and CD.