Files
upstream-tldraw/apps/dotcom/zero-cache/delete_file_states.test.ts
upstream-mirror a774ac673a
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

257 lines
11 KiB
TypeScript

import { readFileSync, readdirSync } from 'fs'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
import pg from 'pg'
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
// Focused integration test for the `delete_file_states` trigger that cleans up
// guest file_state rows and guest home-group file links (group_file rows) when a
// file is unshared (shared: true -> false).
//
// Visiting a shared file links it into the visitor's home group via a group_file
// row (home group id == user id) — that link is what puts the file in their recent
// files, so unshare has to remove it as well as the file_state, or the file name
// lingers in an ex-guest's recent files after access is revoked.
//
// This talks to a real postgres (the trigger is plpgsql, so fakes can't exercise
// it). It is opt-in: set ZERO_CACHE_TEST_POSTGRES_URL (local dev stack:
// postgres://user:password@localhost:6543/postgres) to run it. Without a
// connection string the suite is skipped so CI stays green.
//
// Safety: the suite isolates itself in a throwaway schema, and it must stay safe
// even if the URL points at a shared database through a transaction-mode pooler
// (pgbouncer on 6432, Neon pooler). Transaction pooling can hand each statement
// to a different backend session, so session-level `SET search_path` cannot be
// trusted to stick — and unqualified DDL like `DROP TABLE ... CASCADE` would then
// run against `public`. So no statement here relies on session state: everything
// the test owns is schema-qualified explicitly, and the statements that cannot be
// qualified (the shipped function definition, executed verbatim, and the UPDATEs whose
// trigger body resolves table names at execution time) run inside a transaction
// with `SET LOCAL search_path` — a transaction is exactly the unit a
// transaction-mode pooler pins to a single backend, and SET LOCAL expires with it.
const DIRNAME = dirname(fileURLToPath(import.meta.url))
const CONNECTION_STRING = process.env.ZERO_CACHE_TEST_POSTGRES_URL
// The real, shipped function body, sliced out of whichever migration last defined it so the test
// exercises production's function rather than a hand-copied duplicate. Found by scanning rather
// than by filename: a hard-coded 034 kept this suite green against a body production no longer
// ran once 050 redefined the function.
function loadShippedFunction(name: string): string {
const dir = join(DIRNAME, 'migrations')
const header = new RegExp(`CREATE OR REPLACE FUNCTION (public\\.)?${name}\\(\\)`)
const migrationFile = readdirSync(dir)
.filter((f) => f.endsWith('.sql'))
.sort()
.filter((f) => header.test(readFileSync(join(dir, f), 'utf8')))
.at(-1)
if (!migrationFile) {
throw new Error(`no migration defines ${name}()`)
}
const migration = readFileSync(join(dir, migrationFile), 'utf8')
const start = migration.search(header)
// The body is dollar-quoted with whatever tag the migration chose ($$ early on, $function$
// in 050), and the statement ends at the first semicolon after the closing tag.
const tag = /AS\s+(\$[A-Za-z_]*\$)/.exec(migration.slice(start))
if (!tag) {
throw new Error(`could not find the body of ${name}() in ${migrationFile}`)
}
const close = migration.indexOf(tag[1], start + tag.index + tag[0].length)
const end = close === -1 ? -1 : migration.indexOf(';', close + tag[1].length)
if (end === -1) {
throw new Error(`could not find the end of ${name}() in ${migrationFile}`)
}
// Drop the `public.` qualifier: the migration targets public, while everything here lives
// in a throwaway schema pinned with SET LOCAL search_path. Left in place it would
// CREATE OR REPLACE the *production* function whenever this test runs against a shared
// database — the one way this suite could reach outside its own schema.
const sql = migration
.slice(start, end + 1)
.replace(header, `CREATE OR REPLACE FUNCTION ${name}()`)
if (sql.includes('public.')) {
throw new Error(`refusing to run ${name}(): a public. reference survived unqualifying`)
}
return sql
}
const DELETE_FILE_STATES_SQL = loadShippedFunction('delete_file_states')
const schemaName = `tldraw_test_${process.pid}`
// Minimal schema covering only the tables the trigger reads or writes, plus the
// trigger binding. Recreated before each test (the trigger references the function,
// which we (re)create first). Dropping `file` CASCADE also drops the trigger.
// Fully qualified so it cannot touch `public` no matter what session it runs on.
const SCHEMA_SQL = `
DROP TABLE IF EXISTS "${schemaName}"."file_state", "${schemaName}"."group_file", "${schemaName}"."file", "${schemaName}"."group_user", "${schemaName}"."group" CASCADE;
CREATE TABLE "${schemaName}"."group" (
"id" TEXT PRIMARY KEY
);
CREATE TABLE "${schemaName}"."group_user" (
"userId" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
PRIMARY KEY ("userId", "groupId")
);
CREATE TABLE "${schemaName}"."file" (
"id" TEXT PRIMARY KEY,
-- NOT NULL mirrors production: since 050 every file belongs to a workspace
"owningGroupId" TEXT NOT NULL,
"shared" BOOLEAN NOT NULL
);
CREATE TABLE "${schemaName}"."file_state" (
"userId" TEXT NOT NULL,
"fileId" TEXT NOT NULL,
PRIMARY KEY ("userId", "fileId")
);
CREATE TABLE "${schemaName}"."group_file" (
"fileId" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
PRIMARY KEY ("fileId", "groupId")
);
CREATE TRIGGER file_shared_update
AFTER UPDATE OF shared ON "${schemaName}"."file"
FOR EACH ROW
EXECUTE FUNCTION "${schemaName}".delete_file_states();
`
const describeMaybe = CONNECTION_STRING ? describe : describe.skip
describeMaybe('delete_file_states trigger (unshare cleanup)', () => {
// A single Client, not a Pool. Two reasons: a Pool reaps idle clients (10s
// default) and transparently replaces them, silently resetting session state;
// and inTestSchema's BEGIN/COMMIT must run on one client, while pool.query may
// use a different client per call.
let client: pg.Client
// Runs statements inside one transaction with search_path pinned to the test
// schema. This is for SQL we execute verbatim (the shipped function definition)
// and for statements whose trigger body resolves unqualified table names at
// execution time. SET LOCAL scopes the setting to the transaction, so it works
// through transaction-mode poolers and cannot leak to or from other sessions.
async function inTestSchema(...statements: string[]) {
await client.query('BEGIN')
try {
await client.query(`SET LOCAL search_path TO "${schemaName}"`)
for (const sql of statements) {
await client.query(sql)
}
await client.query('COMMIT')
} catch (err) {
await client.query('ROLLBACK')
throw err
}
}
beforeAll(async () => {
client = new pg.Client({ connectionString: CONNECTION_STRING })
await client.connect()
await client.query(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`)
await client.query(`CREATE SCHEMA "${schemaName}"`)
// sanity-check that SET LOCAL pins the schema for a whole transaction on
// this connection before any unqualified SQL runs
await client.query('BEGIN')
await client.query(`SET LOCAL search_path TO "${schemaName}"`)
const res = await client.query<{ schema: string }>('SELECT current_schema() AS schema')
await client.query('COMMIT')
if (res.rows[0]?.schema !== schemaName) {
throw new Error(
`SET LOCAL search_path did not hold within a transaction (current_schema() is ` +
`"${res.rows[0]?.schema}", expected "${schemaName}"). Refusing to run unqualified ` +
`DDL against this connection.`
)
}
})
afterAll(async () => {
if (!client) return
await client.query(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`)
await client.end()
})
async function seed() {
// group g1 with two members; guest is NOT a member
await client.query(`INSERT INTO "${schemaName}"."group" ("id") VALUES ('g1')`)
await client.query(
`INSERT INTO "${schemaName}"."group_user" ("userId", "groupId") VALUES ('uOwner', 'g1'), ('uMember', 'g1')`
)
await client.query(
`INSERT INTO "${schemaName}"."file" ("id", "owningGroupId", "shared") VALUES ('fGroup', 'g1', true)`
)
// a shared file we will NOT unshare, as a control
await client.query(
`INSERT INTO "${schemaName}"."file" ("id", "owningGroupId", "shared") VALUES ('fControl', 'g1', true)`
)
// everyone has a file_state on every file
for (const fileId of ['fGroup', 'fControl']) {
await client.query(
`INSERT INTO "${schemaName}"."file_state" ("userId", "fileId") VALUES ('uOwner', $1), ('uMember', $1), ('uGuest', $1)`,
[fileId]
)
}
// group_file rows: the owning group's own row for fGroup, plus home-group
// file links (home group id == user id) created by visiting a shared file
await client.query(
`INSERT INTO "${schemaName}"."group_file" ("fileId", "groupId") VALUES
('fGroup', 'g1'),
('fGroup', 'uMember'),
('fGroup', 'uGuest'),
('fControl', 'g1'),
('fControl', 'uGuest')`
)
}
// Unsharing fires the trigger, whose body reads `file_state` and `group_user`
// unqualified — resolved via search_path at execution time — so the UPDATE
// must run with the test schema pinned.
async function unshare(fileId: string) {
await inTestSchema(
`UPDATE "${schemaName}"."file" SET "shared" = false WHERE "id" = '${fileId}'`
)
}
async function statesFor(fileId: string): Promise<string[]> {
const res = await client.query<{ userId: string }>(
`SELECT "userId" FROM "${schemaName}"."file_state" WHERE "fileId" = $1 ORDER BY "userId"`,
[fileId]
)
return res.rows.map((r) => r.userId)
}
async function linksFor(fileId: string): Promise<string[]> {
const res = await client.query<{ groupId: string }>(
`SELECT "groupId" FROM "${schemaName}"."group_file" WHERE "fileId" = $1 ORDER BY "groupId"`,
[fileId]
)
return res.rows.map((r) => r.groupId)
}
beforeEach(async () => {
await inTestSchema(DELETE_FILE_STATES_SQL)
await client.query(SCHEMA_SQL)
await seed()
})
it('removes the guest state but keeps group members when a group-owned file is unshared', async () => {
await unshare('fGroup')
// owner + member of the owning group keep access; guest is cleaned up
expect(await statesFor('fGroup')).toEqual(['uMember', 'uOwner'])
})
it('removes the guest file link but keeps the owning group row and member links', async () => {
await unshare('fGroup')
// the file still lives in its owning group, and the member keeps their
// home-group link (they retain access); the guest's link is cleaned up
// so the file stops showing in their recent files
expect(await linksFor('fGroup')).toEqual(['g1', 'uMember'])
})
it('leaves still-shared files untouched', async () => {
await unshare('fGroup')
expect(await statesFor('fControl')).toEqual(['uGuest', 'uMember', 'uOwner'])
expect(await linksFor('fControl')).toEqual(['g1', 'uGuest'])
})
})