Skip to content
Open
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
2,947 changes: 2,702 additions & 245 deletions package-lock.json

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@
"migrations:types": "tsx src/scripts/migrations-types.ts",
"docs:export": "tsx ./src/scripts/export-docs.ts",
"test:dummy-data": "tsx -r dotenv/config ./src/test/db/import-dummy-data.ts",
"test:schema": "tsx ./test_v2/db/import-schema.ts",
"test": "npm run infra:restart && npm run test:dummy-data && jest --no-cache --runInBand",
"test:oriole": "npm run infra:restart:oriole && npm run test:dummy-data && jest --no-cache --runInBand",
"test:coverage": "npm run infra:restart && npm run test:dummy-data && jest --no-cache --runInBand --coverage",
"test:v2": "npm run infra:restart && npm run test:schema && vitest run",
"test:v2:watch": "vitest",
"test:v2:coverage": "npm run infra:restart && npm run test:schema && vitest run --coverage",
"infra:stop": "docker compose --project-directory . -f ./.docker/docker-compose-infra.yml down --remove-orphans",
"infra:start": "docker compose --project-directory . -f ./.docker/docker-compose-infra.yml up -d && sleep 5 && npm run migration:run",
"infra:start:oriole": "docker compose --project-directory . -f ./.docker/docker-compose-infra.yml -f ./.docker/docker-compose-infra-oriole-override.yml up -d && sleep 5 && npm run migration:run",
Expand Down Expand Up @@ -97,8 +101,9 @@
"@types/pg": "^8.6.4",
"@types/stream-buffers": "^3.0.7",
"@types/xml2js": "^0.4.14",
"@vitest/coverage-v8": "^4.1.3",
"babel-jest": "^30.3.0",
"esbuild": "^0.25.8",
"esbuild": "^0.28.0",
"form-data": "^4.0.0",
"jest": "^30.3.0",
"js-yaml": "^4.1.0",
Expand All @@ -111,7 +116,8 @@
"ts-jest": "^29.4.6",
"tsx": "^4.16.0",
"tus-js-client": "^3.1.0",
"typescript": "5.9.3"
"typescript": "5.9.3",
"vitest": "^4.1.3"
},
"version": "1.11.2",
"description": "Supabase Storage Service",
Expand Down
35 changes: 35 additions & 0 deletions src/internal/testing/helpers/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { signJWT } from '@internal/auth'
import { getConfig } from '../../../config'

export interface JwtClaims {
sub?: string
role?: 'authenticated' | 'anon' | 'service_role' | string
aud?: string
exp?: number
[key: string]: unknown
}

/**
* Mint a JWT signed with the same secret the storage-api verifies against.
*
* Tests that need per-user JWTs (e.g. to exercise RLS) should pass the `sub`
* they created via `factories.user.create()`. The helper defaults to
* `role: 'authenticated'` and a 1-hour expiry so tests don't have to spell
* them out.
*/
export async function mintJWT(claims: JwtClaims = {}): Promise<string> {
const { jwtSecret } = getConfig()
const { role = 'authenticated', aud = 'authenticated', ...rest } = claims
return signJWT({ role, aud, ...rest }, jwtSecret, '1h')
}

export function anonKey(): string {
const key = process.env.ANON_KEY
if (!key) throw new Error('ANON_KEY env var is missing — check .env.test')
return key
}

export async function serviceKey(): Promise<string> {
const { serviceKeyAsync } = getConfig()
return serviceKeyAsync
}
118 changes: 118 additions & 0 deletions src/internal/testing/helpers/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { FastifyInstance, InjectOptions, LightMyRequestResponse } from 'fastify'

type HttpVerb = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD'
type InjectPayload = InjectOptions['payload']
import { anonKey, mintJWT, serviceKey } from './auth'

/**
* Thin wrapper around fastify's `inject` that hides the repetitive
* `headers: { authorization: 'Bearer ...' }` incantation.
*
* ctx.client.asService().get(`/bucket/${b.id}`)
* ctx.client.asUser(user).post('/bucket', { name: 'x' })
* ctx.client.asAnon().delete(`/bucket/${b.id}`)
* ctx.client.raw({ ... }) // escape hatch for odd shapes
*
* Every shortcut returns fastify's `LightMyRequestResponse` unchanged so
* tests keep full access to `.statusCode`, `.json()`, `.body`, `.headers`.
*/

export interface TestClient {
/** Pre-bound to a service-role bearer token. */
asService(): ScopedClient
/** Pre-bound to a user-bound bearer token. Pass the `user.id`. */
asUser(user: { id: string; role?: string }): Promise<ScopedClient>
/** Pre-bound to the env anon key. */
asAnon(): ScopedClient
/** No auth header at all. */
unauthenticated(): ScopedClient
/** Escape hatch — direct passthrough to fastify `inject`. */
raw(opts: InjectOptions): Promise<LightMyRequestResponse>
}

export interface ScopedClient {
get(url: string, opts?: Omit<InjectOptions, 'method' | 'url'>): Promise<LightMyRequestResponse>
post(
url: string,
payload?: InjectPayload,
opts?: Omit<InjectOptions, 'method' | 'url' | 'payload'>
): Promise<LightMyRequestResponse>
put(
url: string,
payload?: InjectPayload,
opts?: Omit<InjectOptions, 'method' | 'url' | 'payload'>
): Promise<LightMyRequestResponse>
patch(
url: string,
payload?: InjectPayload,
opts?: Omit<InjectOptions, 'method' | 'url' | 'payload'>
): Promise<LightMyRequestResponse>
delete(
url: string,
opts?: Omit<InjectOptions, 'method' | 'url'>
): Promise<LightMyRequestResponse>
head(url: string, opts?: Omit<InjectOptions, 'method' | 'url'>): Promise<LightMyRequestResponse>
/** Any other verb / complex shape. */
inject(opts: InjectOptions): Promise<LightMyRequestResponse>
}

function makeScoped(
app: FastifyInstance,
baseHeaders: Record<string, string> = {}
): ScopedClient {
const injectWith = async (
method: HttpVerb,
url: string,
opts: Omit<InjectOptions, 'method' | 'url'> = {}
): Promise<LightMyRequestResponse> => {
const headers = { ...baseHeaders, ...(opts.headers as Record<string, string> | undefined) }
return app.inject({ ...opts, method, url, headers } as InjectOptions)
}

return {
get: (url, opts) => injectWith('GET', url, opts),
delete: (url, opts) => injectWith('DELETE', url, opts),
head: (url, opts) => injectWith('HEAD', url, opts),
post: (url, payload, opts) => injectWith('POST', url, { ...opts, payload }),
put: (url, payload, opts) => injectWith('PUT', url, { ...opts, payload }),
patch: (url, payload, opts) => injectWith('PATCH', url, { ...opts, payload }),
inject: (opts) =>
app.inject({
...opts,
headers: { ...baseHeaders, ...(opts.headers as Record<string, string> | undefined) },
}),
}
}

export function makeClient(getApp: () => FastifyInstance): TestClient {
return {
asService() {
// Service key is resolved lazily (see auth.ts) but we return a sync
// ScopedClient by deferring the header lookup until the request is sent.
// That keeps the test call-site one line.
return new Proxy({} as ScopedClient, {
get(_target, prop: keyof ScopedClient) {
return async (...args: unknown[]) => {
const key = await serviceKey()
const scoped = makeScoped(getApp(), { authorization: `Bearer ${key}` })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (scoped[prop] as any)(...args)
}
},
})
},
async asUser(user) {
const jwt = await mintJWT({ sub: user.id, role: user.role ?? 'authenticated' })
return makeScoped(getApp(), { authorization: `Bearer ${jwt}` })
},
asAnon() {
return makeScoped(getApp(), { authorization: `Bearer ${anonKey()}` })
},
unauthenticated() {
return makeScoped(getApp())
},
raw(opts) {
return getApp().inject(opts)
},
}
}
202 changes: 202 additions & 0 deletions src/internal/testing/helpers/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { afterAll, afterEach, beforeAll, beforeEach } from 'vitest'
import { FastifyInstance } from 'fastify'
import { Knex } from 'knex'
import app from '../../../app'
import { mintJWT, serviceKey } from './auth'
import { makeClient, TestClient } from './client'
import { disposeTestKnex, getTestKnex, withDeleteEnabled } from './db'
import { BucketFactory } from './factories/bucket'
import { ObjectFactory } from './factories/object'
import { TestBucket } from './factories/bucket'
import { CleanupRegistry, createRegistry } from './factories/types'
import { UserFactory } from './factories/user'
import { makeFilePrefix } from './random'
import { deleteS3PrefixesForBuckets, ensureRootBucket } from './s3'
import { Snapshot } from './snapshot'

export interface TestContext {
/**
* The fastify app under test. A *fresh* instance is built per test in
* beforeEach so plugins / decorators don't leak across tests, then closed
* in afterEach. Tests just call `ctx.app.inject(...)`.
*/
readonly app: FastifyInstance
/**
* Shortcut for the common `app.inject(...)` patterns. Prefer this over
* `ctx.app.inject` directly — it hides the `authorization: Bearer ...`
* boilerplate for service / user / anon / unauthenticated callers.
*/
readonly client: TestClient
/** Postgres-superuser knex used by factories. NOT the same connection the app uses. */
readonly db: Knex
/** Per-file random prefix — useful when a test wants to namespace its own names. */
readonly prefix: string
readonly factories: {
user: UserFactory
bucket: BucketFactory
/**
* Build an object factory bound to a bucket. Pass either the TestBucket
* returned by `factories.bucket.create()` or just its id.
Comment on lines +38 to +39
*/
objectsIn(bucket: TestBucket): ObjectFactory
}
/**
* Manually register resources for cleanup. Use this when the *test code*
* (not a factory) creates a bucket / user / object — typically because the
* test wants to exercise the HTTP create endpoint and still get teardown.
*/
readonly track: {
bucket(id: string): void
user(id: string): void
s3Key(key: string): void
}
/** Snapshot-style row assertions ("after this API call, the row looks like..."). */
readonly snapshot: Snapshot
/** Helper for tests that need a service-role bearer token. */
readonly serviceJwt: () => Promise<string>
/** Helper for tests that need a user-bound bearer token without going through user.create. */
readonly mintJwt: typeof mintJWT
}

export interface UseTestContextOptions {
/**
* Set to true if the test file actually uploads to / reads from MinIO. We
* only ensure the root S3 bucket exists when needed — pure DB-level tests
* skip the round-trip.
*/
s3?: boolean
}

/**
* Wires up vitest hooks for a test file. Call once at the top of every spec:
*
* const ctx = useTestContext({ s3: true })
* test('...', async () => { await ctx.app.inject(...) })
*
* Lifecycle (in order):
* beforeAll — open shared knex, ensure S3 root bucket if requested
* beforeEach — fresh fastify app
* afterEach — close fastify app
* afterAll — bulk-delete every row this file inserted, then dispose knex
*/
export function useTestContext(options: UseTestContextOptions = {}): TestContext {
const prefix = makeFilePrefix()
const registry: CleanupRegistry = createRegistry()
let appInstance: FastifyInstance | undefined
let db: Knex | undefined

beforeAll(async () => {
db = getTestKnex()
if (options.s3) {
await ensureRootBucket()
}
})

beforeEach(() => {
appInstance = app()
})

afterEach(async () => {
if (appInstance) {
await appInstance.close()
appInstance = undefined
}
})

afterAll(async () => {
if (!db) return
try {
await teardown(db, registry, options.s3 === true)
} finally {
await disposeTestKnex()
}
})

const getApp = (): FastifyInstance => {
if (!appInstance) {
throw new Error('ctx.app accessed outside a test (no beforeEach hook ran yet)')
}
return appInstance
}
const client = makeClient(getApp)

const ctx: TestContext = {
get app() {
return getApp()
},
client,
get db() {
if (!db) {
throw new Error('ctx.db accessed before beforeAll ran')
}
return db
},
prefix,
factories: {
get user() {
return new UserFactory(getTestKnex(), prefix, registry)
},
get bucket() {
return new BucketFactory(getTestKnex(), prefix, registry)
},
objectsIn(bucket) {
return new ObjectFactory(getTestKnex(), bucket, registry)
},
},
track: {
bucket(id) {
registry.buckets.add(id)
},
user(id) {
registry.users.add(id)
},
s3Key(key) {
registry.s3Keys.add(key)
},
},
get snapshot() {
return new Snapshot(getTestKnex())
},
serviceJwt: serviceKey,
mintJwt: mintJWT,
}

return ctx
}

/**
* Bulk teardown for everything a file created. Each table gets at most ONE
* DELETE statement, so cleanup cost is O(1) in the number of test files
* regardless of how many rows were inserted.
*/
async function teardown(
db: Knex,
registry: CleanupRegistry,
cleanS3: boolean
): Promise<void> {
const bucketIds = [...registry.buckets]
const userIds = [...registry.users]

if (bucketIds.length > 0) {
await withDeleteEnabled(db, async (trx) => {
// Objects first (FK to buckets), then buckets.
await trx('storage.objects').whereIn('bucket_id', bucketIds).del()
await trx('storage.buckets').whereIn('id', bucketIds).del()
})
}

if (userIds.length > 0) {
await db('auth.users').whereIn('id', userIds).del()
}

if (cleanS3 && bucketIds.length > 0) {
try {
await deleteS3PrefixesForBuckets(bucketIds)
} catch (err) {
// Don't fail the suite if MinIO is unhappy — just log it. The next
// infra:restart will wipe state anyway.
// eslint-disable-next-line no-console
console.warn('[test_v2] S3 prefix cleanup failed:', err)
}
}
}
Loading
Loading