Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@
# without Postgres or Redis still gets a green run for the hermetic suites.

# Isolated PostgreSQL. `test:db:check` refuses to fall back to DATABASE_URL.
CARTETHYIA_TEST_DATABASE_URL=postgres://postgres@localhost:5432/cartethyia_test
#
# A dedicated `cartethyia_test` role, not the cluster superuser: the URL has to
# carry a password because `pg_hba` requires scram for TCP, and `postgres` with
# no password failed every database suite at connect time. `bun run
# test-db:up` provisions the same role and database name on port 55432 through
# `docker-compose.test.yml` for machines without a local server.
CARTETHYIA_TEST_DATABASE_URL=postgres://cartethyia_test:cartethyia_test@127.0.0.1:5432/cartethyia_test

# Isolated Redis index (db 15), separate from the runtime REDIS_URL so a
# cleanup in a test cannot flush live state.
Expand Down
20 changes: 18 additions & 2 deletions .env.test.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,18 @@
# Disposable local test database. Never point this at production.
TEST_DATABASE_URL=postgres://cartethyia_test:cartethyia_test@127.0.0.1:55432/cartethyia_test
# Disposable test configuration. Never point this at production.
#
# Template for a machine without a local PostgreSQL: `bun run test-db:up` starts
# the Compose database on port 55432 with exactly these credentials. With a
# local server, use a dedicated role on its own port instead — the URL must
# carry a password, because `pg_hba` requires scram over TCP.
#
# The name is `CARTETHYIA_TEST_DATABASE_URL`: `bun run test-db:check` and the
# suites both read it, and it deliberately never falls back to `DATABASE_URL`.
CARTETHYIA_TEST_DATABASE_URL=postgres://cartethyia_test:cartethyia_test@127.0.0.1:55432/cartethyia_test

# Isolated Redis index (db 15), separate from the runtime REDIS_URL so a
# cleanup in a test cannot flush live state.
CARTETHYIA_TEST_REDIS_URL=redis://localhost:6379/15

# Fixed key so suites that hash bearer tokens at rest are deterministic across
# machines and runs. Never used by a deployed gateway.
CARTETHYIA_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
66 changes: 61 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,34 @@
# Runs the repo's own runners:
# - backend job: typecheck.
# - dashboard job: dashboard:typecheck.
# - test job: the full suite against disposable PostgreSQL and Redis services.
#
# The suite job exists because nothing else runs it. The workflow used to be
# `workflow_dispatch` only, so a defect could sit in `dev` indefinitely: a test
# that opened an absolute path on the author's desktop, and a `homeDir` that Bun
# cached on first use, both survived until someone ran `bun test` by hand. The
# gates `AGENTS.md` names are now enforced on every push and pull request.
#
# `bun-version` is deliberately omitted from every job: `oven-sh/setup-bun`
# reads the committed `.bun-version`, which is the same pin `packageManager` and
# `engines` carry. `latest` would let CI drift onto a Bun the lockfile was never
# resolved against.

name: ci

on:
push:
branches:
- dev
- SeeYouLater
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
Expand All @@ -20,8 +40,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: bun install --frozen-lockfile
- run: bun run typecheck

Expand All @@ -31,7 +49,45 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: bun install --frozen-lockfile
- run: bun run dashboard:typecheck

test:
runs-on: ubuntu-latest
timeout-minutes: 20
services:
# Same image, credentials and host port as `docker-compose.test.yml`, so a
# URL that works here works for `bun run test-db:up` on a laptop too.
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: cartethyia_test
POSTGRES_PASSWORD: cartethyia_test
POSTGRES_DB: cartethyia_test
ports:
- 55432:5432
options: >-
--health-cmd "pg_isready -U cartethyia_test -d cartethyia_test"
--health-interval 2s
--health-timeout 3s
--health-retries 15
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 2s
--health-timeout 3s
--health-retries 15
env:
# `scripts/ci-run-tests.ts` loads `.env.test` without overriding variables
# that are already exported, so these win over the file's localhost URLs.
# `CARTETHYIA_ENCRYPTION_KEY` still comes from the committed `.env.test`.
CARTETHYIA_TEST_DATABASE_URL: postgres://cartethyia_test:cartethyia_test@127.0.0.1:55432/cartethyia_test
CARTETHYIA_TEST_REDIS_URL: redis://127.0.0.1:6379/15
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run test
9 changes: 7 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,13 @@ bun run test:backend # or bun run test / dashboard:test / test:watch
```

The repository carries an active test suite (`test/`, `dashboard/test/`, run via
`scripts/ci-run-tests.ts` against the isolated `.env.test` database). Typecheck
is not behavioral proof. Exercise the real boundary: a live gateway request,
`scripts/ci-run-tests.ts` against the isolated `.env.test` database). A suite whose
service is not configured skips with a stated reason rather than failing, so read the
skip count: a green run with 142 skips is a run where no database suite executed.
`test/persistence/migration-*.test.ts` applies real DDL, so the runner executes those
files first, serially, and excludes them from the parallel pass; a migration suite added
anywhere else re-introduces the `40P01` deadlock they cause under concurrency.
Typecheck is not behavioral proof. Exercise the real boundary: a live gateway request,
headless browser/CDP surface when available, database migration in an isolated
environment, or a temporary script calling production code. If a browser/CDP
runtime is available, UI claims require that automation evidence; if unavailable,
Expand Down
19 changes: 18 additions & 1 deletion documentation/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,15 @@ Database-backed tests use the isolated URL in `CARTETHYIA_TEST_DATABASE_URL`,
not the development `DATABASE_URL`. The setup command creates `.env.test` from
`.env.test.example` when it is missing.

The URL must carry a password. `pg_hba` requires scram for TCP connections, so a
passwordless URL fails every database suite at connect time. Against a local
server, create a dedicated role rather than reusing the cluster superuser:

```sql
CREATE ROLE cartethyia_test LOGIN PASSWORD 'cartethyia_test';
CREATE DATABASE cartethyia_test OWNER cartethyia_test;
```

Check the test database:

```bash
Expand All @@ -250,7 +259,15 @@ bun run test-db:check
bun run test-db:down
```

Never use production data for tests.
A suite whose service is absent skips with a stated reason instead of failing, so
read the skip count: a green run with 142 skips is a run where no database suite
executed. Never use production data for tests.

The suites in `test/persistence/migration-*.test.ts` apply real DDL to the shared
test database. `bun run test` runs those files first and alone, then everything
else in parallel: under concurrency their `ALTER TABLE` and another suite's tenant
teardown deadlock, and PostgreSQL kills one of the two with `40P01`. Add a
migration suite to that directory so the runner picks it up.

## 10. Verification commands

Expand Down
18 changes: 18 additions & 0 deletions migrations/0006_allow_personal_handoff_links.sql
Original file line number Diff line number Diff line change
@@ -1,4 +1,22 @@
-- Existing databases may still have the enroll-only check; personal keys need handoff links.
--
-- Databases written by the previous share vocabulary carry `monitor` (the
-- durable public page) and `setup` (the one-time credential handoff) rows. The
-- check below only admits the current names, so those rows must be renamed
-- first or the constraint fails and the whole upgrade stops mid-chain.
--
-- `monitor` renames to `enroll` (both serve the public policy page) and `setup`
-- to `handoff` (both reveal the key once). Every renamed row is also retired,
-- matching the rule the backup importer already applies to the same two legacy
-- kinds ("import them as inactive enrollment links so their old tokens never
-- regain a usable public endpoint", `src/console/backup/validate.ts`): the old
-- rows were written for the old page contract, and an old token must not start
-- answering the new one. The rows stay for audit; only their tokens stop
-- resolving.
UPDATE "public"."share_links"
SET "kind" = 'enroll', "active" = false WHERE "kind" = 'monitor';
UPDATE "public"."share_links"
SET "kind" = 'handoff', "active" = false WHERE "kind" = 'setup';
ALTER TABLE "public"."share_links"
DROP CONSTRAINT IF EXISTS "share_links_kind_check";
ALTER TABLE "public"."share_links"
Expand Down
153 changes: 153 additions & 0 deletions migrations/0041_reconcile_legacy_schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
-- Reconcile databases built from the pre-rewrite baseline snapshot.
--
-- `0000_baseline.sql` is a snapshot of the schema, and the numbered files after
-- it carry only the delta *between* snapshots. Objects that were introduced by
-- editing the baseline itself therefore reach no existing database: it already
-- has `0000_baseline.sql` in its ledger, so the snapshot never runs again, and
-- no later file adds them. A database created before the rewrite finishes the
-- whole chain while still missing `telemetry_usage_totals` and the share-key
-- columns on `api_keys`, and the gateway then fails on its first key lookup
-- (`column "key_mode" does not exist`).
--
-- This migration closes that gap. Every statement is idempotent, so it is a
-- no-op on databases created from the current baseline.
--
-- Legacy objects that the current baseline no longer declares (`backup_status`,
-- `console_users.last_login_at`, `telemetry_payloads.redaction_applied`, and
-- four superseded indexes) are deliberately left in place: they are unused by
-- the current code and therefore harmless, and dropping them is a destructive
-- change that does not belong in the migration that unblocks the upgrade.

-- 1. telemetry_usage_totals — absent entirely from pre-rewrite databases.
CREATE TABLE IF NOT EXISTS "public"."telemetry_usage_totals" (
"tenant_id" uuid NOT NULL,
"identity_type" text NOT NULL,
"entity_id" uuid NOT NULL,
"requests" bigint DEFAULT 0 NOT NULL,
"errors" bigint DEFAULT 0 NOT NULL,
"input_tokens" bigint DEFAULT 0 NOT NULL,
"output_tokens" bigint DEFAULT 0 NOT NULL,
"last_used_at" timestamptz NOT NULL,
"updated_at" timestamptz DEFAULT now() NOT NULL,
CONSTRAINT "telemetry_usage_totals_identity_type_check"
CHECK ("identity_type" IN ('account', 'api_key'))
);
--> statement-breakpoint
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'telemetry_usage_totals_identity_pk'
AND conrelid = 'public.telemetry_usage_totals'::regclass
) THEN
ALTER TABLE "public"."telemetry_usage_totals"
ADD CONSTRAINT "telemetry_usage_totals_identity_pk"
PRIMARY KEY ("tenant_id", "identity_type", "entity_id");
END IF;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'telemetry_usage_totals_tenant_id_tenants_id_fk'
AND conrelid = 'public.telemetry_usage_totals'::regclass
) THEN
ALTER TABLE "public"."telemetry_usage_totals"
ADD CONSTRAINT "telemetry_usage_totals_tenant_id_tenants_id_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE CASCADE;
END IF;
END $$;
--> statement-breakpoint

-- 2. api_keys — the share-key shape (`key_mode`, parent/issued columns) exists
-- only in the rewritten baseline. `key_hash` becomes nullable because a share
-- template key carries no credential of its own; existing rows all hold a hash,
-- so they satisfy the `personal` branch of the constraint added below.
ALTER TABLE "public"."api_keys"
ADD COLUMN IF NOT EXISTS "key_mode" text DEFAULT 'personal' NOT NULL;
--> statement-breakpoint
ALTER TABLE "public"."api_keys"
ADD COLUMN IF NOT EXISTS "parent_key_id" uuid;
--> statement-breakpoint
ALTER TABLE "public"."api_keys"
ADD COLUMN IF NOT EXISTS "issued_client_ip" text;
--> statement-breakpoint
ALTER TABLE "public"."api_keys"
ADD COLUMN IF NOT EXISTS "issued_client_ip_key" text;
--> statement-breakpoint
ALTER TABLE "public"."api_keys"
ALTER COLUMN "key_hash" DROP NOT NULL;
--> statement-breakpoint
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'api_keys_mode_shape_check'
AND conrelid = 'public.api_keys'::regclass
) THEN
ALTER TABLE "public"."api_keys"
ADD CONSTRAINT "api_keys_mode_shape_check" CHECK (
(
("key_mode" = 'personal')
AND ("key_hash" IS NOT NULL)
AND ("parent_key_id" IS NULL)
AND ("issued_client_ip" IS NULL)
AND ("issued_client_ip_key" IS NULL)
)
OR (
("key_mode" = 'share')
AND (
(
("parent_key_id" IS NULL)
AND ("key_hash" IS NULL)
AND ("key_encrypted" IS NULL)
AND ("issued_client_ip" IS NULL)
AND ("issued_client_ip_key" IS NULL)
)
OR (
("parent_key_id" IS NOT NULL)
AND ("key_hash" IS NOT NULL)
AND ("key_encrypted" IS NULL)
AND ("issued_client_ip" IS NOT NULL)
AND ("issued_client_ip_key" IS NOT NULL)
)
)
)
);
END IF;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'api_keys_parent_key_id_api_keys_id_fk'
AND conrelid = 'public.api_keys'::regclass
) THEN
ALTER TABLE "public"."api_keys"
ADD CONSTRAINT "api_keys_parent_key_id_api_keys_id_fk"
FOREIGN KEY ("parent_key_id") REFERENCES "public"."api_keys"("id") ON DELETE CASCADE;
END IF;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "api_keys_parent_key_id_idx"
ON "public"."api_keys" USING btree ("parent_key_id");
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "api_keys_active_shared_ip_uidx"
ON "public"."api_keys" USING btree ("issued_client_ip_key")
WHERE ("parent_key_id" IS NOT NULL AND "revoked_at" IS NULL);
--> statement-breakpoint

-- 3. share_links — `token_encrypted` and the current kind default. Existing
-- kind values were already normalized by 0006, which runs before this file.
ALTER TABLE "public"."share_links"
ADD COLUMN IF NOT EXISTS "token_encrypted" bytea;
--> statement-breakpoint
ALTER TABLE "public"."share_links"
ALTER COLUMN "kind" SET DEFAULT 'enroll';
--> statement-breakpoint

-- 4. telemetry_events — the tenant/account listing index from the baseline.
CREATE INDEX IF NOT EXISTS "telemetry_events_tenant_account_created_idx"
ON "public"."telemetry_events" USING btree ("tenant_id", "account_id", "created_at");
Loading