mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Second pass over the suite, driven by the inventory in the gaps document. Nine agents wrote suites in parallel against private databases, then a tenth read all of it adversarially and five of its findings were fixed. unit + component 2076 -> 2079 (+888 over the round) api 647 -> 1015 e2e 18 -> 29 What was closed: - lib/route-access.ts, the page-level authorization layer, went from zero tests to 48. Every API route was guarded and none of the pages were. - The five media proxy routes now have a real 2xx beside every 403. The blocker was the positive control, solved by stubbing r2Client.send() and leaving lib/r2-media-proxy.ts itself real. - Every remaining server-side lib module: invitations, email verification, the upload tokens, the logger, request origin, the whole R2 and Bunny lifecycle, notifications and admin stats. - Six video-page hooks, and the chunking arithmetic extracted out of lib/client/r2-video-upload.ts as a pure module. - Five end-to-end flows: workspace members, bulk operations, the admin area, player interaction and failure recovery. Three things about the harness itself turned out to be wrong: - Two @/lib/r2 stubs in tests/setup/api.ts had the wrong return shape, so every route reaching finalizeR2VideoUpload silently took the "not a valid video" branch and no test noticed. - The auth matrix asserted only "not 2xx", which two entries satisfied without their guard existing. It now requires 401 or 403, which makes both load-bearing, and all 60 routes pass the stricter form. - Both admin API routes had no positive control anywhere: replacing their guard with an unconditional refusal left the entire suite green. Found by the adversarial review, now covered. Process: - bun run test:mutation runs StrykerJS over the authorization and validation modules. Diagnostic, not a gate, weekly in CI rather than on a push. - playwright.config.ts gains an opt-in webkit project for the player spec. - AGENTS.md now requires a batch of new tests to be reviewed by somebody who did not write them. Only two production files change, both deliberate: lib/auth.ts loses a verbatim copy of its own permission formulas, and lib/client/r2-video-upload.ts calls the extracted arithmetic. No behaviour change in either.
238 lines
10 KiB
YAML
238 lines
10 KiB
YAML
name: CI
|
|
|
|
on:
|
|
push:
|
|
pull_request:
|
|
# For the `mutation` job below, which is too slow to run on a push. Everything
|
|
# else runs on the schedule too, which costs nothing and catches the class of
|
|
# breakage that comes from a dependency rather than from a commit.
|
|
schedule:
|
|
- cron: '0 4 * * 1'
|
|
workflow_dispatch:
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
check:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: oven-sh/setup-bun@v2
|
|
- run: bun install
|
|
- run: bun run check
|
|
|
|
test:
|
|
runs-on: ubuntu-latest
|
|
services:
|
|
# Postgres for the `api` Vitest project. This job runs directly on the
|
|
# runner, so the service is reachable on localhost through the published
|
|
# port, not by service name.
|
|
postgres:
|
|
image: postgres:16-alpine
|
|
env:
|
|
POSTGRES_USER: openframe
|
|
POSTGRES_PASSWORD: openframe
|
|
POSTGRES_DB: openframe_test
|
|
ports:
|
|
- 5432:5432
|
|
options: >-
|
|
--health-cmd "pg_isready -U openframe -d openframe_test"
|
|
--health-interval 2s
|
|
--health-timeout 3s
|
|
--health-retries 30
|
|
env:
|
|
DATABASE_URL: postgresql://openframe:openframe@localhost:5432/openframe_test?schema=public
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: oven-sh/setup-bun@v2
|
|
- run: bun install
|
|
- name: Create .env.test from the committed example
|
|
# The `api` project's setup files read `.env.test`, which is gitignored.
|
|
# The example carries every value the suites need; only the database host
|
|
# differs, because locally it is the compose service and here it is a
|
|
# service container. The appended line wins inside the file and the
|
|
# exported job env wins over the file, so the override holds either way.
|
|
run: |
|
|
cp .env.test.example .env.test
|
|
printf '\nDATABASE_URL=%s\n' "$DATABASE_URL" >> .env.test
|
|
# No migration step here on purpose. The api project's globalSetup
|
|
# (tests/setup/db-global.ts) builds the schema itself, and it cannot use
|
|
# `prisma migrate deploy`: prisma/migrations is a stack of patches on top
|
|
# of a baseline that was never captured, so the second migration alters an
|
|
# enum that nothing in the history creates. That file explains it in full.
|
|
- name: Unit and component tests
|
|
run: bun run test
|
|
- name: API integration tests
|
|
run: bun run test:api
|
|
- name: Coverage report
|
|
# Diagnostic only. There is no coverage threshold gate on purpose, see
|
|
# TESTING.md section 11.
|
|
#
|
|
# Run under node rather than `bun run test:coverage`: @vitest/coverage-v8
|
|
# needs the V8 inspector API, which bun does not implement, so under bun
|
|
# every file reports "Coverage APIs are not supported" and the numbers
|
|
# come out as zero. The suite itself passes under both runtimes.
|
|
run: node node_modules/vitest/vitest.mjs run --project unit --coverage
|
|
- name: Upload coverage report
|
|
if: always()
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: coverage
|
|
path: coverage/
|
|
if-no-files-found: ignore
|
|
retention-days: 7
|
|
|
|
mutation:
|
|
# Mutation testing on the authorization and validation surface. Not a gate:
|
|
# it reports, it never fails the build (`break: null` in stryker.config.json),
|
|
# for the same reason there is no coverage threshold. See TESTING.md
|
|
# section 11.
|
|
#
|
|
# Weekly and on demand only. A full run is minutes rather than seconds,
|
|
# because Stryker reruns the suite once per mutant, and nobody waits that
|
|
# long on a pull request. The findings it produces are not the kind that
|
|
# need catching within the hour: it finds tests that cannot fail, which is a
|
|
# slow leak rather than a regression.
|
|
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
# node, not bun: Stryker's instrumenter and its vitest runner both expect
|
|
# a node runtime, and @stryker-mutator/core declares `engines.node >= 20`.
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: 22
|
|
- uses: oven-sh/setup-bun@v2
|
|
# bun for the install (it owns bun.lock), node for the run.
|
|
- run: bun install
|
|
- name: Mutation testing
|
|
run: node node_modules/@stryker-mutator/core/bin/stryker.js run
|
|
- name: Upload the mutation report
|
|
if: always()
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: mutation-report
|
|
path: reports/mutation/
|
|
if-no-files-found: ignore
|
|
retention-days: 30
|
|
|
|
e2e:
|
|
runs-on: ubuntu-latest
|
|
needs: [check]
|
|
# Runs directly on the runner rather than in the Playwright container image.
|
|
# Two reasons, both learned the hard way. MinIO cannot be a `services:` entry
|
|
# (see the step that starts it below), and starting it as a step needs a
|
|
# docker CLI, which a container job does not have. And the Playwright image
|
|
# ships neither bun nor unzip, so bun had to be installed through npm and the
|
|
# image tag had to be kept in lockstep with the npm package. Installing the
|
|
# browser here costs about a minute and removes all of that.
|
|
services:
|
|
postgres:
|
|
image: postgres:16-alpine
|
|
env:
|
|
POSTGRES_USER: openframe
|
|
POSTGRES_PASSWORD: openframe
|
|
POSTGRES_DB: openframe_test
|
|
ports:
|
|
- 5432:5432
|
|
options: >-
|
|
--health-cmd "pg_isready -U openframe -d openframe_test"
|
|
--health-interval 2s
|
|
--health-timeout 3s
|
|
--health-retries 30
|
|
env:
|
|
DATABASE_URL: postgresql://openframe:openframe@localhost:5432/openframe_test?schema=public
|
|
# The Playwright web server builds and starts the app, and `next build`
|
|
# does not run with NODE_ENV=test, so it never picks up `.env.test`. These
|
|
# values are therefore set on the job itself. Port 3100 matches
|
|
# playwright.config.ts.
|
|
NEXTAUTH_URL: http://localhost:3100
|
|
NEXTAUTH_SECRET: ci-secret-not-used-for-anything-real
|
|
NEXT_PUBLIC_APP_URL: http://localhost:3100
|
|
# Required. NextAuth v5 answers every /api/auth/* request with
|
|
# `UntrustedHost` in a production build unless the host is trusted, which
|
|
# is why .env.docker.example sets the same variable for real deployments.
|
|
AUTH_TRUST_HOST: 'true'
|
|
# Stripe ON, with dummy credentials, and deliberately not 'false'.
|
|
# hasBillingAccess() short-circuits to `true` when the flag is off and
|
|
# buildBillingAccessWhereInput() returns `{}`, which disarms the whole
|
|
# billing gate: billing-gate.spec.ts would then assert nothing. No spec
|
|
# walks into checkout, so nothing reaches Stripe. This also keeps the
|
|
# e2e job consistent with .env.test, which the api suite already runs
|
|
# with the flag on for the same reason.
|
|
OPENFRAME_ENABLE_STRIPE: 'true'
|
|
STRIPE_SECRET_KEY: sk_test_openframe_dummy
|
|
STRIPE_PRICE_ID: price_test_openframe_dummy
|
|
STRIPE_WEBHOOK_SECRET: whsec_test_openframe_dummy
|
|
OPENFRAME_REQUIRE_INVITE_CODE: 'true'
|
|
INVITE_CODE: test-invite
|
|
TRUSTED_PROXY_MODE: none
|
|
# Direct video uploads, pointed at the MinIO container started below.
|
|
# Without these the `Direct Upload` tab is not rendered and
|
|
# video-upload.spec.ts fails on its first assertion rather than silently
|
|
# testing nothing. The browser PUTs the file straight at the presigned URL,
|
|
# so the app and the browser have to agree on this host, and both run on
|
|
# the runner.
|
|
OPENFRAME_ENABLE_S3_VIDEO_UPLOADS: 'true'
|
|
OPENFRAME_ENABLE_BUNNY_UPLOADS: 'false'
|
|
R2_ENDPOINT: http://localhost:9000
|
|
R2_ACCESS_KEY_ID: openframe
|
|
R2_SECRET_ACCESS_KEY: openframe-test-secret
|
|
R2_BUCKET_NAME: openframe-test
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: oven-sh/setup-bun@v2
|
|
- run: bun install
|
|
- name: Create .env.test from the committed example
|
|
run: |
|
|
cp .env.test.example .env.test
|
|
printf '\nDATABASE_URL=%s\n' "$DATABASE_URL" >> .env.test
|
|
- name: Start MinIO
|
|
# Not a `services:` entry, because a service block cannot pass a command
|
|
# to the container and the MinIO entrypoint requires `server /data`.
|
|
# Without arguments the container prints its usage text, exits, and the
|
|
# job dies at "Failed to initialize container minio/minio:latest".
|
|
run: |
|
|
docker run -d --name minio -p 9000:9000 \
|
|
-e MINIO_ROOT_USER="$R2_ACCESS_KEY_ID" \
|
|
-e MINIO_ROOT_PASSWORD="$R2_SECRET_ACCESS_KEY" \
|
|
-e MINIO_REGION_NAME=auto \
|
|
minio/minio:latest server /data
|
|
for _ in $(seq 1 60); do
|
|
if curl -sf http://localhost:9000/minio/health/live >/dev/null; then
|
|
echo 'minio is ready'
|
|
exit 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
echo 'minio did not become ready within 60 seconds' >&2
|
|
docker logs minio >&2
|
|
exit 1
|
|
- name: Create the MinIO bucket
|
|
# Nothing at runtime creates it: ensureR2BucketExists() lives in
|
|
# scripts/self-host-bootstrap.ts, not on the request path, so a missing
|
|
# bucket would surface as a presigned PUT returning NoSuchBucket.
|
|
run: |
|
|
curl -sSfL -o "$RUNNER_TEMP/mc" https://dl.min.io/client/mc/release/linux-amd64/mc
|
|
chmod +x "$RUNNER_TEMP/mc"
|
|
"$RUNNER_TEMP/mc" alias set ciminio "$R2_ENDPOINT" "$R2_ACCESS_KEY_ID" "$R2_SECRET_ACCESS_KEY"
|
|
"$RUNNER_TEMP/mc" mb --ignore-existing "ciminio/$R2_BUCKET_NAME"
|
|
- name: Install the Playwright browser
|
|
# Only chromium: playwright.config.ts runs a desktop Chromium project and
|
|
# a Pixel 7 project, and the mobile one is Chromium too.
|
|
run: bunx playwright install --with-deps chromium
|
|
# No `bun run test:db:bootstrap` step: tests/e2e/global-setup.ts calls the
|
|
# same setup function before the web server starts, and also clears the
|
|
# rate_limits table so a retry does not inherit a spent window.
|
|
- name: End-to-end tests
|
|
run: bun run test:e2e
|
|
- name: Upload the Playwright report
|
|
if: failure()
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: playwright-report
|
|
path: playwright-report/
|
|
if-no-files-found: ignore
|
|
retention-days: 7
|