diff --git a/.dockerignore b/.dockerignore index 9285191f..f2703e6c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -40,5 +40,8 @@ scripts .vscode .idea +# Archived SQLite migrations (superseded by Postgres) +apps/api/drizzle-sqlite-legacy + # Test images test-*.png diff --git a/.env.example b/.env.example index 433733ef..195942c7 100644 --- a/.env.example +++ b/.env.example @@ -35,9 +35,18 @@ LOGIN_ATTEMPT_LIMIT=10 # Set to true in CI/dev to skip the forced password-change on the default admin # SKIP_MUST_CHANGE_PASSWORD=false -DB_PATH=./data/snapotter.db +# DB_PATH removed in 2.0 -- see DATABASE_URL below WORKSPACE_PATH=./tmp/workspace FILES_STORAGE_PATH=./data/files DEFAULT_THEME=light DEFAULT_LOCALE=en APP_NAME=snapotter + +# --- 2.0 foundation --- +# Postgres connection (required; this default matches docker-compose.dev.yml) +# Start the dev stack first: docker compose -f docker-compose.dev.yml up -d +DATABASE_URL=postgres://snapotter:snapotter@localhost:5432/snapotter +# Redis connection (used from phase 2 onward) +# REDIS_URL=redis://localhost:6379 +# One-time SQLite import on first boot (1.x upgrade path). Leave unset normally. +# SQLITE_MIGRATE_PATH=/data/snapotter.db diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86e13bf8..1bdf78fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,8 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup - run: pnpm lint + - name: License boundary check (D15) + run: pnpm check:license-boundary typecheck: name: Typecheck @@ -87,6 +89,20 @@ jobs: name: E2E Smoke (Chromium) runs-on: ubuntu-latest timeout-minutes: 15 + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: snapotter + POSTGRES_PASSWORD: snapotter + POSTGRES_DB: snapotter + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U snapotter" + --health-interval 5s + --health-timeout 3s + --health-retries 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup @@ -124,9 +140,11 @@ jobs: - run: pip install "pip-audit==2.10.0" - name: Run pip-audit (ignoring CVEs blocked by dependency constraints) + # CVE-2025-3000: torch 2.12.0, no fixed release available as of 2026-06-11 run: >- pip-audit -r packages/ai/python/requirements.txt --ignore-vuln CVE-2024-27763 + --ignore-vuln CVE-2025-3000 --ignore-vuln CVE-2026-40086 --ignore-vuln CVE-2026-25990 --ignore-vuln CVE-2026-40192 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 635cc8a8..24b572fc 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -16,6 +16,20 @@ jobs: name: E2E Full (${{ matrix.shard }}/4) runs-on: ubuntu-latest timeout-minutes: 60 + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: snapotter + POSTGRES_PASSWORD: snapotter + POSTGRES_DB: snapotter + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U snapotter" + --health-interval 5s + --health-timeout 3s + --health-retries 10 strategy: fail-fast: false matrix: @@ -51,6 +65,20 @@ jobs: name: E2E Serial Bucket runs-on: ubuntu-latest timeout-minutes: 60 + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: snapotter + POSTGRES_PASSWORD: snapotter + POSTGRES_DB: snapotter + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U snapotter" + --health-interval 5s + --health-timeout 3s + --health-retries 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install system dependencies @@ -80,6 +108,20 @@ jobs: name: E2E Cross-Browser (Firefox + WebKit) runs-on: ubuntu-latest timeout-minutes: 30 + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: snapotter + POSTGRES_PASSWORD: snapotter + POSTGRES_DB: snapotter + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U snapotter" + --health-interval 5s + --health-timeout 3s + --health-retries 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup @@ -141,6 +183,20 @@ jobs: name: Schemathesis API Fuzz runs-on: ubuntu-latest timeout-minutes: 45 + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: snapotter + POSTGRES_PASSWORD: snapotter + POSTGRES_DB: snapotter + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U snapotter" + --health-interval 5s + --health-timeout 3s + --health-retries 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install system dependencies @@ -154,7 +210,8 @@ jobs: - name: Start API server run: | mkdir -p /tmp/st-data - AUTH_ENABLED=false ANALYTICS_ENABLED=false DB_PATH=/tmp/st-data/st.db \ + AUTH_ENABLED=false ANALYTICS_ENABLED=false \ + DATABASE_URL=postgres://snapotter:snapotter@localhost:5432/snapotter \ WORKSPACE_PATH=/tmp/st-data/workspace DATA_DIR=/tmp/st-data \ pnpm --filter @snapotter/api dev & for i in $(seq 1 60); do diff --git a/LICENSING.md b/LICENSING.md new file mode 100644 index 00000000..f8f899b6 --- /dev/null +++ b/LICENSING.md @@ -0,0 +1,18 @@ +# Licensing + +SnapOtter is open-core: + +- **All content in this repository is licensed under AGPL-3.0 (see `LICENSE`), + EXCEPT the contents of `packages/enterprise/`.** +- `packages/enterprise/` is governed by the SnapOtter Commercial License + (see `packages/enterprise/LICENSE`). + +The community edition is fully functional without any code from +`packages/enterprise`: all tools, batch, pipelines, API keys, and single-node +operation work without a license key. Enterprise features (see +`ENTERPRISE_FEATURES` in `packages/enterprise/src/license.ts`) activate only +with a valid commercial license key. + +Core code may import only the public API of `@snapotter/enterprise` (its +package entry point), never its internals. This boundary is enforced in CI by +`scripts/check-license-boundary.mjs`. diff --git a/README.md b/README.md index 53ae5c46..b2a12d23 100644 --- a/README.md +++ b/README.md @@ -111,3 +111,5 @@ This project is dual-licensed under the [AGPLv3](LICENSE) and a commercial licen - **AGPLv3 (free):** You may use, modify, and distribute this software under the AGPLv3. If you run a modified version as a network service, you must make your source code available under the AGPLv3. - **Commercial license (paid):** For use in proprietary software or SaaS products where AGPLv3 source-disclosure is not suitable, a commercial license is available. [Contact us](mailto:contact@snapotter.com) for pricing and terms. + +See [LICENSING.md](LICENSING.md) for full details on the open-core boundary between AGPLv3 and commercial code. diff --git a/apps/api/drizzle/0000_clammy_madelyne_pryor.sql b/apps/api/drizzle-sqlite-legacy/0000_clammy_madelyne_pryor.sql similarity index 100% rename from apps/api/drizzle/0000_clammy_madelyne_pryor.sql rename to apps/api/drizzle-sqlite-legacy/0000_clammy_madelyne_pryor.sql diff --git a/apps/api/drizzle/0001_amusing_omega_red.sql b/apps/api/drizzle-sqlite-legacy/0001_amusing_omega_red.sql similarity index 100% rename from apps/api/drizzle/0001_amusing_omega_red.sql rename to apps/api/drizzle-sqlite-legacy/0001_amusing_omega_red.sql diff --git a/apps/api/drizzle/0002_pale_silver_sable.sql b/apps/api/drizzle-sqlite-legacy/0002_pale_silver_sable.sql similarity index 100% rename from apps/api/drizzle/0002_pale_silver_sable.sql rename to apps/api/drizzle-sqlite-legacy/0002_pale_silver_sable.sql diff --git a/apps/api/drizzle/0003_add_team_to_users.sql b/apps/api/drizzle-sqlite-legacy/0003_add_team_to_users.sql similarity index 100% rename from apps/api/drizzle/0003_add_team_to_users.sql rename to apps/api/drizzle-sqlite-legacy/0003_add_team_to_users.sql diff --git a/apps/api/drizzle/0004_married_forgotten_one.sql b/apps/api/drizzle-sqlite-legacy/0004_married_forgotten_one.sql similarity index 100% rename from apps/api/drizzle/0004_married_forgotten_one.sql rename to apps/api/drizzle-sqlite-legacy/0004_married_forgotten_one.sql diff --git a/apps/api/drizzle/0005_add_teams_table.sql b/apps/api/drizzle-sqlite-legacy/0005_add_teams_table.sql similarity index 100% rename from apps/api/drizzle/0005_add_teams_table.sql rename to apps/api/drizzle-sqlite-legacy/0005_add_teams_table.sql diff --git a/apps/api/drizzle/0006_rbac_revamp.sql b/apps/api/drizzle-sqlite-legacy/0006_rbac_revamp.sql similarity index 100% rename from apps/api/drizzle/0006_rbac_revamp.sql rename to apps/api/drizzle-sqlite-legacy/0006_rbac_revamp.sql diff --git a/apps/api/drizzle/0007_custom_roles.sql b/apps/api/drizzle-sqlite-legacy/0007_custom_roles.sql similarity index 100% rename from apps/api/drizzle/0007_custom_roles.sql rename to apps/api/drizzle-sqlite-legacy/0007_custom_roles.sql diff --git a/apps/api/drizzle/0008_api_key_expiration.sql b/apps/api/drizzle-sqlite-legacy/0008_api_key_expiration.sql similarity index 100% rename from apps/api/drizzle/0008_api_key_expiration.sql rename to apps/api/drizzle-sqlite-legacy/0008_api_key_expiration.sql diff --git a/apps/api/drizzle/0009_analytics_consent.sql b/apps/api/drizzle-sqlite-legacy/0009_analytics_consent.sql similarity index 100% rename from apps/api/drizzle/0009_analytics_consent.sql rename to apps/api/drizzle-sqlite-legacy/0009_analytics_consent.sql diff --git a/apps/api/drizzle/0010_remove_branding.sql b/apps/api/drizzle-sqlite-legacy/0010_remove_branding.sql similarity index 100% rename from apps/api/drizzle/0010_remove_branding.sql rename to apps/api/drizzle-sqlite-legacy/0010_remove_branding.sql diff --git a/apps/api/drizzle/0011_whole_karen_page.sql b/apps/api/drizzle-sqlite-legacy/0011_whole_karen_page.sql similarity index 100% rename from apps/api/drizzle/0011_whole_karen_page.sql rename to apps/api/drizzle-sqlite-legacy/0011_whole_karen_page.sql diff --git a/apps/api/drizzle/0012_make_password_hash_nullable.sql b/apps/api/drizzle-sqlite-legacy/0012_make_password_hash_nullable.sql similarity index 100% rename from apps/api/drizzle/0012_make_password_hash_nullable.sql rename to apps/api/drizzle-sqlite-legacy/0012_make_password_hash_nullable.sql diff --git a/apps/api/drizzle-sqlite-legacy/README.md b/apps/api/drizzle-sqlite-legacy/README.md new file mode 100644 index 00000000..994d7d5c --- /dev/null +++ b/apps/api/drizzle-sqlite-legacy/README.md @@ -0,0 +1,5 @@ +# Archived SQLite migrations (v1.x) + +Superseded by the Postgres baseline in `apps/api/drizzle/`. Kept for historical +reference only. Do not apply. Note: snapshots 0006-0010 were already absent from +`meta/` before archiving; the SQL files are complete (0000-0012). diff --git a/apps/api/drizzle-sqlite-legacy/meta/0000_snapshot.json b/apps/api/drizzle-sqlite-legacy/meta/0000_snapshot.json new file mode 100644 index 00000000..54abc0aa --- /dev/null +++ b/apps/api/drizzle-sqlite-legacy/meta/0000_snapshot.json @@ -0,0 +1,309 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c7909605-aabf-4832-8ef8-9390c0f7c99a", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Default API Key'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "input_files": { + "name": "input_files", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "must_change_password": { + "name": "must_change_password", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": ["username"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/api/drizzle/meta/0001_snapshot.json b/apps/api/drizzle-sqlite-legacy/meta/0001_snapshot.json similarity index 100% rename from apps/api/drizzle/meta/0001_snapshot.json rename to apps/api/drizzle-sqlite-legacy/meta/0001_snapshot.json diff --git a/apps/api/drizzle/meta/0002_snapshot.json b/apps/api/drizzle-sqlite-legacy/meta/0002_snapshot.json similarity index 100% rename from apps/api/drizzle/meta/0002_snapshot.json rename to apps/api/drizzle-sqlite-legacy/meta/0002_snapshot.json diff --git a/apps/api/drizzle/meta/0003_snapshot.json b/apps/api/drizzle-sqlite-legacy/meta/0003_snapshot.json similarity index 100% rename from apps/api/drizzle/meta/0003_snapshot.json rename to apps/api/drizzle-sqlite-legacy/meta/0003_snapshot.json diff --git a/apps/api/drizzle/meta/0004_snapshot.json b/apps/api/drizzle-sqlite-legacy/meta/0004_snapshot.json similarity index 100% rename from apps/api/drizzle/meta/0004_snapshot.json rename to apps/api/drizzle-sqlite-legacy/meta/0004_snapshot.json diff --git a/apps/api/drizzle/meta/0005_snapshot.json b/apps/api/drizzle-sqlite-legacy/meta/0005_snapshot.json similarity index 100% rename from apps/api/drizzle/meta/0005_snapshot.json rename to apps/api/drizzle-sqlite-legacy/meta/0005_snapshot.json diff --git a/apps/api/drizzle/meta/0011_snapshot.json b/apps/api/drizzle-sqlite-legacy/meta/0011_snapshot.json similarity index 100% rename from apps/api/drizzle/meta/0011_snapshot.json rename to apps/api/drizzle-sqlite-legacy/meta/0011_snapshot.json diff --git a/apps/api/drizzle/meta/0012_snapshot.json b/apps/api/drizzle-sqlite-legacy/meta/0012_snapshot.json similarity index 100% rename from apps/api/drizzle/meta/0012_snapshot.json rename to apps/api/drizzle-sqlite-legacy/meta/0012_snapshot.json diff --git a/apps/api/drizzle-sqlite-legacy/meta/_journal.json b/apps/api/drizzle-sqlite-legacy/meta/_journal.json new file mode 100644 index 00000000..72e7e414 --- /dev/null +++ b/apps/api/drizzle-sqlite-legacy/meta/_journal.json @@ -0,0 +1,97 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1774119039901, + "tag": "0000_clammy_madelyne_pryor", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1774125684700, + "tag": "0001_amusing_omega_red", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1774357742003, + "tag": "0002_pale_silver_sable", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1774598000000, + "tag": "0003_add_team_to_users", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1774372393588, + "tag": "0004_married_forgotten_one", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1774700000000, + "tag": "0005_add_teams_table", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1745366400000, + "tag": "0006_rbac_revamp", + "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1745366500000, + "tag": "0007_custom_roles", + "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1745366600000, + "tag": "0008_api_key_expiration", + "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1776855591000, + "tag": "0009_analytics_consent", + "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1778300000000, + "tag": "0010_remove_branding", + "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1778669228893, + "tag": "0011_whole_karen_page", + "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1778669300000, + "tag": "0012_make_password_hash_nullable", + "breakpoints": true + } + ] +} diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts index 7e895224..f6366c9e 100644 --- a/apps/api/drizzle.config.ts +++ b/apps/api/drizzle.config.ts @@ -3,8 +3,8 @@ import { defineConfig } from "drizzle-kit"; export default defineConfig({ schema: "./src/db/schema.ts", out: "./drizzle", - dialect: "sqlite", + dialect: "postgresql", dbCredentials: { - url: process.env.DB_PATH || "./data/snapotter.db", + url: process.env.DATABASE_URL || "postgres://snapotter:snapotter@localhost:5432/snapotter", }, }); diff --git a/apps/api/drizzle/0000_postgres_baseline.sql b/apps/api/drizzle/0000_postgres_baseline.sql new file mode 100644 index 00000000..eda72eb5 --- /dev/null +++ b/apps/api/drizzle/0000_postgres_baseline.sql @@ -0,0 +1,119 @@ +CREATE TYPE "public"."job_status" AS ENUM('queued', 'processing', 'completed', 'failed');--> statement-breakpoint +CREATE TABLE "api_keys" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "key_hash" text NOT NULL, + "key_prefix" text, + "name" text DEFAULT 'Default API Key' NOT NULL, + "permissions" jsonb, + "created_at" timestamp with time zone NOT NULL, + "last_used_at" timestamp with time zone, + "expires_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "audit_log" ( + "id" text PRIMARY KEY NOT NULL, + "actor_id" text, + "actor_username" text NOT NULL, + "action" text NOT NULL, + "target_type" text, + "target_id" text, + "details" jsonb, + "ip_address" text, + "created_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "jobs" ( + "id" text PRIMARY KEY NOT NULL, + "type" text NOT NULL, + "status" "job_status" DEFAULT 'queued' NOT NULL, + "progress" real DEFAULT 0 NOT NULL, + "input_files" jsonb NOT NULL, + "output_path" text, + "settings" jsonb, + "error" text, + "created_at" timestamp with time zone NOT NULL, + "completed_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "pipelines" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text, + "name" text NOT NULL, + "description" text, + "steps" jsonb NOT NULL, + "created_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "roles" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "description" text DEFAULT '' NOT NULL, + "permissions" jsonb NOT NULL, + "is_builtin" boolean DEFAULT false NOT NULL, + "created_by" text, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + CONSTRAINT "roles_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE "sessions" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "id_token" text, + "created_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "settings" ( + "key" text PRIMARY KEY NOT NULL, + "value" text NOT NULL, + "updated_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "teams" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "created_at" timestamp with time zone NOT NULL, + CONSTRAINT "teams_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE "user_files" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text, + "original_name" text NOT NULL, + "stored_name" text NOT NULL, + "mime_type" text NOT NULL, + "size" integer NOT NULL, + "width" integer, + "height" integer, + "version" integer DEFAULT 1 NOT NULL, + "parent_id" text, + "tool_chain" jsonb, + "created_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" text PRIMARY KEY NOT NULL, + "username" text NOT NULL, + "password_hash" text, + "role" text DEFAULT 'user' NOT NULL, + "team" text DEFAULT 'Default' NOT NULL, + "must_change_password" boolean DEFAULT true NOT NULL, + "auth_provider" text DEFAULT 'local' NOT NULL, + "external_id" text, + "email" text, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + "analytics_enabled" boolean, + "analytics_consent_shown_at" timestamp with time zone, + "analytics_consent_remind_at" timestamp with time zone, + CONSTRAINT "users_username_unique" UNIQUE("username") +); +--> statement-breakpoint +ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pipelines" ADD CONSTRAINT "pipelines_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "roles" ADD CONSTRAINT "roles_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_files" ADD CONSTRAINT "user_files_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; diff --git a/apps/api/drizzle/meta/0000_snapshot.json b/apps/api/drizzle/meta/0000_snapshot.json index 54abc0aa..3aaaff07 100644 --- a/apps/api/drizzle/meta/0000_snapshot.json +++ b/apps/api/drizzle/meta/0000_snapshot.json @@ -1,54 +1,67 @@ { - "version": "6", - "dialect": "sqlite", - "id": "c7909605-aabf-4832-8ef8-9390c0f7c99a", + "id": "47a9d637-64e3-4cca-a916-cf42ea63b335", "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", "tables": { - "api_keys": { + "public.api_keys": { "name": "api_keys", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "key_hash": { "name": "key_hash", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false }, "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true, - "autoincrement": false, "default": "'Default API Key'" }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "last_used_at": { "name": "last_used_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false } }, "indexes": {}, @@ -65,31 +78,109 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "jobs": { - "name": "jobs", + "public.audit_log": { + "name": "audit_log", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_username": { + "name": "actor_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true }, "type": { "name": "type", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "status": { "name": "status", - "type": "text", + "type": "job_status", + "typeSchema": "public", "primaryKey": false, "notNull": true, - "autoincrement": false, "default": "'queued'" }, "progress": { @@ -97,88 +188,224 @@ "type": "real", "primaryKey": false, "notNull": true, - "autoincrement": false, "default": 0 }, "input_files": { "name": "input_files", - "type": "text", + "type": "jsonb", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "output_path": { "name": "output_path", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "settings": { "name": "settings", - "type": "text", + "type": "jsonb", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "error": { "name": "error", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "completed_at": { "name": "completed_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "sessions": { - "name": "sessions", + "public.pipelines": { + "name": "pipelines", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": false }, - "expires_at": { - "name": "expires_at", - "type": "integer", + "name": { + "name": "name", + "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steps": { + "name": "steps", + "type": "jsonb", + "primaryKey": false, + "notNull": true }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "pipelines_user_id_users_id_fk": { + "name": "pipelines_user_id_users_id_fk", + "tableFrom": "pipelines", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", "primaryKey": false, "notNull": true, - "autoincrement": false + "default": "''" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_builtin": { + "name": "is_builtin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "roles_created_by_users_id_fk": { + "name": "roles_created_by_users_id_fk", + "tableFrom": "roles", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true } }, "indexes": {}, @@ -195,115 +422,297 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "settings": { + "public.settings": { "name": "settings", + "schema": "", "columns": { "key": { "name": "key", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "value": { "name": "value", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "updated_at": { "name": "updated_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "users": { - "name": "users", + "public.teams": { + "name": "teams", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_name_unique": { + "name": "teams_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_files": { + "name": "user_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stored_name": { + "name": "stored_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, "notNull": true, - "autoincrement": false + "default": 1 + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_chain": { + "name": "tool_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_files_user_id_users_id_fk": { + "name": "user_files_user_id_users_id_fk", + "tableFrom": "user_files", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true }, "username": { "name": "username", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "password_hash": { "name": "password_hash", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": false }, "role": { "name": "role", "type": "text", "primaryKey": false, "notNull": true, - "autoincrement": false, "default": "'user'" }, + "team": { + "name": "team", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default'" + }, "must_change_password": { "name": "must_change_password", - "type": "integer", + "type": "boolean", "primaryKey": false, "notNull": true, - "autoincrement": false, "default": true }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "updated_at": { "name": "updated_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "users_username_unique": { - "name": "users_username_unique", - "columns": ["username"], - "isUnique": true + "notNull": true + }, + "analytics_enabled": { + "name": "analytics_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "analytics_consent_shown_at": { + "name": "analytics_consent_shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "analytics_consent_remind_at": { + "name": "analytics_consent_remind_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false } }, + "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false } }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} + "enums": { + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": ["queued", "processing", "completed", "failed"] + } }, - "internal": { - "indexes": {} + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} } } diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 72e7e414..35c66525 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -1,96 +1,12 @@ { "version": "7", - "dialect": "sqlite", + "dialect": "postgresql", "entries": [ { "idx": 0, - "version": "6", - "when": 1774119039901, - "tag": "0000_clammy_madelyne_pryor", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1774125684700, - "tag": "0001_amusing_omega_red", - "breakpoints": true - }, - { - "idx": 2, - "version": "6", - "when": 1774357742003, - "tag": "0002_pale_silver_sable", - "breakpoints": true - }, - { - "idx": 3, - "version": "6", - "when": 1774598000000, - "tag": "0003_add_team_to_users", - "breakpoints": true - }, - { - "idx": 4, - "version": "6", - "when": 1774372393588, - "tag": "0004_married_forgotten_one", - "breakpoints": true - }, - { - "idx": 5, - "version": "6", - "when": 1774700000000, - "tag": "0005_add_teams_table", - "breakpoints": true - }, - { - "idx": 6, - "version": "6", - "when": 1745366400000, - "tag": "0006_rbac_revamp", - "breakpoints": true - }, - { - "idx": 7, - "version": "6", - "when": 1745366500000, - "tag": "0007_custom_roles", - "breakpoints": true - }, - { - "idx": 8, - "version": "6", - "when": 1745366600000, - "tag": "0008_api_key_expiration", - "breakpoints": true - }, - { - "idx": 9, - "version": "6", - "when": 1776855591000, - "tag": "0009_analytics_consent", - "breakpoints": true - }, - { - "idx": 10, - "version": "6", - "when": 1778300000000, - "tag": "0010_remove_branding", - "breakpoints": true - }, - { - "idx": 11, - "version": "6", - "when": 1778669228893, - "tag": "0011_whole_karen_page", - "breakpoints": true - }, - { - "idx": 12, - "version": "6", - "when": 1778669300000, - "tag": "0012_make_password_hash_nullable", + "version": "7", + "when": 1781103398348, + "tag": "0000_postgres_baseline", "breakpoints": true } ] diff --git a/apps/api/package.json b/apps/api/package.json index b2846b8f..1cc53332 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,10 +9,10 @@ "start": "tsx src/index.ts", "lint": "biome check src/", "typecheck": "tsc --noEmit", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "migrate:sqlite": "tsx src/db/migrate-from-sqlite.ts" }, "dependencies": { - "@aws-sdk/client-s3": "^3.1063.0", "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.0.0", "@fastify/multipart": "^9.0.0", @@ -38,6 +38,7 @@ "opentype.js": "^2.0.0", "p-queue": "^9.3.0", "pdfkit": "^0.18.0", + "pg": "^8.21.0", "piscina": "^5.1.4", "playwright": "^1.60.0", "posthog-node": "^5.35.9", @@ -55,6 +56,7 @@ "@types/node": "^22.19.19", "@types/opentype.js": "^1.3.10", "@types/pdfkit": "^0.17.6", + "@types/pg": "^8.20.0", "@types/potrace": "^2.1.5", "@types/qrcode": "^1.5.6", "drizzle-kit": "^0.31.0", diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts index 651faa9e..060726ad 100644 --- a/apps/api/src/db/index.ts +++ b/apps/api/src/db/index.ts @@ -1,33 +1,23 @@ -import { mkdirSync } from "node:fs"; -import { dirname } from "node:path"; -import Database, { type Database as DatabaseType } from "better-sqlite3"; -import { drizzle } from "drizzle-orm/better-sqlite3"; +import { drizzle } from "drizzle-orm/node-postgres"; +import pg from "pg"; import { env } from "../config.js"; import * as schema from "./schema.js"; -try { - mkdirSync(dirname(env.DB_PATH), { recursive: true }); -} catch (err: unknown) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "EACCES") { - console.error( - `FATAL: Cannot write to data directory "${dirname(env.DB_PATH)}". Check volume permissions (PUID/PGID).`, - ); - } - throw err; +const pool = new pg.Pool({ + connectionString: env.DATABASE_URL, + max: 10, +}); + +pool.on("error", (err) => { + console.error("Unexpected idle Postgres client error", err); +}); + +export const db = drizzle(pool, { schema }); +export { pool, schema }; + +let ended = false; +export async function closeDb(): Promise { + if (ended) return; + ended = true; + await pool.end(); } - -const sqlite: DatabaseType = new Database(env.DB_PATH); - -// Critical SQLite pragmas for reliability. -// busy_timeout must be set first so journal_mode = WAL can retry -// if another connection holds the lock (e.g. parallel test files). -sqlite.pragma("busy_timeout = 10000"); -sqlite.pragma("journal_mode = WAL"); -sqlite.pragma("synchronous = NORMAL"); -sqlite.pragma("foreign_keys = ON"); -sqlite.pragma("wal_autocheckpoint = 1000"); -sqlite.pragma("journal_size_limit = 67108864"); - -export const db = drizzle(sqlite, { schema }); -export { schema, sqlite }; diff --git a/apps/api/src/db/migrate-from-sqlite.ts b/apps/api/src/db/migrate-from-sqlite.ts new file mode 100644 index 00000000..229787de --- /dev/null +++ b/apps/api/src/db/migrate-from-sqlite.ts @@ -0,0 +1,146 @@ +import { sql } from "drizzle-orm"; +import { db } from "./index.js"; +import { runMigrations } from "./migrate.js"; + +type SqliteRow = Record; +export interface MigrationResult { + tables: Record; +} + +// columns storing epoch-seconds integers in 1.x +const TS = new Set([ + "created_at", + "updated_at", + "expires_at", + "completed_at", + "last_used_at", + "analytics_consent_shown_at", + "analytics_consent_remind_at", +]); +// columns storing 0/1 booleans in 1.x +const BOOL = new Set(["must_change_password", "analytics_enabled", "is_builtin"]); +// per-table columns whose text-JSON becomes jsonb +const JSONB: Record> = { + jobs: new Set(["input_files", "settings"]), + pipelines: new Set(["steps"]), + api_keys: new Set(["permissions"]), + roles: new Set(["permissions"]), + audit_log: new Set(["details"]), + user_files: new Set(["tool_chain"]), +}; +// FK-safe copy order +const TABLE_ORDER = [ + "users", + "teams", + "settings", + "roles", + "sessions", + "api_keys", + "pipelines", + "jobs", + "audit_log", + "user_files", +] as const; + +function convertRow(table: string, row: SqliteRow): SqliteRow { + const out: SqliteRow = {}; + for (const [col, raw] of Object.entries(row)) { + if (raw === null || raw === undefined) { + out[col] = null; + } else if (TS.has(col)) { + out[col] = new Date((raw as number) * 1000); + } else if (BOOL.has(col)) { + out[col] = raw === 1; + } else if (JSONB[table]?.has(col)) { + try { + out[col] = JSON.parse(raw as string); + } catch (e) { + throw new Error( + `Invalid JSON in ${table}.${col} (row id=${String(row.id)}): ${(e as Error).message}`, + ); + } + } else { + out[col] = raw; + } + } + return out; +} + +export async function migrateFromSqlite( + sqlitePath: string, + opts: { force: boolean }, +): Promise { + const { default: Database } = await import("better-sqlite3"); // lazy: only the migrator needs it + // Intentionally also called by the boot path (idempotent via advisory lock + drizzle journal) + // so the CLI works standalone; do not remove. + await runMigrations(); + + const existing = await db.execute(sql`SELECT count(*)::int AS n FROM users`); + if ((existing.rows[0].n as number) > 0 && !opts.force) { + throw new Error( + "Target Postgres database is non-empty; refusing to migrate. Re-run with --force to attempt inserting 1.x rows into the existing database. This will FAIL and roll back if any primary key or unique value (username, team name, role name) collides with existing data.", + ); + } + + const sqlite = new Database(sqlitePath, { readonly: true, fileMustExist: true }); + const result: MigrationResult = { tables: {} }; + try { + await db.transaction(async (tx) => { + for (const table of TABLE_ORDER) { + const rows = sqlite.prepare(`SELECT * FROM ${table}`).all() as SqliteRow[]; + for (const row of rows) { + const converted = convertRow(table, row); + const cols = Object.keys(converted); + const colList = sql.raw(cols.map((c) => `"${c}"`).join(", ")); + const values = sql.join( + cols.map((c) => { + const v = converted[c]; + // jsonb columns: the pg driver sends JS arrays as postgres ARRAY + // literals, not json. Explicitly stringify and cast to jsonb. + if (JSONB[table]?.has(c) && v !== null) { + return sql`${JSON.stringify(v)}::jsonb`; + } + return sql`${v}`; + }), + sql.raw(", "), + ); + await tx.execute( + sql`INSERT INTO ${sql.raw(`"${table}"`)} (${colList}) VALUES (${values})`, + ); + } + const count = ( + await tx.execute(sql`SELECT count(*)::int AS n FROM ${sql.raw(`"${table}"`)}`) + ).rows[0].n as number; + if (count < rows.length) { + throw new Error(`Row count mismatch for ${table}: sqlite=${rows.length} pg=${count}`); + } + result.tables[table] = rows.length; + } + }); + } finally { + sqlite.close(); + } + return result; +} + +// CLI entry: pnpm --filter @snapotter/api migrate:sqlite -- [--force] +const invokedDirectly = /migrate-from-sqlite\.[tj]s$/.test(process.argv[1] ?? ""); +if (invokedDirectly) { + // pnpm forwards "--" as a literal arg; skip it and any flags to find the positional path + const args = process.argv.slice(2); + const path = args.find((a) => a !== "--" && !a.startsWith("--")); + const force = args.includes("--force"); + if (!path) { + console.error("Usage: migrate-from-sqlite [--force]"); + process.exit(1); + } + migrateFromSqlite(path, { force }) + .then((r) => { + console.log("Migration complete:", JSON.stringify(r.tables)); + process.exit(0); + }) + .catch((err) => { + console.error("Migration FAILED (no partial state; transaction rolled back):", err.message); + process.exit(1); + }); +} diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts index 0574fdaf..ea6d5cec 100644 --- a/apps/api/src/db/migrate.ts +++ b/apps/api/src/db/migrate.ts @@ -1,48 +1,29 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { migrate } from "drizzle-orm/better-sqlite3/migrator"; -import { db, sqlite } from "./index.js"; +import { sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { pool } from "./index.js"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +// Advisory lock IDs: pick any unique int32. Reserve 7_421_xxx for SnapOtter app locks. +const MIGRATION_LOCK_KEY = 7_421_001; -// Resolve migrations folder relative to this file, not the working directory -const migrationsFolder = join(__dirname, "../../drizzle"); +export async function runMigrations(): Promise { + const migrationsFolder = join(dirname(fileURLToPath(import.meta.url)), "../../drizzle"); -function isAlreadyExistsError(err: unknown): boolean { - if (err instanceof Error) { - if (err.message.includes("already exists")) return true; - // DrizzleError wraps the real SqliteError in .cause - if ("cause" in err && err.cause instanceof Error) { - return err.cause.message.includes("already exists"); - } - } - return false; -} - -let migrated = false; - -export function runMigrations() { - if (migrated) return; - // Temporarily disable FK checks so table-recreation migrations - // (DROP + RENAME pattern) can proceed without constraint errors. - // Must be set outside any transaction to take effect in SQLite. - sqlite.pragma("foreign_keys = OFF"); + // Advisory locks are session-scoped. With a Pool the lock and unlock could + // land on different connections, so we acquire a dedicated client and run + // lock, migration, and unlock on that single session. + const client = await pool.connect(); try { - migrate(db, { migrationsFolder }); - } catch (err: unknown) { - // In test / multi-process environments, concurrent workers may race to - // apply migrations on the same database file. If a table already exists, - // the schema is in place and we can safely continue. - // Drizzle wraps the SqliteError in a DrizzleError, so check both the - // outer message and the cause chain. - if (isAlreadyExistsError(err)) { - // Tables created by another process — DB is ready - } else { - throw err; + const clientDb = drizzle(client); + await clientDb.execute(sql`SELECT pg_advisory_lock(${MIGRATION_LOCK_KEY})`); + try { + await migrate(clientDb, { migrationsFolder }); + } finally { + await clientDb.execute(sql`SELECT pg_advisory_unlock(${MIGRATION_LOCK_KEY})`); } } finally { - sqlite.pragma("foreign_keys = ON"); + client.release(); } - migrated = true; } diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 0d3d761b..8a87edaa 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1,72 +1,81 @@ -import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { + boolean, + integer, + jsonb, + pgEnum, + pgTable, + real, + text, + timestamp, +} from "drizzle-orm/pg-core"; -export const users = sqliteTable("users", { +export const jobStatus = pgEnum("job_status", ["queued", "processing", "completed", "failed"]); + +export const users = pgTable("users", { id: text("id").primaryKey(), username: text("username").notNull().unique(), passwordHash: text("password_hash"), role: text("role").notNull().default("user"), team: text("team").notNull().default("Default"), - mustChangePassword: integer("must_change_password", { mode: "boolean" }).notNull().default(true), + mustChangePassword: boolean("must_change_password").notNull().default(true), authProvider: text("auth_provider").notNull().default("local"), externalId: text("external_id"), email: text("email"), - createdAt: integer("created_at", { mode: "timestamp" }) + createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .$defaultFn(() => new Date()), - updatedAt: integer("updated_at", { mode: "timestamp" }) + updatedAt: timestamp("updated_at", { withTimezone: true }) .notNull() .$defaultFn(() => new Date()), - analyticsEnabled: integer("analytics_enabled", { mode: "boolean" }), - analyticsConsentShownAt: integer("analytics_consent_shown_at", { mode: "timestamp" }), - analyticsConsentRemindAt: integer("analytics_consent_remind_at", { mode: "timestamp" }), + analyticsEnabled: boolean("analytics_enabled"), + analyticsConsentShownAt: timestamp("analytics_consent_shown_at", { withTimezone: true }), + analyticsConsentRemindAt: timestamp("analytics_consent_remind_at", { withTimezone: true }), }); -export const teams = sqliteTable("teams", { +export const teams = pgTable("teams", { id: text("id").primaryKey(), name: text("name").notNull().unique(), - createdAt: integer("created_at", { mode: "timestamp" }) + createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .$defaultFn(() => new Date()), }); -export const sessions = sqliteTable("sessions", { +export const sessions = pgTable("sessions", { id: text("id").primaryKey(), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), - expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), idToken: text("id_token"), - createdAt: integer("created_at", { mode: "timestamp" }) + createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .$defaultFn(() => new Date()), }); -export const settings = sqliteTable("settings", { +export const settings = pgTable("settings", { key: text("key").primaryKey(), value: text("value").notNull(), - updatedAt: integer("updated_at", { mode: "timestamp" }) + updatedAt: timestamp("updated_at", { withTimezone: true }) .notNull() .$defaultFn(() => new Date()), }); -export const jobs = sqliteTable("jobs", { +export const jobs = pgTable("jobs", { id: text("id").primaryKey(), type: text("type").notNull(), - status: text("status", { enum: ["queued", "processing", "completed", "failed"] }) - .notNull() - .default("queued"), + status: jobStatus("status").notNull().default("queued"), progress: real("progress").notNull().default(0), - inputFiles: text("input_files").notNull(), + inputFiles: jsonb("input_files").$type<{ totalFiles: number } | unknown[]>().notNull(), outputPath: text("output_path"), - settings: text("settings"), + settings: jsonb("settings").$type>(), error: text("error"), - createdAt: integer("created_at", { mode: "timestamp" }) + createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .$defaultFn(() => new Date()), - completedAt: integer("completed_at", { mode: "timestamp" }), + completedAt: timestamp("completed_at", { withTimezone: true }), }); -export const apiKeys = sqliteTable("api_keys", { +export const apiKeys = pgTable("api_keys", { id: text("id").primaryKey(), userId: text("user_id") .notNull() @@ -74,55 +83,55 @@ export const apiKeys = sqliteTable("api_keys", { keyHash: text("key_hash").notNull(), keyPrefix: text("key_prefix"), name: text("name").notNull().default("Default API Key"), - permissions: text("permissions"), - createdAt: integer("created_at", { mode: "timestamp" }) + permissions: jsonb("permissions").$type(), + createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .$defaultFn(() => new Date()), - lastUsedAt: integer("last_used_at", { mode: "timestamp" }), - expiresAt: integer("expires_at", { mode: "timestamp" }), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + expiresAt: timestamp("expires_at", { withTimezone: true }), }); -export const pipelines = sqliteTable("pipelines", { +export const pipelines = pgTable("pipelines", { id: text("id").primaryKey(), userId: text("user_id").references(() => users.id, { onDelete: "cascade" }), name: text("name").notNull(), description: text("description"), - steps: text("steps").notNull(), // JSON array of { toolId, settings } - createdAt: integer("created_at", { mode: "timestamp" }) + steps: jsonb("steps").$type<{ toolId: string; settings: Record }[]>().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .$defaultFn(() => new Date()), }); -export const auditLog = sqliteTable("audit_log", { +export const auditLog = pgTable("audit_log", { id: text("id").primaryKey(), actorId: text("actor_id").references(() => users.id, { onDelete: "set null" }), actorUsername: text("actor_username").notNull(), action: text("action").notNull(), targetType: text("target_type"), targetId: text("target_id"), - details: text("details"), + details: jsonb("details").$type>(), ipAddress: text("ip_address"), - createdAt: integer("created_at", { mode: "timestamp" }) + createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .$defaultFn(() => new Date()), }); -export const roles = sqliteTable("roles", { +export const roles = pgTable("roles", { id: text("id").primaryKey(), name: text("name").notNull().unique(), description: text("description").notNull().default(""), - permissions: text("permissions").notNull(), - isBuiltin: integer("is_builtin", { mode: "boolean" }).notNull().default(false), + permissions: jsonb("permissions").$type().notNull(), + isBuiltin: boolean("is_builtin").notNull().default(false), createdBy: text("created_by").references(() => users.id, { onDelete: "set null" }), - createdAt: integer("created_at", { mode: "timestamp" }) + createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .$defaultFn(() => new Date()), - updatedAt: integer("updated_at", { mode: "timestamp" }) + updatedAt: timestamp("updated_at", { withTimezone: true }) .notNull() .$defaultFn(() => new Date()), }); -export const userFiles = sqliteTable("user_files", { +export const userFiles = pgTable("user_files", { id: text("id").primaryKey(), userId: text("user_id").references(() => users.id, { onDelete: "cascade" }), originalName: text("original_name").notNull(), @@ -133,8 +142,8 @@ export const userFiles = sqliteTable("user_files", { height: integer("height"), version: integer("version").notNull().default(1), parentId: text("parent_id"), - toolChain: text("tool_chain"), - createdAt: integer("created_at", { mode: "timestamp" }) + toolChain: jsonb("tool_chain").$type(), + createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .$defaultFn(() => new Date()), }); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 96980327..402b4e98 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -4,10 +4,10 @@ import cors from "@fastify/cors"; import rateLimit from "@fastify/rate-limit"; import { getDispatcherStatus, initDispatcher, isGpuAvailable } from "@snapotter/ai"; import { APP_VERSION } from "@snapotter/shared"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import Fastify from "fastify"; import { env } from "./config.js"; -import { db, schema } from "./db/index.js"; +import { closeDb, db, schema } from "./db/index.js"; import { runMigrations } from "./db/migrate.js"; import { captureException, initAnalytics, shutdownAnalytics } from "./lib/analytics.js"; import { startCleanupCron } from "./lib/cleanup.js"; @@ -19,6 +19,7 @@ import { authMiddleware, authRoutes, ensureAnonymousUser, + ensureBuiltinRoles, ensureDefaultAdmin, } from "./plugins/auth.js"; import { oidcRoutes } from "./plugins/oidc.js"; @@ -43,55 +44,86 @@ import { registerToolRoutes } from "./routes/tools/index.js"; import { userFileRoutes } from "./routes/user-files.js"; // Run before anything else -runMigrations(); +try { + await runMigrations(); +} catch (err) { + const safeUrl = env.DATABASE_URL.replace(/:\/\/[^@]*@/, "://***@"); + console.error( + `FATAL: Cannot connect to Postgres at ${safeUrl}. Is the database running? (docker compose up, or set DATABASE_URL)`, + ); + console.error(err); + process.exit(1); +} console.log("Database initialized"); +// Auto-import 1.x SQLite database on first boot (before default user creation) +if (env.SQLITE_MIGRATE_PATH) { + const { rows } = await db.execute(sql`SELECT count(*)::int AS n FROM users`); + if ((rows[0].n as number) === 0) { + try { + const { migrateFromSqlite } = await import("./db/migrate-from-sqlite.js"); + const result = await migrateFromSqlite(env.SQLITE_MIGRATE_PATH, { force: false }); + console.log("Imported 1.x SQLite database:", JSON.stringify(result.tables)); + } catch (err) { + console.error( + `FATAL: 1.x SQLite import failed from ${env.SQLITE_MIGRATE_PATH}: ${(err as Error).message}. No partial data was written.`, + ); + process.exit(1); + } + } else { + console.log("SQLITE_MIGRATE_PATH set but target is not empty; skipping import"); + } +} + +// Seed built-in roles (admin, editor, user) that legacy SQLite migrations +// inserted via data statements. The pg baseline is DDL-only, so roles are +// seeded here at boot time. onConflictDoNothing makes this idempotent. +await ensureBuiltinRoles(); + if (env.AUTH_ENABLED) { await ensureDefaultAdmin(); } else { - ensureAnonymousUser(); + await ensureAnonymousUser(); } -function ensureInstanceId() { - const existing = db +async function ensureInstanceId() { + const [existing] = await db .select() .from(schema.settings) - .where(eq(schema.settings.key, "instance_id")) - .get(); + .where(eq(schema.settings.key, "instance_id")); if (!existing) { - db.insert(schema.settings).values({ key: "instance_id", value: randomUUID() }).run(); + await db.insert(schema.settings).values({ key: "instance_id", value: randomUUID() }); } } -ensureInstanceId(); +await ensureInstanceId(); -function ensureDefaultSettings() { +async function ensureDefaultSettings() { const defaults: Record = { defaultTheme: env.DEFAULT_THEME, defaultLocale: env.DEFAULT_LOCALE, defaultToolView: env.DEFAULT_TOOL_VIEW, }; for (const [key, value] of Object.entries(defaults)) { - const existing = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get(); + const [existing] = await db.select().from(schema.settings).where(eq(schema.settings.key, key)); if (!existing) { - db.insert(schema.settings).values({ key, value }).run(); + await db.insert(schema.settings).values({ key, value }); } } } -ensureDefaultSettings(); +await ensureDefaultSettings(); if (!env.COOKIE_SECRET) { - const existing = db + const [existing] = await db .select() .from(schema.settings) - .where(eq(schema.settings.key, "cookie_secret")) - .get(); + .where(eq(schema.settings.key, "cookie_secret")); if (existing) { (env as Record).COOKIE_SECRET = existing.value; } else { const generated = randomUUID() + randomUUID(); - db.insert(schema.settings).values({ key: "cookie_secret", value: generated }).run(); + await db.insert(schema.settings).values({ key: "cookie_secret", value: generated }); (env as Record).COOKIE_SECRET = generated; } } @@ -113,7 +145,7 @@ try { } // Mark any jobs left in processing/queued from a previous unclean shutdown -recoverStaleJobs(); +await recoverStaleJobs(); // Set up AI feature directories and recover from interrupted installs ensureAiDirs(); @@ -271,7 +303,7 @@ await docsRoutes(app); app.get("/api/v1/health", async (_request, reply) => { let dbOk = false; try { - db.select().from(schema.settings).limit(1).get(); + await db.select().from(schema.settings).limit(1); dbOk = true; } catch { /* db unreachable */ @@ -287,12 +319,12 @@ app.get("/api/v1/health", async (_request, reply) => { // Admin health check (full diagnostics) app.get("/api/v1/admin/health", async (request, reply) => { - const admin = requirePermission("system:health")(request, reply); + const admin = await requirePermission("system:health")(request, reply); if (!admin) return; let dbOk = false; try { - db.select().from(schema.settings).limit(1).all(); + await db.select().from(schema.settings).limit(1); dbOk = true; } catch { /* db unreachable */ @@ -330,7 +362,7 @@ if (process.env.NODE_ENV === "production") { } // Start workspace cleanup cron -const cleanupCron = startCleanupCron(); +const cleanupCron = await startCleanupCron(); // Start try { @@ -414,8 +446,7 @@ async function shutdown(signal: string) { } try { - const { sqlite: sqliteConn } = await import("./db/index.js"); - sqliteConn.close(); + await closeDb(); console.log("Database connection closed"); } catch (err) { console.error("Error closing database:", err); diff --git a/apps/api/src/lib/analytics.ts b/apps/api/src/lib/analytics.ts index e2c06669..26f517b4 100644 --- a/apps/api/src/lib/analytics.ts +++ b/apps/api/src/lib/analytics.ts @@ -82,10 +82,14 @@ export async function initAnalytics(): Promise { } } -export function captureException(error: unknown, request?: FastifyRequest): void { - if (!sentryModule) return; - if (request && !isRequestOptedIn(request)) return; - sentryModule.captureException(error); +export async function captureException(error: unknown, request?: FastifyRequest): Promise { + try { + if (!sentryModule) return; + if (request && !(await isRequestOptedIn(request))) return; + sentryModule.captureException(error); + } catch { + // analytics must never throw + } } export async function shutdownAnalytics(): Promise { @@ -100,19 +104,22 @@ export async function shutdownAnalytics(): Promise { } } -function getInstanceId(): string { - const row = db.select().from(schema.settings).where(eq(schema.settings.key, "instance_id")).get(); +async function getInstanceId(): Promise { + const [row] = await db + .select() + .from(schema.settings) + .where(eq(schema.settings.key, "instance_id")); return row?.value ?? "unknown"; } -function isUserOptedIn(userId: string): boolean { +async function isUserOptedIn(userId: string): Promise { if (!env.ANALYTICS_ENABLED) return false; if (userId === "anonymous") return false; - const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); + const [user] = await db.select().from(schema.users).where(eq(schema.users.id, userId)); return user?.analyticsEnabled === true; } -function isRequestOptedIn(request: FastifyRequest): boolean { +async function isRequestOptedIn(request: FastifyRequest): Promise { if (!env.ANALYTICS_ENABLED) return false; const user = getAuthUser(request); if (!user) return false; @@ -120,7 +127,7 @@ function isRequestOptedIn(request: FastifyRequest): boolean { const header = request.headers["x-analytics-consent"]; return header === "true"; } - return isUserOptedIn(user.id); + return await isUserOptedIn(user.id); } function shouldSample(): boolean { @@ -129,19 +136,19 @@ function shouldSample(): boolean { return Math.random() < env.ANALYTICS_SAMPLE_RATE; } -export function trackEvent( +export async function trackEvent( request: FastifyRequest, event: string, properties: Record, -): void { - if (!posthogClient || !isRequestOptedIn(request) || !shouldSample()) return; +): Promise { try { + if (!posthogClient || !(await isRequestOptedIn(request)) || !shouldSample()) return; posthogClient.capture({ - distinctId: getInstanceId(), + distinctId: await getInstanceId(), event, properties, }); } catch { - // never throw from analytics + // analytics must never throw } } diff --git a/apps/api/src/lib/audit.ts b/apps/api/src/lib/audit.ts index c149df88..f0b492f4 100644 --- a/apps/api/src/lib/audit.ts +++ b/apps/api/src/lib/audit.ts @@ -35,11 +35,11 @@ type AuditEvent = * * Dual-writes: structured stdout log (for aggregators) + SQLite row. */ -export function auditLog( +export async function auditLog( logger: FastifyBaseLogger, event: AuditEvent, details: Record = {}, -): void { +): Promise { logger.info({ audit: true, event, ...details }, `[AUDIT] ${event}`); const actorId = (details.userId as string) ?? (details.adminId as string) ?? null; @@ -48,18 +48,16 @@ export function auditLog( const targetType = deriveTargetType(event); try { - db.insert(schema.auditLog) - .values({ - id: randomUUID(), - actorId, - actorUsername, - action: event, - targetType, - targetId, - details: JSON.stringify(details), - ipAddress: null, - }) - .run(); + await db.insert(schema.auditLog).values({ + id: randomUUID(), + actorId, + actorUsername, + action: event, + targetType, + targetId, + details, + ipAddress: null, + }); } catch { logger.warn({ event }, "Failed to write audit log to DB"); } diff --git a/apps/api/src/lib/cleanup.ts b/apps/api/src/lib/cleanup.ts index 20dd0245..a8a38082 100644 --- a/apps/api/src/lib/cleanup.ts +++ b/apps/api/src/lib/cleanup.ts @@ -9,13 +9,12 @@ import { db, schema } from "../db/index.js"; * Read the temp file max age from DB settings, falling back to env var. * Called each cleanup cycle so changes take effect without restart. */ -export function getMaxAgeMs(): number { +export async function getMaxAgeMs(): Promise { try { - const row = db + const [row] = await db .select() .from(schema.settings) - .where(eq(schema.settings.key, "tempFileMaxAgeHours")) - .get(); + .where(eq(schema.settings.key, "tempFileMaxAgeHours")); if (row) { const hours = parseFloat(row.value); if (!Number.isNaN(hours) && hours > 0) return hours * 60 * 60 * 1000; @@ -30,20 +29,19 @@ export function getMaxAgeMs(): number { * Check whether startup cleanup should run. * Returns true by default; only returns false when explicitly set to "false". */ -export function shouldRunStartupCleanup(): boolean { +export async function shouldRunStartupCleanup(): Promise { try { - const row = db + const [row] = await db .select() .from(schema.settings) - .where(eq(schema.settings.key, "startupCleanup")) - .get(); + .where(eq(schema.settings.key, "startupCleanup")); return row ? row.value !== "false" : true; } catch { return true; } } -export function startCleanupCron(): { stop: () => void } { +export async function startCleanupCron(): Promise<{ stop: () => void }> { try { mkdirSync(env.WORKSPACE_PATH, { recursive: true }); } catch (err: unknown) { @@ -59,7 +57,7 @@ export function startCleanupCron(): { stop: () => void } { const intervalMs = env.CLEANUP_INTERVAL_MINUTES * 60 * 1000; const cleanup = async () => { - const maxAgeMs = getMaxAgeMs(); + const maxAgeMs = await getMaxAgeMs(); try { const entries = await readdir(env.WORKSPACE_PATH, { withFileTypes: true }).catch(() => []); const now = Date.now(); @@ -87,12 +85,12 @@ export function startCleanupCron(): { stop: () => void } { }; // Purge expired sessions from the database - const purgeExpiredSessions = () => { + const purgeExpiredSessions = async () => { try { const now = new Date(); - const result = db.delete(schema.sessions).where(lt(schema.sessions.expiresAt, now)).run(); - if (result.changes > 0) { - console.log(`Cleanup: purged ${result.changes} expired sessions`); + const result = await db.delete(schema.sessions).where(lt(schema.sessions.expiresAt, now)); + if (result.rowCount && result.rowCount > 0) { + console.log(`Cleanup: purged ${result.rowCount} expired sessions`); } } catch (err) { console.error("Session cleanup error:", err); @@ -100,7 +98,7 @@ export function startCleanupCron(): { stop: () => void } { }; // Run on startup only if setting allows it - if (shouldRunStartupCleanup()) { + if (await shouldRunStartupCleanup()) { cleanup(); purgeExpiredSessions(); } diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index 2b02fece..f0a93176 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -33,7 +33,8 @@ const envSchema = z CONCURRENT_JOBS: z.coerce.number().default(0), MAX_MEGAPIXELS: z.coerce.number().default(0), RATE_LIMIT_PER_MIN: z.coerce.number().default(1000), - DB_PATH: z.string().default("./data/snapotter.db"), + DATABASE_URL: z.string().default("postgres://snapotter:snapotter@localhost:5432/snapotter"), + SQLITE_MIGRATE_PATH: z.string().default(""), FILES_STORAGE_PATH: z.string().default("./data/files"), WORKSPACE_PATH: z.string().default("./tmp/workspace"), DEFAULT_THEME: z.enum(["light", "dark", "system"]).default("light"), diff --git a/apps/api/src/lib/file-storage.ts b/apps/api/src/lib/file-storage.ts index e143cf92..dd96df81 100644 --- a/apps/api/src/lib/file-storage.ts +++ b/apps/api/src/lib/file-storage.ts @@ -3,6 +3,7 @@ import { createReadStream } from "node:fs"; import { mkdir, readFile, statfs, unlink, writeFile } from "node:fs/promises"; import { extname, join } from "node:path"; import type { Readable } from "node:stream"; +import type { S3StorageModule } from "@snapotter/enterprise"; import { env } from "../config.js"; const MIN_FREE_BYTES = 100 * 1024 * 1024; @@ -49,12 +50,27 @@ const SAFE_STORAGE_EXTENSIONS = new Set([ ".hdr", ]); -// ── S3 backend (lazy-loaded only when STORAGE_MODE=s3) ────────────── +// ── S3 backend (lazy-loaded and configured on first use) ─────────── -let s3Mod: typeof import("./storage-s3.js") | null = null; +let s3Mod: S3StorageModule | null = null; -async function s3(): Promise { - if (!s3Mod) s3Mod = await import("./storage-s3.js"); +// Concurrent calls may double-initialize; configureS3 is idempotent +// (same config values, client rebuilt), so no guard is needed. +async function getS3(): Promise { + if (!s3Mod) { + const { loadS3Storage } = await import("@snapotter/enterprise"); + const mod = await loadS3Storage(); + mod.configureS3({ + bucket: env.S3_BUCKET, + region: env.S3_REGION, + endpoint: env.S3_ENDPOINT, + accessKeyId: env.S3_ACCESS_KEY_ID, + secretAccessKey: env.S3_SECRET_ACCESS_KEY, + forcePathStyle: env.S3_FORCE_PATH_STYLE, + prefix: env.S3_PREFIX, + }); + s3Mod = mod; + } return s3Mod; } @@ -77,8 +93,8 @@ let storageReady = false; export async function ensureStorageDir(): Promise { if (storageReady) return; if (useS3()) { - const mod = await s3(); - await mod.checkConnection(); + const s3 = await getS3(); + await s3.checkConnection(); storageReady = true; return; } @@ -100,8 +116,8 @@ export async function ensureStorageDir(): Promise { export async function saveFile(buffer: Buffer, originalName: string): Promise { const storedName = generateStoredName(originalName); if (useS3()) { - const mod = await s3(); - await mod.putObject(storedName, buffer); + const s3 = await getS3(); + await s3.putObject(storedName, buffer); return storedName; } await ensureStorageDir(); @@ -123,24 +139,24 @@ export async function saveFile(buffer: Buffer, originalName: string): Promise { if (useS3()) { - const mod = await s3(); - return mod.getObject(storedName); + const s3 = await getS3(); + return s3.getObject(storedName); } return readFile(join(env.FILES_STORAGE_PATH, storedName)); } export async function streamStoredFile(storedName: string): Promise { if (useS3()) { - const mod = await s3(); - return mod.getObjectStream(storedName); + const s3 = await getS3(); + return s3.getObjectStream(storedName); } return createReadStream(join(env.FILES_STORAGE_PATH, storedName)); } export async function deleteStoredFile(storedName: string): Promise { if (useS3()) { - const mod = await s3(); - await mod.deleteObject(storedName); + const s3 = await getS3(); + await s3.deleteObject(storedName); return; } try { @@ -182,8 +198,8 @@ function thumbPath(storedName: string): string { export async function getCachedThumbnail(storedName: string): Promise { if (useS3()) { - const mod = await s3(); - return mod.getThumbnail(storedName); + const s3 = await getS3(); + return s3.getThumbnail(storedName); } try { return await readFile(thumbPath(storedName)); @@ -194,8 +210,8 @@ export async function getCachedThumbnail(storedName: string): Promise { if (useS3()) { - const mod = await s3(); - await mod.putThumbnail(storedName, buffer); + const s3 = await getS3(); + await s3.putThumbnail(storedName, buffer); return; } await ensureThumbDir(); @@ -204,8 +220,8 @@ export async function saveThumbnail(storedName: string, buffer: Buffer): Promise export async function deleteThumbnail(storedName: string): Promise { if (useS3()) { - const mod = await s3(); - await mod.deleteThumbnail(storedName); + const s3 = await getS3(); + await s3.deleteThumbnail(storedName); return; } try { diff --git a/apps/api/src/permissions.ts b/apps/api/src/permissions.ts index d3e6738a..84320856 100644 --- a/apps/api/src/permissions.ts +++ b/apps/api/src/permissions.ts @@ -33,18 +33,17 @@ const ROLE_PERMISSIONS: Record = { user: ["tools:use", "files:own", "apikeys:own", "pipelines:own", "settings:read"], }; -export function getPermissions(role: Role | string): Permission[] { +export async function getPermissions(role: Role | string): Promise { if (role in ROLE_PERMISSIONS) { return ROLE_PERMISSIONS[role as Role]; } try { - const customRole = db + const [customRole] = await db .select() .from(schema.roles) - .where(eq(schema.roles.name, role as string)) - .get(); + .where(eq(schema.roles.name, role as string)); if (customRole) { - return JSON.parse(customRole.permissions) as Permission[]; + return customRole.permissions as Permission[]; } } catch { // DB not yet available during early startup @@ -52,26 +51,31 @@ export function getPermissions(role: Role | string): Permission[] { return []; } -export function hasPermission(role: Role | string, permission: Permission): boolean { - return getPermissions(role).includes(permission); +export async function hasPermission(role: Role | string, permission: Permission): Promise { + return (await getPermissions(role)).includes(permission); } -export function hasEffectivePermission(user: AuthUser, permission: Permission): boolean { - if (!hasPermission(user.role, permission)) return false; +export async function hasEffectivePermission( + user: AuthUser, + permission: Permission, +): Promise { + if (!(await hasPermission(user.role, permission))) return false; if (user.apiKeyPermissions) { return user.apiKeyPermissions.includes(permission); } return true; } -export function requirePermission(permission: Permission) { - return (request: FastifyRequest, reply: FastifyReply) => { +export function requirePermission( + permission: Permission, +): (request: FastifyRequest, reply: FastifyReply) => Promise { + return async (request: FastifyRequest, reply: FastifyReply) => { const user = getAuthUser(request); if (!user) { reply.status(401).send({ error: "Authentication required", code: "AUTH_REQUIRED" }); return null; } - if (!hasEffectivePermission(user, permission)) { + if (!(await hasEffectivePermission(user, permission))) { reply.status(403).send({ error: "Insufficient permissions", code: "FORBIDDEN" }); return null; } @@ -79,7 +83,7 @@ export function requirePermission(permission: Permission) { }; } -export function requireOwnershipOrPermission( +export async function requireOwnershipOrPermission( request: FastifyRequest, reply: FastifyReply, resourceUserId: string | null, @@ -90,7 +94,7 @@ export function requireOwnershipOrPermission( reply.status(401).send({ error: "Authentication required", code: "AUTH_REQUIRED" }); return null; } - if (resourceUserId !== user.id && !hasEffectivePermission(user, allPermission)) { + if (resourceUserId !== user.id && !(await hasEffectivePermission(user, allPermission))) { return null; } return user; diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 7c721c1b..40c09fa8 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -1,6 +1,6 @@ import { createHash, randomBytes, randomUUID, scrypt, timingSafeEqual } from "node:crypto"; import { promisify } from "node:util"; -import { eq, sql } from "drizzle-orm"; +import { and, eq, ne, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { env } from "../config.js"; @@ -136,11 +136,12 @@ export function createSessionToken(): string { // ── Default admin creation ───────────────────────────────────────── -export function ensureAnonymousUser(): void { - const existing = db.select().from(schema.users).where(eq(schema.users.id, "anonymous")).get(); +export async function ensureAnonymousUser(): Promise { + const [existing] = await db.select().from(schema.users).where(eq(schema.users.id, "anonymous")); if (existing) return; - db.insert(schema.users) + await db + .insert(schema.users) .values({ id: "anonymous", username: "anonymous", @@ -148,19 +149,18 @@ export function ensureAnonymousUser(): void { mustChangePassword: false, authProvider: "local", }) - .onConflictDoNothing() - .run(); + .onConflictDoNothing(); } export async function ensureDefaultAdmin(): Promise { - const existingUsers = db.select().from(schema.users).all(); + const existingUsers = await db.select().from(schema.users); if (existingUsers.length > 0) return; const id = randomUUID(); const passwordHash = await hashPassword(env.DEFAULT_PASSWORD); const mustChange = !env.SKIP_MUST_CHANGE_PASSWORD; - const result = db + const result = await db .insert(schema.users) .values({ id, @@ -169,26 +169,87 @@ export async function ensureDefaultAdmin(): Promise { role: "admin", mustChangePassword: mustChange, }) - .onConflictDoNothing() - .run(); + .onConflictDoNothing(); - if (result.changes > 0) { + if (result.rowCount && result.rowCount > 0) { console.log( mustChange - ? `Default admin user '${env.DEFAULT_USERNAME}' created — password change required on first login` + ? `Default admin user '${env.DEFAULT_USERNAME}' created - password change required on first login` : `Default admin user '${env.DEFAULT_USERNAME}' created (password change skipped via env)`, ); } } +/** + * Seed the three built-in roles (admin, editor, user) that the legacy SQLite + * migration 0007_custom_roles.sql used to insert. The Postgres baseline is + * DDL-only, so these must be created at boot time instead. + * + * Uses onConflictDoNothing so the function is safe to call when: + * - Roles already exist from a previous boot + * - Roles were imported by the 1.x SQLite-to-Postgres data migrator + */ +// Must match ROLE_PERMISSIONS in permissions.ts (the 1.x post-0010 state). +export async function ensureBuiltinRoles(): Promise { + const builtinRoles = [ + { + id: "builtin-admin", + name: "admin", + description: "Full administrative access", + permissions: [ + "tools:use", + "files:own", + "files:all", + "apikeys:own", + "apikeys:all", + "pipelines:own", + "pipelines:all", + "settings:read", + "settings:write", + "users:manage", + "teams:manage", + "features:manage", + "system:health", + "audit:read", + ], + isBuiltin: true, + }, + { + id: "builtin-editor", + name: "editor", + description: "Can see all files and pipelines", + permissions: [ + "tools:use", + "files:own", + "files:all", + "apikeys:own", + "pipelines:own", + "pipelines:all", + "settings:read", + ], + isBuiltin: true, + }, + { + id: "builtin-user", + name: "user", + description: "Basic tool access", + permissions: ["tools:use", "files:own", "apikeys:own", "pipelines:own", "settings:read"], + isBuiltin: true, + }, + ]; + + for (const role of builtinRoles) { + await db.insert(schema.roles).values(role).onConflictDoNothing(); + } +} + // ── Login attempt limit ────────────────────────────────────────── -function getLoginAttemptLimit(): number { - const row = db +async function getLoginAttemptLimit(): Promise { + const [row] = await db .select() .from(schema.settings) - .where(eq(schema.settings.key, "loginAttemptLimit")) - .get(); + .where(eq(schema.settings.key, "loginAttemptLimit")); if (row) { const parsed = parseInt(row.value, 10); if (!Number.isNaN(parsed) && parsed > 0) return parsed; @@ -214,14 +275,20 @@ export async function authRoutes(app: FastifyInstance): Promise { } const body = parsed.data; - const user = db + // Postgres rejects NUL bytes (\x00) in text columns. Valid usernames + // never contain NUL, so such credentials can never match -- return 401 + // immediately (same result SQLite produced by running the query). + if (body.username.includes("\x00") || body.password.includes("\x00")) { + return reply.status(401).send({ error: "Invalid credentials" }); + } + + const [user] = await db .select() .from(schema.users) - .where(eq(schema.users.username, body.username)) - .get(); + .where(eq(schema.users.username, body.username)); if (!user || !user.passwordHash) { - auditLog(request.log, "LOGIN_FAILED", { + await auditLog(request.log, "LOGIN_FAILED", { username: sanitizeAuditInput(body.username), reason: "unknown_user", }); @@ -230,7 +297,7 @@ export async function authRoutes(app: FastifyInstance): Promise { const valid = await verifyPassword(body.password, user.passwordHash); if (!valid) { - auditLog(request.log, "LOGIN_FAILED", { + await auditLog(request.log, "LOGIN_FAILED", { username: sanitizeAuditInput(body.username), reason: "bad_password", }); @@ -241,17 +308,15 @@ export async function authRoutes(app: FastifyInstance): Promise { const token = createSessionToken(); const expiresAt = new Date(Date.now() + SESSION_DURATION_MS); - db.insert(schema.sessions) - .values({ - id: token, - userId: user.id, - expiresAt, - }) - .run(); + await db.insert(schema.sessions).values({ + id: token, + userId: user.id, + expiresAt, + }); - auditLog(request.log, "LOGIN_SUCCESS", { userId: user.id, username: user.username }); + await auditLog(request.log, "LOGIN_SUCCESS", { userId: user.id, username: user.username }); - const teamRow = db.select().from(schema.teams).where(eq(schema.teams.id, user.team)).get(); + const [teamRow] = await db.select().from(schema.teams).where(eq(schema.teams.id, user.team)); return reply.send({ token, @@ -260,7 +325,7 @@ export async function authRoutes(app: FastifyInstance): Promise { username: user.username, role: user.role, mustChangePassword: env.SKIP_MUST_CHANGE_PASSWORD ? false : user.mustChangePassword, - permissions: getPermissions(user.role), + permissions: await getPermissions(user.role), teamName: teamRow?.name ?? user.team, analyticsEnabled: user.analyticsEnabled ?? null, analyticsConsentShownAt: user.analyticsConsentShownAt?.getTime() ?? null, @@ -278,7 +343,10 @@ export async function authRoutes(app: FastifyInstance): Promise { let logoutUrl: string | undefined; if (token) { - const session = db.select().from(schema.sessions).where(eq(schema.sessions.id, token)).get(); + const [session] = await db + .select() + .from(schema.sessions) + .where(eq(schema.sessions.id, token)); if (session?.idToken && env.OIDC_ENABLED) { try { @@ -296,7 +364,7 @@ export async function authRoutes(app: FastifyInstance): Promise { } } - db.delete(schema.sessions).where(eq(schema.sessions.id, token)).run(); + await db.delete(schema.sessions).where(eq(schema.sessions.id, token)); } // Clear the session cookie @@ -307,7 +375,7 @@ export async function authRoutes(app: FastifyInstance): Promise { cookieReply.clearCookie("snapotter-session", { path: "/" }); } - auditLog(request.log, "LOGOUT", { userId: user?.id }); + await auditLog(request.log, "LOGOUT", { userId: user?.id }); return reply.send({ ok: true, ...(logoutUrl && { logoutUrl }) }); }); @@ -320,7 +388,7 @@ export async function authRoutes(app: FastifyInstance): Promise { username: "anonymous", role: "admin", mustChangePassword: false, - permissions: getPermissions("admin"), + permissions: await getPermissions("admin"), analyticsEnabled: null, analyticsConsentShownAt: null, analyticsConsentRemindAt: null, @@ -334,16 +402,16 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.status(401).send({ error: "No session token provided" }); } - const session = db.select().from(schema.sessions).where(eq(schema.sessions.id, token)).get(); + const [session] = await db.select().from(schema.sessions).where(eq(schema.sessions.id, token)); if (!session || session.expiresAt < new Date()) { if (session) { - db.delete(schema.sessions).where(eq(schema.sessions.id, token)).run(); + await db.delete(schema.sessions).where(eq(schema.sessions.id, token)); } return reply.status(401).send({ error: "Session expired or invalid" }); } - const user = db.select().from(schema.users).where(eq(schema.users.id, session.userId)).get(); + const [user] = await db.select().from(schema.users).where(eq(schema.users.id, session.userId)); if (!user) { return reply.status(401).send({ error: "User not found" }); @@ -355,7 +423,7 @@ export async function authRoutes(app: FastifyInstance): Promise { username: user.username, role: user.role, mustChangePassword: env.SKIP_MUST_CHANGE_PASSWORD ? false : user.mustChangePassword, - permissions: getPermissions(user.role), + permissions: await getPermissions(user.role), authProvider: user.authProvider ?? "local", loginMethod: session.idToken ? "oidc" : "local", email: user.email ?? null, @@ -391,7 +459,7 @@ export async function authRoutes(app: FastifyInstance): Promise { }); } - const user = db.select().from(schema.users).where(eq(schema.users.id, authUser.id)).get(); + const [user] = await db.select().from(schema.users).where(eq(schema.users.id, authUser.id)); if (!user) { return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" }); @@ -413,38 +481,36 @@ export async function authRoutes(app: FastifyInstance): Promise { const newHash = await hashPassword(body.newPassword); - db.update(schema.users) + await db + .update(schema.users) .set({ passwordHash: newHash, mustChangePassword: false, updatedAt: new Date() }) - .where(eq(schema.users.id, authUser.id)) - .run(); + .where(eq(schema.users.id, authUser.id)); // Invalidate all other sessions for this user const currentToken = extractToken(request); - const allSessions = db - .select() - .from(schema.sessions) - .where(eq(schema.sessions.userId, authUser.id)) - .all(); - for (const s of allSessions) { - if (s.id !== currentToken) { - db.delete(schema.sessions).where(eq(schema.sessions.id, s.id)).run(); - } + if (currentToken) { + await db + .delete(schema.sessions) + .where(and(eq(schema.sessions.userId, authUser.id), ne(schema.sessions.id, currentToken))); } - // Revoke all API keys — if credentials were compromised, keys must be rotated too - db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, authUser.id)).run(); + // Revoke all API keys - if credentials were compromised, keys must be rotated too + await db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, authUser.id)); - auditLog(request.log, "PASSWORD_CHANGED", { userId: authUser.id, username: authUser.username }); + await auditLog(request.log, "PASSWORD_CHANGED", { + userId: authUser.id, + username: authUser.username, + }); return reply.send({ ok: true }); }); // GET /api/auth/users (admin only) app.get("/api/auth/users", async (request: FastifyRequest, reply: FastifyReply) => { - const admin = requirePermission("users:manage")(request, reply); + const admin = await requirePermission("users:manage")(request, reply); if (!admin) return; - const users = db + const users = await db .select({ id: schema.users.id, username: schema.users.username, @@ -456,11 +522,10 @@ export async function authRoutes(app: FastifyInstance): Promise { passwordHash: schema.users.passwordHash, createdAt: schema.users.createdAt, }) - .from(schema.users) - .all(); + .from(schema.users); // Build a team ID -> name lookup - const allTeams = db.select().from(schema.teams).all(); + const allTeams = await db.select().from(schema.teams); const teamNameById = new Map(allTeams.map((t) => [t.id, t.name])); return reply.send({ @@ -481,7 +546,7 @@ export async function authRoutes(app: FastifyInstance): Promise { // POST /api/auth/register (admin only) app.post("/api/auth/register", async (request: FastifyRequest, reply: FastifyReply) => { - const admin = requirePermission("users:manage")(request, reply); + const admin = await requirePermission("users:manage")(request, reply); if (!admin) return; const parsed = registerSchema.safeParse(request.body); @@ -515,11 +580,10 @@ export async function authRoutes(app: FastifyInstance): Promise { if (validBuiltinRoles.includes(body.role)) { role = body.role; } else { - const customRole = db + const [customRole] = await db .select() .from(schema.roles) - .where(eq(schema.roles.name, body.role)) - .get(); + .where(eq(schema.roles.name, body.role)); if (customRole) { role = body.role; } @@ -544,35 +608,32 @@ export async function authRoutes(app: FastifyInstance): Promise { if (requestedTeam) { // Look up by name first, then fall back to ID - const teamByName = db + const [teamByName] = await db .select() .from(schema.teams) - .where(eq(schema.teams.name, requestedTeam)) - .get(); - const teamById = teamByName - ? null - : db.select().from(schema.teams).where(eq(schema.teams.id, requestedTeam)).get(); + .where(eq(schema.teams.name, requestedTeam)); + const [teamById] = teamByName + ? [null] + : await db.select().from(schema.teams).where(eq(schema.teams.id, requestedTeam)); const found = teamByName || teamById; if (!found) return reply.status(400).send({ error: "Team not found", code: "VALIDATION_ERROR" }); teamId = found.id; teamName = found.name; } else { - const defaultTeam = db + const [defaultTeam] = await db .select() .from(schema.teams) - .where(eq(schema.teams.name, "Default")) - .get(); + .where(eq(schema.teams.name, "Default")); teamId = defaultTeam?.id || "default-team-00000000"; teamName = defaultTeam?.name || "Default"; } // Check for duplicate username first (so 409 takes priority over limit) - const existing = db + const [existing] = await db .select() .from(schema.users) - .where(eq(schema.users.username, body.username)) - .get(); + .where(eq(schema.users.username, body.username)); if (existing) { return reply.status(409).send({ @@ -583,7 +644,8 @@ export async function authRoutes(app: FastifyInstance): Promise { // Check user limit (0 = unlimited) if (MAX_USERS > 0) { - const userCount = db.select().from(schema.users).all().length; + const allUsers = await db.select().from(schema.users); + const userCount = allUsers.length; if (userCount >= MAX_USERS) { return reply.status(403).send({ error: `User limit reached (${MAX_USERS} max)`, @@ -595,18 +657,16 @@ export async function authRoutes(app: FastifyInstance): Promise { const id = randomUUID(); const passwordHash = await hashPassword(body.password); - db.insert(schema.users) - .values({ - id, - username: body.username, - passwordHash, - role, - team: teamId, - mustChangePassword: true, - }) - .run(); + await db.insert(schema.users).values({ + id, + username: body.username, + passwordHash, + role, + team: teamId, + mustChangePassword: true, + }); - auditLog(request.log, "USER_CREATED", { + await auditLog(request.log, "USER_CREATED", { adminId: admin.id, newUserId: id, newUsername: body.username, @@ -625,7 +685,7 @@ export async function authRoutes(app: FastifyInstance): Promise { app.put( "/api/auth/users/:id", async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { - const admin = requirePermission("users:manage")(request, reply); + const admin = await requirePermission("users:manage")(request, reply); if (!admin) return; const { id } = request.params; @@ -638,7 +698,7 @@ export async function authRoutes(app: FastifyInstance): Promise { } const body = parsed.data; - const user = db.select().from(schema.users).where(eq(schema.users.id, id)).get(); + const [user] = await db.select().from(schema.users).where(eq(schema.users.id, id)); if (!user) { return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" }); @@ -663,9 +723,10 @@ export async function authRoutes(app: FastifyInstance): Promise { if (body.role) { const validBuiltinRoles = ["admin", "editor", "user"]; - const isValid = - validBuiltinRoles.includes(body.role) || - db.select().from(schema.roles).where(eq(schema.roles.name, body.role)).get(); + const [customRoleRow] = validBuiltinRoles.includes(body.role) + ? [null] + : await db.select().from(schema.roles).where(eq(schema.roles.name, body.role)); + const isValid = validBuiltinRoles.includes(body.role) || customRoleRow; if (isValid) { // Prevent removing your own admin role if (id === admin.id && body.role !== "admin") { @@ -677,11 +738,10 @@ export async function authRoutes(app: FastifyInstance): Promise { // Last admin protection if (user.role === "admin" && body.role !== "admin") { - const adminCount = db + const [adminCount] = await db .select({ count: sql`COUNT(*)` }) .from(schema.users) - .where(eq(schema.users.role, "admin")) - .get(); + .where(eq(schema.users.role, "admin")); if (adminCount && adminCount.count <= 1) { return reply.status(400).send({ error: "Cannot demote the last admin", @@ -696,14 +756,13 @@ export async function authRoutes(app: FastifyInstance): Promise { if (body.team?.trim()) { // Look up by name first, then fall back to ID - const teamByName = db + const [teamByName] = await db .select() .from(schema.teams) - .where(eq(schema.teams.name, body.team.trim())) - .get(); - const teamById = teamByName - ? null - : db.select().from(schema.teams).where(eq(schema.teams.id, body.team.trim())).get(); + .where(eq(schema.teams.name, body.team.trim())); + const [teamById] = teamByName + ? [null] + : await db.select().from(schema.teams).where(eq(schema.teams.id, body.team.trim())); const found = teamByName || teamById; if (!found) { return reply.status(400).send({ error: "Team not found", code: "VALIDATION_ERROR" }); @@ -711,18 +770,18 @@ export async function authRoutes(app: FastifyInstance): Promise { updates.team = found.id; } - db.update(schema.users).set(updates).where(eq(schema.users.id, id)).run(); + await db.update(schema.users).set(updates).where(eq(schema.users.id, id)); // Invalidate all sessions when role changes to force re-login with new permissions if (updates.role && updates.role !== user.role) { - db.delete(schema.sessions).where(eq(schema.sessions.userId, id)).run(); + await db.delete(schema.sessions).where(eq(schema.sessions.userId, id)); request.log.info( { targetUserId: id, oldRole: user.role, newRole: updates.role }, "Sessions invalidated due to role change", ); } - auditLog(request.log, "USER_UPDATED", { + await auditLog(request.log, "USER_UPDATED", { adminId: admin.id, targetUserId: id, changes: { role: updates.role, team: updates.team }, @@ -736,7 +795,7 @@ export async function authRoutes(app: FastifyInstance): Promise { app.post( "/api/auth/users/:id/reset-password", async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { - const admin = requirePermission("users:manage")(request, reply); + const admin = await requirePermission("users:manage")(request, reply); if (!admin) return; const { id } = request.params; @@ -757,7 +816,7 @@ export async function authRoutes(app: FastifyInstance): Promise { }); } - const user = db.select().from(schema.users).where(eq(schema.users.id, id)).get(); + const [user] = await db.select().from(schema.users).where(eq(schema.users.id, id)); if (!user) { return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" }); @@ -772,18 +831,18 @@ export async function authRoutes(app: FastifyInstance): Promise { const newHash = await hashPassword(body.newPassword); - db.update(schema.users) + await db + .update(schema.users) .set({ passwordHash: newHash, mustChangePassword: true, updatedAt: new Date() }) - .where(eq(schema.users.id, id)) - .run(); + .where(eq(schema.users.id, id)); // Invalidate all sessions for this user - db.delete(schema.sessions).where(eq(schema.sessions.userId, id)).run(); + await db.delete(schema.sessions).where(eq(schema.sessions.userId, id)); // Revoke all API keys - db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, id)).run(); + await db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, id)); - auditLog(request.log, "PASSWORD_RESET", { + await auditLog(request.log, "PASSWORD_RESET", { adminId: admin.id, targetUserId: id, targetUsername: user.username, @@ -797,7 +856,7 @@ export async function authRoutes(app: FastifyInstance): Promise { app.delete( "/api/auth/users/:id", async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { - const admin = requirePermission("users:manage")(request, reply); + const admin = await requirePermission("users:manage")(request, reply); if (!admin) return; const { id } = request.params; @@ -809,19 +868,19 @@ export async function authRoutes(app: FastifyInstance): Promise { }); } - const user = db.select().from(schema.users).where(eq(schema.users.id, id)).get(); + const [user] = await db.select().from(schema.users).where(eq(schema.users.id, id)); if (!user) { return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" }); } // Delete associated sessions - db.delete(schema.sessions).where(eq(schema.sessions.userId, id)).run(); + await db.delete(schema.sessions).where(eq(schema.sessions.userId, id)); // Delete the user (cascades to api_keys via FK) - db.delete(schema.users).where(eq(schema.users.id, id)).run(); + await db.delete(schema.users).where(eq(schema.users.id, id)); - auditLog(request.log, "USER_DELETED", { + await auditLog(request.log, "USER_DELETED", { adminId: admin.id, deletedUserId: id, deletedUsername: user.username, @@ -886,22 +945,21 @@ export async function authMiddleware(app: FastifyInstance): Promise { return reply.status(401).send({ error: "Authentication required" }); } - const session = db.select().from(schema.sessions).where(eq(schema.sessions.id, token)).get(); + const [session] = await db.select().from(schema.sessions).where(eq(schema.sessions.id, token)); if (!session || session.expiresAt < new Date()) { if (session) { - db.delete(schema.sessions).where(eq(schema.sessions.id, token)).run(); + await db.delete(schema.sessions).where(eq(schema.sessions.id, token)); } // Try API key authentication if token has si_ prefix if (token.startsWith("si_")) { const prefix = computeKeyPrefix(token); // Lookup by prefix (O(1) instead of scanning all keys) - const candidates = db + const candidates = await db .select() .from(schema.apiKeys) - .where(eq(schema.apiKeys.keyPrefix, prefix)) - .all(); + .where(eq(schema.apiKeys.keyPrefix, prefix)); // Fall back to full scan for legacy keys without a prefix (bounded to 100) let keysToCheck: typeof candidates; if (candidates.length > 0) { @@ -910,43 +968,36 @@ export async function authMiddleware(app: FastifyInstance): Promise { request.log.warn( "Legacy API key lookup triggered (no keyPrefix match). Migrate keys to use prefix-based lookup.", ); - keysToCheck = db - .select() - .from(schema.apiKeys) - .all() - .filter((k) => !k.keyPrefix) - .slice(0, 100); + const allKeys = await db.select().from(schema.apiKeys); + keysToCheck = allKeys.filter((k) => !k.keyPrefix).slice(0, 100); } for (const key of keysToCheck) { const matches = await verifyPassword(token, key.keyHash); if (matches) { // Check expiration if (key.expiresAt && key.expiresAt < new Date()) { - // Key expired — skip it + // Key expired - skip it continue; } // Backfill prefix for legacy keys if (!key.keyPrefix) { - db.update(schema.apiKeys) + await db + .update(schema.apiKeys) .set({ keyPrefix: prefix, lastUsedAt: new Date() }) - .where(eq(schema.apiKeys.id, key.id)) - .run(); + .where(eq(schema.apiKeys.id, key.id)); } else { - db.update(schema.apiKeys) + await db + .update(schema.apiKeys) .set({ lastUsedAt: new Date() }) - .where(eq(schema.apiKeys.id, key.id)) - .run(); + .where(eq(schema.apiKeys.id, key.id)); } // Load the user - const apiUser = db + const [apiUser] = await db .select() .from(schema.users) - .where(eq(schema.users.id, key.userId)) - .get(); + .where(eq(schema.users.id, key.userId)); if (apiUser) { - const keyPermissions = key.permissions - ? JSON.parse(key.permissions as string) - : undefined; + const keyPermissions = key.permissions ?? undefined; (request as FastifyRequest & { user?: AuthUser }).user = { id: apiUser.id, username: apiUser.username, @@ -964,7 +1015,7 @@ export async function authMiddleware(app: FastifyInstance): Promise { return reply.status(401).send({ error: "Session expired or invalid" }); } - const user = db.select().from(schema.users).where(eq(schema.users.id, session.userId)).get(); + const [user] = await db.select().from(schema.users).where(eq(schema.users.id, session.userId)); if (!user) { if (isPublic) return; diff --git a/apps/api/src/plugins/oidc.ts b/apps/api/src/plugins/oidc.ts index f7d8049c..464c925e 100644 --- a/apps/api/src/plugins/oidc.ts +++ b/apps/api/src/plugins/oidc.ts @@ -103,22 +103,20 @@ function sanitizeUsername(raw: string): string { return sanitized; } -function findUniqueUsername(base: string): string { - const existing = db +async function findUniqueUsername(base: string): Promise { + const [existing] = await db .select({ username: schema.users.username }) .from(schema.users) - .where(eq(schema.users.username, base)) - .get(); + .where(eq(schema.users.username, base)); if (!existing) return base; for (let i = 2; i <= 1000; i++) { const candidate = `${base}_${i}`; - const taken = db + const [taken] = await db .select({ username: schema.users.username }) .from(schema.users) - .where(eq(schema.users.username, candidate)) - .get(); + .where(eq(schema.users.username, candidate)); if (!taken) return candidate; } @@ -229,7 +227,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise { { error: query.error, description: query.error_description }, "OIDC IdP returned error", ); - auditLog(request.log, "OIDC_LOGIN_FAILED", { + await auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: sanitizeAuditInput(String(query.error)), }); return redirectToLogin(reply, "oidc_auth_failed"); @@ -259,7 +257,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise { }); } catch (err) { request.log.error({ err }, "OIDC token exchange failed"); - auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: "token_exchange_failed" }); + await auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: "token_exchange_failed" }); return redirectToLogin(reply, "oidc_auth_failed"); } @@ -267,7 +265,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise { const claims = tokenResponse.claims(); if (!claims) { request.log.error("OIDC callback: no ID token claims"); - auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: "no_id_token" }); + await auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: "no_id_token" }); return redirectToLogin(reply, "oidc_auth_failed"); } @@ -282,41 +280,41 @@ export async function oidcRoutes(app: FastifyInstance): Promise { let userId: string | null = null; // 4a. Find by externalId (OIDC subject) - const existingByExtId = db + const [existingByExtId] = await db .select() .from(schema.users) .where(eq(schema.users.externalId, sub)) - .get(); + .limit(1); if (existingByExtId) { userId = existingByExtId.id; // Update email if changed if (email && email !== existingByExtId.email) { - db.update(schema.users) + await db + .update(schema.users) .set({ email, updatedAt: new Date() }) - .where(eq(schema.users.id, existingByExtId.id)) - .run(); + .where(eq(schema.users.id, existingByExtId.id)); } } // 4b. Auto-link: match by email if (!userId && env.OIDC_AUTO_LINK_USERS && email && emailVerified) { - const existingByEmail = db + const [existingByEmail] = await db .select() .from(schema.users) .where(eq(schema.users.email, email)) - .get(); + .limit(1); if (existingByEmail) { - db.update(schema.users) + await db + .update(schema.users) .set({ externalId: sub, updatedAt: new Date(), }) - .where(eq(schema.users.id, existingByEmail.id)) - .run(); + .where(eq(schema.users.id, existingByEmail.id)); userId = existingByEmail.id; - auditLog(request.log, "OIDC_USER_LINKED", { + await auditLog(request.log, "OIDC_USER_LINKED", { userId: existingByEmail.id, username: existingByEmail.username, email, @@ -328,40 +326,37 @@ export async function oidcRoutes(app: FastifyInstance): Promise { if (!userId && env.OIDC_AUTO_CREATE_USERS) { // Check user limit if (env.MAX_USERS > 0) { - const countResult = db.select({ count: sql`COUNT(*)` }).from(schema.users).get(); + const [countResult] = await db.select({ count: sql`COUNT(*)` }).from(schema.users); if (countResult && countResult.count >= env.MAX_USERS) { request.log.warn("OIDC auto-create blocked: user limit reached"); return redirectToLogin(reply, "oidc_user_limit_reached"); } } - const uniqueUsername = findUniqueUsername(username); + const uniqueUsername = await findUniqueUsername(username); const newUserId = randomUUID(); // Look up the default team - const defaultTeam = db + const [defaultTeam] = await db .select() .from(schema.teams) - .where(eq(schema.teams.name, "Default")) - .get(); + .where(eq(schema.teams.name, "Default")); const teamId = defaultTeam?.id ?? "default-team-00000000"; - db.insert(schema.users) - .values({ - id: newUserId, - username: uniqueUsername, - passwordHash: null, - role: env.OIDC_DEFAULT_ROLE, - team: teamId, - mustChangePassword: false, - authProvider: "oidc", - externalId: sub, - email: email ?? null, - }) - .run(); + await db.insert(schema.users).values({ + id: newUserId, + username: uniqueUsername, + passwordHash: null, + role: env.OIDC_DEFAULT_ROLE, + team: teamId, + mustChangePassword: false, + authProvider: "oidc", + externalId: sub, + email: email ?? null, + }); userId = newUserId; - auditLog(request.log, "OIDC_USER_CREATED", { + await auditLog(request.log, "OIDC_USER_CREATED", { userId: newUserId, username: uniqueUsername, email, @@ -372,7 +367,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise { // 4d. No user found and no auto-create if (!userId) { request.log.warn({ sub, email }, "OIDC user not authorized"); - auditLog(request.log, "OIDC_LOGIN_FAILED", { + await auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: "user_not_authorized", sub: sanitizeAuditInput(String(sub)), }); @@ -383,19 +378,17 @@ export async function oidcRoutes(app: FastifyInstance): Promise { const token = createSessionToken(); const expiresAt = new Date(Date.now() + SESSION_DURATION_MS); - db.insert(schema.sessions) - .values({ - id: token, - userId, - expiresAt, - idToken, - }) - .run(); + await db.insert(schema.sessions).values({ + id: token, + userId, + expiresAt, + idToken, + }); // Fetch the user for audit logging - const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); + const [user] = await db.select().from(schema.users).where(eq(schema.users.id, userId)); - auditLog(request.log, "OIDC_LOGIN_SUCCESS", { + await auditLog(request.log, "OIDC_LOGIN_SUCCESS", { userId, username: user?.username ?? username, }); diff --git a/apps/api/src/routes/analytics.ts b/apps/api/src/routes/analytics.ts index 080416bd..f78c2815 100644 --- a/apps/api/src/routes/analytics.ts +++ b/apps/api/src/routes/analytics.ts @@ -23,11 +23,10 @@ export async function analyticsRoutes(app: FastifyInstance): Promise { }; } - const row = db + const [row] = await db .select() .from(schema.settings) - .where(eq(schema.settings.key, "instance_id")) - .get(); + .where(eq(schema.settings.key, "instance_id")); return { enabled: true, @@ -56,28 +55,28 @@ export async function analyticsRoutes(app: FastifyInstance): Promise { if (body.remindLater) { const remindAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); - db.update(schema.users) + await db + .update(schema.users) .set({ analyticsEnabled: null, analyticsConsentShownAt: now, analyticsConsentRemindAt: remindAt, updatedAt: now, }) - .where(eq(schema.users.id, user.id)) - .run(); + .where(eq(schema.users.id, user.id)); return reply.send({ ok: true, analyticsEnabled: null }); } const enabled = body.enabled === true; - db.update(schema.users) + await db + .update(schema.users) .set({ analyticsEnabled: enabled, analyticsConsentShownAt: now, analyticsConsentRemindAt: null, updatedAt: now, }) - .where(eq(schema.users.id, user.id)) - .run(); + .where(eq(schema.users.id, user.id)); return reply.send({ ok: true, analyticsEnabled: enabled }); }); } diff --git a/apps/api/src/routes/api-keys.ts b/apps/api/src/routes/api-keys.ts index 7909cfec..87016e0e 100644 --- a/apps/api/src/routes/api-keys.ts +++ b/apps/api/src/routes/api-keys.ts @@ -38,7 +38,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise { let scopedPermissions: string[] | null = null; if (body.permissions && body.permissions.length > 0) { - const userPerms = getPermissions(user.role); + const userPerms = await getPermissions(user.role); const permSet = new Set(userPerms); const invalid = body.permissions.filter((p) => !permSet.has(p)); if (invalid.length > 0) { @@ -73,22 +73,20 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise { const id = randomUUID(); try { - db.insert(schema.apiKeys) - .values({ - id, - userId: user.id, - keyHash, - keyPrefix, - name, - permissions: scopedPermissions ? JSON.stringify(scopedPermissions) : null, - expiresAt, - }) - .run(); + await db.insert(schema.apiKeys).values({ + id, + userId: user.id, + keyHash, + keyPrefix, + name, + permissions: scopedPermissions, + expiresAt, + }); } catch { return reply.status(409).send({ error: "Failed to create API key" }); } - auditLog(request.log, "API_KEY_CREATED", { userId: user.id, keyId: id, keyName: name }); + await auditLog(request.log, "API_KEY_CREATED", { userId: user.id, keyId: id, keyName: name }); // Return the raw key ONCE — it cannot be retrieved again return reply.status(201).send({ @@ -114,19 +112,18 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise { lastUsedAt: schema.apiKeys.lastUsedAt, expiresAt: schema.apiKeys.expiresAt, }; - const keys = hasEffectivePermission(user, "apikeys:all") - ? db.select(selectFields).from(schema.apiKeys).all() - : db + const keys = (await hasEffectivePermission(user, "apikeys:all")) + ? await db.select(selectFields).from(schema.apiKeys) + : await db .select(selectFields) .from(schema.apiKeys) - .where(eq(schema.apiKeys.userId, user.id)) - .all(); + .where(eq(schema.apiKeys.userId, user.id)); return reply.send({ apiKeys: keys.map((k) => ({ id: k.id, name: k.name, - permissions: k.permissions ? JSON.parse(k.permissions) : null, + permissions: k.permissions ?? null, createdAt: k.createdAt.toISOString(), lastUsedAt: k.lastUsedAt?.toISOString() ?? null, expiresAt: k.expiresAt?.toISOString() ?? null, @@ -144,11 +141,10 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise { const { id } = request.params; // Ensure the key belongs to the requesting user - const existing = db + const [existing] = await db .select() .from(schema.apiKeys) - .where(and(eq(schema.apiKeys.id, id), eq(schema.apiKeys.userId, user.id))) - .get(); + .where(and(eq(schema.apiKeys.id, id), eq(schema.apiKeys.userId, user.id))); if (!existing) { return reply.status(404).send({ @@ -157,9 +153,9 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise { }); } - db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id)).run(); + await db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id)); - auditLog(request.log, "API_KEY_DELETED", { userId: user.id, keyId: id }); + await auditLog(request.log, "API_KEY_DELETED", { userId: user.id, keyId: id }); return reply.send({ ok: true }); }, diff --git a/apps/api/src/routes/audit-log.ts b/apps/api/src/routes/audit-log.ts index 2ecac445..b5a75d03 100644 --- a/apps/api/src/routes/audit-log.ts +++ b/apps/api/src/routes/audit-log.ts @@ -18,7 +18,7 @@ export async function auditLogRoutes(app: FastifyInstance): Promise { }>, reply: FastifyReply, ) => { - const user = requirePermission("audit:read")(request, reply); + const user = await requirePermission("audit:read")(request, reply); if (!user) return; const page = Math.max(1, parseInt(request.query.page ?? "1", 10) || 1); @@ -45,20 +45,18 @@ export async function auditLogRoutes(app: FastifyInstance): Promise { const where = conditions.length > 0 ? and(...conditions) : undefined; - const entries = db + const entries = await db .select() .from(schema.auditLog) .where(where) .orderBy(desc(schema.auditLog.createdAt)) .limit(limit) - .offset(offset) - .all(); + .offset(offset); - const countResult = db - .select({ count: sql`count(*)` }) + const [countResult] = await db + .select({ count: sql`count(*)::int` }) .from(schema.auditLog) - .where(where) - .get(); + .where(where); return reply.send({ entries: entries.map((e) => ({ @@ -68,7 +66,7 @@ export async function auditLogRoutes(app: FastifyInstance): Promise { action: e.action, targetType: e.targetType, targetId: e.targetId, - details: e.details ? JSON.parse(e.details) : null, + details: e.details ?? null, ipAddress: e.ipAddress, createdAt: e.createdAt.toISOString(), })), diff --git a/apps/api/src/routes/config.ts b/apps/api/src/routes/config.ts index f5a67af6..f4a8093f 100644 --- a/apps/api/src/routes/config.ts +++ b/apps/api/src/routes/config.ts @@ -4,11 +4,10 @@ import { db, schema } from "../db/index.js"; export async function configRoutes(app: FastifyInstance): Promise { app.get("/api/v1/config/locale", async (_request, reply) => { - const row = db + const [row] = await db .select() .from(schema.settings) - .where(eq(schema.settings.key, "defaultLocale")) - .get(); + .where(eq(schema.settings.key, "defaultLocale")); return reply.send({ defaultLocale: row?.value ?? "en" }); }); } diff --git a/apps/api/src/routes/features.ts b/apps/api/src/routes/features.ts index e0c08eb8..d2a00f21 100644 --- a/apps/api/src/routes/features.ts +++ b/apps/api/src/routes/features.ts @@ -129,7 +129,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise app.post( "/api/v1/admin/features/:bundleId/install", async (request: FastifyRequest<{ Params: BundleIdParams }>, reply: FastifyReply) => { - const admin = requirePermission("features:manage")(request, reply); + const admin = await requirePermission("features:manage")(request, reply); if (!admin) return; const { bundleId } = request.params; @@ -280,7 +280,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise app.post( "/api/v1/admin/features/:bundleId/uninstall", async (request: FastifyRequest<{ Params: BundleIdParams }>, reply: FastifyReply) => { - const admin = requirePermission("features:manage")(request, reply); + const admin = await requirePermission("features:manage")(request, reply); if (!admin) return; const { bundleId } = request.params; @@ -355,7 +355,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise app.get( "/api/v1/admin/features/disk-usage", async (request: FastifyRequest, reply: FastifyReply) => { - const admin = requirePermission("features:manage")(request, reply); + const admin = await requirePermission("features:manage")(request, reply); if (!admin) return; const totalBytes = getDirSize(getAiDir()); diff --git a/apps/api/src/routes/pipeline.ts b/apps/api/src/routes/pipeline.ts index bc6060ad..f750fb24 100644 --- a/apps/api/src/routes/pipeline.ts +++ b/apps/api/src/routes/pipeline.ts @@ -352,15 +352,13 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise !row.userId || row.userId === user.id); @@ -393,7 +391,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise>(); + +/** Await any pending persist writes for a specific job (used by tests). */ +export async function drainPersistQueue(jobId: string): Promise { + const pending = persistQueues.get(jobId); + if (pending) await pending; +} + +function enqueuePersist(jobId: string, fn: () => Promise): void { + const prev = persistQueues.get(jobId) ?? Promise.resolve(); + const next = prev.then(fn, fn); // run even if prior rejected + persistQueues.set(jobId, next); + // Clean up the map entry once the queue drains + next.then(() => { + if (persistQueues.get(jobId) === next) persistQueues.delete(jobId); + }); +} + +async function persistJobProgress(progress: JobProgress): Promise { try { const completionRatio = progress.totalFiles > 0 ? progress.completedFiles / progress.totalFiles : 0; - const existing = db + const [existing] = await db .select({ id: schema.jobs.id }) .from(schema.jobs) - .where(eq(schema.jobs.id, progress.jobId)) - .get(); + .where(eq(schema.jobs.id, progress.jobId)); if (existing) { - db.update(schema.jobs) + await db + .update(schema.jobs) .set({ status: progress.status, progress: completionRatio, @@ -66,26 +91,25 @@ function persistJobProgress(progress: JobProgress): void { completedAt: progress.status === "completed" || progress.status === "failed" ? new Date() : null, }) - .where(eq(schema.jobs.id, progress.jobId)) - .run(); + .where(eq(schema.jobs.id, progress.jobId)); } else { - db.insert(schema.jobs) - .values({ - id: progress.jobId, - type: "batch", - status: progress.status, - progress: completionRatio, - inputFiles: JSON.stringify({ totalFiles: progress.totalFiles }), - error: progress.errors.length > 0 ? JSON.stringify(progress.errors) : null, - }) - .run(); + await db.insert(schema.jobs).values({ + id: progress.jobId, + type: "batch", + status: progress.status, + progress: completionRatio, + inputFiles: { totalFiles: progress.totalFiles }, + error: progress.errors.length > 0 ? JSON.stringify(progress.errors) : null, + }); } } catch { // DB persistence is best-effort; don't break real-time SSE } } -function persistSingleFileProgress(progress: Omit): void { +async function persistSingleFileProgress( + progress: Omit, +): Promise { try { const status = progress.phase === "complete" @@ -93,33 +117,30 @@ function persistSingleFileProgress(progress: Omit): : progress.phase === "failed" ? "failed" : "processing"; - const existing = db + const [existing] = await db .select({ id: schema.jobs.id }) .from(schema.jobs) - .where(eq(schema.jobs.id, progress.jobId)) - .get(); + .where(eq(schema.jobs.id, progress.jobId)); if (existing) { - db.update(schema.jobs) + await db + .update(schema.jobs) .set({ status, progress: progress.percent / 100, error: progress.error ?? null, completedAt: status === "completed" || status === "failed" ? new Date() : null, }) - .where(eq(schema.jobs.id, progress.jobId)) - .run(); + .where(eq(schema.jobs.id, progress.jobId)); } else { - db.insert(schema.jobs) - .values({ - id: progress.jobId, - type: "single", - status, - progress: progress.percent / 100, - inputFiles: "[]", - error: progress.error ?? null, - }) - .run(); + await db.insert(schema.jobs).values({ + id: progress.jobId, + type: "single", + status, + progress: progress.percent / 100, + inputFiles: [], + error: progress.error ?? null, + }); } } catch { // Best-effort @@ -130,27 +151,25 @@ function persistSingleFileProgress(progress: Omit): * Mark any jobs left in "processing" or "queued" state as failed. * Called once at startup to recover from unclean shutdown. */ -export function recoverStaleJobs(): void { +export async function recoverStaleJobs(): Promise { try { - const result = db + const result = await db .update(schema.jobs) .set({ status: "failed", error: "Server restarted while job was in progress", completedAt: new Date(), }) - .where(eq(schema.jobs.status, "processing")) - .run(); - const result2 = db + .where(eq(schema.jobs.status, "processing")); + const result2 = await db .update(schema.jobs) .set({ status: "failed", error: "Server restarted while job was queued", completedAt: new Date(), }) - .where(eq(schema.jobs.status, "queued")) - .run(); - const total = result.changes + result2.changes; + .where(eq(schema.jobs.status, "queued")); + const total = (result.rowCount ?? 0) + (result2.rowCount ?? 0); if (total > 0) { console.log(`Recovered ${total} stale jobs from previous run`); } @@ -166,7 +185,7 @@ export function recoverStaleJobs(): void { */ export function updateJobProgress(progress: JobProgress): void { jobProgressStore.set(progress.jobId, progress); - persistJobProgress(progress); + enqueuePersist(progress.jobId, () => persistJobProgress(progress)); // Notify all SSE listeners (add type: "batch" so the frontend can distinguish // batch events from single-file events in the shared SSE stream) const subs = listeners.get(progress.jobId); @@ -187,7 +206,7 @@ export function updateJobProgress(progress: JobProgress): void { export function updateSingleFileProgress(progress: Omit): void { const event: SingleFileProgress = { ...progress, type: "single" }; - persistSingleFileProgress(progress); + enqueuePersist(progress.jobId, () => persistSingleFileProgress(progress)); if (progress.phase === "complete" || progress.phase === "failed") { if (singleFileCompletions.size >= 10_000) { diff --git a/apps/api/src/routes/roles.ts b/apps/api/src/routes/roles.ts index a14ceca8..d7e208a7 100644 --- a/apps/api/src/routes/roles.ts +++ b/apps/api/src/routes/roles.ts @@ -53,18 +53,17 @@ const updateRoleSchema = z.object({ export async function rolesRoutes(app: FastifyInstance): Promise { // GET /api/v1/roles — List all roles (requires audit:read to view) app.get("/api/v1/roles", async (request: FastifyRequest, reply: FastifyReply) => { - const user = requirePermission("audit:read")(request, reply); + const user = await requirePermission("audit:read")(request, reply); if (!user) return; - const roles = db.select().from(schema.roles).all(); - const userCounts = db + const roles = await db.select().from(schema.roles); + const userCounts = await db .select({ role: schema.users.role, - count: sql`COUNT(*)`, + count: sql`COUNT(*)::int`, }) .from(schema.users) - .groupBy(schema.users.role) - .all(); + .groupBy(schema.users.role); const countMap = new Map(userCounts.map((r) => [r.role, r.count])); return reply.send({ @@ -72,7 +71,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise { id: r.id, name: r.name, description: r.description, - permissions: JSON.parse(r.permissions), + permissions: r.permissions, isBuiltin: r.isBuiltin, userCount: countMap.get(r.name) ?? 0, createdAt: r.createdAt.toISOString(), @@ -83,7 +82,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise { // POST /api/v1/roles — Create custom role app.post("/api/v1/roles", async (request: FastifyRequest, reply: FastifyReply) => { - const user = requirePermission("users:manage")(request, reply); + const user = await requirePermission("users:manage")(request, reply); if (!user) return; const parsed = createRoleSchema.safeParse(request.body); @@ -102,24 +101,22 @@ export async function rolesRoutes(app: FastifyInstance): Promise { .send({ error: `Invalid permissions: ${invalid.join(", ")}`, code: "VALIDATION_ERROR" }); } - const existing = db.select().from(schema.roles).where(eq(schema.roles.name, name)).get(); + const [existing] = await db.select().from(schema.roles).where(eq(schema.roles.name, name)); if (existing) { return reply.status(409).send({ error: "Role name already exists", code: "CONFLICT" }); } const id = randomUUID(); - db.insert(schema.roles) - .values({ - id, - name, - description: description?.trim() ?? "", - permissions: JSON.stringify(permissions), - isBuiltin: false, - createdBy: user.id, - }) - .run(); + await db.insert(schema.roles).values({ + id, + name, + description: description?.trim() ?? "", + permissions, + isBuiltin: false, + createdBy: user.id, + }); - auditLog(request.log, "ROLE_CREATED", { adminId: user.id, roleId: id, roleName: name }); + await auditLog(request.log, "ROLE_CREATED", { adminId: user.id, roleId: id, roleName: name }); return reply.status(201).send({ id, @@ -134,11 +131,11 @@ export async function rolesRoutes(app: FastifyInstance): Promise { app.put( "/api/v1/roles/:id", async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { - const user = requirePermission("users:manage")(request, reply); + const user = await requirePermission("users:manage")(request, reply); if (!user) return; const { id } = request.params; - const role = db.select().from(schema.roles).where(eq(schema.roles.id, id)).get(); + const [role] = await db.select().from(schema.roles).where(eq(schema.roles.id, id)); if (!role) { return reply.status(404).send({ error: "Role not found", code: "NOT_FOUND" }); } @@ -159,15 +156,15 @@ export async function rolesRoutes(app: FastifyInstance): Promise { const updates: Record = { updatedAt: new Date() }; if (body.name) { - const dup = db.select().from(schema.roles).where(eq(schema.roles.name, body.name)).get(); + const [dup] = await db.select().from(schema.roles).where(eq(schema.roles.name, body.name)); if (dup && dup.id !== id) { return reply.status(409).send({ error: "Role name already exists", code: "CONFLICT" }); } // Update users on old role name to new name - db.update(schema.users) + await db + .update(schema.users) .set({ role: body.name }) - .where(eq(schema.users.role, role.name)) - .run(); + .where(eq(schema.users.role, role.name)); updates.name = body.name; } if (body.description !== undefined) { @@ -181,11 +178,11 @@ export async function rolesRoutes(app: FastifyInstance): Promise { code: "VALIDATION_ERROR", }); } - updates.permissions = JSON.stringify(body.permissions); + updates.permissions = body.permissions; } - db.update(schema.roles).set(updates).where(eq(schema.roles.id, id)).run(); - auditLog(request.log, "ROLE_UPDATED", { adminId: user.id, roleId: id }); + await db.update(schema.roles).set(updates).where(eq(schema.roles.id, id)); + await auditLog(request.log, "ROLE_UPDATED", { adminId: user.id, roleId: id }); return reply.send({ ok: true }); }, @@ -195,11 +192,11 @@ export async function rolesRoutes(app: FastifyInstance): Promise { app.delete( "/api/v1/roles/:id", async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { - const user = requirePermission("users:manage")(request, reply); + const user = await requirePermission("users:manage")(request, reply); if (!user) return; const { id } = request.params; - const role = db.select().from(schema.roles).where(eq(schema.roles.id, id)).get(); + const [role] = await db.select().from(schema.roles).where(eq(schema.roles.id, id)); if (!role) { return reply.status(404).send({ error: "Role not found", code: "NOT_FOUND" }); } @@ -209,13 +206,13 @@ export async function rolesRoutes(app: FastifyInstance): Promise { .send({ error: "Cannot delete built-in roles", code: "VALIDATION_ERROR" }); } - db.update(schema.users) + await db + .update(schema.users) .set({ role: "user", updatedAt: new Date() }) - .where(eq(schema.users.role, role.name)) - .run(); + .where(eq(schema.users.role, role.name)); - db.delete(schema.roles).where(eq(schema.roles.id, id)).run(); - auditLog(request.log, "ROLE_DELETED", { + await db.delete(schema.roles).where(eq(schema.roles.id, id)); + await auditLog(request.log, "ROLE_DELETED", { adminId: user.id, roleId: id, roleName: role.name, diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 1db49214..2b29dcc6 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -27,7 +27,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise { if (!user) return; const isAdmin = user.role === "admin"; - const rows = db.select().from(schema.settings).all(); + const rows = await db.select().from(schema.settings); const settings: Record = {}; for (const row of rows) { @@ -40,7 +40,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise { // PUT /api/v1/settings — Save settings (admin only) app.put("/api/v1/settings", async (request: FastifyRequest, reply: FastifyReply) => { - const admin = requirePermission("settings:write")(request, reply); + const admin = await requirePermission("settings:write")(request, reply); if (!admin) return; const parsed = settingsBodySchema.safeParse(request.body); @@ -75,20 +75,23 @@ export async function settingsRoutes(app: FastifyInstance): Promise { for (const { key, strValue } of entries) { // Upsert: insert or update on conflict - const existing = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get(); + const [existing] = await db + .select() + .from(schema.settings) + .where(eq(schema.settings.key, key)); if (existing) { - db.update(schema.settings) + await db + .update(schema.settings) .set({ value: strValue, updatedAt: now }) - .where(eq(schema.settings.key, key)) - .run(); + .where(eq(schema.settings.key, key)); } else { - db.insert(schema.settings).values({ key, value: strValue }).run(); + await db.insert(schema.settings).values({ key, value: strValue }); } } if (entries.length > 0) { - auditLog(request.log, "SETTINGS_UPDATED", { + await auditLog(request.log, "SETTINGS_UPDATED", { adminId: admin.id, username: admin.username, keys: entries.map((e) => e.key), @@ -111,7 +114,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise { return reply.status(403).send({ error: "Forbidden", code: "FORBIDDEN" }); } - const row = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get(); + const [row] = await db.select().from(schema.settings).where(eq(schema.settings.key, key)); if (!row) { return reply.status(404).send({ diff --git a/apps/api/src/routes/teams.ts b/apps/api/src/routes/teams.ts index 5ebebc2b..21d804ab 100644 --- a/apps/api/src/routes/teams.ts +++ b/apps/api/src/routes/teams.ts @@ -29,18 +29,17 @@ const teamNameSchema = z.object({ export async function teamsRoutes(app: FastifyInstance): Promise { // GET /api/v1/teams — List all teams with member count (admin only) app.get("/api/v1/teams", async (request: FastifyRequest, reply: FastifyReply) => { - const user = requirePermission("teams:manage")(request, reply); + const user = await requirePermission("teams:manage")(request, reply); if (!user) return; - const teams = db + const teams = await db .select({ id: schema.teams.id, name: schema.teams.name, - memberCount: sql`(SELECT COUNT(*) FROM users WHERE users.team = ${schema.teams.id})`, + memberCount: sql`(SELECT COUNT(*)::int FROM users WHERE users.team = ${schema.teams.id})`, createdAt: schema.teams.createdAt, }) - .from(schema.teams) - .all(); + .from(schema.teams); return reply.send({ teams: teams.map((t) => ({ @@ -52,7 +51,7 @@ export async function teamsRoutes(app: FastifyInstance): Promise { // POST /api/v1/teams — Create team (admin only) app.post("/api/v1/teams", async (request: FastifyRequest, reply: FastifyReply) => { - const admin = requirePermission("teams:manage")(request, reply); + const admin = await requirePermission("teams:manage")(request, reply); if (!admin) return; const parsed = teamNameSchema.safeParse(request.body); @@ -65,11 +64,10 @@ export async function teamsRoutes(app: FastifyInstance): Promise { const trimmedName = parsed.data.name; // Check for duplicate name (case-insensitive) - const existing = db + const [existing] = await db .select() .from(schema.teams) - .where(sql`LOWER(${schema.teams.name}) = LOWER(${trimmedName})`) - .get(); + .where(sql`LOWER(${schema.teams.name}) = LOWER(${trimmedName})`); if (existing) { return reply.status(409).send({ error: "Team name already exists", code: "CONFLICT" }); @@ -77,7 +75,7 @@ export async function teamsRoutes(app: FastifyInstance): Promise { const id = randomUUID(); - db.insert(schema.teams).values({ id, name: trimmedName }).run(); + await db.insert(schema.teams).values({ id, name: trimmedName }); return reply.status(201).send({ id, name: trimmedName }); }); @@ -86,12 +84,12 @@ export async function teamsRoutes(app: FastifyInstance): Promise { app.put( "/api/v1/teams/:id", async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { - const admin = requirePermission("teams:manage")(request, reply); + const admin = await requirePermission("teams:manage")(request, reply); if (!admin) return; const { id } = request.params; - const team = db.select().from(schema.teams).where(eq(schema.teams.id, id)).get(); + const [team] = await db.select().from(schema.teams).where(eq(schema.teams.id, id)); if (!team) { return reply.status(404).send({ error: "Team not found", code: "NOT_FOUND" }); } @@ -106,19 +104,18 @@ export async function teamsRoutes(app: FastifyInstance): Promise { const trimmedName = parsed.data.name; // Check for duplicate name (case-insensitive), excluding current team - const duplicate = db + const [duplicate] = await db .select() .from(schema.teams) .where( sql`LOWER(${schema.teams.name}) = LOWER(${trimmedName}) AND ${schema.teams.id} != ${id}`, - ) - .get(); + ); if (duplicate) { return reply.status(409).send({ error: "Team name already exists", code: "CONFLICT" }); } - db.update(schema.teams).set({ name: trimmedName }).where(eq(schema.teams.id, id)).run(); + await db.update(schema.teams).set({ name: trimmedName }).where(eq(schema.teams.id, id)); return reply.send({ ok: true }); }, @@ -128,12 +125,12 @@ export async function teamsRoutes(app: FastifyInstance): Promise { app.delete( "/api/v1/teams/:id", async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { - const admin = requirePermission("teams:manage")(request, reply); + const admin = await requirePermission("teams:manage")(request, reply); if (!admin) return; const { id } = request.params; - const team = db.select().from(schema.teams).where(eq(schema.teams.id, id)).get(); + const [team] = await db.select().from(schema.teams).where(eq(schema.teams.id, id)); if (!team) { return reply.status(404).send({ error: "Team not found", code: "NOT_FOUND" }); } @@ -147,11 +144,10 @@ export async function teamsRoutes(app: FastifyInstance): Promise { } // Cannot delete a team that has members - const memberCount = db - .select({ count: sql`COUNT(*)` }) + const [memberCount] = await db + .select({ count: sql`COUNT(*)::int` }) .from(schema.users) - .where(eq(schema.users.team, id)) - .get(); + .where(eq(schema.users.team, id)); if (memberCount && memberCount.count > 0) { return reply.status(400).send({ @@ -160,7 +156,7 @@ export async function teamsRoutes(app: FastifyInstance): Promise { }); } - db.delete(schema.teams).where(eq(schema.teams.id, id)).run(); + await db.delete(schema.teams).where(eq(schema.teams.id, id)); return reply.send({ ok: true }); }, diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index 96116da5..2f20cc2c 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -457,14 +457,13 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig if (fileId) { try { const { saveFile } = await import("../lib/file-storage.js"); - const parent = db + const [parent] = await db .select() .from(schema.userFiles) - .where(eq(schema.userFiles.id, fileId)) - .get(); + .where(eq(schema.userFiles.id, fileId)); if (parent) { const newVersion = parent.version + 1; - const parentChain: string[] = parent.toolChain ? JSON.parse(parent.toolChain) : []; + const parentChain: string[] = parent.toolChain ?? []; const newToolChain = [...parentChain, config.toolId]; const storedName = await saveFile(result.buffer, result.filename); // Get image dimensions from the processed output @@ -478,21 +477,19 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig // dimensions are non-critical } const newId = randomUUID(); - db.insert(schema.userFiles) - .values({ - id: newId, - userId: parent.userId, - originalName: result.filename, - storedName, - mimeType: result.contentType, - size: result.buffer.length, - width, - height, - version: newVersion, - parentId: fileId, - toolChain: JSON.stringify(newToolChain), - }) - .run(); + await db.insert(schema.userFiles).values({ + id: newId, + userId: parent.userId, + originalName: result.filename, + storedName, + mimeType: result.contentType, + size: result.buffer.length, + width, + height, + version: newVersion, + parentId: fileId, + toolChain: newToolChain, + }); savedFileId = newId; } } catch (saveErr) { diff --git a/apps/api/src/routes/tools/index.ts b/apps/api/src/routes/tools/index.ts index cd08d4c2..ae9658aa 100644 --- a/apps/api/src/routes/tools/index.ts +++ b/apps/api/src/routes/tools/index.ts @@ -65,19 +65,17 @@ import { registerWatermarkText } from "./watermark-text.js"; */ export async function registerToolRoutes(app: FastifyInstance): Promise { // Read disabled tools from settings - const disabledRow = db + const [disabledRow] = await db .select() .from(schema.settings) - .where(eq(schema.settings.key, "disabledTools")) - .get(); + .where(eq(schema.settings.key, "disabledTools")); const disabledTools: string[] = disabledRow ? JSON.parse(disabledRow.value) : []; // Read experimental flag - const expRow = db + const [expRow] = await db .select() .from(schema.settings) - .where(eq(schema.settings.key, "enableExperimentalTools")) - .get(); + .where(eq(schema.settings.key, "enableExperimentalTools")); const enableExperimental = expRow?.value === "true"; // Get experimental tool IDs from shared constants diff --git a/apps/api/src/routes/user-files.ts b/apps/api/src/routes/user-files.ts index be64d563..9e40479d 100644 --- a/apps/api/src/routes/user-files.ts +++ b/apps/api/src/routes/user-files.ts @@ -16,7 +16,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { z } from "zod"; import { env } from "../config.js"; -import { db, schema, sqlite } from "../db/index.js"; +import { db, schema } from "../db/index.js"; import { auditLog } from "../lib/audit.js"; import { deleteStoredFile, @@ -76,7 +76,7 @@ function serializeFile(row: typeof schema.userFiles.$inferSelect) { height: row.height, version: row.version, parentId: row.parentId, - toolChain: row.toolChain ? JSON.parse(row.toolChain) : [], + toolChain: row.toolChain ?? [], createdAt: row.createdAt.toISOString(), }; } @@ -85,14 +85,13 @@ function serializeFile(row: typeof schema.userFiles.$inferSelect) { * Check whether a user has exceeded their storage quota. * Returns the total bytes used, or throws if the quota is exceeded. */ -function checkStorageQuota(userId: string | null): void { +async function checkStorageQuota(userId: string | null): Promise { if (!userId || env.MAX_STORAGE_PER_USER_MB <= 0) return; - const result = db + const [result] = await db .select({ total: sql`coalesce(sum(${schema.userFiles.size}), 0)` }) .from(schema.userFiles) - .where(eq(schema.userFiles.userId, userId)) - .get(); + .where(eq(schema.userFiles.userId, userId)); const usedBytes = result?.total ?? 0; const limitBytes = env.MAX_STORAGE_PER_USER_MB * 1024 * 1024; @@ -145,7 +144,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise { const conditions = [latestCondition]; // Users without files:all only see their own files - if (!hasEffectivePermission(user, "files:all")) { + if (!(await hasEffectivePermission(user, "files:all"))) { conditions.push(eq(schema.userFiles.userId, user.id)); } @@ -154,21 +153,19 @@ export async function userFileRoutes(app: FastifyInstance): Promise { conditions.push(like(schema.userFiles.originalName, `%${escaped}%`)); } - const rows = db + const rows = await db .select() .from(schema.userFiles) .where(and(...conditions)) .orderBy(desc(schema.userFiles.createdAt)) .limit(limit) - .offset(offset) - .all(); + .offset(offset); // Total count (for pagination) - const countResult = db - .select({ count: sql`count(*)` }) + const [countResult] = await db + .select({ count: sql`count(*)::int` }) .from(schema.userFiles) - .where(and(...conditions)) - .get(); + .where(and(...conditions)); return reply.send({ files: rows.map(serializeFile), @@ -194,7 +191,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise { // Enforce per-user storage quota before accepting uploads try { - checkStorageQuota(userId); + await checkStorageQuota(userId); } catch (err) { const statusCode = (err as Error & { statusCode?: number }).statusCode ?? 413; return reply.status(statusCode).send({ error: (err as Error).message }); @@ -236,26 +233,24 @@ export async function userFileRoutes(app: FastifyInstance): Promise { // Create DB record const id = randomUUID(); try { - db.insert(schema.userFiles) - .values({ - id, - userId, - originalName: safeName, - storedName, - mimeType, - size: safeBuffer.length, - width: validation.width, - height: validation.height, - version: 1, - parentId: null, - toolChain: null, - }) - .run(); + await db.insert(schema.userFiles).values({ + id, + userId, + originalName: safeName, + storedName, + mimeType, + size: safeBuffer.length, + width: validation.width, + height: validation.height, + version: 1, + parentId: null, + toolChain: null, + }); } catch { return reply.status(409).send({ error: "Failed to save file record" }); } - const row = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get(); + const [row] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)); if (row) created.push(serializeFile(row)); } @@ -264,7 +259,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise { return reply.status(400).send({ error: "No valid files uploaded" }); } - auditLog(request.log, "FILE_UPLOADED", { + await auditLog(request.log, "FILE_UPLOADED", { userId, count: created.length, files: created.map((f) => f.originalName), @@ -288,15 +283,22 @@ export async function userFileRoutes(app: FastifyInstance): Promise { const { id } = request.params; - const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get(); + const [file] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)); - if (!file || (file.userId !== user.id && !hasEffectivePermission(user, "files:all"))) { + if ( + !file || + (file.userId !== user.id && !(await hasEffectivePermission(user, "files:all"))) + ) { return reply.status(404).send({ error: "File not found" }); } // Walk the full version chain using a recursive CTE. // First find the root ancestor, then collect all descendants. - interface ChainRow { + // + // node-postgres returns: + // tool_chain as parsed jsonb (string[] | null) - do NOT JSON.parse + // created_at as Date (timestamptz) - use directly, no * 1000 + type ChainRow = { id: string; original_name: string; mime_type: string; @@ -305,35 +307,34 @@ export async function userFileRoutes(app: FastifyInstance): Promise { height: number | null; version: number; parent_id: string | null; - tool_chain: string | null; - created_at: number; - } + tool_chain: string[] | null; + created_at: Date; + }; - const chainRows = sqlite - .prepare(` - WITH RECURSIVE - ancestors(id, parent_id) AS ( - SELECT id, parent_id FROM user_files WHERE id = ? - UNION ALL - SELECT uf.id, uf.parent_id FROM user_files uf - INNER JOIN ancestors a ON uf.id = a.parent_id - ), - chain(id, original_name, mime_type, size, width, height, - version, parent_id, tool_chain, created_at) AS ( - SELECT f.id, f.original_name, f.mime_type, f.size, f.width, f.height, - f.version, f.parent_id, f.tool_chain, f.created_at - FROM user_files f - WHERE f.id = (SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT 1) - UNION ALL - SELECT child.id, child.original_name, child.mime_type, child.size, - child.width, child.height, child.version, child.parent_id, - child.tool_chain, child.created_at - FROM user_files child - INNER JOIN chain c ON child.parent_id = c.id - ) - SELECT * FROM chain ORDER BY version ASC - `) - .all(id) as ChainRow[]; + const cteResult = await db.execute(sql` + WITH RECURSIVE + ancestors(id, parent_id) AS ( + SELECT id, parent_id FROM user_files WHERE id = ${id} + UNION ALL + SELECT uf.id, uf.parent_id FROM user_files uf + INNER JOIN ancestors a ON uf.id = a.parent_id + ), + chain(id, original_name, mime_type, size, width, height, + version, parent_id, tool_chain, created_at) AS ( + SELECT f.id, f.original_name, f.mime_type, f.size, f.width, f.height, + f.version, f.parent_id, f.tool_chain, f.created_at + FROM user_files f + WHERE f.id = (SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT 1) + UNION ALL + SELECT child.id, child.original_name, child.mime_type, child.size, + child.width, child.height, child.version, child.parent_id, + child.tool_chain, child.created_at + FROM user_files child + INNER JOIN chain c ON child.parent_id = c.id + ) + SELECT * FROM chain ORDER BY version ASC + `); + const chainRows = cteResult.rows; const versions = chainRows.map((r) => ({ id: r.id, @@ -344,8 +345,8 @@ export async function userFileRoutes(app: FastifyInstance): Promise { height: r.height, version: r.version, parentId: r.parent_id, - toolChain: r.tool_chain ? JSON.parse(r.tool_chain) : [], - createdAt: new Date(r.created_at * 1000).toISOString(), + toolChain: r.tool_chain ?? [], + createdAt: new Date(r.created_at).toISOString(), })); return reply.send({ @@ -368,9 +369,12 @@ export async function userFileRoutes(app: FastifyInstance): Promise { const { id } = request.params; - const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get(); + const [file] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)); - if (!file || (file.userId !== user.id && !hasEffectivePermission(user, "files:all"))) { + if ( + !file || + (file.userId !== user.id && !(await hasEffectivePermission(user, "files:all"))) + ) { return reply.status(404).send({ error: "File not found" }); } @@ -404,9 +408,12 @@ export async function userFileRoutes(app: FastifyInstance): Promise { const { id } = request.params; - const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get(); + const [file] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)); - if (!file || (file.userId !== user.id && !hasEffectivePermission(user, "files:all"))) { + if ( + !file || + (file.userId !== user.id && !(await hasEffectivePermission(user, "files:all"))) + ) { return reply.status(404).send({ error: "File not found" }); } @@ -476,49 +483,48 @@ export async function userFileRoutes(app: FastifyInstance): Promise { let deletedCount = 0; - interface DeleteChainRow { + type DeleteChainRow = { id: string; stored_name: string; - } + }; for (const id of ids) { // Ownership check: non-admin users can only delete their own files - const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get(); - if (!file || (file.userId !== user.id && !hasEffectivePermission(user, "files:all"))) + const [file] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)); + if (!file || (file.userId !== user.id && !(await hasEffectivePermission(user, "files:all")))) continue; // Collect all files in the chain using a recursive CTE - const chainRows = sqlite - .prepare(` - WITH RECURSIVE chain(id, stored_name) AS ( - SELECT f.id, f.stored_name - FROM user_files f - WHERE f.id = ( - WITH RECURSIVE ancestors(id, parent_id) AS ( - SELECT id, parent_id FROM user_files WHERE id = ? - UNION ALL - SELECT uf.id, uf.parent_id FROM user_files uf - INNER JOIN ancestors a ON uf.id = a.parent_id - ) - SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT 1 - ) + const cteResult = await db.execute(sql` + WITH RECURSIVE chain(id, stored_name) AS ( + SELECT f.id, f.stored_name + FROM user_files f + WHERE f.id = ( + WITH RECURSIVE ancestors(id, parent_id) AS ( + SELECT id, parent_id FROM user_files WHERE id = ${id} UNION ALL - SELECT child.id, child.stored_name - FROM user_files child - INNER JOIN chain c ON child.parent_id = c.id + SELECT uf.id, uf.parent_id FROM user_files uf + INNER JOIN ancestors a ON uf.id = a.parent_id ) - SELECT id, stored_name FROM chain - `) - .all(id) as DeleteChainRow[]; + SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT 1 + ) + UNION ALL + SELECT child.id, child.stored_name + FROM user_files child + INNER JOIN chain c ON child.parent_id = c.id + ) + SELECT id, stored_name FROM chain + `); + const chainRows = cteResult.rows; for (const row of chainRows) { await deleteStoredFile(row.stored_name); await deleteThumbnail(row.stored_name); - db.delete(schema.userFiles).where(eq(schema.userFiles.id, row.id)).run(); + await db.delete(schema.userFiles).where(eq(schema.userFiles.id, row.id)); deletedCount++; } } - auditLog(request.log, "FILE_DELETED", { userId: user.id, count: deletedCount, ids }); + await auditLog(request.log, "FILE_DELETED", { userId: user.id, count: deletedCount, ids }); return reply.send({ deleted: deletedCount }); }); @@ -538,7 +544,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise { // Enforce per-user storage quota before saving results try { - checkStorageQuota(userId); + await checkStorageQuota(userId); } catch (err) { const statusCode = (err as Error & { statusCode?: number }).statusCode ?? 413; return reply.status(statusCode).send({ error: (err as Error).message }); @@ -582,11 +588,10 @@ export async function userFileRoutes(app: FastifyInstance): Promise { } // Look up the parent to compute the next version and carry forward the tool chain - const parent = db + const [parent] = await db .select() .from(schema.userFiles) - .where(eq(schema.userFiles.id, parentId)) - .get(); + .where(eq(schema.userFiles.id, parentId)); if (!parent) { return reply.status(404).send({ error: "Parent file not found" }); @@ -595,7 +600,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise { const nextVersion = parent.version + 1; // Build the tool chain: append the new toolId to the parent's chain - const existingChain: string[] = parent.toolChain ? JSON.parse(parent.toolChain) : []; + const existingChain: string[] = parent.toolChain ?? []; const newChain = toolId ? [...existingChain, toolId] : existingChain; // Determine the original filename (preserve parent's name, update extension) @@ -614,26 +619,24 @@ export async function userFileRoutes(app: FastifyInstance): Promise { // Create DB record const id = randomUUID(); try { - db.insert(schema.userFiles) - .values({ - id, - userId, - originalName: resultName, - storedName, - mimeType, - size: safeResultBuffer.length, - width: validation.width, - height: validation.height, - version: nextVersion, - parentId, - toolChain: JSON.stringify(newChain), - }) - .run(); + await db.insert(schema.userFiles).values({ + id, + userId, + originalName: resultName, + storedName, + mimeType, + size: safeResultBuffer.length, + width: validation.width, + height: validation.height, + version: nextVersion, + parentId, + toolChain: newChain, + }); } catch { return reply.status(409).send({ error: "Failed to save result record" }); } - const row = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get(); + const [row] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)); return reply.status(201).send({ file: row ? serializeFile(row) : null }); }); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..30036a0d --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,43 @@ +# Dev infrastructure only. App runs via `pnpm dev` on the host. +# Usage: docker compose -f docker-compose.dev.yml up -d +name: snapotter-dev + +services: + postgres: + image: postgres:17-alpine + container_name: snapotter-dev-postgres + restart: unless-stopped + environment: + POSTGRES_USER: snapotter + POSTGRES_PASSWORD: snapotter + POSTGRES_DB: snapotter + ports: + - "5432:5432" + volumes: + - snapotter-dev-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U snapotter -d snapotter"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + + redis: + image: redis:8-alpine + container_name: snapotter-dev-redis + restart: unless-stopped + command: ["redis-server", "--maxmemory-policy", "noeviction", "--appendonly", "yes"] + ports: + - "6379:6379" + volumes: + - snapotter-dev-redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + +volumes: + snapotter-dev-pgdata: + snapotter-dev-redisdata: diff --git a/docker/Dockerfile b/docker/Dockerfile index 60e31b0e..1648f890 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -289,7 +289,6 @@ RUN mkdir -p /data /data/files /data/ai/models /data/ai/pip-cache /tmp/workspace ENV PORT=1349 \ NODE_ENV=production \ STORAGE_MODE=local \ - DB_PATH=/data/snapotter.db \ WORKSPACE_PATH=/tmp/workspace \ FILES_STORAGE_PATH=/data/files \ PYTHON_VENV_PATH=/data/ai/venv \ @@ -337,6 +336,7 @@ RUN chown -R snapotter:snapotter /app /data /tmp/workspace /opt/venv # Entrypoint fixes volume permissions then drops to snapotter via gosu COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +COPY docker/wait-for-postgres.mjs /app/docker/wait-for-postgres.mjs RUN chmod +x /usr/local/bin/entrypoint.sh EXPOSE 1349 diff --git a/docker/Dockerfile.test b/docker/Dockerfile.test index ff7a0e8a..0b271135 100644 --- a/docker/Dockerfile.test +++ b/docker/Dockerfile.test @@ -73,7 +73,6 @@ ENV NODE_ENV=test \ AUTH_ENABLED=true \ DEFAULT_USERNAME=admin \ DEFAULT_PASSWORD=admin \ - DB_PATH=/tmp/test-snapotter.db \ WORKSPACE_PATH=/tmp/test-workspace \ MAX_MEGAPIXELS=100 \ MAX_UPLOAD_SIZE_MB=100 \ diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml index b2b03561..80ccbf68 100644 --- a/docker/docker-compose.test.yml +++ b/docker/docker-compose.test.yml @@ -2,6 +2,7 @@ # Test infrastructure - run with: # docker compose -f docker/docker-compose.test.yml up --build --abort-on-container-exit ############################################################################### +name: snapotter-test services: # ── Unit + Integration tests ───────────────────────────────────────────── test-unit: @@ -15,13 +16,22 @@ services: - AUTH_ENABLED=true - DEFAULT_USERNAME=admin - DEFAULT_PASSWORD=admin - - DB_PATH=/tmp/test-snapotter.db + - DATABASE_URL=postgres://snapotter:snapotter@postgres:5432/snapotter_test + # Tells tests/global-setup.ts to use this server instead of spawning a + # testcontainer (no docker daemon inside the test container). + - TEST_DATABASE_URL=postgres://snapotter:snapotter@postgres:5432/snapotter_test + - REDIS_URL=redis://redis:6379 - WORKSPACE_PATH=/tmp/test-workspace - MAX_MEGAPIXELS=100 - RATE_LIMIT_PER_MIN=1000 tmpfs: - /tmp/test-workspace - /tmp + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy # ── E2E tests (Playwright against full app) ───────────────────────────── test-e2e: @@ -42,7 +52,8 @@ services: - AUTH_ENABLED=true - DEFAULT_USERNAME=admin - DEFAULT_PASSWORD=admin - - DB_PATH=/tmp/test-snapotter.db + - DATABASE_URL=postgres://snapotter:snapotter@postgres:5432/snapotter_test + - REDIS_URL=redis://redis:6379 - WORKSPACE_PATH=/tmp/test-workspace - MAX_MEGAPIXELS=100 - RATE_LIMIT_PER_MIN=1000 @@ -53,3 +64,36 @@ services: depends_on: test-unit: condition: service_completed_successfully + postgres: + condition: service_healthy + redis: + condition: service_healthy + + postgres: + image: postgres:17-alpine + container_name: SnapOtter-test-postgres + environment: + POSTGRES_USER: snapotter + POSTGRES_PASSWORD: snapotter + POSTGRES_DB: snapotter_test + healthcheck: + test: ["CMD-SHELL", "pg_isready -U snapotter"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 15s + tmpfs: + - /var/lib/postgresql/data + + redis: + image: redis:8-alpine + container_name: SnapOtter-test-redis + command: ["redis-server", "--maxmemory-policy", "noeviction", "--appendonly", "no"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 10s + tmpfs: + - /data diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6352f242..a094c99c 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -36,6 +36,11 @@ services: - MAX_USERS=${MAX_USERS:-0} - SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS:-168} - TRUST_PROXY=${TRUST_PROXY:-true} + - DATABASE_URL=postgres://${POSTGRES_USER:-snapotter}:${POSTGRES_PASSWORD:-snapotter}@postgres:5432/${POSTGRES_DB:-snapotter} + - REDIS_URL=redis://redis:6379 + # 1.x upgrade: uncomment to import the old SQLite database on first boot; + # re-comment after the migration succeeds. + # - SQLITE_MIGRATE_PATH=/data/snapotter.db # OIDC Authentication (optional) # - EXTERNAL_URL=https://photos.example.com # - OIDC_ENABLED=false @@ -60,6 +65,11 @@ services: # - COOKIE_SECRET_FILE=/run/secrets/cookie_secret # - SNAPOTTER_LICENSE_KEY_FILE=/run/secrets/license_key restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy # --- Security hardening --- mem_limit: 6g memswap_limit: 6g @@ -92,6 +102,38 @@ services: max-size: "50m" max-file: "5" + postgres: + image: postgres:17-alpine + container_name: SnapOtter-postgres + environment: + POSTGRES_USER: ${POSTGRES_USER:-snapotter} + # Set a strong password -- CHANGE THIS for any non-local deployment. + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-snapotter} + POSTGRES_DB: ${POSTGRES_DB:-snapotter} + volumes: + - SnapOtter-pgdata:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-snapotter}"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 15s + + redis: + image: redis:8-alpine + container_name: SnapOtter-redis + command: ["redis-server", "--maxmemory-policy", "noeviction", "--appendonly", "yes"] + volumes: + - SnapOtter-redisdata:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 10s + # Uncomment to use Docker secrets (requires Docker Swarm or compose v2.23+): # secrets: # snapotter_password: @@ -102,3 +144,5 @@ services: volumes: SnapOtter-data: SnapOtter-workspace: + SnapOtter-pgdata: + SnapOtter-redisdata: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 273b3ce2..bcec56e4 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -63,6 +63,18 @@ if [ ! -d "$AI_VENV" ] && [ -d "/opt/venv" ]; then echo "AI venv ready at $AI_VENV" fi +# Wait for Postgres to be reachable before starting the app +if [ -n "${DATABASE_URL:-}" ]; then + echo "Waiting for Postgres..." + i=0 + until node /app/docker/wait-for-postgres.mjs; do + i=$((i+1)) + if [ "$i" -ge 60 ]; then echo "FATAL: Postgres unreachable after 60s"; exit 1; fi + sleep 1 + done + echo "Postgres is reachable." +fi + print_banner() { RST='\033[0m' printf '\n' diff --git a/docker/wait-for-postgres.mjs b/docker/wait-for-postgres.mjs new file mode 100644 index 00000000..9f3c558d --- /dev/null +++ b/docker/wait-for-postgres.mjs @@ -0,0 +1,9 @@ +import { connect } from "node:net"; + +const url = new URL(process.env.DATABASE_URL); +const socket = connect(Number(url.port || 5432), url.hostname, () => { + socket.end(); + process.exit(0); +}); +socket.on("error", () => process.exit(1)); +setTimeout(() => process.exit(1), 3000).unref(); diff --git a/package.json b/package.json index 494364cb..16b3d584 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "test:e2e:docs": "playwright test --config playwright.docs.config.ts", "test:e2e:analytics": "playwright test --config playwright.analytics-local.config.ts", "test:docker": "docker compose -f docker/docker-compose.test.yml up --build --abort-on-container-exit", + "check:license-boundary": "node scripts/check-license-boundary.mjs", "version:sync": "./scripts/sync-version.sh", "release": "semantic-release", "release:dry": "semantic-release --dry-run", @@ -63,6 +64,7 @@ "@semantic-release/github": "^12.0.8", "@semantic-release/npm": "^13.1.5", "@semantic-release/release-notes-generator": "^14.1.1", + "@testcontainers/postgresql": "^12.0.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/packages/enterprise/LICENSE b/packages/enterprise/LICENSE index 775a7f5f..53284d4f 100644 --- a/packages/enterprise/LICENSE +++ b/packages/enterprise/LICENSE @@ -1,14 +1,13 @@ -SnapOtter Enterprise License +SnapOtter Commercial License -Copyright (c) 2026 SnapOtter. All rights reserved. +Copyright (c) 2026 SnapOtter -The code in this directory and its subdirectories ("Enterprise -Features") is licensed separately from the rest of the SnapOtter -project, which is available under the AGPLv3 license. +All code in this directory (packages/enterprise) and its subdirectories is +NOT covered by the AGPL-3.0 license that governs the rest of this repository. -You may view and contribute to the Enterprise Features under the terms -of the SnapOtter Contributor License Agreement. However, you may NOT -use, deploy, or distribute the Enterprise Features in production -without a valid, paid SnapOtter Enterprise license. +Use of this code in production requires a valid SnapOtter commercial license +key (team or enterprise plan). You may view, build, and modify this code for +evaluation and development purposes. You may not use it in production, offer +it as a service, or redistribute it without a commercial agreement. -To purchase a license: enterprise@snapotter.com +Contact: snapotter.hq@gmail.com diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 5ef466bc..7e5d8af3 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -11,6 +11,7 @@ "clean": "rm -rf dist" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1063.0", "@snapotter/shared": "workspace:*" }, "devDependencies": { diff --git a/packages/enterprise/src/index.ts b/packages/enterprise/src/index.ts index b0bfcb46..c374d924 100644 --- a/packages/enterprise/src/index.ts +++ b/packages/enterprise/src/index.ts @@ -27,4 +27,8 @@ export function getActiveLicense(): LicensePayload | null { return activeLicense; } +export type S3StorageModule = typeof import("./storage-s3.js"); +export async function loadS3Storage(): Promise { + return import("./storage-s3.js"); +} export { ENTERPRISE_FEATURES, type EnterpriseFeature, type LicensePayload }; diff --git a/apps/api/src/lib/storage-s3.ts b/packages/enterprise/src/storage-s3.ts similarity index 68% rename from apps/api/src/lib/storage-s3.ts rename to packages/enterprise/src/storage-s3.ts index b8777d68..34337ac1 100644 --- a/apps/api/src/lib/storage-s3.ts +++ b/packages/enterprise/src/storage-s3.ts @@ -6,19 +6,42 @@ import { PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; -import { env } from "../config.js"; +export interface S3Config { + bucket: string; + region: string; + endpoint: string; + accessKeyId: string; + secretAccessKey: string; + forcePathStyle: boolean; + prefix: string; +} + +let config: S3Config | null = null; let client: S3Client | null = null; +export function configureS3(opts: S3Config): void { + config = opts; + client = null; // Reset so next getClient() picks up new config +} + +function cfg(): S3Config { + if (!config) { + throw new Error("S3 storage not configured. Call configureS3() first."); + } + return config; +} + function getClient(): S3Client { if (!client) { + const c = cfg(); client = new S3Client({ - region: env.S3_REGION, - endpoint: env.S3_ENDPOINT || undefined, - forcePathStyle: env.S3_FORCE_PATH_STYLE, + region: c.region, + endpoint: c.endpoint || undefined, + forcePathStyle: c.forcePathStyle, credentials: { - accessKeyId: env.S3_ACCESS_KEY_ID, - secretAccessKey: env.S3_SECRET_ACCESS_KEY, + accessKeyId: c.accessKeyId, + secretAccessKey: c.secretAccessKey, }, }); } @@ -26,23 +49,23 @@ function getClient(): S3Client { } function fileKey(storedName: string): string { - const prefix = env.S3_PREFIX ? `${env.S3_PREFIX}/` : ""; + const prefix = cfg().prefix ? `${cfg().prefix}/` : ""; return `${prefix}files/${storedName}`; } function thumbKey(storedName: string): string { - const prefix = env.S3_PREFIX ? `${env.S3_PREFIX}/` : ""; + const prefix = cfg().prefix ? `${cfg().prefix}/` : ""; return `${prefix}thumbs/${storedName}.thumb.jpg`; } export async function checkConnection(): Promise { - await getClient().send(new HeadBucketCommand({ Bucket: env.S3_BUCKET })); + await getClient().send(new HeadBucketCommand({ Bucket: cfg().bucket })); } export async function putObject(storedName: string, buffer: Buffer): Promise { await getClient().send( new PutObjectCommand({ - Bucket: env.S3_BUCKET, + Bucket: cfg().bucket, Key: fileKey(storedName), Body: buffer, }), @@ -52,7 +75,7 @@ export async function putObject(storedName: string, buffer: Buffer): Promise { const response = await getClient().send( new GetObjectCommand({ - Bucket: env.S3_BUCKET, + Bucket: cfg().bucket, Key: fileKey(storedName), }), ); @@ -62,7 +85,7 @@ export async function getObject(storedName: string): Promise { export async function getObjectStream(storedName: string): Promise { const response = await getClient().send( new GetObjectCommand({ - Bucket: env.S3_BUCKET, + Bucket: cfg().bucket, Key: fileKey(storedName), }), ); @@ -73,7 +96,7 @@ export async function deleteObject(storedName: string): Promise { try { await getClient().send( new DeleteObjectCommand({ - Bucket: env.S3_BUCKET, + Bucket: cfg().bucket, Key: fileKey(storedName), }), ); @@ -86,7 +109,7 @@ export async function getThumbnail(storedName: string): Promise { try { const response = await getClient().send( new GetObjectCommand({ - Bucket: env.S3_BUCKET, + Bucket: cfg().bucket, Key: thumbKey(storedName), }), ); @@ -99,7 +122,7 @@ export async function getThumbnail(storedName: string): Promise { export async function putThumbnail(storedName: string, buffer: Buffer): Promise { await getClient().send( new PutObjectCommand({ - Bucket: env.S3_BUCKET, + Bucket: cfg().bucket, Key: thumbKey(storedName), Body: buffer, ContentType: "image/jpeg", @@ -111,7 +134,7 @@ export async function deleteThumbnail(storedName: string): Promise { try { await getClient().send( new DeleteObjectCommand({ - Bucket: env.S3_BUCKET, + Bucket: cfg().bucket, Key: thumbKey(storedName), }), ); diff --git a/playwright.analytics-local.config.ts b/playwright.analytics-local.config.ts index 283505be..e9c4c187 100644 --- a/playwright.analytics-local.config.ts +++ b/playwright.analytics-local.config.ts @@ -1,12 +1,23 @@ +import { randomBytes } from "node:crypto"; import path from "node:path"; import { defineConfig, devices } from "@playwright/test"; const authFile = path.join(__dirname, "test-results", ".auth", "analytics-local-user.json"); -const testDbPath = path.join(__dirname, "test-results", ".e2e-analytics-db", "snapotter.db"); const TEST_API_PORT = 13491; const TEST_WEB_PORT = 2350; +// Fresh Postgres database per analytics-local e2e run (same mechanism as the +// main playwright.config.ts). +const E2E_PG_BASE_URL = + process.env.E2E_PG_BASE_URL || "postgres://snapotter:snapotter@localhost:5432/snapotter"; +const e2eDbName = `snapotter_e2e_${process.pid}_${randomBytes(4).toString("hex")}`; +const e2eDatabaseUrl = (() => { + const url = new URL(E2E_PG_BASE_URL); + url.pathname = `/${e2eDbName}`; + return url.toString(); +})(); + export default defineConfig({ testDir: "./tests/e2e-analytics", timeout: 30_000, @@ -36,7 +47,7 @@ export default defineConfig({ ], webServer: [ { - command: `rm -f "${testDbPath}" "${testDbPath}-shm" "${testDbPath}-wal" && mkdir -p "${path.dirname(testDbPath)}" && pnpm --filter @snapotter/api dev`, + command: `node tests/e2e-pg-create-db.cjs ${e2eDbName} && pnpm --filter @snapotter/api dev`, port: TEST_API_PORT, reuseExistingServer: !process.env.CI, env: { @@ -46,7 +57,7 @@ export default defineConfig({ RATE_LIMIT_PER_MIN: "50000", SKIP_MUST_CHANGE_PASSWORD: "true", ANALYTICS_ENABLED: "true", - DB_PATH: testDbPath, + DATABASE_URL: e2eDatabaseUrl, PORT: String(TEST_API_PORT), }, timeout: 30_000, diff --git a/playwright.config.ts b/playwright.config.ts index ca024d9e..8191e840 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,11 +1,23 @@ +import { randomBytes } from "node:crypto"; import path from "node:path"; import { defineConfig, devices } from "@playwright/test"; const authFile = path.join(__dirname, ".playwright", ".auth", "user.json"); -const testDbPath = path.join(__dirname, "test-results", ".e2e-db", "snapotter.db"); const TEST_WEB_PORT = 2349; +// Fresh Postgres database per e2e run. The create-db script (chained before +// the API in the webServer command) CREATEs it and cleans up stale databases +// from previous runs. The API auto-migrates the empty database at boot. +const E2E_PG_BASE_URL = + process.env.E2E_PG_BASE_URL || "postgres://snapotter:snapotter@localhost:5432/snapotter"; +const e2eDbName = `snapotter_e2e_${process.pid}_${randomBytes(4).toString("hex")}`; +const e2eDatabaseUrl = (() => { + const url = new URL(E2E_PG_BASE_URL); + url.pathname = `/${e2eDbName}`; + return url.toString(); +})(); + // Specs that mutate global server state (settings, users, roles, API keys) // or assert on global lists/timing. These run in the chromium-serial project // with --workers=1; everything else parallelizes safely. @@ -98,7 +110,7 @@ export default defineConfig({ ], webServer: [ { - command: `rm -f "${testDbPath}" "${testDbPath}-shm" "${testDbPath}-wal" && mkdir -p "${path.dirname(testDbPath)}" && pnpm --filter @snapotter/api dev`, + command: `node tests/e2e-pg-create-db.cjs ${e2eDbName} && pnpm --filter @snapotter/api dev`, port: 13490, reuseExistingServer: !process.env.CI, env: { @@ -108,7 +120,7 @@ export default defineConfig({ RATE_LIMIT_PER_MIN: "50000", SKIP_MUST_CHANGE_PASSWORD: "true", ANALYTICS_ENABLED: "false", - DB_PATH: testDbPath, + DATABASE_URL: e2eDatabaseUrl, // The in-repo docker/feature-manifest.json makes the API think it is // inside Docker and try to mkdir /data; point it somewhere writable. DATA_DIR: path.join(__dirname, "test-results", ".e2e-data"), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ddb095b..41d888c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: '@semantic-release/release-notes-generator': specifier: ^14.1.1 version: 14.1.1(semantic-release@25.0.3(typescript@5.9.3)) + '@testcontainers/postgresql': + specifier: ^12.0.1 + version: 12.0.1 '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -113,9 +116,6 @@ importers: apps/api: dependencies: - '@aws-sdk/client-s3': - specifier: ^3.1063.0 - version: 3.1063.0 '@fastify/cookie': specifier: ^11.0.2 version: 11.0.2 @@ -163,7 +163,7 @@ importers: version: 16.6.1 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@11.10.0)(gel@2.2.0) + version: 0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(gel@2.2.0)(pg@8.21.0) exif-reader: specifier: ^2.0.3 version: 2.0.3 @@ -191,6 +191,9 @@ importers: pdfkit: specifier: ^0.18.0 version: 0.18.0 + pg: + specifier: ^8.21.0 + version: 8.21.0 piscina: specifier: ^5.1.4 version: 5.1.4 @@ -237,6 +240,9 @@ importers: '@types/pdfkit': specifier: ^0.17.6 version: 0.17.6 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 '@types/potrace': specifier: ^2.1.5 version: 2.1.5 @@ -457,6 +463,9 @@ importers: packages/enterprise: dependencies: + '@aws-sdk/client-s3': + specifier: ^3.1063.0 + version: 3.1063.0 '@snapotter/shared': specifier: workspace:* version: link:../shared @@ -939,6 +948,9 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': + resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -1611,6 +1623,20 @@ packages: '@fastify/static@9.1.3': resolution: {integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==} + '@grpc/grpc-js@1.14.4': + resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + '@iconify-json/simple-icons@1.2.74': resolution: {integrity: sha512-yqaohfY6jnYjTVpuTkaBQHrWbdUrQyWXhau0r/0EZiNWYXPX/P8WWwl1DoLH5CbvDjjcWQw5J0zADhgCUklOqA==} @@ -2245,6 +2271,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@kwsites/file-exists@1.1.1': + resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} + '@lukeed/ms@2.0.2': resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} @@ -3084,6 +3116,9 @@ packages: peerDependencies: vite: '>=6.4.2' + '@testcontainers/postgresql@12.0.1': + resolution: {integrity: sha512-6SyyduUM6lTAo4UwKG1aCochP6wTe0k7/W/m5uODZQMUrV3STk6/28Km3474JLGZdw/P793BUEF2IeU7rDyoEg==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -3182,6 +3217,12 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/docker-modem@3.0.6': + resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} + + '@types/dockerode@4.0.1': + resolution: {integrity: sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==} + '@types/emscripten@1.41.5': resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==} @@ -3218,6 +3259,9 @@ packages: '@types/node@16.9.1': resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==} + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@22.19.19': resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} @@ -3233,8 +3277,8 @@ packages: '@types/pdfkit@0.17.6': resolution: {integrity: sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==} - '@types/pg@8.15.6': - resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} '@types/potrace@2.1.5': resolution: {integrity: sha512-Sgk3f5pv0LFlBHg5xlEJQZcqZLyzvdOHOuHb1lux1a1r4cEFgtqBDQx4igQEEb+Xddu0T9KXanPNjlJhpRKsXQ==} @@ -3269,6 +3313,15 @@ packages: '@types/readdir-glob@1.1.5': resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} + '@types/ssh2-streams@0.1.13': + resolution: {integrity: sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA==} + + '@types/ssh2@0.5.52': + resolution: {integrity: sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==} + + '@types/ssh2@1.15.5': + resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -3550,6 +3603,9 @@ packages: array-ify@1.0.0: resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -3557,6 +3613,9 @@ packages: ast-v8-to-istanbul@0.3.12: resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + async-lock@1.4.1: + resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -3635,6 +3694,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} @@ -3698,6 +3760,14 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + + byline@5.0.0: + resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==} + engines: {node: '>=0.10.0'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -3924,6 +3994,10 @@ packages: typescript: optional: true + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -4017,6 +4091,18 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + docker-compose@1.4.2: + resolution: {integrity: sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww==} + engines: {node: '>= 6.0.0'} + + docker-modem@5.0.7: + resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==} + engines: {node: '>= 8.0'} + + dockerode@5.0.0: + resolution: {integrity: sha512-C52mvJ+7lcyhWNfrzVfFsbTrBfy/ezE9FGEYLpu17FUeBcCkxERk9nN7uDl/478ynDiQ4U+5DbQC2vENHkVEtQ==} + engines: {node: '>= 14.17'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -4498,6 +4584,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-port@7.2.0: + resolution: {integrity: sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==} + engines: {node: '>=16'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -5000,6 +5090,9 @@ packages: lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.capitalize@4.2.1: resolution: {integrity: sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==} @@ -5025,6 +5118,9 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -5268,6 +5364,11 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} @@ -5303,6 +5404,9 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nan@2.27.0: + resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -5661,17 +5765,40 @@ packages: perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.13.0: + resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==} + pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} - pg-protocol@1.13.0: - resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.14.0: + resolution: {integrity: sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==} pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} + pg@8.21.0: + resolution: {integrity: sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + phin@3.7.1: resolution: {integrity: sha512-GEazpTWwTZaEQ9RhL7Nyz0WwqilbqgLahDM3D0hxWwmVDI52nXEybHqiN6/elwpkJBhcuj+WbBu+QfT0uhPGfQ==} engines: {node: '>= 8'} @@ -5808,12 +5935,23 @@ packages: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + properties-reader@3.0.1: + resolution: {integrity: sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==} + engines: {node: '>=18'} + property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + protobufjs@8.6.2: + resolution: {integrity: sha512-CCERJxzRvKMeEdJSLwdQf40TXWNPc8M4RkN7j/lxY6FQB+4do8rETWqj60AqxP9n0XIsxnSefZ8uhAaGKg2njw==} + engines: {node: '>=12.0.0'} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -6033,6 +6171,10 @@ packages: resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -6242,6 +6384,9 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + split-ca@1.0.1: + resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} + split2@1.0.0: resolution: {integrity: sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg==} @@ -6252,6 +6397,13 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + ssh-remote-port-forward@1.0.4: + resolution: {integrity: sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==} + + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -6399,6 +6551,9 @@ packages: tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + tar-fs@3.1.2: + resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==} + tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -6426,6 +6581,9 @@ packages: resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} engines: {node: '>=18'} + testcontainers@12.0.1: + resolution: {integrity: sha512-EMjjfMNJf3HlL7V3elkxqKUO1r3CtqNBTdmKGwwma/lOtUGfoWvFJ0WQ/KQf1DHEMnRjLWzW4cXbv/Tndsbcbw==} + text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} @@ -6493,6 +6651,10 @@ packages: resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} hasBin: true + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -6553,6 +6715,9 @@ packages: resolution: {integrity: sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg==} hasBin: true + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-fest@1.4.0: resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} engines: {node: '>=10'} @@ -6597,6 +6762,9 @@ packages: underscore@1.13.8: resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -7672,6 +7840,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@balena/dockerignore@1.0.2': {} + '@bcoe/v8-coverage@1.0.2': {} '@biomejs/biome@2.4.16': @@ -8126,6 +8296,25 @@ snapshots: fastq: 1.20.1 glob: 13.0.6 + '@grpc/grpc-js@1.14.4': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 8.6.2 + yargs: 17.7.2 + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 8.6.2 + yargs: 17.7.2 + '@iconify-json/simple-icons@1.2.74': dependencies: '@iconify/types': 2.0.0 @@ -8881,6 +9070,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} + + '@kwsites/file-exists@1.1.1': + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@lukeed/ms@2.0.2': {} '@napi-rs/nice-android-arm-eabi@1.1.1': @@ -9729,6 +9926,15 @@ snapshots: tailwindcss: 4.3.0 vite: 8.0.16(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3) + '@testcontainers/postgresql@12.0.1': + dependencies: + testcontainers: 12.0.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -9834,6 +10040,17 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/docker-modem@3.0.6': + dependencies: + '@types/node': 22.19.19 + '@types/ssh2': 1.15.5 + + '@types/dockerode@4.0.1': + dependencies: + '@types/docker-modem': 3.0.6 + '@types/node': 22.19.19 + '@types/ssh2': 1.15.5 + '@types/emscripten@1.41.5': {} '@types/estree@1.0.9': {} @@ -9867,6 +10084,10 @@ snapshots: '@types/node@16.9.1': {} + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@22.19.19': dependencies: undici-types: 6.21.0 @@ -9884,12 +10105,11 @@ snapshots: dependencies: '@types/node': 22.19.19 - '@types/pg@8.15.6': + '@types/pg@8.20.0': dependencies: '@types/node': 22.19.19 - pg-protocol: 1.13.0 + pg-protocol: 1.14.0 pg-types: 2.2.0 - optional: true '@types/potrace@2.1.5': dependencies: @@ -9931,6 +10151,19 @@ snapshots: dependencies: '@types/node': 22.19.19 + '@types/ssh2-streams@0.1.13': + dependencies: + '@types/node': 22.19.19 + + '@types/ssh2@0.5.52': + dependencies: + '@types/node': 22.19.19 + '@types/ssh2-streams': 0.1.13 + + '@types/ssh2@1.15.5': + dependencies: + '@types/node': 18.19.130 + '@types/trusted-types@2.0.7': optional: true @@ -10249,6 +10482,10 @@ snapshots: array-ify@1.0.0: {} + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} ast-v8-to-istanbul@0.3.12: @@ -10257,6 +10494,8 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + async-lock@1.4.1: {} + async@3.2.6: {} atomic-sleep@1.0.0: {} @@ -10313,6 +10552,10 @@ snapshots: baseline-browser-mapping@2.10.10: {} + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + before-after-hook@4.0.0: {} better-sqlite3@11.10.0: @@ -10382,6 +10625,11 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buildcheck@0.0.7: + optional: true + + byline@5.0.0: {} + cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -10601,6 +10849,12 @@ snapshots: optionalDependencies: typescript: 5.9.3 + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.7 + nan: 2.27.0 + optional: true + crc-32@1.2.2: {} crc32-stream@6.0.0: @@ -10679,6 +10933,30 @@ snapshots: dependencies: path-type: 4.0.0 + docker-compose@1.4.2: + dependencies: + yaml: 2.8.3 + + docker-modem@5.0.7: + dependencies: + debug: 4.4.3 + readable-stream: 3.6.2 + split-ca: 1.0.1 + ssh2: 1.17.0 + transitivePeerDependencies: + - supports-color + + dockerode@5.0.0: + dependencies: + '@balena/dockerignore': 1.0.2 + '@grpc/grpc-js': 1.14.4 + '@grpc/proto-loader': 0.7.15 + docker-modem: 5.0.7 + protobufjs: 8.6.2 + tar-fs: 2.1.4 + transitivePeerDependencies: + - supports-color + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -10702,13 +10980,14 @@ snapshots: esbuild: 0.25.12 tsx: 4.22.4 - drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@11.10.0)(gel@2.2.0): + drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(gel@2.2.0)(pg@8.21.0): optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/better-sqlite3': 7.6.13 - '@types/pg': 8.15.6 + '@types/pg': 8.20.0 better-sqlite3: 11.10.0 gel: 2.2.0 + pg: 8.21.0 dunder-proto@1.0.1: dependencies: @@ -11153,6 +11432,8 @@ snapshots: hasown: 2.0.4 math-intrinsics: 1.1.0 + get-port@7.2.0: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -11674,6 +11955,8 @@ snapshots: lodash-es@4.18.1: {} + lodash.camelcase@4.3.0: {} + lodash.capitalize@4.2.1: {} lodash.escaperegexp@4.1.2: {} @@ -11696,6 +11979,8 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 + long@5.3.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -12029,6 +12314,8 @@ snapshots: dependencies: minimist: 1.2.8 + mkdirp@3.0.1: {} + module-details-from-path@1.0.4: {} motion-dom@11.18.1: @@ -12061,6 +12348,9 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nan@2.27.0: + optional: true + nanoid@3.3.12: {} nanoid@5.1.7: {} @@ -12318,11 +12608,18 @@ snapshots: perfect-debounce@1.0.0: {} - pg-int8@1.0.1: + pg-cloudflare@1.4.0: optional: true - pg-protocol@1.13.0: - optional: true + pg-connection-string@2.13.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.21.0): + dependencies: + pg: 8.21.0 + + pg-protocol@1.14.0: {} pg-types@2.2.0: dependencies: @@ -12331,7 +12628,20 @@ snapshots: postgres-bytea: 1.0.1 postgres-date: 1.0.7 postgres-interval: 1.2.0 - optional: true + + pg@8.21.0: + dependencies: + pg-connection-string: 2.13.0 + pg-pool: 3.14.0(pg@8.21.0) + pg-protocol: 1.14.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 phin@3.7.1: dependencies: @@ -12404,19 +12714,15 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postgres-array@2.0.0: - optional: true + postgres-array@2.0.0: {} - postgres-bytea@1.0.1: - optional: true + postgres-bytea@1.0.1: {} - postgres-date@1.0.7: - optional: true + postgres-date@1.0.7: {} postgres-interval@1.2.0: dependencies: xtend: 4.0.2 - optional: true posthog-js@1.379.2: dependencies: @@ -12480,10 +12786,27 @@ snapshots: progress@2.0.3: {} + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + properties-reader@3.0.1: + dependencies: + '@kwsites/file-exists': 1.1.1 + mkdirp: 3.0.1 + transitivePeerDependencies: + - supports-color + property-information@7.1.0: {} proto-list@1.2.4: {} + protobufjs@8.6.2: + dependencies: + long: 5.3.2 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -12734,6 +13057,8 @@ snapshots: ret@0.5.0: {} + retry@0.12.0: {} + reusify@1.1.0: {} rfdc@1.4.1: {} @@ -13002,6 +13327,8 @@ snapshots: speakingurl@14.0.1: {} + split-ca@1.0.1: {} + split2@1.0.0: dependencies: through2: 2.0.5 @@ -13010,6 +13337,19 @@ snapshots: sprintf-js@1.0.3: {} + ssh-remote-port-forward@1.0.4: + dependencies: + '@types/ssh2': 0.5.52 + ssh2: 1.17.0 + + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.27.0 + stackback@0.0.2: {} statuses@2.0.2: {} @@ -13150,6 +13490,18 @@ snapshots: pump: 3.0.4 tar-stream: 2.2.0 + tar-fs@3.1.2: + dependencies: + pump: 3.0.4 + tar-stream: 3.1.8 + optionalDependencies: + bare-fs: 4.5.6 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -13199,6 +13551,29 @@ snapshots: glob: 10.5.0 minimatch: 10.2.5 + testcontainers@12.0.1: + dependencies: + '@balena/dockerignore': 1.0.2 + '@types/dockerode': 4.0.1 + archiver: 7.0.1 + async-lock: 1.4.1 + byline: 5.0.0 + debug: 4.4.3 + docker-compose: 1.4.2 + dockerode: 5.0.0 + get-port: 7.2.0 + proper-lockfile: 4.1.2 + properties-reader: 3.0.1 + ssh-remote-port-forward: 1.0.4 + tar-fs: 3.1.2 + tmp: 0.2.7 + undici: 7.25.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + text-decoder@1.2.7: dependencies: b4a: 1.8.0 @@ -13260,6 +13635,8 @@ snapshots: dependencies: tldts-core: 7.0.30 + tmp@0.2.7: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -13314,6 +13691,8 @@ snapshots: '@turbo/windows-64': 2.9.16 '@turbo/windows-arm64': 2.9.16 + tweetnacl@0.14.5: {} + type-fest@1.4.0: {} type-fest@2.19.0: {} @@ -13347,6 +13726,8 @@ snapshots: underscore@1.13.8: {} + undici-types@5.26.5: {} + undici-types@6.21.0: {} undici-types@7.24.6: diff --git a/scripts/check-license-boundary.mjs b/scripts/check-license-boundary.mjs new file mode 100644 index 00000000..77f547b0 --- /dev/null +++ b/scripts/check-license-boundary.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +// Enforces D15: core never reaches into packages/enterprise internals, +// and packages/enterprise never imports from apps/* (it must stay standalone). +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +const ROOT = process.cwd(); +const SKIP_DIRS = new Set(["node_modules", "dist", ".git", ".turbo", "coverage"]); + +function* walk(dir) { + for (const entry of readdirSync(dir)) { + if (SKIP_DIRS.has(entry)) continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + yield* walk(full); + } else if (/\.(ts|tsx|js|mjs)$/.test(entry)) { + yield full; + } + } +} + +const ENTERPRISE_DIR = join(ROOT, "packages/enterprise"); +// Catches static `from "..."`, side-effect `import "..."`, and dynamic `import("...")` +// forms. The bare package entry "@snapotter/enterprise" (no trailing slash) stays +// allowed; only reaching INTO the package is a violation. +const INTERNAL_IMPORT = /(from|import)\s*[\s(]\s*["'](@snapotter\/enterprise\/|[./]*packages\/enterprise\/)/; +const APP_IMPORT = /(from|import)\s*[\s(]\s*["'][^"']*apps\/(api|web)\//; + +const violations = []; + +for (const root of [join(ROOT, "apps"), join(ROOT, "packages")]) { + for (const file of walk(root)) { + const insideEnterprise = file.startsWith(ENTERPRISE_DIR); + const text = readFileSync(file, "utf8"); + for (const [lineNo, line] of text.split("\n").entries()) { + if (!insideEnterprise && INTERNAL_IMPORT.test(line)) { + violations.push(`core imports enterprise internals: ${relative(ROOT, file)}:${lineNo + 1}`); + } + if (insideEnterprise && APP_IMPORT.test(line)) { + violations.push(`enterprise imports app code: ${relative(ROOT, file)}:${lineNo + 1}`); + } + } + } +} + +if (violations.length > 0) { + console.error("License boundary violations (D15):"); + for (const v of violations) console.error(` ${v}`); + process.exit(1); +} +console.log("License boundary check passed."); diff --git a/tests/e2e-pg-create-db.cjs b/tests/e2e-pg-create-db.cjs new file mode 100644 index 00000000..da1a24f7 --- /dev/null +++ b/tests/e2e-pg-create-db.cjs @@ -0,0 +1,62 @@ +/** + * Create a fresh Postgres database for an e2e Playwright run. + * + * Usage: node tests/e2e-pg-create-db.cjs + * + * Connects to E2E_PG_BASE_URL (default postgres://snapotter:snapotter@localhost:5432/snapotter), + * drops the target database if it exists, then creates it. The API server + * (started right after this script) auto-migrates the empty database at boot. + */ +"use strict"; + +const { createRequire } = require("node:module"); +const { join } = require("node:path"); + +const apiRequire = createRequire(join(process.cwd(), "apps/api/package.json")); +const pg = apiRequire("pg"); + +const baseUrl = + process.env.E2E_PG_BASE_URL || + "postgres://snapotter:snapotter@localhost:5432/snapotter"; +const dbName = process.argv[2]; + +if (!dbName) { + console.error("Usage: node tests/e2e-pg-create-db.cjs "); + process.exit(1); +} + +async function main() { + const client = new pg.Client({ connectionString: baseUrl }); + await client.connect(); + try { + // Clean up stale e2e databases from previous runs (best-effort). + const { rows } = await client.query( + "SELECT datname FROM pg_database WHERE datname LIKE 'snapotter_e2e_%'", + ); + for (const row of rows) { + if (row.datname !== dbName) { + try { + await client.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [row.datname], + ); + await client.query(`DROP DATABASE IF EXISTS "${row.datname}"`); + } catch { + // ignore: another process may still be using it + } + } + } + + await client.query(`DROP DATABASE IF EXISTS "${dbName}"`); + await client.query(`CREATE DATABASE "${dbName}"`); + console.log(`[e2e-pg] created database: ${dbName}`); + } finally { + await client.end(); + } +} + +main().catch((err) => { + console.error("[e2e-pg] failed to create database:", err.message); + process.exit(1); +}); diff --git a/tests/global-setup.ts b/tests/global-setup.ts new file mode 100644 index 00000000..1f3e3edc --- /dev/null +++ b/tests/global-setup.ts @@ -0,0 +1,50 @@ +import { createRequire } from "node:module"; +import { join } from "node:path"; +import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql"; + +// pg and drizzle-orm live in the api workspace's node_modules. Global-setup +// files run outside Vite's transform pipeline, so vitest resolve.alias does +// not apply. Use createRequire pointed at the api workspace instead. +const apiRequire = createRequire(join(process.cwd(), "apps/api/package.json")); +const pg = apiRequire("pg") as typeof import("pg"); +const { drizzle } = apiRequire( + "drizzle-orm/node-postgres", +) as typeof import("drizzle-orm/node-postgres"); +const { migrate } = apiRequire( + "drizzle-orm/node-postgres/migrator", +) as typeof import("drizzle-orm/node-postgres/migrator"); + +let container: StartedPostgreSqlContainer | undefined; + +export async function setup(): Promise { + // Base server: testcontainer by default, or an existing server via + // TEST_DATABASE_URL (must allow CREATE DATABASE, e.g. postgres://...:5432/postgres). + let baseUrl: string; + if (process.env.TEST_DATABASE_URL) { + baseUrl = process.env.TEST_DATABASE_URL; + } else { + container = await new PostgreSqlContainer("postgres:17-alpine").start(); + baseUrl = container.getConnectionUri(); + } + process.env.TEST_PG_BASE_URL = baseUrl; // forks inherit this + + // Build the migrated template database once; forks clone it. + const admin = new pg.Client({ connectionString: baseUrl }); + await admin.connect(); + await admin.query("DROP DATABASE IF EXISTS snapotter_template"); + await admin.query("CREATE DATABASE snapotter_template"); + await admin.end(); + + const templateUrl = new URL(baseUrl); + templateUrl.pathname = "/snapotter_template"; + const pool = new pg.Pool({ connectionString: templateUrl.toString(), max: 1 }); + try { + await migrate(drizzle(pool), { migrationsFolder: join(process.cwd(), "apps/api/drizzle") }); + } finally { + await pool.end(); + } +} + +export async function teardown(): Promise { + await container?.stop(); +} diff --git a/tests/integration/anonymous-mode.test.ts b/tests/integration/anonymous-mode.test.ts index a0fa342f..f60201ee 100644 --- a/tests/integration/anonymous-mode.test.ts +++ b/tests/integration/anonymous-mode.test.ts @@ -13,7 +13,7 @@ beforeAll(async () => { testApp = await buildTestApp(); app = testApp.app; adminToken = await loginAsAdmin(app); - ensureAnonymousUser(); + await ensureAnonymousUser(); }, 30_000); afterAll(async () => { @@ -21,18 +21,18 @@ afterAll(async () => { }, 10_000); describe("ensureAnonymousUser", () => { - it("creates anonymous row in the users table", () => { - const row = db.select().from(schema.users).where(eq(schema.users.id, "anonymous")).get(); + it("creates anonymous row in the users table", async () => { + const [row] = await db.select().from(schema.users).where(eq(schema.users.id, "anonymous")); expect(row).toBeDefined(); expect(row?.username).toBe("anonymous"); expect(row?.role).toBe("admin"); expect(row?.mustChangePassword).toBe(false); }); - it("is idempotent", () => { - ensureAnonymousUser(); - ensureAnonymousUser(); - const rows = db.select().from(schema.users).where(eq(schema.users.id, "anonymous")).all(); + it("is idempotent", async () => { + await ensureAnonymousUser(); + await ensureAnonymousUser(); + const rows = await db.select().from(schema.users).where(eq(schema.users.id, "anonymous")); expect(rows).toHaveLength(1); }); }); @@ -40,64 +40,52 @@ describe("ensureAnonymousUser", () => { describe("FK constraint with anonymous userId", () => { it("can insert an API key with userId 'anonymous'", async () => { const keyHash = await hashPassword("si_test"); - expect(() => - db - .insert(schema.apiKeys) - .values({ - id: randomUUID(), - userId: "anonymous", - keyHash, - keyPrefix: "si_te", - name: "FK test key", - }) - .run(), - ).not.toThrow(); + await expect( + db.insert(schema.apiKeys).values({ + id: randomUUID(), + userId: "anonymous", + keyHash, + keyPrefix: "si_te", + name: "FK test key", + }), + ).resolves.toBeDefined(); }); - it("can insert a pipeline with userId 'anonymous'", () => { - expect(() => - db - .insert(schema.pipelines) - .values({ - id: randomUUID(), - userId: "anonymous", - name: "FK test pipeline", - steps: JSON.stringify([{ toolId: "resize", settings: { width: 100 } }]), - }) - .run(), - ).not.toThrow(); + it("can insert a pipeline with userId 'anonymous'", async () => { + await expect( + db.insert(schema.pipelines).values({ + id: randomUUID(), + userId: "anonymous", + name: "FK test pipeline", + steps: JSON.stringify([{ toolId: "resize", settings: { width: 100 } }]), + }), + ).resolves.toBeDefined(); }); - it("can insert a user file with userId 'anonymous'", () => { - expect(() => - db - .insert(schema.userFiles) - .values({ - id: randomUUID(), - userId: "anonymous", - originalName: "test.png", - storedName: "fk-test-stored.png", - mimeType: "image/png", - size: 1024, - }) - .run(), - ).not.toThrow(); + it("can insert a user file with userId 'anonymous'", async () => { + await expect( + db.insert(schema.userFiles).values({ + id: randomUUID(), + userId: "anonymous", + originalName: "test.png", + storedName: "fk-test-stored.png", + mimeType: "image/png", + size: 1024, + }), + ).resolves.toBeDefined(); }); it("rejects FK violation for nonexistent userId", async () => { const keyHash = await hashPassword("si_bad"); - expect(() => - db - .insert(schema.apiKeys) - .values({ - id: randomUUID(), - userId: "nonexistent-user-id", - keyHash, - keyPrefix: "si_ba", - name: "bad FK key", - }) - .run(), - ).toThrow(); + await expect( + db.insert(schema.apiKeys).values({ + id: randomUUID(), + userId: "nonexistent-user-id", + keyHash, + keyPrefix: "si_ba", + name: "bad FK key", + }), + ).rejects.toThrow(); }); }); diff --git a/tests/integration/api-key-edge-cases.test.ts b/tests/integration/api-key-edge-cases.test.ts index c7847360..4ae24bf2 100644 --- a/tests/integration/api-key-edge-cases.test.ts +++ b/tests/integration/api-key-edge-cases.test.ts @@ -38,10 +38,10 @@ async function createUserAndLogin( } const regBody = JSON.parse(regRes.body); - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, username)) - .run(); + .where(eq(schema.users.username, username)); const loginRes = await testApp.app.inject({ method: "POST", diff --git a/tests/integration/api-key-scoping.test.ts b/tests/integration/api-key-scoping.test.ts index 93180f38..4a524d46 100644 --- a/tests/integration/api-key-scoping.test.ts +++ b/tests/integration/api-key-scoping.test.ts @@ -36,10 +36,10 @@ describe("API key permission scoping", () => { headers: { authorization: `Bearer ${adminToken}` }, payload: { username: "scopetest", password: "ScopeTest1", role: "user" }, }); - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, "scopetest")) - .run(); + .where(eq(schema.users.username, "scopetest")); const loginRes = await testApp.app.inject({ method: "POST", @@ -152,10 +152,10 @@ describe("API key expiration", () => { const keyId = JSON.parse(createRes.body).id; // Manually expire the key in DB - db.update(schema.apiKeys) + await db + .update(schema.apiKeys) .set({ expiresAt: new Date(Date.now() - 1000) }) - .where(eq(schema.apiKeys.id, keyId)) - .run(); + .where(eq(schema.apiKeys.id, keyId)); const res = await testApp.app.inject({ method: "GET", diff --git a/tests/integration/audit-log.test.ts b/tests/integration/audit-log.test.ts index 4bcb53b6..df8f8171 100644 --- a/tests/integration/audit-log.test.ts +++ b/tests/integration/audit-log.test.ts @@ -46,10 +46,10 @@ describe("audit log", () => { role: "user", }, }); - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, "auditnoread")) - .run(); + .where(eq(schema.users.username, "auditnoread")); const loginRes = await testApp.app.inject({ method: "POST", diff --git a/tests/integration/auth-edge-cases.test.ts b/tests/integration/auth-edge-cases.test.ts index 9c9b7768..5d476c40 100644 --- a/tests/integration/auth-edge-cases.test.ts +++ b/tests/integration/auth-edge-cases.test.ts @@ -38,10 +38,10 @@ async function createUser( if (res.statusCode !== 201) { throw new Error(`createUser failed: ${res.statusCode} ${res.body}`); } - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, username)) - .run(); + .where(eq(schema.users.username, username)); return { username, password, id: body.id }; } @@ -146,10 +146,10 @@ describe("Session edge cases", () => { const token = await loginAs("admin", "Adminpass1"); // Manually expire the session in the DB - db.update(schema.sessions) + await db + .update(schema.sessions) .set({ expiresAt: new Date(Date.now() - 60_000) }) - .where(eq(schema.sessions.id, token)) - .run(); + .where(eq(schema.sessions.id, token)); const res = await testApp.app.inject({ method: "GET", diff --git a/tests/integration/cleanup.test.ts b/tests/integration/cleanup.test.ts index 74cac5d6..60d019bc 100644 --- a/tests/integration/cleanup.test.ts +++ b/tests/integration/cleanup.test.ts @@ -13,24 +13,24 @@ import { startCleanupCron, } from "../../apps/api/src/lib/cleanup.js"; -beforeAll(() => { - runMigrations(); +beforeAll(async () => { + await runMigrations(); }); -function setSetting(key: string, value: string) { - const existing = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get(); +async function setSetting(key: string, value: string) { + const [existing] = await db.select().from(schema.settings).where(eq(schema.settings.key, key)); if (existing) { - db.update(schema.settings) + await db + .update(schema.settings) .set({ value, updatedAt: new Date() }) - .where(eq(schema.settings.key, key)) - .run(); + .where(eq(schema.settings.key, key)); } else { - db.insert(schema.settings).values({ key, value }).run(); + await db.insert(schema.settings).values({ key, value }); } } -function removeSetting(key: string) { - db.delete(schema.settings).where(eq(schema.settings.key, key)).run(); +async function removeSetting(key: string) { + await db.delete(schema.settings).where(eq(schema.settings.key, key)); } async function waitForCleanup(): Promise { @@ -39,69 +39,69 @@ async function waitForCleanup(): Promise { } } -afterEach(() => { - removeSetting("tempFileMaxAgeHours"); - removeSetting("startupCleanup"); +afterEach(async () => { + await removeSetting("tempFileMaxAgeHours"); + await removeSetting("startupCleanup"); }); describe("getMaxAgeMs", () => { - it("returns DB value when tempFileMaxAgeHours is set", () => { - setSetting("tempFileMaxAgeHours", "48"); - const result = getMaxAgeMs(); + it("returns DB value when tempFileMaxAgeHours is set", async () => { + await setSetting("tempFileMaxAgeHours", "48"); + const result = await getMaxAgeMs(); expect(result).toBe(48 * 60 * 60 * 1000); }); - it("returns env fallback when no DB setting exists", () => { - removeSetting("tempFileMaxAgeHours"); - const result = getMaxAgeMs(); + it("returns env fallback when no DB setting exists", async () => { + await removeSetting("tempFileMaxAgeHours"); + const result = await getMaxAgeMs(); expect(result).toBe(1 * 60 * 60 * 1000); }); - it("returns env fallback for invalid (non-numeric) DB value", () => { - setSetting("tempFileMaxAgeHours", "notanumber"); - const result = getMaxAgeMs(); + it("returns env fallback for invalid (non-numeric) DB value", async () => { + await setSetting("tempFileMaxAgeHours", "notanumber"); + const result = await getMaxAgeMs(); expect(result).toBe(1 * 60 * 60 * 1000); }); - it("returns env fallback for zero or negative DB value", () => { - setSetting("tempFileMaxAgeHours", "0"); - const result = getMaxAgeMs(); + it("returns env fallback for zero or negative DB value", async () => { + await setSetting("tempFileMaxAgeHours", "0"); + const result = await getMaxAgeMs(); expect(result).toBe(1 * 60 * 60 * 1000); - setSetting("tempFileMaxAgeHours", "-5"); - const result2 = getMaxAgeMs(); + await setSetting("tempFileMaxAgeHours", "-5"); + const result2 = await getMaxAgeMs(); expect(result2).toBe(1 * 60 * 60 * 1000); }); - it("handles fractional hours", () => { - setSetting("tempFileMaxAgeHours", "0.5"); - const result = getMaxAgeMs(); + it("handles fractional hours", async () => { + await setSetting("tempFileMaxAgeHours", "0.5"); + const result = await getMaxAgeMs(); expect(result).toBe(0.5 * 60 * 60 * 1000); }); }); describe("shouldRunStartupCleanup", () => { - it("returns false when setting is 'false'", () => { - setSetting("startupCleanup", "false"); - expect(shouldRunStartupCleanup()).toBe(false); + it("returns false when setting is 'false'", async () => { + await setSetting("startupCleanup", "false"); + expect(await shouldRunStartupCleanup()).toBe(false); }); - it("returns true when setting is 'true'", () => { - setSetting("startupCleanup", "true"); - expect(shouldRunStartupCleanup()).toBe(true); + it("returns true when setting is 'true'", async () => { + await setSetting("startupCleanup", "true"); + expect(await shouldRunStartupCleanup()).toBe(true); }); - it("returns true when setting is not set", () => { - removeSetting("startupCleanup"); - expect(shouldRunStartupCleanup()).toBe(true); + it("returns true when setting is not set", async () => { + await removeSetting("startupCleanup"); + expect(await shouldRunStartupCleanup()).toBe(true); }); - it("returns true for any value other than 'false'", () => { - setSetting("startupCleanup", "yes"); - expect(shouldRunStartupCleanup()).toBe(true); + it("returns true for any value other than 'false'", async () => { + await setSetting("startupCleanup", "yes"); + expect(await shouldRunStartupCleanup()).toBe(true); - setSetting("startupCleanup", "1"); - expect(shouldRunStartupCleanup()).toBe(true); + await setSetting("startupCleanup", "1"); + expect(await shouldRunStartupCleanup()).toBe(true); }); }); @@ -109,42 +109,42 @@ describe("startCleanupCron", () => { let tempDir: string; let originalWorkspacePath: string; - beforeEach(() => { + beforeEach(async () => { tempDir = join(tmpdir(), `cleanup-test-${randomUUID().slice(0, 8)}`); originalWorkspacePath = env.WORKSPACE_PATH; env.WORKSPACE_PATH = tempDir; - setSetting("startupCleanup", "false"); + await setSetting("startupCleanup", "false"); }); - afterEach(() => { + afterEach(async () => { env.WORKSPACE_PATH = originalWorkspacePath; - removeSetting("startupCleanup"); + await removeSetting("startupCleanup"); if (existsSync(tempDir)) { rmSync(tempDir, { recursive: true, force: true }); } }); - it("returns object with stop() method", () => { + it("returns object with stop() method", async () => { vi.useFakeTimers(); - const cron = startCleanupCron(); + const cron = await startCleanupCron(); expect(typeof cron.stop).toBe("function"); cron.stop(); vi.useRealTimers(); }); - it("creates workspace directory", () => { + it("creates workspace directory", async () => { vi.useFakeTimers(); expect(existsSync(tempDir)).toBe(false); - const cron = startCleanupCron(); + const cron = await startCleanupCron(); expect(existsSync(tempDir)).toBe(true); cron.stop(); vi.useRealTimers(); }); - it("stop() clears intervals", () => { + it("stop() clears intervals", async () => { vi.useFakeTimers(); const clearSpy = vi.spyOn(globalThis, "clearInterval"); - const cron = startCleanupCron(); + const cron = await startCleanupCron(); cron.stop(); expect(clearSpy).toHaveBeenCalledTimes(2); clearSpy.mockRestore(); @@ -158,8 +158,8 @@ describe("startCleanupCron", () => { const pastTime = new Date(Date.now() - 2 * 60 * 60 * 1000); utimesSync(oldFile, pastTime, pastTime); - setSetting("startupCleanup", "true"); - const cron = startCleanupCron(); + await setSetting("startupCleanup", "true"); + const cron = await startCleanupCron(); await waitForCleanup(); expect(existsSync(oldFile)).toBe(false); cron.stop(); @@ -170,8 +170,8 @@ describe("startCleanupCron", () => { const recentFile = join(tempDir, "recent-file.txt"); writeFileSync(recentFile, "recent content"); - setSetting("startupCleanup", "true"); - const cron = startCleanupCron(); + await setSetting("startupCleanup", "true"); + const cron = await startCleanupCron(); await waitForCleanup(); expect(existsSync(recentFile)).toBe(true); cron.stop(); @@ -187,8 +187,8 @@ describe("startCleanupCron", () => { utimesSync(nestedFile, pastTime, pastTime); utimesSync(oldDir, pastTime, pastTime); - setSetting("startupCleanup", "true"); - const cron = startCleanupCron(); + await setSetting("startupCleanup", "true"); + const cron = await startCleanupCron(); await waitForCleanup(); expect(existsSync(oldDir)).toBe(false); cron.stop(); @@ -201,97 +201,87 @@ describe("startCleanupCron", () => { const pastTime = new Date(Date.now() - 2 * 60 * 60 * 1000); utimesSync(oldFile, pastTime, pastTime); - setSetting("startupCleanup", "false"); - const cron = startCleanupCron(); + await setSetting("startupCleanup", "false"); + const cron = await startCleanupCron(); await waitForCleanup(); expect(existsSync(oldFile)).toBe(true); cron.stop(); }); - it("purges expired sessions on startup when enabled", () => { + it("purges expired sessions on startup when enabled", async () => { const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000); const userId = `test-user-${randomUUID().slice(0, 8)}`; - const existing = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); + const [existing] = await db.select().from(schema.users).where(eq(schema.users.id, userId)); if (!existing) { - db.insert(schema.users) - .values({ - id: userId, - username: `cleanup-test-${randomUUID().slice(0, 8)}`, - passwordHash: "hash", - role: "user", - team: "Default", - mustChangePassword: false, - }) - .run(); - } - - const sessionId = `sess-${randomUUID().slice(0, 8)}`; - db.insert(schema.sessions) - .values({ - id: sessionId, - userId, - expiresAt: pastDate, - }) - .run(); - - setSetting("startupCleanup", "true"); - const cron = startCleanupCron(); - - const session = db - .select() - .from(schema.sessions) - .where(eq(schema.sessions.id, sessionId)) - .get(); - expect(session).toBeUndefined(); - cron.stop(); - - db.delete(schema.users).where(eq(schema.users.id, userId)).run(); - }); - - it("does not purge non-expired sessions", () => { - const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000); - - const userId = `test-user-${randomUUID().slice(0, 8)}`; - db.insert(schema.users) - .values({ + await db.insert(schema.users).values({ id: userId, - username: `cleanup-keep-${randomUUID().slice(0, 8)}`, + username: `cleanup-test-${randomUUID().slice(0, 8)}`, passwordHash: "hash", role: "user", team: "Default", mustChangePassword: false, - }) - .run(); + }); + } const sessionId = `sess-${randomUUID().slice(0, 8)}`; - db.insert(schema.sessions) - .values({ - id: sessionId, - userId, - expiresAt: futureDate, - }) - .run(); + await db.insert(schema.sessions).values({ + id: sessionId, + userId, + expiresAt: pastDate, + }); - setSetting("startupCleanup", "true"); - const cron = startCleanupCron(); + await setSetting("startupCleanup", "true"); + const cron = await startCleanupCron(); - const session = db + const [session] = await db .select() .from(schema.sessions) - .where(eq(schema.sessions.id, sessionId)) - .get(); + .where(eq(schema.sessions.id, sessionId)); + expect(session).toBeUndefined(); + cron.stop(); + + await db.delete(schema.users).where(eq(schema.users.id, userId)); + }); + + it("does not purge non-expired sessions", async () => { + const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000); + + const userId = `test-user-${randomUUID().slice(0, 8)}`; + await db.insert(schema.users).values({ + id: userId, + username: `cleanup-keep-${randomUUID().slice(0, 8)}`, + passwordHash: "hash", + role: "user", + team: "Default", + mustChangePassword: false, + }); + + const sessionId = `sess-${randomUUID().slice(0, 8)}`; + await db.insert(schema.sessions).values({ + id: sessionId, + userId, + expiresAt: futureDate, + }); + + await setSetting("startupCleanup", "true"); + const cron = await startCleanupCron(); + + const [session] = await db + .select() + .from(schema.sessions) + .where(eq(schema.sessions.id, sessionId)); expect(session).toBeDefined(); cron.stop(); - db.delete(schema.sessions).where(eq(schema.sessions.id, sessionId)).run(); - db.delete(schema.users).where(eq(schema.users.id, userId)).run(); + await db.delete(schema.sessions).where(eq(schema.sessions.id, sessionId)); + await db.delete(schema.users).where(eq(schema.users.id, userId)); }); it("handles empty workspace directory gracefully", async () => { mkdirSync(tempDir, { recursive: true }); - setSetting("startupCleanup", "true"); - const cron = startCleanupCron(); + await setSetting("startupCleanup", "true"); + const cron = await startCleanupCron(); await waitForCleanup(); expect(existsSync(tempDir)).toBe(true); cron.stop(); diff --git a/tests/integration/custom-roles-edge-cases.test.ts b/tests/integration/custom-roles-edge-cases.test.ts index ce8f4d3d..04bb95f5 100644 --- a/tests/integration/custom-roles-edge-cases.test.ts +++ b/tests/integration/custom-roles-edge-cases.test.ts @@ -50,10 +50,10 @@ async function createUserAndLogin( headers: { authorization: `Bearer ${adminToken}` }, payload: { username, password, role }, }); - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, username)) - .run(); + .where(eq(schema.users.username, username)); const loginRes = await testApp.app.inject({ method: "POST", @@ -223,10 +223,10 @@ describe("CRUD edge cases", () => { role: roleName, }, }); - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, `multi-u${i}-${suffix}`)) - .run(); + .where(eq(schema.users.username, `multi-u${i}-${suffix}`)); } // Delete the role diff --git a/tests/integration/custom-roles.test.ts b/tests/integration/custom-roles.test.ts index 0012be9f..5fc335c7 100644 --- a/tests/integration/custom-roles.test.ts +++ b/tests/integration/custom-roles.test.ts @@ -67,10 +67,10 @@ describe("custom roles", () => { headers: { authorization: `Bearer ${adminToken}` }, payload: { username: "customroleuser", password: "CustomRole1", role: "reviewer" }, }); - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, "customroleuser")) - .run(); + .where(eq(schema.users.username, "customroleuser")); const loginRes = await testApp.app.inject({ method: "POST", diff --git a/tests/integration/escalation.test.ts b/tests/integration/escalation.test.ts index e1b8ac65..a79ecec6 100644 --- a/tests/integration/escalation.test.ts +++ b/tests/integration/escalation.test.ts @@ -33,10 +33,10 @@ async function registerUser(token: string, username: string, role: string, passw * Helper: log in as a given user and return the session token. */ async function loginAs(username: string, password = "Testpass1"): Promise { - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, username)) - .run(); + .where(eq(schema.users.username, username)); const res = await testApp.app.inject({ method: "POST", @@ -196,10 +196,10 @@ describe("update user role escalation", () => { const originalAdminId = JSON.parse(sessionRes.body).user.id; // Demote ALL admins except the original via DB - const allUsers = db.select().from(schema.users).all(); + const allUsers = await db.select().from(schema.users); for (const u of allUsers) { if (u.role === "admin" && u.id !== originalAdminId) { - db.update(schema.users).set({ role: "user" }).where(eq(schema.users.id, u.id)).run(); + await db.update(schema.users).set({ role: "user" }).where(eq(schema.users.id, u.id)); } } diff --git a/tests/integration/migrate-from-sqlite.test.ts b/tests/integration/migrate-from-sqlite.test.ts new file mode 100644 index 00000000..71c5f8d6 --- /dev/null +++ b/tests/integration/migrate-from-sqlite.test.ts @@ -0,0 +1,131 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import Database from "better-sqlite3"; +import { sql } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { db } from "../../apps/api/src/db/index.js"; +import { migrateFromSqlite } from "../../apps/api/src/db/migrate-from-sqlite.js"; + +function buildFixtureSqlite(path: string): void { + const s = new Database(path); + s.exec(` + CREATE TABLE users (id text PRIMARY KEY, username text NOT NULL, password_hash text, + role text NOT NULL DEFAULT 'user', team text NOT NULL DEFAULT 'Default', + must_change_password integer NOT NULL DEFAULT 1, auth_provider text NOT NULL DEFAULT 'local', + external_id text, email text, created_at integer NOT NULL, updated_at integer NOT NULL, + analytics_enabled integer, analytics_consent_shown_at integer, analytics_consent_remind_at integer); + CREATE TABLE teams (id text PRIMARY KEY, name text NOT NULL, created_at integer NOT NULL); + CREATE TABLE settings ("key" text PRIMARY KEY, value text NOT NULL, updated_at integer NOT NULL); + CREATE TABLE roles (id text PRIMARY KEY, name text NOT NULL, description text NOT NULL DEFAULT '', + permissions text NOT NULL, is_builtin integer NOT NULL DEFAULT 0, created_by text, + created_at integer NOT NULL, updated_at integer NOT NULL); + CREATE TABLE sessions (id text PRIMARY KEY, user_id text NOT NULL, expires_at integer NOT NULL, + id_token text, created_at integer NOT NULL); + CREATE TABLE api_keys (id text PRIMARY KEY, user_id text NOT NULL, key_hash text NOT NULL, + key_prefix text, name text NOT NULL DEFAULT 'Default API Key', permissions text, + created_at integer NOT NULL, last_used_at integer, expires_at integer); + CREATE TABLE pipelines (id text PRIMARY KEY, user_id text, name text NOT NULL, description text, + steps text NOT NULL, created_at integer NOT NULL); + CREATE TABLE jobs (id text PRIMARY KEY, type text NOT NULL, status text NOT NULL DEFAULT 'queued', + progress real NOT NULL DEFAULT 0, input_files text NOT NULL, output_path text, settings text, + error text, created_at integer NOT NULL, completed_at integer); + CREATE TABLE audit_log (id text PRIMARY KEY, actor_id text, actor_username text NOT NULL, + action text NOT NULL, target_type text, target_id text, details text, ip_address text, + created_at integer NOT NULL); + CREATE TABLE user_files (id text PRIMARY KEY, user_id text, original_name text NOT NULL, + stored_name text NOT NULL, mime_type text NOT NULL, size integer NOT NULL, width integer, + height integer, version integer NOT NULL DEFAULT 1, parent_id text, tool_chain text, + created_at integer NOT NULL); + `); + const now = 1750000000; // seconds epoch, as 1.x stored + s.prepare( + "INSERT INTO users (id, username, password_hash, must_change_password, created_at, updated_at, analytics_enabled, analytics_consent_shown_at) VALUES (?,?,?,?,?,?,?,?)", + ).run("u1", "alice", "hash", 0, now, now, 1, null); + s.prepare("INSERT INTO teams (id, name, created_at) VALUES (?,?,?)").run("t1", "Legal", now); + s.prepare('INSERT INTO settings ("key", value, updated_at) VALUES (?,?,?)').run( + "cookieSecret", + "not-json-value", + now, + ); + s.prepare( + "INSERT INTO roles (id, name, permissions, is_builtin, created_by, created_at, updated_at) VALUES (?,?,?,?,?,?,?)", + ).run("r1", "auditor", '["audit:read"]', 1, "u1", now, now); + s.prepare("INSERT INTO pipelines (id, user_id, name, steps, created_at) VALUES (?,?,?,?,?)").run( + "p1", + "u1", + "shrink", + '[{"toolId":"compress","settings":{"quality":70}}]', + now, + ); + s.prepare( + "INSERT INTO jobs (id, type, status, progress, input_files, created_at, completed_at) VALUES (?,?,?,?,?,?,?)", + ).run("j1", "batch", "completed", 1, '["a.png"]', now, null); + s.prepare( + "INSERT INTO audit_log (id, actor_username, action, details, created_at) VALUES (?,?,?,?,?)", + ).run("al1", "alice", "login", null, now); + s.prepare( + "INSERT INTO user_files (id, user_id, original_name, stored_name, mime_type, size, version, tool_chain, created_at) VALUES (?,?,?,?,?,?,?,?,?)", + ).run("uf1", "u1", "photo.png", "abc123.png", "image/png", 1024, 1, null, now); + s.close(); +} + +describe("migrate-from-sqlite", () => { + const dir = mkdtempSync(join(tmpdir(), "snapotter-migrator-")); + const sqlitePath = join(dir, "snapotter-1x.db"); + + beforeAll(async () => { + buildFixtureSqlite(sqlitePath); + // simulate empty 2.0 target: wipe all rows the suite DB may have + await db.execute( + sql`TRUNCATE user_files, audit_log, jobs, pipelines, api_keys, sessions, roles, settings, teams, users CASCADE`, + ); + }); + + afterAll(async () => { + await db.execute( + sql`TRUNCATE user_files, audit_log, jobs, pipelines, api_keys, sessions, roles, settings, teams, users CASCADE`, + ); + }); + + it("copies all rows with converted types", async () => { + const result = await migrateFromSqlite(sqlitePath, { force: false }); + expect(result.tables.users).toBe(1); + expect(result.tables.pipelines).toBe(1); + const [user] = (await db.execute(sql`SELECT * FROM users WHERE id = 'u1'`)).rows; + expect(user.username).toBe("alice"); + expect(user.must_change_password).toBe(false); // 0 became boolean false + expect(user.analytics_enabled).toBe(true); // 1 became boolean true + expect(user.analytics_consent_shown_at).toBeNull(); // explicit NULL preserved + expect(new Date(user.created_at as string).getTime()).toBe(1750000000 * 1000); // seconds became timestamptz + const [pipeline] = (await db.execute(sql`SELECT * FROM pipelines WHERE id = 'p1'`)).rows; + expect((pipeline.steps as Array<{ toolId: string }>)[0].toolId).toBe("compress"); // text JSON became jsonb + const [setting] = (await db.execute(sql`SELECT * FROM settings WHERE key = 'cookieSecret'`)) + .rows; + expect(setting.value).toBe("not-json-value"); // settings.value stayed text, untouched + const [job] = (await db.execute(sql`SELECT * FROM jobs WHERE id = 'j1'`)).rows; + expect(job.completed_at).toBeNull(); // explicit NULL preserved + // audit_log with NULL details + const alRows = (await db.execute(sql`SELECT * FROM audit_log WHERE id = 'al1'`)).rows; + expect(alRows).toHaveLength(1); + expect(alRows[0].details).toBeNull(); + expect(result.tables.audit_log).toBe(1); + // user_files with NULL tool_chain + const ufRows = (await db.execute(sql`SELECT * FROM user_files WHERE id = 'uf1'`)).rows; + expect(ufRows).toHaveLength(1); + expect(ufRows[0].tool_chain).toBeNull(); + expect(result.tables.user_files).toBe(1); + }); + + it("refuses a non-empty target without force", async () => { + await expect(migrateFromSqlite(sqlitePath, { force: false })).rejects.toThrow(/non-empty/i); + }); + + it("force on populated target fails on PK collision and rolls back", async () => { + // The first test already inserted rows; forcing again should hit a PK/unique violation + await expect(migrateFromSqlite(sqlitePath, { force: true })).rejects.toThrow(); + // Rollback must leave previous data intact + const { rows } = await db.execute(sql`SELECT count(*)::int AS n FROM users`); + expect(rows[0].n).toBe(1); + }); +}); diff --git a/tests/integration/oidc-auth.test.ts b/tests/integration/oidc-auth.test.ts index 6a6e64ba..50f4af6b 100644 --- a/tests/integration/oidc-auth.test.ts +++ b/tests/integration/oidc-auth.test.ts @@ -35,38 +35,36 @@ afterAll(async () => { * Insert an OIDC-only user directly into the DB (no passwordHash). * Returns a session token for the user. */ -function createOidcUser(opts: { username?: string; email?: string; role?: string } = {}): { +async function createOidcUser( + opts: { username?: string; email?: string; role?: string } = {}, +): Promise<{ userId: string; username: string; sessionToken: string; -} { +}> { const userId = randomUUID(); const username = opts.username || `oidc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; - db.insert(schema.users) - .values({ - id: userId, - username, - passwordHash: null, - role: opts.role || "user", - team: "default-team-00000000", - mustChangePassword: false, - authProvider: "oidc", - externalId: `sub-${userId}`, - email: opts.email || `${username}@example.com`, - }) - .run(); + await db.insert(schema.users).values({ + id: userId, + username, + passwordHash: null, + role: opts.role || "user", + team: "default-team-00000000", + mustChangePassword: false, + authProvider: "oidc", + externalId: `sub-${userId}`, + email: opts.email || `${username}@example.com`, + }); // Create a session (simulates what the OIDC callback would do) const sessionToken = randomUUID(); - db.insert(schema.sessions) - .values({ - id: sessionToken, - userId, - expiresAt: new Date(Date.now() + 3_600_000), - idToken: "mock-id-token-jwt", - }) - .run(); + await db.insert(schema.sessions).values({ + id: sessionToken, + userId, + expiresAt: new Date(Date.now() + 3_600_000), + idToken: "mock-id-token-jwt", + }); return { userId, username, sessionToken }; } @@ -74,26 +72,24 @@ function createOidcUser(opts: { username?: string; email?: string; role?: string /** * Insert a session for an existing user with custom options. */ -function createOidcSession( +async function createOidcSession( userId: string, opts: { expiresAt?: Date; idToken?: string | null } = {}, -): string { +): Promise { const sessionToken = randomUUID(); - db.insert(schema.sessions) - .values({ - id: sessionToken, - userId, - expiresAt: opts.expiresAt ?? new Date(Date.now() + 3_600_000), - idToken: opts.idToken ?? null, - }) - .run(); + await db.insert(schema.sessions).values({ + id: sessionToken, + userId, + expiresAt: opts.expiresAt ?? new Date(Date.now() + 3_600_000), + idToken: opts.idToken ?? null, + }); return sessionToken; } /** * Insert a "hybrid" user -- has both a local password AND an OIDC link. */ -function _createHybridUser( +async function _createHybridUser( passwordHash: string, opts: { username?: string; email?: string } = {}, ): { userId: string; username: string; sessionToken: string } { @@ -101,28 +97,24 @@ function _createHybridUser( const username = opts.username || `hybrid_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; - db.insert(schema.users) - .values({ - id: userId, - username, - passwordHash, - role: "user", - team: "default-team-00000000", - mustChangePassword: false, - authProvider: "oidc", - externalId: `sub-${userId}`, - email: opts.email || `${username}@example.com`, - }) - .run(); + await db.insert(schema.users).values({ + id: userId, + username, + passwordHash, + role: "user", + team: "default-team-00000000", + mustChangePassword: false, + authProvider: "oidc", + externalId: `sub-${userId}`, + email: opts.email || `${username}@example.com`, + }); const sessionToken = randomUUID(); - db.insert(schema.sessions) - .values({ - id: sessionToken, - userId, - expiresAt: new Date(Date.now() + 3_600_000), - }) - .run(); + await db.insert(schema.sessions).values({ + id: sessionToken, + userId, + expiresAt: new Date(Date.now() + 3_600_000), + }); return { userId, username, sessionToken }; } @@ -132,7 +124,7 @@ function _createHybridUser( // ===================================================================== describe("Session response fields", () => { it("returns OIDC fields for an OIDC user session", async () => { - const { sessionToken, username } = createOidcUser(); + const { sessionToken, username } = await createOidcUser(); const res = await testApp.app.inject({ method: "GET", @@ -185,7 +177,7 @@ describe("Session response fields", () => { // ===================================================================== describe("Password guards for OIDC users", () => { it("OIDC user (no passwordHash) cannot change password", async () => { - const { sessionToken } = createOidcUser(); + const { sessionToken } = await createOidcUser(); const res = await testApp.app.inject({ method: "POST", @@ -203,7 +195,7 @@ describe("Password guards for OIDC users", () => { }); it("admin cannot reset password for OIDC user", async () => { - const { userId } = createOidcUser(); + const { userId } = await createOidcUser(); const res = await testApp.app.inject({ method: "POST", @@ -223,7 +215,7 @@ describe("Password guards for OIDC users", () => { // ===================================================================== describe("Users list includes OIDC fields", () => { it("GET /api/auth/users includes authProvider, hasLocalPassword, hasOidcLink", async () => { - const { username: oidcUsername } = createOidcUser(); + const { username: oidcUsername } = await createOidcUser(); const res = await testApp.app.inject({ method: "GET", @@ -251,7 +243,7 @@ describe("Users list includes OIDC fields", () => { }); it("users list does not expose passwordHash or externalId directly", async () => { - createOidcUser(); + await createOidcUser(); const res = await testApp.app.inject({ method: "GET", @@ -275,7 +267,7 @@ describe("Users list includes OIDC fields", () => { describe("Backward compatibility", () => { it("local login still works when OIDC users exist in the DB", async () => { // Create an OIDC user (just to prove it doesn't break local login) - createOidcUser(); + await createOidcUser(); const res = await testApp.app.inject({ method: "POST", @@ -290,7 +282,7 @@ describe("Backward compatibility", () => { }); it("OIDC user cannot log in via local login (no passwordHash)", async () => { - const { username } = createOidcUser(); + const { username } = await createOidcUser(); const res = await testApp.app.inject({ method: "POST", @@ -641,7 +633,7 @@ describe("OIDC callback edge cases", () => { // ===================================================================== describe("Admin operations on OIDC users", () => { it("admin can delete an OIDC user", async () => { - const { userId } = createOidcUser(); + const { userId } = await createOidcUser(); const res = await testApp.app.inject({ method: "DELETE", @@ -652,12 +644,12 @@ describe("Admin operations on OIDC users", () => { expect(res.statusCode).toBe(200); // Verify user is gone - const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); + const [user] = await db.select().from(schema.users).where(eq(schema.users.id, userId)); expect(user).toBeUndefined(); }); it("admin can update role of an OIDC user", async () => { - const { userId } = createOidcUser(); + const { userId } = await createOidcUser(); const res = await testApp.app.inject({ method: "PUT", @@ -668,7 +660,7 @@ describe("Admin operations on OIDC users", () => { expect(res.statusCode).toBe(200); - const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); + const [user] = await db.select().from(schema.users).where(eq(schema.users.id, userId)); expect(user?.role).toBe("editor"); }); }); @@ -678,7 +670,7 @@ describe("Admin operations on OIDC users", () => { // ===================================================================== describe("Cookie-based session auth", () => { it("OIDC session cookie works for authenticated requests", async () => { - const { sessionToken, username } = createOidcUser(); + const { sessionToken, username } = await createOidcUser(); const res = await testApp.app.inject({ method: "GET", @@ -693,7 +685,7 @@ describe("Cookie-based session auth", () => { }); it("Both cookie and Bearer work simultaneously", async () => { - const { sessionToken: oidcToken, username: oidcUsername } = createOidcUser(); + const { sessionToken: oidcToken, username: oidcUsername } = await createOidcUser(); // Bearer token for local user (admin) const bearerRes = await testApp.app.inject({ @@ -735,10 +727,10 @@ describe("Cookie-based session auth", () => { // ===================================================================== describe("Session expiry", () => { it("expired OIDC session returns 401", async () => { - const { userId } = createOidcUser(); + const { userId } = await createOidcUser(); // Create a session that expired 10 minutes ago - const expiredToken = createOidcSession(userId, { + const expiredToken = await createOidcSession(userId, { expiresAt: new Date(Date.now() - 600_000), }); @@ -751,11 +743,10 @@ describe("Session expiry", () => { expect(res.statusCode).toBe(401); // Verify the expired session was cleaned up from DB - const session = db + const [session] = await db .select() .from(schema.sessions) - .where(eq(schema.sessions.id, expiredToken)) - .get(); + .where(eq(schema.sessions.id, expiredToken)); expect(session).toBeUndefined(); }); }); @@ -765,7 +756,7 @@ describe("Session expiry", () => { // ===================================================================== describe("API keys for OIDC users", () => { it("OIDC user can create an API key", async () => { - const { sessionToken } = createOidcUser(); + const { sessionToken } = await createOidcUser(); const res = await testApp.app.inject({ method: "POST", @@ -781,7 +772,7 @@ describe("API keys for OIDC users", () => { }); it("API key works for auth after creation", async () => { - const { sessionToken } = createOidcUser(); + const { sessionToken } = await createOidcUser(); // Create the API key via cookie auth const createRes = await testApp.app.inject({ @@ -824,7 +815,7 @@ describe("API keys for OIDC users", () => { // ===================================================================== describe("Logout", () => { it("logout clears the snapotter-session cookie", async () => { - const { sessionToken } = createOidcUser(); + const { sessionToken } = await createOidcUser(); const res = await testApp.app.inject({ method: "POST", @@ -850,14 +841,13 @@ describe("Logout", () => { }); it("session is deleted from DB after logout", async () => { - const { sessionToken } = createOidcUser(); + const { sessionToken } = await createOidcUser(); // Verify session exists before logout - const before = db + const [before] = await db .select() .from(schema.sessions) - .where(eq(schema.sessions.id, sessionToken)) - .get(); + .where(eq(schema.sessions.id, sessionToken)); expect(before).toBeDefined(); await testApp.app.inject({ @@ -867,16 +857,15 @@ describe("Logout", () => { }); // Verify session is gone - const after = db + const [after] = await db .select() .from(schema.sessions) - .where(eq(schema.sessions.id, sessionToken)) - .get(); + .where(eq(schema.sessions.id, sessionToken)); expect(after).toBeUndefined(); }); it("logout returns logoutUrl when session has idToken and OIDC discovery is cached", async () => { - const { sessionToken } = createOidcUser(); + const { sessionToken } = await createOidcUser(); // The default createOidcUser sets idToken to "mock-id-token-jwt". // Without a running OIDC provider and cached discovery, the logout diff --git a/tests/integration/ownership-enforcement.test.ts b/tests/integration/ownership-enforcement.test.ts index af081d0e..e5d3caf8 100644 --- a/tests/integration/ownership-enforcement.test.ts +++ b/tests/integration/ownership-enforcement.test.ts @@ -45,10 +45,10 @@ async function createAndLogin( headers: { authorization: `Bearer ${token}` }, payload: { username, password: "TestPass1", role }, }); - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, username)) - .run(); + .where(eq(schema.users.username, username)); const loginRes = await app.inject({ method: "POST", url: "/api/auth/login", diff --git a/tests/integration/permissions.test.ts b/tests/integration/permissions.test.ts index c039649f..1c7d9d65 100644 --- a/tests/integration/permissions.test.ts +++ b/tests/integration/permissions.test.ts @@ -39,10 +39,10 @@ describe("permissions in auth responses", () => { headers: { authorization: `Bearer ${adminToken}` }, payload: { username: "permtest", password: "TestPass1", role: "user" }, }); - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, "permtest")) - .run(); + .where(eq(schema.users.username, "permtest")); const res = await testApp.app.inject({ method: "POST", @@ -96,10 +96,10 @@ describe("permission enforcement on routes", () => { } // Clear mustChangePassword so the user can access routes - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, "regularuser")) - .run(); + .where(eq(schema.users.username, "regularuser")); // Login as the regular user const loginRes = await testApp.app.inject({ @@ -314,10 +314,10 @@ describe("API key ownership scoping", () => { headers: { authorization: `Bearer ${adminToken}` }, payload: { username: "keyuserA", password: "TestPass1", role: "user" }, }); - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, "keyuserA")) - .run(); + .where(eq(schema.users.username, "keyuserA")); const loginA = await testApp.app.inject({ method: "POST", url: "/api/auth/login", @@ -332,10 +332,10 @@ describe("API key ownership scoping", () => { headers: { authorization: `Bearer ${adminToken}` }, payload: { username: "keyuserB", password: "TestPass1", role: "user" }, }); - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, "keyuserB")) - .run(); + .where(eq(schema.users.username, "keyuserB")); const loginB = await testApp.app.inject({ method: "POST", url: "/api/auth/login", @@ -417,10 +417,10 @@ describe("file ownership scoping", () => { headers: { authorization: `Bearer ${adminToken}` }, payload: { username: name, password: "TestPass1", role: "user" }, }); - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, name)) - .run(); + .where(eq(schema.users.username, name)); } const loginA = await testApp.app.inject({ method: "POST", @@ -524,10 +524,10 @@ describe("escalation prevention", () => { headers: { authorization: `Bearer ${adminToken}` }, payload: { username: "esceditor", password: "EscEditor1", role: "editor" }, }); - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, "esceditor")) - .run(); + .where(eq(schema.users.username, "esceditor")); const editorLogin = await testApp.app.inject({ method: "POST", @@ -574,10 +574,10 @@ describe("escalation prevention", () => { headers: { authorization: `Bearer ${adminToken}` }, payload: { username: "admin2esc", password: "Admin2Esc1", role: "admin" }, }); - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, "admin2esc")) - .run(); + .where(eq(schema.users.username, "admin2esc")); const listRes = await testApp.app.inject({ method: "GET", @@ -607,10 +607,10 @@ describe("pipeline ownership scoping", () => { headers: { authorization: `Bearer ${adminToken}` }, payload: { username: "pipeuser", password: "TestPass1", role: "user" }, }); - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, "pipeuser")) - .run(); + .where(eq(schema.users.username, "pipeuser")); const loginRes = await testApp.app.inject({ method: "POST", url: "/api/auth/login", diff --git a/tests/integration/progress.test.ts b/tests/integration/progress.test.ts index 625c12d7..b70229df 100644 --- a/tests/integration/progress.test.ts +++ b/tests/integration/progress.test.ts @@ -21,6 +21,7 @@ import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { db, schema } from "../../apps/api/src/db/index.js"; import { + drainPersistQueue, recoverStaleJobs, updateJobProgress, updateSingleFileProgress, @@ -46,6 +47,30 @@ afterAll(async () => { }, 10_000); // ── Batch progress tracking via X-Job-Id ──────────────────────── + +// Fire-and-forget persist calls need time to flush to the DB. +// With Postgres, async writes involve network round-trips that may exceed a +// fixed delay. Poll for the expected terminal status with a generous ceiling. +const flushPersist = async ( + jobId?: string, + terminalStatuses: string[] = ["completed", "failed"], + maxMs = 2000, +) => { + if (!jobId) { + // Fallback: fixed delay when no jobId is available + await new Promise((r) => setTimeout(r, 200)); + return; + } + // Drain any pending persist writes before polling the DB + await drainPersistQueue(jobId); + const start = Date.now(); + while (Date.now() - start < maxMs) { + const [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); + if (row && terminalStatuses.includes(row.status)) return; + await new Promise((r) => setTimeout(r, 50)); + } +}; + describe("Batch progress tracking", () => { it("assigns a job ID to batch operations", async () => { const { body, contentType } = createMultipartPayload([ @@ -114,9 +139,10 @@ describe("Batch progress tracking", () => { }); expect(res.statusCode).toBe(200); + await flushPersist(clientJobId); // Check the jobs table for the persisted progress - const job = db.select().from(schema.jobs).where(eq(schema.jobs.id, clientJobId)).get(); + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, clientJobId)); expect(job).toBeDefined(); expect(job?.status).toBe("completed"); @@ -152,7 +178,8 @@ describe("Batch progress tracking", () => { // Should fail (422 = all files failed) expect(res.statusCode).toBe(422); - const job = db.select().from(schema.jobs).where(eq(schema.jobs.id, clientJobId)).get(); + await flushPersist(clientJobId); + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, clientJobId)); expect(job).toBeDefined(); expect(job?.status).toBe("failed"); @@ -161,7 +188,7 @@ describe("Batch progress tracking", () => { it("tracks progress for multi-file batch with partial success", async () => { const clientJobId = randomUUID(); - // Mix valid image + invalid data — partial success + // Mix valid image + invalid data -- partial success const { body, contentType } = createMultipartPayload([ { name: "file", filename: "good.png", contentType: "image/png", content: PNG }, { @@ -187,7 +214,8 @@ describe("Batch progress tracking", () => { // Should succeed (at least one file processed) expect(res.statusCode).toBe(200); - const job = db.select().from(schema.jobs).where(eq(schema.jobs.id, clientJobId)).get(); + await flushPersist(clientJobId); + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, clientJobId)); expect(job).toBeDefined(); expect(job?.status).toBe("completed"); @@ -230,7 +258,8 @@ describe("Pipeline batch progress tracking", () => { expect(res.headers["x-job-id"]).toBe(clientJobId); // Verify DB persistence - const job = db.select().from(schema.jobs).where(eq(schema.jobs.id, clientJobId)).get(); + await flushPersist(clientJobId); + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, clientJobId)); expect(job).toBeDefined(); expect(job?.status).toBe("completed"); @@ -285,7 +314,8 @@ describe("Job DB record structure", () => { body, }); - const job = db.select().from(schema.jobs).where(eq(schema.jobs.id, clientJobId)).get(); + await flushPersist(clientJobId); + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, clientJobId)); expect(job).toBeDefined(); expect(job?.id).toBe(clientJobId); @@ -310,6 +340,7 @@ describe("SSE progress endpoint", () => { failedFiles: 0, errors: [], }); + await flushPersist(jobId); const res = await app.inject({ method: "GET", @@ -367,7 +398,7 @@ describe("SSE progress endpoint", () => { // ── updateJobProgress direct tests ───────────────────────────── describe("updateJobProgress direct calls", () => { - it("persists job progress to the database for a new job", () => { + it("persists job progress to the database for a new job", async () => { const jobId = randomUUID(); updateJobProgress({ @@ -378,15 +409,16 @@ describe("updateJobProgress direct calls", () => { failedFiles: 0, errors: [], }); + await flushPersist(jobId, ["processing"]); - const job = db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)).get(); + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); expect(job).toBeDefined(); expect(job?.status).toBe("processing"); expect(job?.progress).toBeCloseTo(0.4, 1); // 2/5 expect(job?.type).toBe("batch"); }); - it("updates existing job progress in the database", () => { + it("updates existing job progress in the database", async () => { const jobId = randomUUID(); // Create initial progress @@ -398,6 +430,7 @@ describe("updateJobProgress direct calls", () => { failedFiles: 0, errors: [], }); + await flushPersist(jobId, ["processing"]); // Update progress updateJobProgress({ @@ -408,15 +441,16 @@ describe("updateJobProgress direct calls", () => { failedFiles: 0, errors: [], }); + await flushPersist(jobId); - const job = db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)).get(); + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); expect(job).toBeDefined(); expect(job?.status).toBe("completed"); expect(job?.progress).toBe(1); expect(job?.completedAt).not.toBeNull(); }); - it("persists errors to the database", () => { + it("persists errors to the database", async () => { const jobId = randomUUID(); updateJobProgress({ @@ -430,16 +464,17 @@ describe("updateJobProgress direct calls", () => { { filename: "b.png", error: "Corrupt file" }, ], }); + await flushPersist(jobId); - const job = db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)).get(); + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); expect(job).toBeDefined(); expect(job?.status).toBe("failed"); expect(job?.error).not.toBeNull(); - const errors = JSON.parse(job?.error!); + const errors = JSON.parse(job?.error ?? "[]"); expect(errors).toHaveLength(2); }); - it("handles zero totalFiles without division by zero", () => { + it("handles zero totalFiles without division by zero", async () => { const jobId = randomUUID(); updateJobProgress({ @@ -450,8 +485,9 @@ describe("updateJobProgress direct calls", () => { failedFiles: 0, errors: [], }); + await flushPersist(jobId); - const job = db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)).get(); + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); expect(job).toBeDefined(); expect(job?.progress).toBe(0); }); @@ -459,7 +495,7 @@ describe("updateJobProgress direct calls", () => { // ── updateSingleFileProgress direct tests ────────────────────── describe("updateSingleFileProgress direct calls", () => { - it("persists single-file progress for new job", () => { + it("persists single-file progress for new job", async () => { const jobId = randomUUID(); updateSingleFileProgress({ @@ -468,15 +504,16 @@ describe("updateSingleFileProgress direct calls", () => { percent: 50, stage: "encoding", }); + await flushPersist(jobId, ["processing"]); - const job = db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)).get(); + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); expect(job).toBeDefined(); expect(job?.status).toBe("processing"); expect(job?.progress).toBeCloseTo(0.5, 1); expect(job?.type).toBe("single"); }); - it("persists complete phase", () => { + it("persists complete phase", async () => { const jobId = randomUUID(); updateSingleFileProgress({ @@ -484,8 +521,9 @@ describe("updateSingleFileProgress direct calls", () => { phase: "complete", percent: 100, }); + await flushPersist(jobId); - const job = db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)).get(); + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); expect(job).toBeDefined(); expect(job?.status).toBe("completed"); expect(job?.progress).toBe(1); @@ -493,7 +531,7 @@ describe("updateSingleFileProgress direct calls", () => { expect(job?.type).toBe("single"); }); - it("persists failed phase with error", () => { + it("persists failed phase with error", async () => { const jobId = randomUUID(); updateSingleFileProgress({ @@ -502,15 +540,16 @@ describe("updateSingleFileProgress direct calls", () => { percent: 30, error: "Processing timeout", }); + await flushPersist(jobId); - const job = db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)).get(); + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); expect(job).toBeDefined(); expect(job?.status).toBe("failed"); expect(job?.error).toBe("Processing timeout"); expect(job?.type).toBe("single"); }); - it("sets completedAt when updating existing job to complete", () => { + it("sets completedAt when updating existing job to complete", async () => { const jobId = randomUUID(); // Create initial job @@ -519,6 +558,7 @@ describe("updateSingleFileProgress direct calls", () => { phase: "processing", percent: 50, }); + await flushPersist(jobId, ["processing"]); // Update to complete updateSingleFileProgress({ @@ -526,14 +566,15 @@ describe("updateSingleFileProgress direct calls", () => { phase: "complete", percent: 100, }); + await flushPersist(jobId); - const job = db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)).get(); + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); expect(job).toBeDefined(); expect(job?.status).toBe("completed"); expect(job?.completedAt).not.toBeNull(); }); - it("sets completedAt when updating existing job to failed", () => { + it("sets completedAt when updating existing job to failed", async () => { const jobId = randomUUID(); // Create initial job @@ -542,6 +583,7 @@ describe("updateSingleFileProgress direct calls", () => { phase: "processing", percent: 25, }); + await flushPersist(jobId, ["processing"]); // Update to failed updateSingleFileProgress({ @@ -550,15 +592,16 @@ describe("updateSingleFileProgress direct calls", () => { percent: 25, error: "Timeout error", }); + await flushPersist(jobId); - const job = db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)).get(); + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); expect(job).toBeDefined(); expect(job?.status).toBe("failed"); expect(job?.completedAt).not.toBeNull(); expect(job?.error).toBe("Timeout error"); }); - it("updates existing single-file job progress", () => { + it("updates existing single-file job progress", async () => { const jobId = randomUUID(); // Create @@ -568,6 +611,7 @@ describe("updateSingleFileProgress direct calls", () => { percent: 25, stage: "analyzing", }); + await flushPersist(jobId, ["processing"]); // Update updateSingleFileProgress({ @@ -576,8 +620,9 @@ describe("updateSingleFileProgress direct calls", () => { percent: 75, stage: "encoding", }); + await flushPersist(jobId, ["processing"]); - const job = db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)).get(); + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); expect(job).toBeDefined(); expect(job?.progress).toBeCloseTo(0.75, 1); }); @@ -585,89 +630,81 @@ describe("updateSingleFileProgress direct calls", () => { // ── recoverStaleJobs ─────────────────────────────────────────── describe("recoverStaleJobs", () => { - it("marks processing jobs as failed on recovery", () => { + it("marks processing jobs as failed on recovery", async () => { const jobId = randomUUID(); // Insert a processing job directly - db.insert(schema.jobs) - .values({ - id: jobId, - type: "batch", - status: "processing", - progress: 0.5, - inputFiles: "[]", - }) - .run(); + await db.insert(schema.jobs).values({ + id: jobId, + type: "batch", + status: "processing", + progress: 0.5, + inputFiles: "[]", + }); - recoverStaleJobs(); + await recoverStaleJobs(); - const job = db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)).get(); + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); expect(job).toBeDefined(); expect(job?.status).toBe("failed"); expect(job?.error).toContain("Server restarted"); expect(job?.completedAt).not.toBeNull(); }); - it("marks queued jobs as failed on recovery", () => { + it("marks queued jobs as failed on recovery", async () => { const jobId = randomUUID(); - db.insert(schema.jobs) - .values({ - id: jobId, - type: "batch", - status: "queued", - progress: 0, - inputFiles: "[]", - }) - .run(); + await db.insert(schema.jobs).values({ + id: jobId, + type: "batch", + status: "queued", + progress: 0, + inputFiles: "[]", + }); - recoverStaleJobs(); + await recoverStaleJobs(); - const job = db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)).get(); + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); expect(job).toBeDefined(); expect(job?.status).toBe("failed"); expect(job?.error).toContain("Server restarted"); }); - it("does not modify completed jobs", () => { + it("does not modify completed jobs", async () => { const jobId = randomUUID(); - db.insert(schema.jobs) - .values({ - id: jobId, - type: "batch", - status: "completed", - progress: 1, - inputFiles: "[]", - completedAt: new Date(), - }) - .run(); + await db.insert(schema.jobs).values({ + id: jobId, + type: "batch", + status: "completed", + progress: 1, + inputFiles: "[]", + completedAt: new Date(), + }); - recoverStaleJobs(); + await recoverStaleJobs(); - const job = db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)).get(); + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); expect(job).toBeDefined(); expect(job?.status).toBe("completed"); }); - it("does not modify already-failed jobs", () => { + it("does not modify already-failed jobs", async () => { const jobId = randomUUID(); - db.insert(schema.jobs) - .values({ - id: jobId, - type: "batch", - status: "failed", - progress: 0, - inputFiles: "[]", - error: "Original error", - completedAt: new Date(), - }) - .run(); + await db.insert(schema.jobs).values({ + id: jobId, + type: "batch", + status: "failed", + progress: 0, + inputFiles: "[]", + error: "Original error", + completedAt: new Date(), + }); - recoverStaleJobs(); + await recoverStaleJobs(); - const job = db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)).get(); + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); expect(job).toBeDefined(); expect(job?.error).toBe("Original error"); }); diff --git a/tests/integration/rbac-matrix-full.test.ts b/tests/integration/rbac-matrix-full.test.ts index 0cd1fc18..3fdf7556 100644 --- a/tests/integration/rbac-matrix-full.test.ts +++ b/tests/integration/rbac-matrix-full.test.ts @@ -29,10 +29,10 @@ beforeAll(async () => { headers: { authorization: `Bearer ${adminToken}` }, payload: { username: editorUsername, password: "EditorPass1", role: "editor" }, }); - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, editorUsername)) - .run(); + .where(eq(schema.users.username, editorUsername)); const editorLogin = await testApp.app.inject({ method: "POST", url: "/api/auth/login", @@ -48,10 +48,10 @@ beforeAll(async () => { headers: { authorization: `Bearer ${adminToken}` }, payload: { username: userUsername, password: "UserPass12", role: "user" }, }); - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, userUsername)) - .run(); + .where(eq(schema.users.username, userUsername)); const userLogin = await testApp.app.inject({ method: "POST", url: "/api/auth/login", @@ -360,10 +360,10 @@ describe("Cross-role isolation", () => { headers: { authorization: `Bearer ${adminToken}` }, payload: { username: expiredUsername, password: "ExpiredPass1", role: "user" }, }); - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, expiredUsername)) - .run(); + .where(eq(schema.users.username, expiredUsername)); const loginRes = await testApp.app.inject({ method: "POST", @@ -373,10 +373,10 @@ describe("Cross-role isolation", () => { const expiredToken = JSON.parse(loginRes.body).token; // Manually expire the session - db.update(schema.sessions) + await db + .update(schema.sessions) .set({ expiresAt: new Date(Date.now() - 60_000) }) - .where(eq(schema.sessions.id, expiredToken)) - .run(); + .where(eq(schema.sessions.id, expiredToken)); const res = await testApp.app.inject({ method: "GET", diff --git a/tests/integration/rbac-matrix.test.ts b/tests/integration/rbac-matrix.test.ts index 6cb27916..bc4da89d 100644 --- a/tests/integration/rbac-matrix.test.ts +++ b/tests/integration/rbac-matrix.test.ts @@ -19,10 +19,10 @@ beforeAll(async () => { headers: { authorization: `Bearer ${adminToken}` }, payload: { username: "matrix_editor", password: "EditorPass1", role: "editor" }, }); - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, "matrix_editor")) - .run(); + .where(eq(schema.users.username, "matrix_editor")); const editorLogin = await testApp.app.inject({ method: "POST", url: "/api/auth/login", @@ -37,10 +37,10 @@ beforeAll(async () => { headers: { authorization: `Bearer ${adminToken}` }, payload: { username: "matrix_user", password: "UserPass12", role: "user" }, }); - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, "matrix_user")) - .run(); + .where(eq(schema.users.username, "matrix_user")); const userLogin = await testApp.app.inject({ method: "POST", url: "/api/auth/login", diff --git a/tests/integration/s3-storage.test.ts b/tests/integration/s3-storage.test.ts index 35589515..23e130b9 100644 --- a/tests/integration/s3-storage.test.ts +++ b/tests/integration/s3-storage.test.ts @@ -19,6 +19,7 @@ import { ListObjectsV2Command, S3Client, } from "@aws-sdk/client-s3"; +import { loadS3Storage, type S3StorageModule } from "@snapotter/enterprise"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { env } from "../../apps/api/src/config.js"; @@ -38,6 +39,7 @@ const minioAvailable = (() => { })(); let s3Client: S3Client; +let s3Storage: S3StorageModule; let originalStorageMode: string; async function listKeys(): Promise { @@ -67,6 +69,8 @@ describe.skipIf(!minioAvailable)("S3 storage backend", () => { await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET })); + s3Storage = await loadS3Storage(); + originalStorageMode = env.STORAGE_MODE; const e = env as Record; e.STORAGE_MODE = "s3"; @@ -77,6 +81,16 @@ describe.skipIf(!minioAvailable)("S3 storage backend", () => { e.S3_SECRET_ACCESS_KEY = CREDS.secretAccessKey; e.S3_FORCE_PATH_STYLE = true; e.S3_PREFIX = ""; + + s3Storage.configureS3({ + bucket: BUCKET, + region: "us-east-1", + endpoint: S3_ENDPOINT, + accessKeyId: CREDS.accessKeyId, + secretAccessKey: CREDS.secretAccessKey, + forcePathStyle: true, + prefix: "", + }); }, 15_000); afterAll(async () => { @@ -160,6 +174,15 @@ describe.skipIf(!minioAvailable)("S3 storage backend", () => { const { saveFile, deleteStoredFile } = await import("../../apps/api/src/lib/file-storage.js"); (env as Record).S3_PREFIX = "tenant-123"; + s3Storage.configureS3({ + bucket: BUCKET, + region: "us-east-1", + endpoint: S3_ENDPOINT, + accessKeyId: CREDS.accessKeyId, + secretAccessKey: CREDS.secretAccessKey, + forcePathStyle: true, + prefix: "tenant-123", + }); const name = await saveFile(PNG, "prefixed.png"); expect(await objectExists(`tenant-123/files/${name}`)).toBe(true); @@ -169,6 +192,15 @@ describe.skipIf(!minioAvailable)("S3 storage backend", () => { expect(await objectExists(`tenant-123/files/${name}`)).toBe(false); (env as Record).S3_PREFIX = ""; + s3Storage.configureS3({ + bucket: BUCKET, + region: "us-east-1", + endpoint: S3_ENDPOINT, + accessKeyId: CREDS.accessKeyId, + secretAccessKey: CREDS.secretAccessKey, + forcePathStyle: true, + prefix: "", + }); }); it("bucket is empty after all operations", async () => { diff --git a/tests/integration/security-auth-hardening.test.ts b/tests/integration/security-auth-hardening.test.ts index fc8abb06..ec476b88 100644 --- a/tests/integration/security-auth-hardening.test.ts +++ b/tests/integration/security-auth-hardening.test.ts @@ -38,10 +38,10 @@ async function createUser( if (res.statusCode !== 201) { throw new Error(`createUser failed: ${res.statusCode} ${res.body}`); } - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, username)) - .run(); + .where(eq(schema.users.username, username)); return { username, password, id: body.id }; } diff --git a/tests/integration/security-rate-limiting.test.ts b/tests/integration/security-rate-limiting.test.ts index 8a261614..a9104f60 100644 --- a/tests/integration/security-rate-limiting.test.ts +++ b/tests/integration/security-rate-limiting.test.ts @@ -103,34 +103,32 @@ describe("Per-user storage quota enforcement (L3)", () => { it("tracks file sizes in the database for quota calculation", async () => { // Verify we can query total storage per user (the quota check mechanism) - const adminUser = db + const [adminUser] = await db .select() .from(schema.users) - .where(eq(schema.users.username, "admin")) - .get(); + .where(eq(schema.users.username, "admin")); expect(adminUser).toBeDefined(); - const result = db + const [result] = await db .select({ total: sql`coalesce(sum(${schema.userFiles.size}), 0)` }) .from(schema.userFiles) - .where(eq(schema.userFiles.userId, adminUser?.id ?? "")) - .get(); + .where(eq(schema.userFiles.userId, adminUser?.id ?? "")); // Should have some bytes from the uploads in previous tests expect(result).toBeDefined(); - expect(typeof result?.total).toBe("number"); - expect(result?.total).toBeGreaterThanOrEqual(0); + // Postgres returns SUM as string (bigint); coerce for comparison + expect(Number(result?.total)).toBeGreaterThanOrEqual(0); }); it("quota check query returns 0 for users with no files", async () => { - const result = db + const [result] = await db .select({ total: sql`coalesce(sum(${schema.userFiles.size}), 0)` }) .from(schema.userFiles) - .where(eq(schema.userFiles.userId, "nonexistent-user-id")) - .get(); + .where(eq(schema.userFiles.userId, "nonexistent-user-id")); expect(result).toBeDefined(); - expect(result?.total).toBe(0); + // Postgres returns SUM as string (bigint); coerce for comparison + expect(Number(result?.total)).toBe(0); }); }); diff --git a/tests/integration/teams.test.ts b/tests/integration/teams.test.ts index c6fea7ba..4bb2b88f 100644 --- a/tests/integration/teams.test.ts +++ b/tests/integration/teams.test.ts @@ -26,19 +26,19 @@ afterAll(async () => { }, 10_000); // Helper: seed a Default team if not present -function ensureDefaultTeam(): string { - const existing = db.select().from(schema.teams).where(eq(schema.teams.name, "Default")).get(); +async function ensureDefaultTeam(): Promise { + const [existing] = await db.select().from(schema.teams).where(eq(schema.teams.name, "Default")); if (existing) return existing.id; const id = randomUUID(); - db.insert(schema.teams).values({ id, name: "Default" }).run(); + await db.insert(schema.teams).values({ id, name: "Default" }); return id; } // Helper: clean all teams except Default, and recreate Default if missing -function resetTeams(): string { +async function resetTeams(): Promise { // Delete non-Default teams - db.delete(schema.teams).where(sql`${schema.teams.name} != 'Default'`).run(); - return ensureDefaultTeam(); + await db.delete(schema.teams).where(sql`${schema.teams.name} != 'Default'`); + return await ensureDefaultTeam(); } // ═══════════════════════════════════════════════════════════════════════════ @@ -106,10 +106,10 @@ describe("POST /api/v1/teams", () => { // Login as non-admin // First clear mustChangePassword const userId = JSON.parse(regRes.body).id; - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.id, userId)) - .run(); + .where(eq(schema.users.id, userId)); const loginRes = await app.inject({ method: "POST", @@ -127,7 +127,7 @@ describe("POST /api/v1/teams", () => { expect(res.statusCode).toBe(403); // Cleanup - db.delete(schema.users).where(eq(schema.users.id, userId)).run(); + await db.delete(schema.users).where(eq(schema.users.id, userId)); }); it("rejects duplicate names (case-insensitive)", async () => { @@ -211,11 +211,11 @@ describe("POST /api/v1/teams", () => { describe("PUT /api/v1/teams/:id", () => { let teamId: string; - beforeEach(() => { - resetTeams(); + beforeEach(async () => { + await resetTeams(); // Create a team to rename teamId = randomUUID(); - db.insert(schema.teams).values({ id: teamId, name: "OldName" }).run(); + await db.insert(schema.teams).values({ id: teamId, name: "OldName" }); }); it("renames a team", async () => { @@ -229,7 +229,7 @@ describe("PUT /api/v1/teams/:id", () => { expect(JSON.parse(res.body).ok).toBe(true); // Verify - const team = db.select().from(schema.teams).where(eq(schema.teams.id, teamId)).get(); + const [team] = await db.select().from(schema.teams).where(eq(schema.teams.id, teamId)); expect(team?.name).toBe("NewName"); }); @@ -293,13 +293,13 @@ describe("PUT /api/v1/teams/:id", () => { describe("DELETE /api/v1/teams/:id", () => { let defaultTeamId: string; - beforeEach(() => { - defaultTeamId = resetTeams(); + beforeEach(async () => { + defaultTeamId = await resetTeams(); }); it("deletes an empty team", async () => { const teamId = randomUUID(); - db.insert(schema.teams).values({ id: teamId, name: "ToDelete" }).run(); + await db.insert(schema.teams).values({ id: teamId, name: "ToDelete" }); const res = await app.inject({ method: "DELETE", @@ -310,25 +310,23 @@ describe("DELETE /api/v1/teams/:id", () => { expect(JSON.parse(res.body).ok).toBe(true); // Verify it's gone - const team = db.select().from(schema.teams).where(eq(schema.teams.id, teamId)).get(); + const [team] = await db.select().from(schema.teams).where(eq(schema.teams.id, teamId)); expect(team).toBeUndefined(); }); it("rejects deleting a team with members", async () => { const teamId = randomUUID(); - db.insert(schema.teams).values({ id: teamId, name: "HasMembers" }).run(); + await db.insert(schema.teams).values({ id: teamId, name: "HasMembers" }); // Assign a user to this team const userId = randomUUID(); - db.insert(schema.users) - .values({ - id: userId, - username: "memberuser", - passwordHash: "dummy:hash", - role: "user", - team: teamId, - }) - .run(); + await db.insert(schema.users).values({ + id: userId, + username: "memberuser", + passwordHash: "dummy:hash", + role: "user", + team: teamId, + }); const res = await app.inject({ method: "DELETE", @@ -339,8 +337,8 @@ describe("DELETE /api/v1/teams/:id", () => { expect(JSON.parse(res.body).error).toMatch(/members/i); // Cleanup - db.delete(schema.users).where(eq(schema.users.id, userId)).run(); - db.delete(schema.teams).where(eq(schema.teams.id, teamId)).run(); + await db.delete(schema.users).where(eq(schema.users.id, userId)); + await db.delete(schema.teams).where(eq(schema.teams.id, teamId)); }); it("rejects deleting the Default team", async () => { @@ -355,7 +353,7 @@ describe("DELETE /api/v1/teams/:id", () => { it("requires admin", async () => { const teamId = randomUUID(); - db.insert(schema.teams).values({ id: teamId, name: "NoAuth" }).run(); + await db.insert(schema.teams).values({ id: teamId, name: "NoAuth" }); const res = await app.inject({ method: "DELETE", @@ -364,7 +362,7 @@ describe("DELETE /api/v1/teams/:id", () => { expect(res.statusCode).toBe(401); // Cleanup - db.delete(schema.teams).where(eq(schema.teams.id, teamId)).run(); + await db.delete(schema.teams).where(eq(schema.teams.id, teamId)); }); it("returns 404 for non-existent team", async () => { diff --git a/tests/integration/test-server.ts b/tests/integration/test-server.ts index b0d014a1..1b313dff 100644 --- a/tests/integration/test-server.ts +++ b/tests/integration/test-server.ts @@ -1,22 +1,21 @@ /** - * Test server helper — builds a real Fastify app with an isolated temp - * SQLite database for integration tests. + * Test server helper -- builds a real Fastify app with an isolated Postgres + * database for integration tests. * - * Environment variables are injected via vitest.config.ts `test.env` BEFORE - * this module is loaded, ensuring apps/api/src/config.ts picks them up. + * Environment variables (DATABASE_URL, WORKSPACE_PATH) are set per-fork in + * tests/setup/per-fork-env.ts BEFORE this module is loaded, ensuring + * apps/api/src/config.ts picks them up. * * Each call to `buildTestApp()` returns a fresh, fully-wired server instance * that can be exercised with `app.inject()` (no port binding required). */ import { randomUUID } from "node:crypto"; import { mkdirSync } from "node:fs"; -import { dirname } from "node:path"; // --------------------------------------------------------------------------- -// 1. Ensure directories exist for the DB and workspace paths that vitest.config -// injected into process.env. +// 1. Ensure the workspace directory exists. The Postgres database is already +// created by per-fork-env.ts (cloned from the migrated template). // --------------------------------------------------------------------------- -mkdirSync(dirname(process.env.DB_PATH!), { recursive: true }); mkdirSync(process.env.WORKSPACE_PATH!, { recursive: true }); import cookie from "@fastify/cookie"; @@ -31,7 +30,12 @@ import { env } from "../../apps/api/src/config.js"; import { db, schema } from "../../apps/api/src/db/index.js"; import { runMigrations } from "../../apps/api/src/db/migrate.js"; import { requirePermission } from "../../apps/api/src/permissions.js"; -import { authMiddleware, authRoutes, ensureDefaultAdmin } from "../../apps/api/src/plugins/auth.js"; +import { + authMiddleware, + authRoutes, + ensureBuiltinRoles, + ensureDefaultAdmin, +} from "../../apps/api/src/plugins/auth.js"; import { oidcRoutes } from "../../apps/api/src/plugins/oidc.js"; import { registerUpload } from "../../apps/api/src/plugins/upload.js"; import { analyticsRoutes } from "../../apps/api/src/routes/analytics.js"; @@ -50,8 +54,9 @@ import { teamsRoutes } from "../../apps/api/src/routes/teams.js"; import { registerToolRoutes } from "../../apps/api/src/routes/tools/index.js"; import { userFileRoutes } from "../../apps/api/src/routes/user-files.js"; -// Run migrations to create all tables in the temp DB -runMigrations(); +// Run migrations (idempotent -- template already has the schema, but this +// ensures the __drizzle_migrations journal is consistent in each fork). +await runMigrations(); // --------------------------------------------------------------------------- // 3. Public API @@ -62,14 +67,15 @@ export interface TestApp { } export async function buildTestApp(): Promise { - // Seed the default admin user (idempotent — skips if users already exist) + // Seed built-in roles and default admin user (both idempotent) + await ensureBuiltinRoles(); await ensureDefaultAdmin(); // Clear the mustChangePassword flag so tests can use the admin freely - db.update(schema.users) + await db + .update(schema.users) .set({ mustChangePassword: false }) - .where(eq(schema.users.username, "admin")) - .run(); + .where(eq(schema.users.username, "admin")); const app = Fastify({ logger: false, // quiet during tests @@ -152,7 +158,7 @@ export async function buildTestApp(): Promise { let dbOk = false; try { - db.select().from(schema.settings).limit(1).all(); + await db.select().from(schema.settings).limit(1); dbOk = true; } catch { /* db unreachable */ @@ -184,14 +190,10 @@ export async function buildTestApp(): Promise { const cleanup = async () => { await app.close(); - // Checkpoint WAL to prevent unbounded growth across sequential test files. - // Without this, the WAL/SHM files grow until SQLite hits SQLITE_IOERR_SHMSIZE. - try { - const { sqlite } = await import("../../apps/api/src/db/index.js"); - sqlite.pragma("wal_checkpoint(TRUNCATE)"); - } catch { - // best-effort - } + // The pg pool is a module-level singleton shared across the fork. + // Do NOT call closeDb() here; let the fork process exit naturally. + // Closing prematurely would break tests that run DB queries after + // the app is closed (e.g. verifying DB state in assertions). }; return { app, cleanup }; diff --git a/tests/setup/per-fork-env.ts b/tests/setup/per-fork-env.ts index 7849cb80..7ec68264 100644 --- a/tests/setup/per-fork-env.ts +++ b/tests/setup/per-fork-env.ts @@ -1,13 +1,37 @@ import crypto from "node:crypto"; import os from "node:os"; import path from "node:path"; +import pg from "pg"; -// Each Vitest fork gets its own SQLite DB + workspace so test files can run -// in parallel. setupFiles run before any test file (and therefore before any -// app module) loads, so apps/api/src/config.ts captures the per-fork paths. -const forkDir = path.join( - os.tmpdir(), - `SnapOtter-test-${process.pid}-${crypto.randomUUID().slice(0, 8)}`, -); -process.env.DB_PATH = path.join(forkDir, "test.db"); +// Each test file (forks pool, isolated) gets its own Postgres database cloned +// from the migrated template built in tests/global-setup.ts, plus its own +// workspace dir. setupFiles run before any app module loads, so +// apps/api/src/config.ts captures the per-file DATABASE_URL. +const suffix = `${process.pid}_${crypto.randomUUID().slice(0, 8).replace(/-/g, "")}`; +const forkDir = path.join(os.tmpdir(), `SnapOtter-test-${suffix}`); process.env.WORKSPACE_PATH = path.join(forkDir, "workspace"); + +const baseUrl = process.env.TEST_PG_BASE_URL; +if (!baseUrl) { + throw new Error("TEST_PG_BASE_URL missing; tests/global-setup.ts did not run"); +} +const dbName = `snapotter_test_${suffix}`; // pid digits + uuid hex: identifier-safe +const admin = new pg.Client({ connectionString: baseUrl }); +await admin.connect(); +// Concurrent CREATE DATABASE ... TEMPLATE from parallel forks can transiently +// conflict; retry briefly. +let created = false; +for (let attempt = 0; attempt < 5 && !created; attempt++) { + try { + await admin.query(`CREATE DATABASE ${dbName} TEMPLATE snapotter_template`); + created = true; + } catch (err) { + if (attempt === 4) throw err; + await new Promise((r) => setTimeout(r, 150 + Math.floor(150 * attempt))); + } +} +await admin.end(); + +const forkUrl = new URL(baseUrl); +forkUrl.pathname = `/${dbName}`; +process.env.DATABASE_URL = forkUrl.toString(); diff --git a/tests/unit/api/analytics-no-leak.test.ts b/tests/unit/api/analytics-no-leak.test.ts index 33e5b17a..425ea0a8 100644 --- a/tests/unit/api/analytics-no-leak.test.ts +++ b/tests/unit/api/analytics-no-leak.test.ts @@ -25,9 +25,9 @@ describe("Server-side Analytics No-Leak Invariant", () => { const source = fs.readFileSync("apps/api/src/lib/analytics.ts", "utf8"); expect(source).toContain( - "export function captureException(error: unknown, request?: FastifyRequest)", + "export async function captureException(error: unknown, request?: FastifyRequest)", ); - expect(source).toContain("if (request && !isRequestOptedIn(request)) return;"); + expect(source).toContain("if (request && !(await isRequestOptedIn(request))) return;"); }); it("error handler passes request to captureException", async () => { @@ -60,7 +60,8 @@ describe("Server-side Analytics No-Leak Invariant", () => { }); it("rate 1.0 always accepts (checked before Math.random call)", () => { - expect(1.0 >= 1.0).toBe(true); + const rate = 1.0; + expect(rate >= 1.0).toBe(true); }); it("rate between 0 and 1 produces a mix", () => { @@ -78,14 +79,14 @@ describe("Server-side Analytics No-Leak Invariant", () => { const fs = await import("node:fs"); const source = fs.readFileSync("apps/api/src/lib/analytics.ts", "utf8"); expect(source).toContain( - "if (!posthogClient || !isRequestOptedIn(request) || !shouldSample()) return;", + "if (!posthogClient || !(await isRequestOptedIn(request)) || !shouldSample()) return;", ); }); it("trackEvent wraps capture in try-catch (never throws)", async () => { const fs = await import("node:fs"); const source = fs.readFileSync("apps/api/src/lib/analytics.ts", "utf8"); - const trackEventBlock = source.slice(source.indexOf("export function trackEvent")); + const trackEventBlock = source.slice(source.indexOf("export async function trackEvent")); expect(trackEventBlock).toContain("try {"); expect(trackEventBlock).toContain("catch {"); }); diff --git a/tests/unit/api/analytics.test.ts b/tests/unit/api/analytics.test.ts index 9bb43fe1..a0d39014 100644 --- a/tests/unit/api/analytics.test.ts +++ b/tests/unit/api/analytics.test.ts @@ -32,12 +32,15 @@ vi.mock("../../../apps/api/src/db/index.js", () => ({ db: { select: () => ({ from: () => ({ - where: () => ({ - get: () => dbGetResult.value, - }), + where: () => { + const val = dbGetResult.value; + return Promise.resolve(val ? [val] : []); + }, }), }), }, + pool: {}, + closeDb: async () => {}, schema: { settings: { key: "key" }, users: { id: "id", analyticsEnabled: "analyticsEnabled" }, @@ -128,8 +131,8 @@ describe("initAnalytics", () => { }); describe("captureException", () => { - it("does nothing when sentryModule is null", () => { - expect(() => mod.captureException(new Error("test"))).not.toThrow(); + it("does nothing when sentryModule is null", async () => { + await expect(mod.captureException(new Error("test"))).resolves.toBeUndefined(); }); it("does nothing when request user is not opted in", async () => { @@ -140,7 +143,7 @@ describe("captureException", () => { mockAuthUser.value = null; const fakeRequest = { headers: {} } as Parameters[1]; - mod.captureException(new Error("test"), fakeRequest); + await mod.captureException(new Error("test"), fakeRequest); expect(mockSentryCapture).not.toHaveBeenCalled(); }); @@ -151,7 +154,7 @@ describe("captureException", () => { await mod.initAnalytics(); const err = new Error("test error"); - mod.captureException(err); + await mod.captureException(err); expect(mockSentryCapture).toHaveBeenCalledWith(err); }); @@ -165,7 +168,7 @@ describe("captureException", () => { dbGetResult.value = { analyticsEnabled: true }; const fakeRequest = { headers: {} } as Parameters[1]; const err = new Error("opted in error"); - mod.captureException(err, fakeRequest); + await mod.captureException(err, fakeRequest); expect(mockSentryCapture).toHaveBeenCalledWith(err); }); }); @@ -194,15 +197,19 @@ describe("shutdownAnalytics", () => { }); describe("trackEvent", () => { - it("does nothing when posthogClient is null", () => { + it("does nothing when posthogClient is null", async () => { const fakeRequest = {} as Parameters[0]; - expect(() => mod.trackEvent(fakeRequest, "test_event", { key: "value" })).not.toThrow(); + await expect( + mod.trackEvent(fakeRequest, "test_event", { key: "value" }), + ).resolves.toBeUndefined(); }); - it("does nothing when ANALYTICS_ENABLED is false", () => { + it("does nothing when ANALYTICS_ENABLED is false", async () => { config.ANALYTICS_ENABLED = false; const fakeRequest = {} as Parameters[0]; - expect(() => mod.trackEvent(fakeRequest, "test_event", { key: "value" })).not.toThrow(); + await expect( + mod.trackEvent(fakeRequest, "test_event", { key: "value" }), + ).resolves.toBeUndefined(); }); it("does nothing when request user is not opted in", async () => { @@ -212,7 +219,7 @@ describe("trackEvent", () => { mockAuthUser.value = null; const fakeRequest = { headers: {} } as Parameters[0]; - mod.trackEvent(fakeRequest, "test_event", { key: "value" }); + await mod.trackEvent(fakeRequest, "test_event", { key: "value" }); expect(mockCapture).not.toHaveBeenCalled(); }); @@ -225,7 +232,7 @@ describe("trackEvent", () => { mockAuthUser.value = { id: "user-1", analyticsEnabled: true }; dbGetResult.value = { analyticsEnabled: true }; const fakeRequest = { headers: {} } as Parameters[0]; - mod.trackEvent(fakeRequest, "test_event", { key: "value" }); + await mod.trackEvent(fakeRequest, "test_event", { key: "value" }); expect(mockCapture).not.toHaveBeenCalled(); }); @@ -238,7 +245,7 @@ describe("trackEvent", () => { mockAuthUser.value = { id: "user-1", analyticsEnabled: true }; dbGetResult.value = { analyticsEnabled: true }; const fakeRequest = { headers: {} } as Parameters[0]; - mod.trackEvent(fakeRequest, "tool_used", { tool: "resize" }); + await mod.trackEvent(fakeRequest, "tool_used", { tool: "resize" }); expect(mockCapture).toHaveBeenCalledWith({ distinctId: "unknown", event: "tool_used", @@ -255,7 +262,7 @@ describe("trackEvent", () => { dbGetResult.value = { value: "inst-abc-123", analyticsEnabled: true }; mockAuthUser.value = { id: "user-1", analyticsEnabled: true }; const fakeRequest = { headers: {} } as Parameters[0]; - mod.trackEvent(fakeRequest, "tool_used", { tool: "crop" }); + await mod.trackEvent(fakeRequest, "tool_used", { tool: "crop" }); expect(mockCapture).toHaveBeenCalledWith( expect.objectContaining({ distinctId: "inst-abc-123", @@ -273,7 +280,7 @@ describe("trackEvent", () => { const fakeRequest = { headers: { "x-analytics-consent": "true" }, } as unknown as Parameters[0]; - mod.trackEvent(fakeRequest, "test_event", { key: "value" }); + await mod.trackEvent(fakeRequest, "test_event", { key: "value" }); expect(mockCapture).toHaveBeenCalled(); }); diff --git a/tests/unit/api/anonymous-admin.test.ts b/tests/unit/api/anonymous-admin.test.ts index 479e716a..5ef000bc 100644 --- a/tests/unit/api/anonymous-admin.test.ts +++ b/tests/unit/api/anonymous-admin.test.ts @@ -14,6 +14,8 @@ vi.mock("../../../apps/api/src/db/index.js", () => ({ delete: () => ({ where: () => ({ run: vi.fn() }) }), update: () => ({ set: () => ({ where: () => ({ run: vi.fn() }) }) }), }, + pool: {}, + closeDb: async () => {}, schema: { users: { id: {}, username: {}, role: {} }, sessions: { id: {}, userId: {} }, @@ -67,11 +69,11 @@ describe("anonymous user when AUTH_ENABLED=false", () => { expect(capturedUser?.username).toBe("anonymous"); }); - it("anonymous admin has settings:write permission", () => { - expect(hasPermission("admin", "settings:write")).toBe(true); + it("anonymous admin has settings:write permission", async () => { + expect(await hasPermission("admin", "settings:write")).toBe(true); }); - it("anonymous admin has all admin permissions", () => { + it("anonymous admin has all admin permissions", async () => { const adminPerms = [ "tools:use", "files:own", @@ -90,7 +92,7 @@ describe("anonymous user when AUTH_ENABLED=false", () => { ] as const; for (const perm of adminPerms) { - expect(hasPermission("admin", perm)).toBe(true); + expect(await hasPermission("admin", perm)).toBe(true); } }); @@ -101,7 +103,7 @@ describe("anonymous user when AUTH_ENABLED=false", () => { const req = { user } as never; const reply = { status: vi.fn().mockReturnThis(), send: vi.fn() } as never; - const result = requirePermission("settings:write")(req, reply); + const result = await requirePermission("settings:write")(req, reply); expect(result).toEqual(user); expect((reply as { status: ReturnType }).status).not.toHaveBeenCalled(); }); diff --git a/tests/unit/api/audit-lib.test.ts b/tests/unit/api/audit-lib.test.ts index 392a1716..d1a85cff 100644 --- a/tests/unit/api/audit-lib.test.ts +++ b/tests/unit/api/audit-lib.test.ts @@ -13,9 +13,14 @@ const mockInsertRun = vi.fn(); vi.mock("../../../apps/api/src/db/index.js", () => ({ db: { insert: () => ({ - values: () => ({ run: mockInsertRun }), + values: (...args: unknown[]) => { + mockInsertRun(...args); + return Promise.resolve(); + }, }), }, + pool: {}, + closeDb: async () => {}, schema: { auditLog: {}, }, @@ -42,8 +47,8 @@ describe("auditLog", () => { vi.clearAllMocks(); }); - it("logs to the structured logger with audit flag", () => { - auditLog(mockLogger as never, "LOGIN_SUCCESS", { userId: "u1", username: "alice" }); + it("logs to the structured logger with audit flag", async () => { + await auditLog(mockLogger as never, "LOGIN_SUCCESS", { userId: "u1", username: "alice" }); expect(mockLogger.info).toHaveBeenCalledTimes(1); const [logData, logMessage] = mockLogger.info.mock.calls[0]; @@ -53,8 +58,8 @@ describe("auditLog", () => { expect(logMessage).toBe("[AUDIT] LOGIN_SUCCESS"); }); - it("inserts a record into the database", () => { - auditLog(mockLogger as never, "USER_CREATED", { + it("inserts a record into the database", async () => { + await auditLog(mockLogger as never, "USER_CREATED", { adminId: "admin-1", targetUserId: "new-user-1", }); @@ -62,8 +67,8 @@ describe("auditLog", () => { expect(mockInsertRun).toHaveBeenCalledTimes(1); }); - it("extracts actorId from userId field first", () => { - auditLog(mockLogger as never, "FILE_UPLOADED", { + it("extracts actorId from userId field first", async () => { + await auditLog(mockLogger as never, "FILE_UPLOADED", { userId: "u1", adminId: "a1", }); @@ -75,16 +80,16 @@ describe("auditLog", () => { expect(logData.adminId).toBe("a1"); }); - it("falls back to adminId when userId is absent", () => { - auditLog(mockLogger as never, "ROLE_CREATED", { adminId: "admin-1", roleName: "editor" }); + it("falls back to adminId when userId is absent", async () => { + await auditLog(mockLogger as never, "ROLE_CREATED", { adminId: "admin-1", roleName: "editor" }); expect(mockLogger.info).toHaveBeenCalledTimes(1); const logData = mockLogger.info.mock.calls[0][0]; expect(logData.adminId).toBe("admin-1"); }); - it("extracts username from details", () => { - auditLog(mockLogger as never, "LOGIN_SUCCESS", { + it("extracts username from details", async () => { + await auditLog(mockLogger as never, "LOGIN_SUCCESS", { userId: "u1", username: "alice", }); @@ -93,20 +98,20 @@ describe("auditLog", () => { expect(logData.username).toBe("alice"); }); - it("handles empty details object", () => { - auditLog(mockLogger as never, "LOGOUT"); + it("handles empty details object", async () => { + await auditLog(mockLogger as never, "LOGOUT"); expect(mockLogger.info).toHaveBeenCalledTimes(1); expect(mockInsertRun).toHaveBeenCalledTimes(1); }); - it("survives DB insert failure", () => { + it("survives DB insert failure", async () => { mockInsertRun.mockImplementationOnce(() => { throw new Error("DB write failed"); }); // Should not throw - auditLog(mockLogger as never, "SETTINGS_UPDATED", { userId: "u1" }); + await auditLog(mockLogger as never, "SETTINGS_UPDATED", { userId: "u1" }); expect(mockLogger.info).toHaveBeenCalledTimes(1); expect(mockLogger.warn).toHaveBeenCalledTimes(1); @@ -114,7 +119,7 @@ describe("auditLog", () => { expect(warnData.event).toBe("SETTINGS_UPDATED"); }); - it("logs different event types correctly", () => { + it("logs different event types correctly", async () => { const events = [ "LOGIN_SUCCESS", "LOGIN_FAILED", @@ -129,7 +134,7 @@ describe("auditLog", () => { for (const event of events) { vi.clearAllMocks(); - auditLog(mockLogger as never, event, { userId: "u1" }); + await auditLog(mockLogger as never, event, { userId: "u1" }); expect(mockLogger.info).toHaveBeenCalledTimes(1); const logMessage = mockLogger.info.mock.calls[0][1]; @@ -137,8 +142,8 @@ describe("auditLog", () => { } }); - it("serializes details as JSON for DB storage", () => { - auditLog(mockLogger as never, "USER_UPDATED", { + it("serializes details as JSON for DB storage", async () => { + await auditLog(mockLogger as never, "USER_UPDATED", { adminId: "admin-1", targetUserId: "u2", changes: { role: "editor" }, @@ -147,14 +152,14 @@ describe("auditLog", () => { expect(mockInsertRun).toHaveBeenCalledTimes(1); }); - it("includes all detail fields in the log output", () => { + it("includes all detail fields in the log output", async () => { const details = { userId: "u1", keyId: "key-123", keyName: "Production Key", }; - auditLog(mockLogger as never, "API_KEY_CREATED", details); + await auditLog(mockLogger as never, "API_KEY_CREATED", details); const logData = mockLogger.info.mock.calls[0][0]; expect(logData.keyId).toBe("key-123"); diff --git a/tests/unit/api/audit-log-route.test.ts b/tests/unit/api/audit-log-route.test.ts index 70202eb6..c2fe075f 100644 --- a/tests/unit/api/audit-log-route.test.ts +++ b/tests/unit/api/audit-log-route.test.ts @@ -29,6 +29,8 @@ vi.mock("../../../apps/api/src/db/index.js", () => ({ }), }), }, + pool: {}, + closeDb: async () => {}, schema: { auditLog: { action: {}, diff --git a/tests/unit/api/auth-functions.test.ts b/tests/unit/api/auth-functions.test.ts index 42112cb7..faf91d2a 100644 --- a/tests/unit/api/auth-functions.test.ts +++ b/tests/unit/api/auth-functions.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("../../../apps/api/src/db/index.js", () => ({ db: {}, + pool: {}, + closeDb: async () => {}, schema: {}, })); diff --git a/tests/unit/api/auth-routes.test.ts b/tests/unit/api/auth-routes.test.ts index e8b4e8ee..950edee8 100644 --- a/tests/unit/api/auth-routes.test.ts +++ b/tests/unit/api/auth-routes.test.ts @@ -22,6 +22,8 @@ vi.mock("../../../apps/api/src/db/index.js", () => ({ delete: () => ({ where: () => ({ run: vi.fn() }) }), update: () => ({ set: () => ({ where: () => ({ run: vi.fn() }) }) }), }, + pool: {}, + closeDb: async () => {}, schema: { users: { id: {}, username: {}, role: {} }, sessions: { id: {}, userId: {} }, diff --git a/tests/unit/api/batch.test.ts b/tests/unit/api/batch.test.ts index 4cc4c5ac..cd8f471a 100644 --- a/tests/unit/api/batch.test.ts +++ b/tests/unit/api/batch.test.ts @@ -18,6 +18,8 @@ vi.mock("../../../apps/api/src/db/index.js", () => ({ insert: () => ({ values: () => ({ run: vi.fn() }) }), update: () => ({ set: () => ({ where: () => ({ run: vi.fn() }) }) }), }, + pool: {}, + closeDb: async () => {}, schema: { settings: { key: {} }, jobs: { id: {}, status: {} }, diff --git a/tests/unit/api/docs-route.test.ts b/tests/unit/api/docs-route.test.ts index e066a9c6..1efb4e32 100644 --- a/tests/unit/api/docs-route.test.ts +++ b/tests/unit/api/docs-route.test.ts @@ -10,6 +10,8 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("../../../apps/api/src/db/index.js", () => ({ db: {}, + pool: {}, + closeDb: async () => {}, schema: {}, })); diff --git a/tests/unit/api/effective-permissions.test.ts b/tests/unit/api/effective-permissions.test.ts index c4ee4107..1b3c14d4 100644 --- a/tests/unit/api/effective-permissions.test.ts +++ b/tests/unit/api/effective-permissions.test.ts @@ -12,6 +12,8 @@ vi.mock("../../../apps/api/src/db/index.js", () => ({ db: { select: () => ({ from: () => ({ where: () => ({ get: () => null }) }) }), }, + pool: {}, + closeDb: async () => {}, schema: { roles: {}, settings: {} }, })); @@ -40,148 +42,148 @@ function makeUser(overrides: Partial & { role: string }): AuthUser { describe("hasEffectivePermission", () => { describe("without API key scoping (apiKeyPermissions undefined)", () => { - it("admin can use any permission", () => { + it("admin can use any permission", async () => { const admin = makeUser({ role: "admin" }); - const allAdmin = getPermissions("admin"); + const allAdmin = await getPermissions("admin"); for (const perm of allAdmin) { - expect(hasEffectivePermission(admin, perm)).toBe(true); + expect(await hasEffectivePermission(admin, perm)).toBe(true); } }); - it("editor can use all editor permissions", () => { + it("editor can use all editor permissions", async () => { const editor = makeUser({ role: "editor" }); - const editorPerms = getPermissions("editor"); + const editorPerms = await getPermissions("editor"); for (const perm of editorPerms) { - expect(hasEffectivePermission(editor, perm)).toBe(true); + expect(await hasEffectivePermission(editor, perm)).toBe(true); } }); - it("editor cannot use admin-only permissions", () => { + it("editor cannot use admin-only permissions", async () => { const editor = makeUser({ role: "editor" }); - expect(hasEffectivePermission(editor, "users:manage")).toBe(false); - expect(hasEffectivePermission(editor, "settings:write")).toBe(false); - expect(hasEffectivePermission(editor, "teams:manage")).toBe(false); - expect(hasEffectivePermission(editor, "features:manage")).toBe(false); - expect(hasEffectivePermission(editor, "system:health")).toBe(false); - expect(hasEffectivePermission(editor, "audit:read")).toBe(false); + expect(await hasEffectivePermission(editor, "users:manage")).toBe(false); + expect(await hasEffectivePermission(editor, "settings:write")).toBe(false); + expect(await hasEffectivePermission(editor, "teams:manage")).toBe(false); + expect(await hasEffectivePermission(editor, "features:manage")).toBe(false); + expect(await hasEffectivePermission(editor, "system:health")).toBe(false); + expect(await hasEffectivePermission(editor, "audit:read")).toBe(false); }); - it("user can use all user permissions", () => { + it("user can use all user permissions", async () => { const user = makeUser({ role: "user" }); - const userPerms = getPermissions("user"); + const userPerms = await getPermissions("user"); for (const perm of userPerms) { - expect(hasEffectivePermission(user, perm)).toBe(true); + expect(await hasEffectivePermission(user, perm)).toBe(true); } }); - it("user cannot use editor or admin permissions", () => { + it("user cannot use editor or admin permissions", async () => { const user = makeUser({ role: "user" }); - expect(hasEffectivePermission(user, "files:all")).toBe(false); - expect(hasEffectivePermission(user, "pipelines:all")).toBe(false); - expect(hasEffectivePermission(user, "users:manage")).toBe(false); - expect(hasEffectivePermission(user, "settings:write")).toBe(false); + expect(await hasEffectivePermission(user, "files:all")).toBe(false); + expect(await hasEffectivePermission(user, "pipelines:all")).toBe(false); + expect(await hasEffectivePermission(user, "users:manage")).toBe(false); + expect(await hasEffectivePermission(user, "settings:write")).toBe(false); }); - it("unknown role has no effective permissions", () => { + it("unknown role has no effective permissions", async () => { const unknown = makeUser({ role: "ghost" }); - expect(hasEffectivePermission(unknown, "tools:use")).toBe(false); - expect(hasEffectivePermission(unknown, "users:manage")).toBe(false); + expect(await hasEffectivePermission(unknown, "tools:use")).toBe(false); + expect(await hasEffectivePermission(unknown, "users:manage")).toBe(false); }); }); describe("API key scoping restricts permissions", () => { - it("admin scoped to tools:use can only use tools:use", () => { + it("admin scoped to tools:use can only use tools:use", async () => { const admin = makeUser({ role: "admin", apiKeyPermissions: ["tools:use"], }); - expect(hasEffectivePermission(admin, "tools:use")).toBe(true); - expect(hasEffectivePermission(admin, "users:manage")).toBe(false); - expect(hasEffectivePermission(admin, "files:all")).toBe(false); + expect(await hasEffectivePermission(admin, "tools:use")).toBe(true); + expect(await hasEffectivePermission(admin, "users:manage")).toBe(false); + expect(await hasEffectivePermission(admin, "files:all")).toBe(false); }); - it("editor scoped to files:own and tools:use only has those", () => { + it("editor scoped to files:own and tools:use only has those", async () => { const editor = makeUser({ role: "editor", apiKeyPermissions: ["files:own", "tools:use"], }); - expect(hasEffectivePermission(editor, "files:own")).toBe(true); - expect(hasEffectivePermission(editor, "tools:use")).toBe(true); - expect(hasEffectivePermission(editor, "files:all")).toBe(false); - expect(hasEffectivePermission(editor, "settings:read")).toBe(false); + expect(await hasEffectivePermission(editor, "files:own")).toBe(true); + expect(await hasEffectivePermission(editor, "tools:use")).toBe(true); + expect(await hasEffectivePermission(editor, "files:all")).toBe(false); + expect(await hasEffectivePermission(editor, "settings:read")).toBe(false); }); - it("user scoped to settings:read only has that", () => { + it("user scoped to settings:read only has that", async () => { const user = makeUser({ role: "user", apiKeyPermissions: ["settings:read"], }); - expect(hasEffectivePermission(user, "settings:read")).toBe(true); - expect(hasEffectivePermission(user, "tools:use")).toBe(false); - expect(hasEffectivePermission(user, "files:own")).toBe(false); + expect(await hasEffectivePermission(user, "settings:read")).toBe(true); + expect(await hasEffectivePermission(user, "tools:use")).toBe(false); + expect(await hasEffectivePermission(user, "files:own")).toBe(false); }); }); describe("API key cannot grant permissions the role lacks", () => { - it("user with apiKeyPermissions including users:manage still denied", () => { + it("user with apiKeyPermissions including users:manage still denied", async () => { const user = makeUser({ role: "user", apiKeyPermissions: ["tools:use", "users:manage"], }); - expect(hasEffectivePermission(user, "users:manage")).toBe(false); + expect(await hasEffectivePermission(user, "users:manage")).toBe(false); // But role-granted permission that is also in the key works - expect(hasEffectivePermission(user, "tools:use")).toBe(true); + expect(await hasEffectivePermission(user, "tools:use")).toBe(true); }); - it("editor with apiKeyPermissions including settings:write still denied", () => { + it("editor with apiKeyPermissions including settings:write still denied", async () => { const editor = makeUser({ role: "editor", apiKeyPermissions: ["settings:write", "files:all"], }); - expect(hasEffectivePermission(editor, "settings:write")).toBe(false); + expect(await hasEffectivePermission(editor, "settings:write")).toBe(false); // files:all is in editor role, so it works - expect(hasEffectivePermission(editor, "files:all")).toBe(true); + expect(await hasEffectivePermission(editor, "files:all")).toBe(true); }); - it("unknown role gains nothing even with full apiKeyPermissions", () => { + it("unknown role gains nothing even with full apiKeyPermissions", async () => { const unknown = makeUser({ role: "nobody", apiKeyPermissions: ["tools:use", "files:own", "users:manage", "settings:write"], }); - expect(hasEffectivePermission(unknown, "tools:use")).toBe(false); - expect(hasEffectivePermission(unknown, "users:manage")).toBe(false); + expect(await hasEffectivePermission(unknown, "tools:use")).toBe(false); + expect(await hasEffectivePermission(unknown, "users:manage")).toBe(false); }); }); describe("empty apiKeyPermissions blocks everything", () => { - it("admin with empty array has no effective permissions", () => { + it("admin with empty array has no effective permissions", async () => { const admin = makeUser({ role: "admin", apiKeyPermissions: [] }); - const allAdmin = getPermissions("admin"); + const allAdmin = await getPermissions("admin"); for (const perm of allAdmin) { - expect(hasEffectivePermission(admin, perm)).toBe(false); + expect(await hasEffectivePermission(admin, perm)).toBe(false); } }); - it("user with empty array has no effective permissions", () => { + it("user with empty array has no effective permissions", async () => { const user = makeUser({ role: "user", apiKeyPermissions: [] }); - expect(hasEffectivePermission(user, "tools:use")).toBe(false); - expect(hasEffectivePermission(user, "files:own")).toBe(false); + expect(await hasEffectivePermission(user, "tools:use")).toBe(false); + expect(await hasEffectivePermission(user, "files:own")).toBe(false); }); }); describe("undefined apiKeyPermissions inherits all role permissions", () => { - it("admin without apiKeyPermissions gets full admin access", () => { + it("admin without apiKeyPermissions gets full admin access", async () => { const admin = makeUser({ role: "admin" }); expect(admin.apiKeyPermissions).toBeUndefined(); - expect(hasEffectivePermission(admin, "users:manage")).toBe(true); - expect(hasEffectivePermission(admin, "audit:read")).toBe(true); + expect(await hasEffectivePermission(admin, "users:manage")).toBe(true); + expect(await hasEffectivePermission(admin, "audit:read")).toBe(true); }); - it("user without apiKeyPermissions gets full user access", () => { + it("user without apiKeyPermissions gets full user access", async () => { const user = makeUser({ role: "user" }); expect(user.apiKeyPermissions).toBeUndefined(); - expect(hasEffectivePermission(user, "tools:use")).toBe(true); - expect(hasEffectivePermission(user, "pipelines:own")).toBe(true); + expect(await hasEffectivePermission(user, "tools:use")).toBe(true); + expect(await hasEffectivePermission(user, "pipelines:own")).toBe(true); }); }); }); @@ -190,69 +192,69 @@ describe("hasEffectivePermission", () => { describe("getPermissions", () => { describe("exact counts for built-in roles", () => { - it("admin has exactly 14 permissions", () => { - expect(getPermissions("admin")).toHaveLength(14); + it("admin has exactly 14 permissions", async () => { + expect(await getPermissions("admin")).toHaveLength(14); }); - it("editor has exactly 7 permissions", () => { - expect(getPermissions("editor")).toHaveLength(7); + it("editor has exactly 7 permissions", async () => { + expect(await getPermissions("editor")).toHaveLength(7); }); - it("user has exactly 5 permissions", () => { - expect(getPermissions("user")).toHaveLength(5); + it("user has exactly 5 permissions", async () => { + expect(await getPermissions("user")).toHaveLength(5); }); }); describe("invalid and edge-case role names", () => { - it("empty string returns empty array", () => { - expect(getPermissions("")).toEqual([]); + it("empty string returns empty array", async () => { + expect(await getPermissions("")).toEqual([]); }); - it("null coerced to string returns empty array", () => { - expect(getPermissions(null as unknown as Role)).toEqual([]); + it("null coerced to string returns empty array", async () => { + expect(await getPermissions(null as unknown as Role)).toEqual([]); }); - it("undefined coerced to string returns empty array", () => { - expect(getPermissions(undefined as unknown as Role)).toEqual([]); + it("undefined coerced to string returns empty array", async () => { + expect(await getPermissions(undefined as unknown as Role)).toEqual([]); }); - it("case-sensitive: Admin (capitalized) returns empty array", () => { - expect(getPermissions("Admin" as Role)).toEqual([]); + it("case-sensitive: Admin (capitalized) returns empty array", async () => { + expect(await getPermissions("Admin" as Role)).toEqual([]); }); - it("case-sensitive: ADMIN (uppercase) returns empty array", () => { - expect(getPermissions("ADMIN" as Role)).toEqual([]); + it("case-sensitive: ADMIN (uppercase) returns empty array", async () => { + expect(await getPermissions("ADMIN" as Role)).toEqual([]); }); - it("case-sensitive: User (capitalized) returns empty array", () => { - expect(getPermissions("User" as Role)).toEqual([]); + it("case-sensitive: User (capitalized) returns empty array", async () => { + expect(await getPermissions("User" as Role)).toEqual([]); }); - it("whitespace-padded role name returns empty array", () => { - expect(getPermissions(" admin " as Role)).toEqual([]); + it("whitespace-padded role name returns empty array", async () => { + expect(await getPermissions(" admin " as Role)).toEqual([]); }); }); describe("role permission subsets", () => { - it("editor permissions are a subset of admin permissions", () => { - const adminPerms = getPermissions("admin"); - const editorPerms = getPermissions("editor"); + it("editor permissions are a subset of admin permissions", async () => { + const adminPerms = await getPermissions("admin"); + const editorPerms = await getPermissions("editor"); for (const perm of editorPerms) { expect(adminPerms).toContain(perm); } }); - it("user permissions are a subset of admin permissions", () => { - const adminPerms = getPermissions("admin"); - const userPerms = getPermissions("user"); + it("user permissions are a subset of admin permissions", async () => { + const adminPerms = await getPermissions("admin"); + const userPerms = await getPermissions("user"); for (const perm of userPerms) { expect(adminPerms).toContain(perm); } }); - it("user permissions are a subset of editor permissions", () => { - const editorPerms = getPermissions("editor"); - const userPerms = getPermissions("user"); + it("user permissions are a subset of editor permissions", async () => { + const editorPerms = await getPermissions("editor"); + const userPerms = await getPermissions("user"); for (const perm of userPerms) { expect(editorPerms).toContain(perm); } @@ -263,19 +265,19 @@ describe("getPermissions", () => { // ── hasPermission edge cases ───────────────────────────────────────── describe("hasPermission edge cases", () => { - it("returns false for a non-existent permission string", () => { - expect(hasPermission("admin", "fake:perm" as Permission)).toBe(false); + it("returns false for a non-existent permission string", async () => { + expect(await hasPermission("admin", "fake:perm" as Permission)).toBe(false); }); - it("returns false for an empty string permission", () => { - expect(hasPermission("admin", "" as Permission)).toBe(false); + it("returns false for an empty string permission", async () => { + expect(await hasPermission("admin", "" as Permission)).toBe(false); }); - it("returns false for unknown role even with valid permission", () => { - expect(hasPermission("visitor" as Role, "tools:use")).toBe(false); + it("returns false for unknown role even with valid permission", async () => { + expect(await hasPermission("visitor" as Role, "tools:use")).toBe(false); }); - it("returns false for both unknown role and unknown permission", () => { - expect(hasPermission("visitor" as Role, "x:y" as Permission)).toBe(false); + it("returns false for both unknown role and unknown permission", async () => { + expect(await hasPermission("visitor" as Role, "x:y" as Permission)).toBe(false); }); }); diff --git a/tests/unit/api/files-route.test.ts b/tests/unit/api/files-route.test.ts index d7f2a8c9..c6b1c7ee 100644 --- a/tests/unit/api/files-route.test.ts +++ b/tests/unit/api/files-route.test.ts @@ -10,6 +10,8 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("../../../apps/api/src/db/index.js", () => ({ db: {}, + pool: {}, + closeDb: async () => {}, schema: {}, })); diff --git a/tests/unit/api/heic-converter.test.ts b/tests/unit/api/heic-converter.test.ts index 64afc2a4..ed776a61 100644 --- a/tests/unit/api/heic-converter.test.ts +++ b/tests/unit/api/heic-converter.test.ts @@ -59,14 +59,17 @@ describe("decodeHeic", () => { const heicBuf = await readFile(join(FIXTURES, "formats/sample.heic")); const { tmpdir } = await import("node:os"); const { readdirSync } = await import("node:fs"); - const before = readdirSync(tmpdir()).filter( - (f) => f.startsWith("heic-in-") || f.startsWith("heic-out-"), + const beforeSet = new Set( + readdirSync(tmpdir()).filter((f) => f.startsWith("heic-in-") || f.startsWith("heic-out-")), ); await decodeHeic(heicBuf); const after = readdirSync(tmpdir()).filter( (f) => f.startsWith("heic-in-") || f.startsWith("heic-out-"), ); - expect(after.length).toBeLessThanOrEqual(before.length); + // Only check that files created during THIS call were cleaned up. + // Other concurrent test workers may create temp files in the same dir. + const leftover = after.filter((f) => !beforeSet.has(f)); + expect(leftover).toHaveLength(0); }); }); diff --git a/tests/unit/api/permissions-extended.test.ts b/tests/unit/api/permissions-extended.test.ts index e2486d93..4e2189c1 100644 --- a/tests/unit/api/permissions-extended.test.ts +++ b/tests/unit/api/permissions-extended.test.ts @@ -7,6 +7,8 @@ vi.mock("../../../apps/api/src/db/index.js", () => ({ from: () => ({ where: () => ({ get: () => null }) }), }), }, + pool: {}, + closeDb: async () => {}, schema: { roles: {}, settings: {} }, })); @@ -68,13 +70,13 @@ describe("hasPermission extended", () => { "audit:read", ]; - it("admin has all 14 permissions", () => { + it("admin has all 14 permissions", async () => { for (const perm of adminPerms) { - expect(hasPermission("admin", perm)).toBe(true); + expect(await hasPermission("admin", perm)).toBe(true); } }); - it("editor has the expected permissions", () => { + it("editor has the expected permissions", async () => { const editorYes: Permission[] = [ "tools:use", "files:own", @@ -85,11 +87,11 @@ describe("hasPermission extended", () => { "settings:read", ]; for (const perm of editorYes) { - expect(hasPermission("editor", perm)).toBe(true); + expect(await hasPermission("editor", perm)).toBe(true); } }); - it("editor does NOT have admin-only permissions", () => { + it("editor does NOT have admin-only permissions", async () => { const editorNo: Permission[] = [ "users:manage", "teams:manage", @@ -99,11 +101,11 @@ describe("hasPermission extended", () => { "audit:read", ]; for (const perm of editorNo) { - expect(hasPermission("editor", perm)).toBe(false); + expect(await hasPermission("editor", perm)).toBe(false); } }); - it("user has the expected permissions", () => { + it("user has the expected permissions", async () => { const userYes: Permission[] = [ "tools:use", "files:own", @@ -112,11 +114,11 @@ describe("hasPermission extended", () => { "settings:read", ]; for (const perm of userYes) { - expect(hasPermission("user", perm)).toBe(true); + expect(await hasPermission("user", perm)).toBe(true); } }); - it("user does NOT have elevated permissions", () => { + it("user does NOT have elevated permissions", async () => { const userNo: Permission[] = [ "files:all", "pipelines:all", @@ -129,65 +131,65 @@ describe("hasPermission extended", () => { "audit:read", ]; for (const perm of userNo) { - expect(hasPermission("user", perm)).toBe(false); + expect(await hasPermission("user", perm)).toBe(false); } }); - it("unknown role returns false for any permission", () => { - expect(hasPermission("ghost" as Role, "tools:use")).toBe(false); - expect(hasPermission("ghost" as Role, "users:manage")).toBe(false); + it("unknown role returns false for any permission", async () => { + expect(await hasPermission("ghost" as Role, "tools:use")).toBe(false); + expect(await hasPermission("ghost" as Role, "users:manage")).toBe(false); }); }); describe("hasEffectivePermission extended", () => { - it("admin without apiKeyPermissions has all permissions", () => { + it("admin without apiKeyPermissions has all permissions", async () => { const admin = makeUser({ role: "admin" }); - expect(hasEffectivePermission(admin, "tools:use")).toBe(true); - expect(hasEffectivePermission(admin, "users:manage")).toBe(true); - expect(hasEffectivePermission(admin, "audit:read")).toBe(true); + expect(await hasEffectivePermission(admin, "tools:use")).toBe(true); + expect(await hasEffectivePermission(admin, "users:manage")).toBe(true); + expect(await hasEffectivePermission(admin, "audit:read")).toBe(true); }); - it("user with apiKeyPermissions only gets intersecting permissions", () => { + it("user with apiKeyPermissions only gets intersecting permissions", async () => { const user = makeUser({ role: "user", apiKeyPermissions: ["tools:use", "settings:read"], }); - expect(hasEffectivePermission(user, "tools:use")).toBe(true); - expect(hasEffectivePermission(user, "settings:read")).toBe(true); - expect(hasEffectivePermission(user, "files:own")).toBe(false); + expect(await hasEffectivePermission(user, "tools:use")).toBe(true); + expect(await hasEffectivePermission(user, "settings:read")).toBe(true); + expect(await hasEffectivePermission(user, "files:own")).toBe(false); }); - it("apiKeyPermissions that include the permission returns true", () => { + it("apiKeyPermissions that include the permission returns true", async () => { const editor = makeUser({ role: "editor", apiKeyPermissions: ["files:all"], }); - expect(hasEffectivePermission(editor, "files:all")).toBe(true); + expect(await hasEffectivePermission(editor, "files:all")).toBe(true); }); - it("apiKeyPermissions that do NOT include the permission returns false", () => { + it("apiKeyPermissions that do NOT include the permission returns false", async () => { const editor = makeUser({ role: "editor", apiKeyPermissions: ["tools:use"], }); - expect(hasEffectivePermission(editor, "files:all")).toBe(false); + expect(await hasEffectivePermission(editor, "files:all")).toBe(false); }); - it("role lacking the permission returns false even if apiKeyPermissions include it", () => { + it("role lacking the permission returns false even if apiKeyPermissions include it", async () => { const user = makeUser({ role: "user", apiKeyPermissions: ["users:manage", "settings:write"], }); - expect(hasEffectivePermission(user, "users:manage")).toBe(false); - expect(hasEffectivePermission(user, "settings:write")).toBe(false); + expect(await hasEffectivePermission(user, "users:manage")).toBe(false); + expect(await hasEffectivePermission(user, "settings:write")).toBe(false); }); }); describe("requirePermission", () => { - it("returns null and sends 401 when getAuthUser returns null", () => { + it("returns null and sends 401 when getAuthUser returns null", async () => { mockGetAuthUser.mockReturnValue(null); const { reply, sent } = makeMockReply(); - const result = requirePermission("tools:use")({} as never, reply as never); + const result = await requirePermission("tools:use")({} as never, reply as never); expect(result).toBeNull(); expect(sent.status).toBe(401); expect(sent.body).toEqual({ @@ -196,10 +198,10 @@ describe("requirePermission", () => { }); }); - it("returns null and sends 403 when user lacks permission", () => { + it("returns null and sends 403 when user lacks permission", async () => { mockGetAuthUser.mockReturnValue(makeUser({ role: "user" })); const { reply, sent } = makeMockReply(); - const result = requirePermission("users:manage")({} as never, reply as never); + const result = await requirePermission("users:manage")({} as never, reply as never); expect(result).toBeNull(); expect(sent.status).toBe(403); expect(sent.body).toEqual({ @@ -208,28 +210,28 @@ describe("requirePermission", () => { }); }); - it("returns user when user has the permission", () => { + it("returns user when user has the permission", async () => { const admin = makeUser({ role: "admin" }); mockGetAuthUser.mockReturnValue(admin); const { reply } = makeMockReply(); - const result = requirePermission("users:manage")({} as never, reply as never); + const result = await requirePermission("users:manage")({} as never, reply as never); expect(result).toEqual(admin); }); - it("returns user when editor has an editor-level permission", () => { + it("returns user when editor has an editor-level permission", async () => { const editor = makeUser({ role: "editor" }); mockGetAuthUser.mockReturnValue(editor); const { reply } = makeMockReply(); - const result = requirePermission("tools:use")({} as never, reply as never); + const result = await requirePermission("tools:use")({} as never, reply as never); expect(result).toEqual(editor); }); }); describe("requireOwnershipOrPermission", () => { - it("returns null and sends 401 when no user", () => { + it("returns null and sends 401 when no user", async () => { mockGetAuthUser.mockReturnValue(null); const { reply, sent } = makeMockReply(); - const result = requireOwnershipOrPermission( + const result = await requireOwnershipOrPermission( {} as never, reply as never, "other-user", @@ -239,11 +241,11 @@ describe("requireOwnershipOrPermission", () => { expect(sent.status).toBe(401); }); - it("returns user when resourceUserId matches user.id (own resource)", () => { + it("returns user when resourceUserId matches user.id (own resource)", async () => { const user = makeUser({ role: "user", id: "u-owner" }); mockGetAuthUser.mockReturnValue(user); const { reply } = makeMockReply(); - const result = requireOwnershipOrPermission( + const result = await requireOwnershipOrPermission( {} as never, reply as never, "u-owner", @@ -252,11 +254,11 @@ describe("requireOwnershipOrPermission", () => { expect(result).toEqual(user); }); - it("returns user when user has the allPermission", () => { + it("returns user when user has the allPermission", async () => { const admin = makeUser({ role: "admin", id: "u-admin" }); mockGetAuthUser.mockReturnValue(admin); const { reply } = makeMockReply(); - const result = requireOwnershipOrPermission( + const result = await requireOwnershipOrPermission( {} as never, reply as never, "u-someone-else", @@ -265,11 +267,11 @@ describe("requireOwnershipOrPermission", () => { expect(result).toEqual(admin); }); - it("returns null when not owner and lacks allPermission", () => { + it("returns null when not owner and lacks allPermission", async () => { const user = makeUser({ role: "user", id: "u-basic" }); mockGetAuthUser.mockReturnValue(user); const { reply } = makeMockReply(); - const result = requireOwnershipOrPermission( + const result = await requireOwnershipOrPermission( {} as never, reply as never, "u-someone-else", diff --git a/tests/unit/api/permissions.test.ts b/tests/unit/api/permissions.test.ts index 6b83f807..76eef3af 100644 --- a/tests/unit/api/permissions.test.ts +++ b/tests/unit/api/permissions.test.ts @@ -12,6 +12,8 @@ vi.mock("../../../apps/api/src/db/index.js", () => ({ db: { select: () => ({ from: () => ({ where: () => ({ get: () => null }) }) }), }, + pool: {}, + closeDb: async () => {}, schema: { roles: {}, settings: {} }, })); @@ -23,8 +25,8 @@ import { getPermissions, hasPermission } from "../../../apps/api/src/permissions describe("permissions", () => { describe("getPermissions", () => { - it("returns all 14 permissions for admin", () => { - const perms = getPermissions("admin"); + it("returns all 14 permissions for admin", async () => { + const perms = await getPermissions("admin"); expect(perms).toHaveLength(14); expect(perms).toContain("tools:use"); expect(perms).toContain("files:own"); @@ -42,8 +44,8 @@ describe("permissions", () => { expect(perms).toContain("audit:read"); }); - it("returns only basic permissions for user role", () => { - const perms = getPermissions("user"); + it("returns only basic permissions for user role", async () => { + const perms = await getPermissions("user"); expect(perms).toEqual([ "tools:use", "files:own", @@ -53,8 +55,8 @@ describe("permissions", () => { ]); }); - it("does NOT contain admin-only permissions for user role", () => { - const perms = getPermissions("user"); + it("does NOT contain admin-only permissions for user role", async () => { + const perms = await getPermissions("user"); expect(perms).not.toContain("files:all"); expect(perms).not.toContain("apikeys:all"); expect(perms).not.toContain("pipelines:all"); @@ -63,27 +65,27 @@ describe("permissions", () => { expect(perms).not.toContain("teams:manage"); }); - it("returns empty array for unknown role", () => { - const perms = getPermissions("unknown" as Role); + it("returns empty array for unknown role", async () => { + const perms = await getPermissions("unknown" as Role); expect(perms).toEqual([]); }); }); describe("hasPermission", () => { - it("returns true for admin with users:manage", () => { - expect(hasPermission("admin", "users:manage")).toBe(true); + it("returns true for admin with users:manage", async () => { + expect(await hasPermission("admin", "users:manage")).toBe(true); }); - it("returns true for user with tools:use", () => { - expect(hasPermission("user", "tools:use")).toBe(true); + it("returns true for user with tools:use", async () => { + expect(await hasPermission("user", "tools:use")).toBe(true); }); - it("returns false for user with users:manage", () => { - expect(hasPermission("user", "users:manage")).toBe(false); + it("returns false for user with users:manage", async () => { + expect(await hasPermission("user", "users:manage")).toBe(false); }); - it("returns false for user with settings:write", () => { - expect(hasPermission("user", "settings:write")).toBe(false); + it("returns false for user with settings:write", async () => { + expect(await hasPermission("user", "settings:write")).toBe(false); }); }); }); diff --git a/tests/unit/api/pipeline.test.ts b/tests/unit/api/pipeline.test.ts index ae29b1c5..2ebb83bd 100644 --- a/tests/unit/api/pipeline.test.ts +++ b/tests/unit/api/pipeline.test.ts @@ -19,6 +19,8 @@ vi.mock("../../../apps/api/src/db/index.js", () => ({ delete: () => ({ where: () => ({ run: vi.fn() }) }), update: () => ({ set: () => ({ where: () => ({ run: vi.fn() }) }) }), }, + pool: {}, + closeDb: async () => {}, schema: { settings: { key: {} }, pipelines: { id: {} }, diff --git a/tests/unit/api/progress.test.ts b/tests/unit/api/progress.test.ts index 79622093..f77bf426 100644 --- a/tests/unit/api/progress.test.ts +++ b/tests/unit/api/progress.test.ts @@ -21,6 +21,8 @@ vi.mock("../../../apps/api/src/db/index.js", () => ({ }), }), }, + pool: {}, + closeDb: async () => {}, schema: { jobs: { id: {}, status: {} }, }, diff --git a/tests/unit/api/rbac-enforcement.test.ts b/tests/unit/api/rbac-enforcement.test.ts index f586a32c..8246535f 100644 --- a/tests/unit/api/rbac-enforcement.test.ts +++ b/tests/unit/api/rbac-enforcement.test.ts @@ -4,6 +4,8 @@ vi.mock("../../../apps/api/src/db/index.js", () => ({ db: { select: () => ({ from: () => ({ where: () => ({ get: () => null }) }) }), }, + pool: {}, + closeDb: async () => {}, schema: { roles: {}, settings: {} }, })); @@ -14,8 +16,8 @@ vi.mock("../../../apps/api/src/plugins/auth.js", () => ({ import { getPermissions, hasPermission } from "../../../apps/api/src/permissions.js"; describe("role permissions", () => { - it("admin has all 14 permissions", () => { - const perms = getPermissions("admin"); + it("admin has all 14 permissions", async () => { + const perms = await getPermissions("admin"); expect(perms).toContain("tools:use"); expect(perms).toContain("files:all"); expect(perms).toContain("users:manage"); @@ -25,8 +27,8 @@ describe("role permissions", () => { expect(perms.length).toBe(14); }); - it("editor has collaborative but not admin permissions", () => { - const perms = getPermissions("editor"); + it("editor has collaborative but not admin permissions", async () => { + const perms = await getPermissions("editor"); expect(perms).toContain("tools:use"); expect(perms).toContain("files:own"); expect(perms).toContain("files:all"); @@ -40,8 +42,8 @@ describe("role permissions", () => { expect(perms).not.toContain("audit:read"); }); - it("user has basic permissions only", () => { - const perms = getPermissions("user"); + it("user has basic permissions only", async () => { + const perms = await getPermissions("user"); expect(perms).toContain("tools:use"); expect(perms).toContain("files:own"); expect(perms).toContain("apikeys:own"); @@ -51,15 +53,15 @@ describe("role permissions", () => { expect(perms).not.toContain("users:manage"); }); - it("unknown role returns empty permissions", () => { - const perms = getPermissions("bogus" as any); + it("unknown role returns empty permissions", async () => { + const perms = await getPermissions("bogus" as any); expect(perms).toEqual([]); }); - it("hasPermission checks correctly", () => { - expect(hasPermission("admin", "users:manage")).toBe(true); - expect(hasPermission("editor", "users:manage")).toBe(false); - expect(hasPermission("user", "tools:use")).toBe(true); - expect(hasPermission("user", "files:all")).toBe(false); + it("hasPermission checks correctly", async () => { + expect(await hasPermission("admin", "users:manage")).toBe(true); + expect(await hasPermission("editor", "users:manage")).toBe(false); + expect(await hasPermission("user", "tools:use")).toBe(true); + expect(await hasPermission("user", "files:all")).toBe(false); }); }); diff --git a/tests/unit/api/settings-route.test.ts b/tests/unit/api/settings-route.test.ts index e25728f7..dc25f971 100644 --- a/tests/unit/api/settings-route.test.ts +++ b/tests/unit/api/settings-route.test.ts @@ -32,6 +32,8 @@ vi.mock("../../../apps/api/src/db/index.js", () => ({ set: () => ({ where: () => ({ run: mockUpdateRun }) }), }), }, + pool: {}, + closeDb: async () => {}, schema: { settings: { key: {} }, }, diff --git a/tests/unit/api/tool-factory-route.test.ts b/tests/unit/api/tool-factory-route.test.ts index e48391c2..6d68eca2 100644 --- a/tests/unit/api/tool-factory-route.test.ts +++ b/tests/unit/api/tool-factory-route.test.ts @@ -22,6 +22,8 @@ vi.mock("../../../apps/api/src/db/index.js", () => ({ }), insert: () => ({ values: () => ({ run: vi.fn() }) }), }, + pool: {}, + closeDb: async () => {}, schema: { settings: {}, userFiles: { id: {} } }, })); diff --git a/tests/unit/api/tool-factory.test.ts b/tests/unit/api/tool-factory.test.ts index 1da60c38..89b7b906 100644 --- a/tests/unit/api/tool-factory.test.ts +++ b/tests/unit/api/tool-factory.test.ts @@ -7,6 +7,8 @@ vi.mock("../../../apps/api/src/db/index.js", () => ({ }), insert: () => ({ values: () => ({ run: vi.fn() }) }), }, + pool: {}, + closeDb: async () => {}, schema: { settings: {}, userFiles: {} }, })); diff --git a/tests/unit/api/user-files-route.test.ts b/tests/unit/api/user-files-route.test.ts index fde5b665..d0e02fcb 100644 --- a/tests/unit/api/user-files-route.test.ts +++ b/tests/unit/api/user-files-route.test.ts @@ -19,12 +19,11 @@ vi.mock("../../../apps/api/src/db/index.js", () => ({ insert: () => ({ values: () => ({ run: vi.fn() }) }), delete: () => ({ where: () => ({ run: vi.fn() }) }), }, + pool: {}, + closeDb: async () => {}, schema: { userFiles: { id: {}, userId: {}, parentId: {}, createdAt: {}, originalName: {} }, }, - sqlite: { - prepare: () => ({ all: () => [] }), - }, })); // ── Reproduce helper functions from user-files.ts ────────────────────── diff --git a/tests/unit/api/utilities.test.ts b/tests/unit/api/utilities.test.ts index 9fc713af..9ef23558 100644 --- a/tests/unit/api/utilities.test.ts +++ b/tests/unit/api/utilities.test.ts @@ -870,7 +870,7 @@ describe("loadEnv", () => { "MAX_MEGAPIXELS", "RATE_LIMIT_PER_MIN", "SKIP_MUST_CHANGE_PASSWORD", - "DB_PATH", + "DATABASE_URL", "WORKSPACE_PATH", "DEFAULT_THEME", "DEFAULT_LOCALE", @@ -912,7 +912,7 @@ describe("loadEnv", () => { expect(typeof env.MAX_MEGAPIXELS).toBe("number"); expect(typeof env.RATE_LIMIT_PER_MIN).toBe("number"); expect(typeof env.SKIP_MUST_CHANGE_PASSWORD).toBe("boolean"); - expect(typeof env.DB_PATH).toBe("string"); + expect(typeof env.DATABASE_URL).toBe("string"); expect(typeof env.WORKSPACE_PATH).toBe("string"); expect(["light", "dark"]).toContain(env.DEFAULT_THEME); expect(typeof env.DEFAULT_LOCALE).toBe("string"); @@ -1008,11 +1008,11 @@ describe("loadEnv", () => { }); it("accepts string values for string fields", async () => { - process.env.DB_PATH = "/var/data/mydb.sqlite"; + process.env.DATABASE_URL = "postgres://user:pass@localhost:5432/testdb"; process.env.DEFAULT_LOCALE = "fr"; const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); const env = loadEnv(); - expect(env.DB_PATH).toBe("/var/data/mydb.sqlite"); + expect(env.DATABASE_URL).toBe("postgres://user:pass@localhost:5432/testdb"); expect(env.DEFAULT_LOCALE).toBe("fr"); }); @@ -1059,6 +1059,8 @@ describe("loadEnv", () => { vi.mock("../../../apps/api/src/db/index.js", () => ({ db: {}, + pool: {}, + closeDb: async () => {}, schema: { users: {}, sessions: {} }, })); diff --git a/tests/unit/security-auth-hardening.test.ts b/tests/unit/security-auth-hardening.test.ts index 49be093a..4d232e62 100644 --- a/tests/unit/security-auth-hardening.test.ts +++ b/tests/unit/security-auth-hardening.test.ts @@ -10,6 +10,8 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("../../apps/api/src/db/index.js", () => ({ db: {}, + pool: {}, + closeDb: async () => {}, schema: {}, })); diff --git a/vitest.config.ts b/vitest.config.ts index a48f655a..b75747e6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ maxForks: process.env.CI ? 4 : Math.max(2, Math.floor(os.availableParallelism() / 2)), }, }, + globalSetup: ["tests/global-setup.ts"], setupFiles: ["tests/setup/per-fork-env.ts"], exclude: [ "tests/e2e/**", @@ -45,7 +46,7 @@ export default defineConfig({ AUTH_ENABLED: "true", DEFAULT_USERNAME: "admin", DEFAULT_PASSWORD: "Adminpass1", - // DB_PATH and WORKSPACE_PATH are set per-fork in tests/setup/per-fork-env.ts + // DATABASE_URL and WORKSPACE_PATH are set per-fork in tests/setup/per-fork-env.ts MAX_UPLOAD_SIZE_MB: "10", MAX_BATCH_SIZE: "10", RATE_LIMIT_PER_MIN: "10000", @@ -93,6 +94,7 @@ export default defineConfig({ "@/components/navbar": path.resolve(__dirname, "apps/landing/src/components/navbar"), "@": path.resolve(__dirname, "apps/web/src"), "framer-motion": path.join(landingNodeModules, "framer-motion"), + "@snapotter/enterprise": path.resolve(__dirname, "packages/enterprise/src/index.ts"), "@snapotter/image-engine": path.resolve(__dirname, "packages/image-engine/src/index.ts"), "@snapotter/shared/i18n": path.resolve(__dirname, "packages/shared/src/i18n"), "@snapotter/shared": path.resolve(__dirname, "packages/shared/src/index.ts"), @@ -105,6 +107,7 @@ export default defineConfig({ "@fastify/swagger": path.join(apiNodeModules, "@fastify/swagger"), "@fastify/swagger-ui": path.join(apiNodeModules, "@fastify/swagger-ui"), "better-sqlite3": path.join(apiNodeModules, "better-sqlite3"), + pg: path.join(apiNodeModules, "pg"), "drizzle-orm": path.join(apiNodeModules, "drizzle-orm"), archiver: path.join(apiNodeModules, "archiver"), "p-queue": path.join(apiNodeModules, "p-queue"), @@ -118,7 +121,10 @@ export default defineConfig({ "opentype.js": path.join(apiNodeModules, "opentype.js"), "posthog-node": path.join(apiNodeModules, "posthog-node"), "@sentry/node": path.join(apiNodeModules, "@sentry/node"), - "@aws-sdk/client-s3": path.join(apiNodeModules, "@aws-sdk/client-s3"), + "@aws-sdk/client-s3": path.join( + path.resolve(__dirname, "packages/enterprise/node_modules"), + "@aws-sdk/client-s3", + ), react: path.join(webNodeModules, "react"), "react-dom": path.join(webNodeModules, "react-dom"), "react-router-dom": path.join(webNodeModules, "react-router-dom"),