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
257 lines
11 KiB
TypeScript
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'])
|
|
})
|
|
})
|