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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,46 @@ jobs:
DATABASE_URL: postgres://unused
run: ./node_modules/.bin/drizzle-kit check

integration:
name: Integration tests
runs-on: ubuntu-latest
timeout-minutes: 25
# A real PostgreSQL, because what these cover is the routes against the schema, not pure logic.
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: shhh_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Enable corepack
run: corepack enable

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24

- name: Install
run: pnpm install --frozen-lockfile

# Turnstile runs for real, on Cloudflare's always-passes test keys, so this step needs egress.
- name: Integration tests
env:
TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/shhh_test
run: pnpm test:integration

docker-app:
name: Docker image (app)
runs-on: ubuntu-latest
Expand Down
14 changes: 14 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ pnpm test
Schema changes need a migration committed alongside them: `pnpm db:generate`. CI fails if the
migrations and the schema disagree.

## Integration tests

`pnpm test:integration` builds the app, boots it and drives the routes over HTTP.
It needs a database of its own: the schema is dropped on every run, which is why it reads `TEST_DATABASE_URL` rather than `DATABASE_URL`.

```bash
createdb shhh_test
TEST_DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/shhh_test pnpm test:integration
```

Turnstile is exercised for real, on Cloudflare's always-passes test keys, so the run needs network access.
`TEST_SKIP_BUILD=true` reuses the last `.output` while you iterate on the tests themselves.
[ROADMAP.md](ROADMAP.md) lists the suites still to write.

By taking part, you agree to the [Code of Conduct](CODE_OF_CONDUCT.md).

## Commit messages
Expand Down
26 changes: 12 additions & 14 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,30 +15,28 @@ Anything not listed here is either done or deliberately out of scope (see the bo
The mail templates have no header image, which is deliberate: an image in an email means either an
external URL that tells the sender the message was opened, or a base64 payload in every message.

- **Integration tests.** Unit tests cover the pure logic (see below), but nothing exercises the
routes — and that gap has already shipped a broken release. v1.1.0 and v1.1.1 could not complete
their setup wizard at all: bumping Better Auth to 1.7 needed a new `issuer` column on `accounts`,
and nothing noticed, because lint, types, 86 unit tests, both builds and both Docker images all
pass without a single request ever reaching `signUpEmail`. The paths where a silent regression
would cost most, in order:
1. **Sign-up and the setup wizard** — the one that was actually broken. A fresh database, the
wizard completed, a super admin created. It would have caught the schema drift on the commit
that introduced it rather than on a self-hoster's first boot.
- **Integration tests.** The harness exists — `pnpm test:integration`, see
[CONTRIBUTING.md](CONTRIBUTING.md) — and two of the seven suites are written. What it cost to get
here: v1.1.0 and v1.1.1 could not complete their setup wizard at all, because bumping Better Auth
to 1.7 needed a new `issuer` column on `accounts` and nothing noticed. Lint, types, 86 unit tests,
both builds and both Docker images all pass without a single request ever reaching `signUpEmail`.
The remaining paths, in the order a silent regression would cost most:
1. ~~**Sign-up and the setup wizard**~~ — done, `tests/integration/setup-wizard.test.ts`.
2. **The read counter under concurrency** — fire many simultaneous reveals at a one-read paste and
assert exactly one succeeds. This is the test that guards the atomic
`UPDATE … WHERE read_count < max_reads RETURNING`, and now also that a caller without the
unlock hash moves nothing.
3. **The permission matrix** — the table in the security docs, case by case.
3. ~~**The permission matrix**~~done, `tests/integration/permissions.test.ts`.
4. **Invitations** — single use including two concurrent accepts, address fixed by the invitation
rather than the request body, role always `user`, registration bypass.
5. **Anonymous restrictions** — no uploads, no server-side sharing, `public_paste_enabled` off.
6. **Settings semantics** — an absent row means the default, a row holding `null` means unlimited.
7. **Account deletion** — full cascade, audit log anonymised rather than deleted.

Needs Vitest with `@nuxt/test-utils` and a real PostgreSQL (a service container in CI), with
tables truncated between tests. Turnstile is bypassed with Cloudflare's always-valid test keys.
Assert on `paste_email_recipients` rows rather than on delivered mail, to avoid depending on a
mail server in CI.
Invitations and account deletion need a mail provider, which the harness deliberately runs without:
sign-up would then wait on a verification click. They need a second server configuration, or a
provider stub. Assert on `paste_email_recipients` rows rather than on delivered mail, to avoid
depending on a mail server in CI.

## Waiting on upstream

Expand Down
2 changes: 2 additions & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"db:studio": "node ../../scripts/drizzle-kit.mjs studio",
"typecheck": "nuxt typecheck",
"test": "vitest run",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:watch": "vitest",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
Expand All @@ -40,6 +41,7 @@
},
"devDependencies": {
"@nuxt/eslint": "1.17.0",
"@nuxt/test-utils": "^4.2.0",
"@types/nodemailer": "^8.0.1",
"@types/qrcode": "^1.5.6",
"drizzle-kit": "^0.31.10",
Expand Down
15 changes: 6 additions & 9 deletions apps/app/server/api/setup/complete.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,12 @@ const SETUP_LOCK_KEY = 4_827_302
export default defineEventHandler(async (event) => {
const body = await readValidatedBody(event, setupSchema.parse)

// The check and the write have to be one critical section. Without it two requests arriving
// together both see an empty instance and both create a super_admin — the account that owns
// everything, so "unlikely" is not a good enough guarantee.
await db.execute(sql`select pg_advisory_lock(${SETUP_LOCK_KEY})`)
try {
return await completeSetup(event, body)
} finally {
await db.execute(sql`select pg_advisory_unlock(${SETUP_LOCK_KEY})`)
}
// The check and the write have to be one critical section, or two requests both create a super_admin.
// Transaction-scoped: a session lock and its unlock are two pool checkouts, and land on different connections.
return await db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(${SETUP_LOCK_KEY})`)
return completeSetup(event, body)
})
})

async function completeSetup(event: Parameters<typeof appendResponseHeader>[0], body: z.infer<typeof setupSchema>) {
Comment thread
thoda-dev marked this conversation as resolved.
Expand Down
11 changes: 5 additions & 6 deletions apps/app/server/plugins/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,16 @@ export default defineNitroPlugin(async () => {
const migrationsFolder = process.env.MIGRATIONS_DIR ?? './migrations'

// Arbitrary but fixed key, so two containers starting at once can't both apply the same migration.
// Session-scoped: a crashed process releases it when its connection drops rather than deadlocking every later boot.
const LOCK_KEY = 4_827_301

try {
await db.execute(sql`select pg_advisory_lock(${LOCK_KEY})`)
try {
// Transaction-scoped: a session lock and its unlock are two pool checkouts, and land on different connections.
await db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(${LOCK_KEY})`)
// `db`, not `tx`: the migrator opens a transaction of its own on whatever session it is handed.
await migrate(db, { migrationsFolder })
console.log('[migrate] database is up to date')
Comment thread
thoda-dev marked this conversation as resolved.
} finally {
await db.execute(sql`select pg_advisory_unlock(${LOCK_KEY})`)
}
})
} catch (error) {
// Hard stop: serving against a schema the code doesn't expect corrupts data far more quietly than refusing to start.
console.error('[migrate] migration failed:', error instanceof Error ? error.message : error)
Expand Down
Loading