139 lines
6.4 KiB
TypeScript
139 lines
6.4 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
const migrationPath = path.resolve(
|
|
currentDir,
|
|
'../../src/infra/database/migrations/036_storage-third-party-auth-support.sql'
|
|
);
|
|
|
|
describe('036_storage-third-party-auth-support migration', () => {
|
|
const sql = fs.readFileSync(migrationPath, 'utf8');
|
|
|
|
it('migration file exists', () => {
|
|
expect(fs.existsSync(migrationPath)).toBe(true);
|
|
});
|
|
|
|
// ── FK drop is idempotent ────────────────────────────────────────────
|
|
it('drops the uploaded_by FK with IF EXISTS', () => {
|
|
expect(sql).toMatch(
|
|
/ALTER TABLE storage\.objects\s+DROP CONSTRAINT IF EXISTS objects_uploaded_by_fkey/i
|
|
);
|
|
});
|
|
|
|
// ── Column widen is idempotent ───────────────────────────────────────
|
|
it('guards ALTER COLUMN uploaded_by TYPE TEXT behind information_schema check', () => {
|
|
// Once policies reference uploaded_by, Postgres rejects ALTER TYPE.
|
|
expect(sql).toMatch(
|
|
/information_schema\.columns[\s\S]*?column_name\s*=\s*'uploaded_by'[\s\S]*?<>\s*'text'[\s\S]*?ALTER COLUMN uploaded_by TYPE TEXT/i
|
|
);
|
|
});
|
|
|
|
it('does NOT have a bare ALTER COLUMN uploaded_by TYPE TEXT outside a DO block', () => {
|
|
// Strip DO $tag$ ... $tag$ blocks (matched dollar-quoted tags so we
|
|
// don't stop at nested $sql$ ... $sql$ inside).
|
|
const outsideDoBlocks = sql.replace(/DO\s*\$(\w+)\$[\s\S]*?\$\1\$/g, '');
|
|
expect(outsideDoBlocks).not.toMatch(/ALTER COLUMN uploaded_by TYPE TEXT/i);
|
|
});
|
|
|
|
// ── Path helpers use CREATE OR REPLACE ───────────────────────────────
|
|
it('defines storage.foldername with CREATE OR REPLACE (replay-safe)', () => {
|
|
expect(sql).toMatch(/CREATE OR REPLACE FUNCTION storage\.foldername/i);
|
|
});
|
|
|
|
it('defines storage.filename with CREATE OR REPLACE', () => {
|
|
expect(sql).toMatch(/CREATE OR REPLACE FUNCTION storage\.filename/i);
|
|
});
|
|
|
|
it('defines storage.extension with CREATE OR REPLACE', () => {
|
|
expect(sql).toMatch(/CREATE OR REPLACE FUNCTION storage\.extension/i);
|
|
});
|
|
|
|
// ── auth.jwt() helper ────────────────────────────────────────────────
|
|
it('defines auth.jwt() with CREATE OR REPLACE', () => {
|
|
expect(sql).toMatch(/CREATE OR REPLACE FUNCTION auth\.jwt\(\)/i);
|
|
});
|
|
|
|
it('auth.jwt() reads request.jwt.claims (the GUC withUserContext sets)', () => {
|
|
expect(sql).toMatch(/current_setting\(\s*'request\.jwt\.claims'\s*,\s*true\s*\)/i);
|
|
});
|
|
|
|
it('auth.jwt() does NOT read the never-set request.jwt.claim singular form', () => {
|
|
// withUserContext sets request.jwt.claims (jsonb), not a singular request.jwt.claim object.
|
|
expect(sql).not.toMatch(/current_setting\(\s*'request\.jwt\.claim'\s*,/i);
|
|
});
|
|
|
|
// ── RLS enablement ───────────────────────────────────────────────────
|
|
it('enables RLS on storage.objects', () => {
|
|
expect(sql).toMatch(/ALTER TABLE storage\.objects ENABLE ROW LEVEL SECURITY/i);
|
|
});
|
|
|
|
// ── Policy install is idempotent and gated on existing projects ──────
|
|
it('gates policy install on existing buckets (fresh installs ship deny-by-default)', () => {
|
|
expect(sql).toMatch(/IF EXISTS\s*\(\s*SELECT\s+1\s+FROM\s+storage\.buckets/i);
|
|
});
|
|
|
|
// Postgres has no CREATE POLICY IF NOT EXISTS through PG17 — each CREATE
|
|
// must be wrapped in a pg_policy lookup to stay replay-safe.
|
|
it.each([
|
|
'storage_objects_owner_select',
|
|
'storage_objects_owner_insert',
|
|
'storage_objects_owner_update',
|
|
'storage_objects_owner_delete',
|
|
])('guards CREATE POLICY %s with a pg_policy NOT EXISTS check', (polname) => {
|
|
const guardedRegex = new RegExp(
|
|
`IF NOT EXISTS\\s*\\(\\s*SELECT\\s+1\\s+FROM\\s+pg_policy[\\s\\S]*?polname\\s*=\\s*'${polname}'[\\s\\S]*?CREATE POLICY ${polname}`,
|
|
'i'
|
|
);
|
|
expect(sql).toMatch(guardedRegex);
|
|
});
|
|
|
|
it('does NOT have a bare CREATE POLICY outside the guarded DO block', () => {
|
|
// Strip dollar-quoted DO blocks (matched tags so $sql$ inside doesn't
|
|
// close $migration$ early) and -- line comments before the assertion.
|
|
const stripped = sql.replace(/DO\s*\$(\w+)\$[\s\S]*?\$\1\$/g, '').replace(/^\s*--.*$/gm, '');
|
|
expect(stripped).not.toMatch(/CREATE POLICY/i);
|
|
});
|
|
|
|
it("uses (SELECT auth.jwt() ->> 'sub') hoisted form (cached once per query)", () => {
|
|
const matches = sql.match(/\(\s*SELECT\s+auth\.jwt\(\)\s*->>\s*'sub'\s*\)/gi);
|
|
expect(matches?.length ?? 0).toBeGreaterThanOrEqual(4);
|
|
});
|
|
|
|
// ── Grants ───────────────────────────────────────────────────────────
|
|
it('grants storage.objects DML to authenticated', () => {
|
|
expect(sql).toMatch(
|
|
/GRANT\s+SELECT,\s*INSERT,\s*UPDATE,\s*DELETE\s+ON\s+storage\.objects\s+TO\s+authenticated/i
|
|
);
|
|
});
|
|
|
|
it('grants USAGE on schema storage to authenticated', () => {
|
|
expect(sql).toMatch(/GRANT\s+USAGE\s+ON\s+SCHEMA\s+storage\s+TO\s+authenticated/i);
|
|
});
|
|
|
|
it('grants EXECUTE on auth.jwt() to authenticated and anon', () => {
|
|
expect(sql).toMatch(
|
|
/GRANT\s+EXECUTE\s+ON\s+FUNCTION\s+auth\.jwt\(\)\s+TO\s+authenticated,\s*anon/i
|
|
);
|
|
});
|
|
|
|
// ── Ordering ─────────────────────────────────────────────────────────
|
|
it('runs after migration 035', () => {
|
|
const migrationDir = path.resolve(currentDir, '../../src/infra/database/migrations');
|
|
const migrations = fs
|
|
.readdirSync(migrationDir)
|
|
.filter((file) => file.endsWith('.sql'))
|
|
.sort();
|
|
|
|
const idx036 = migrations.indexOf('036_storage-third-party-auth-support.sql');
|
|
expect(idx036).toBeGreaterThanOrEqual(0);
|
|
const earlierMigrations = migrations.filter((m) => m.startsWith('035_'));
|
|
if (earlierMigrations.length > 0) {
|
|
const idx035 = migrations.indexOf(earlierMigrations[0]);
|
|
expect(idx036).toBeGreaterThan(idx035);
|
|
}
|
|
});
|
|
});
|