tests/integration/transfer.test.ts
Rama main · réplica de lectura del repositorio original.
import { sql } from 'drizzle-orm'
import { beforeEach, describe, expect, it } from 'vitest'
import { buildServer } from '../../src/server.js'
import {
createAdminKey,
createClientKey,
createProject,
createRootKey,
} from '../helpers/fixtures.js'
import { getTestDb, truncateAll } from '../helpers/db.js'
interface TransferWarning {
environment?: string
kind: string
detail: string
}
interface NativeDocument {
format: string
version: number
contextFields: { key: string; type: string; enumValues: string[] | null }[]
flags: {
key: string
name: string
description: string | null
environments: Record<string, { enabled: boolean; strategies: { constraints: unknown[] }[] }>
}[]
}
interface ExportBody {
document: NativeDocument
warnings: TransferWarning[]
}
interface ImportBody {
report: {
dryRun: boolean
source: string
counts: Record<string, number>
contextFieldsCreated: string[]
unmatchedEnvironments: string[]
flags: { key: string; action: string; reason?: string; warnings: TransferWarning[] }[]
}
}
interface FlagBody {
name: string
state: Record<string, { on: boolean } | undefined>
}
const describeIfDb = process.env.TEST_DATABASE_URL ? describe : describe.skip
describeIfDb('native flag transfer', () => {
const db = process.env.TEST_DATABASE_URL ? getTestDb() : undefined
beforeEach(async () => {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL
await truncateAll(db!)
/** truncateAll skips users; clear them so invite-based roles stay hermetic. */
await db!.execute(sql`TRUNCATE users, user_projects RESTART IDENTITY CASCADE`)
})
/**
* A project with one context field, and one flag that is on in development
* with a strategy and off in production with none.
*/
async function seeded() {
const app = await buildServer({ db })
const rootKey = await createRootKey(db!)
const project = await createProject(app, rootKey, 'src-project')
const adminKey = await createAdminKey(app, rootKey, project.id)
const auth = { authorization: adminKey }
const base = `/api/v1/admin/projects/${project.id}`
await app.inject({
method: 'POST',
url: `${base}/context-fields`,
headers: auth,
payload: { key: 'plan', type: 'enum', enumValues: ['free', 'pro'] },
})
await app.inject({
method: 'POST',
url: `${base}/flags`,
headers: auth,
payload: { key: 'checkout', name: 'Checkout', description: 'New funnel' },
})
await app.inject({
method: 'POST',
url: `${base}/flags/checkout/environments/development/enable`,
headers: auth,
})
await app.inject({
method: 'PUT',
url: `${base}/flags/checkout/environments/development/strategies`,
headers: auth,
payload: {
strategies: [{ constraints: [{ fieldKey: 'plan', operator: 'in', values: ['pro'] }] }],
},
})
return { app, project, rootKey, auth, base }
}
it('exports flags, states, strategies and context fields', async () => {
const { app, auth, base } = await seeded()
const res = await app.inject({ method: 'GET', url: `${base}/transfer/export`, headers: auth })
expect(res.statusCode).toBe(200)
const { document, warnings } = res.json<ExportBody>()
expect(warnings).toEqual([])
expect(document.format).toBe('flagraft.export')
expect(document.version).toBe(1)
expect(document.contextFields).toEqual([
{ key: 'plan', type: 'enum', description: null, enumValues: ['free', 'pro'] },
])
expect(document.flags).toHaveLength(1)
expect(document.flags[0]).toMatchObject({
key: 'checkout',
name: 'Checkout',
description: 'New funnel',
})
expect(document.flags[0].environments.development).toEqual({
enabled: true,
strategies: [{ constraints: [{ fieldKey: 'plan', operator: 'in', values: ['pro'] }] }],
})
expect(document.flags[0].environments.production).toEqual({ enabled: false, strategies: [] })
await app.close()
})
it('carries no ids, timestamps or secrets', async () => {
const { app, auth, base } = await seeded()
const res = await app.inject({ method: 'GET', url: `${base}/transfer/export`, headers: auth })
const body = JSON.stringify(res.json<ExportBody>().document)
for (const forbidden of ['"id"', 'authorId', 'createdAt', 'updatedAt', 'keyHash', 'ff_']) {
expect(body).not.toContain(forbidden)
}
await app.close()
})
it('exports only the requested keys', async () => {
const { app, auth, base } = await seeded()
await app.inject({
method: 'POST',
url: `${base}/flags`,
headers: auth,
payload: { key: 'banner', name: 'Banner' },
})
const res = await app.inject({
method: 'GET',
url: `${base}/transfer/export?keys=checkout`,
headers: auth,
})
expect(res.json<ExportBody>().document.flags.map((f) => f.key)).toEqual(['checkout'])
await app.close()
})
/** A second, empty project plus an admin key for it. */
async function freshTarget(
app: Awaited<ReturnType<typeof buildServer>>,
rootKey: string,
slug: string,
) {
const target = await createProject(app, rootKey, slug)
const targetKey = await createAdminKey(app, rootKey, target.id)
return {
target,
targetAuth: { authorization: targetKey },
targetBase: `/api/v1/admin/projects/${target.id}`,
}
}
/** Exports the seeded project and imports it into a fresh empty one. */
async function transferToFresh(overrides: Record<string, unknown> = {}) {
const { app, auth, base, rootKey } = await seeded()
const exported = await app.inject({
method: 'GET',
url: `${base}/transfer/export`,
headers: auth,
})
const { targetAuth, targetBase } = await freshTarget(app, rootKey, 'dst-project')
const res = await app.inject({
method: 'POST',
url: `${targetBase}/transfer/import`,
headers: targetAuth,
payload: { document: exported.json<ExportBody>().document, ...overrides },
})
return {
app,
res,
rootKey,
targetAuth,
targetBase,
source: exported.json<ExportBody>().document,
}
}
it('imports flags, states, strategies and context fields into an empty project', async () => {
const { app, res, targetAuth, targetBase, source } = await transferToFresh()
expect(res.statusCode).toBe(200)
const { report } = res.json<ImportBody>()
expect(report.source).toBe('flagraft')
expect(report.dryRun).toBe(false)
expect(report.counts).toMatchObject({
flagsCreated: 1,
flagsUpdated: 0,
flagsSkipped: 0,
contextFieldsCreated: 1,
strategiesImported: 1,
strategiesSkipped: 0,
})
expect(report.contextFieldsCreated).toEqual(['plan'])
expect(report.unmatchedEnvironments).toEqual([])
expect(report.flags).toEqual([{ key: 'checkout', action: 'created', warnings: [] }])
/** Round trip: exporting the target must reproduce the source document. */
const again = await app.inject({
method: 'GET',
url: `${targetBase}/transfer/export`,
headers: targetAuth,
})
const roundTripped = again.json<ExportBody>().document
expect(roundTripped.flags).toEqual(source.flags)
expect(roundTripped.contextFields).toEqual(source.contextFields)
await app.close()
})
it('rolls the whole import back when one flag fails', async () => {
const { app, auth, base, rootKey } = await seeded()
const exported = await app.inject({
method: 'GET',
url: `${base}/transfer/export`,
headers: auth,
})
const document = exported.json<ExportBody>().document
/**
* The same key twice. Either the importer rejects the document up front or
* the second insert violates feature_flags_project_id_key_unique -- both
* are acceptable, and what this pins down is that the first flag does not
* survive either way.
*
* Deliberately not an invalid name: that is caught before the transaction
* opens, so the test would pass without a rollback ever happening.
*/
document.flags.push({ ...document.flags[0], name: 'Duplicate key' })
const { targetAuth, targetBase } = await freshTarget(app, rootKey, 'rollback-project')
const res = await app.inject({
method: 'POST',
url: `${targetBase}/transfer/import`,
headers: targetAuth,
payload: { document },
})
expect(res.statusCode).toBeGreaterThanOrEqual(400)
const after = await app.inject({
method: 'GET',
url: `${targetBase}/transfer/export`,
headers: targetAuth,
})
expect(after.json<ExportBody>().document.flags).toEqual([])
expect(after.json<ExportBody>().document.contextFields).toEqual([])
await app.close()
})
it('skips an existing flag by default and leaves it untouched', async () => {
const { app, targetAuth, targetBase } = await transferToFresh()
await app.inject({
method: 'PATCH',
url: `${targetBase}/flags/checkout`,
headers: targetAuth,
payload: { name: 'Renamed locally' },
})
const again = await app.inject({
method: 'POST',
url: `${targetBase}/transfer/import`,
headers: targetAuth,
payload: {
document: {
format: 'flagraft.export',
version: 1,
flags: [{ key: 'checkout', name: 'From file', environments: {} }],
},
},
})
const { report } = again.json<ImportBody>()
expect(report.counts).toMatchObject({ flagsCreated: 0, flagsSkipped: 1 })
expect(report.flags[0]).toMatchObject({ key: 'checkout', action: 'skipped' })
expect(report.flags[0].reason).toContain('onConflict=skip')
const after = await app.inject({
method: 'GET',
url: `${targetBase}/flags/checkout`,
headers: targetAuth,
})
expect(after.json<FlagBody>().name).toBe('Renamed locally')
await app.close()
})
it('overwrite replaces name, description, states and strategies', async () => {
const { app, targetAuth, targetBase } = await transferToFresh()
const res = await app.inject({
method: 'POST',
url: `${targetBase}/transfer/import`,
headers: targetAuth,
payload: {
onConflict: 'overwrite',
document: {
format: 'flagraft.export',
version: 1,
flags: [
{
key: 'checkout',
name: 'Overwritten',
description: null,
environments: { development: { enabled: false, strategies: [] } },
},
],
},
},
})
expect(res.json<ImportBody>().report.counts).toMatchObject({ flagsUpdated: 1, flagsCreated: 0 })
const flag = await app.inject({
method: 'GET',
url: `${targetBase}/flags/checkout`,
headers: targetAuth,
})
expect(flag.json<FlagBody>().name).toBe('Overwritten')
expect(flag.json<FlagBody>().state.development?.on).toBe(false)
const strategies = await app.inject({
method: 'GET',
url: `${targetBase}/flags/checkout/environments/development/strategies`,
headers: targetAuth,
})
expect(strategies.json<unknown[]>()).toEqual([])
await app.close()
})
it('reports an unmatched environment and still creates the flag', async () => {
const { app, targetAuth, targetBase } = await transferToFresh()
const res = await app.inject({
method: 'POST',
url: `${targetBase}/transfer/import`,
headers: targetAuth,
payload: {
document: {
format: 'flagraft.export',
version: 1,
flags: [
{
key: 'staging-only',
name: 'Staging only',
environments: {
staging: { enabled: true, strategies: [] },
production: { enabled: true, strategies: [] },
},
},
],
},
},
})
const { report } = res.json<ImportBody>()
expect(report.unmatchedEnvironments).toEqual(['staging'])
expect(report.flags[0].action).toBe('created')
expect(report.flags[0].warnings).toHaveLength(1)
expect(report.flags[0].warnings[0]).toMatchObject({
environment: 'staging',
kind: 'unknown-environment',
})
expect(report.flags[0].warnings[0].detail).toBeTruthy()
const flag = await app.inject({
method: 'GET',
url: `${targetBase}/flags/staging-only`,
headers: targetAuth,
})
expect(flag.json<FlagBody>().state.production?.on).toBe(true)
expect(flag.json<FlagBody>().state.staging).toBeUndefined()
await app.close()
})
it('environmentMap redirects a name in the file to a local slug', async () => {
const { app, targetAuth, targetBase } = await transferToFresh()
const res = await app.inject({
method: 'POST',
url: `${targetBase}/transfer/import`,
headers: targetAuth,
payload: {
environmentMap: { prod: 'production' },
document: {
format: 'flagraft.export',
version: 1,
flags: [
{
key: 'mapped',
name: 'Mapped',
environments: { prod: { enabled: true, strategies: [] } },
},
],
},
},
})
expect(res.json<ImportBody>().report.unmatchedEnvironments).toEqual([])
const flag = await app.inject({
method: 'GET',
url: `${targetBase}/flags/mapped`,
headers: targetAuth,
})
expect(flag.json<FlagBody>().state.production?.on).toBe(true)
await app.close()
})
it('never creates a missing environment', async () => {
const { app, targetAuth, targetBase } = await transferToFresh()
await app.inject({
method: 'POST',
url: `${targetBase}/transfer/import`,
headers: targetAuth,
payload: {
document: {
format: 'flagraft.export',
version: 1,
flags: [{ key: 'x', name: 'X', environments: { qa: { enabled: true, strategies: [] } } }],
},
},
})
const envs = await app.inject({
method: 'GET',
url: `${targetBase}/environments`,
headers: targetAuth,
})
expect(envs.json<{ slug: string }[]>().map((e) => e.slug)).not.toContain('qa')
await app.close()
})
it('drops a whole strategy when one constraint is rejected, never just the constraint', async () => {
const { app, targetAuth, targetBase } = await transferToFresh()
const res = await app.inject({
method: 'POST',
url: `${targetBase}/transfer/import`,
headers: targetAuth,
payload: {
document: {
format: 'flagraft.export',
version: 1,
contextFields: [{ key: 'plan', type: 'enum', enumValues: ['free', 'pro'] }],
flags: [
{
key: 'narrow',
name: 'Narrow',
environments: {
production: {
enabled: true,
strategies: [
{
constraints: [
{ fieldKey: 'plan', operator: 'in', values: ['pro'] },
{ fieldKey: 'ghost', operator: 'in', values: ['x'] },
],
},
],
},
},
},
],
},
},
})
const { report } = res.json<ImportBody>()
expect(report.counts.strategiesImported).toBe(0)
expect(report.counts.strategiesSkipped).toBe(1)
expect(report.flags[0].warnings[0]).toMatchObject({
environment: 'production',
kind: 'constraint-rejected',
})
/**
* The dangerous outcome would be a surviving strategy with no constraints,
* which matches everybody. There must be no strategy at all.
*/
const strategies = await app.inject({
method: 'GET',
url: `${targetBase}/flags/narrow/environments/production/strategies`,
headers: targetAuth,
})
expect(strategies.json<unknown[]>()).toEqual([])
await app.close()
})
it('refuses to change enabled in a protected environment when approval is required', async () => {
const { app, rootKey } = await seeded()
const { target, targetAuth, targetBase } = await freshTarget(app, rootKey, 'guarded-project')
const patched = await app.inject({
method: 'PATCH',
url: `/api/v1/admin/projects/${target.id}`,
headers: { authorization: rootKey },
payload: { settings: { security: { requireApprovalInProd: true } } },
})
expect(patched.statusCode).toBe(200)
const res = await app.inject({
method: 'POST',
url: `${targetBase}/transfer/import`,
headers: targetAuth,
payload: {
document: {
format: 'flagraft.export',
version: 1,
flags: [
{
key: 'killswitch',
name: 'Kill switch',
environments: {
development: { enabled: true, strategies: [] },
production: { enabled: true, strategies: [] },
},
},
],
},
},
})
const { report } = res.json<ImportBody>()
expect(report.flags[0].warnings).toHaveLength(1)
expect(report.flags[0].warnings[0]).toMatchObject({
environment: 'production',
kind: 'approval-required',
})
expect(report.flags[0].warnings[0].detail).toBeTruthy()
const flag = await app.inject({
method: 'GET',
url: `${targetBase}/flags/killswitch`,
headers: targetAuth,
})
expect(flag.json<FlagBody>().state.development?.on).toBe(true)
/**
* Production keeps what initialEnabled gives a protected environment for a
* project with no flagDefaults -- false -- not the file's `true`.
*/
expect(flag.json<FlagBody>().state.production?.on).toBe(false)
await app.close()
})
it('refuses an import that would push the project past the context field ceiling', async () => {
const { app, rootKey } = await seeded()
const { targetAuth, targetBase } = await freshTarget(app, rootKey, 'ceiling-project')
/**
* The create route caps a project at 25 context fields. The importer writes
* to the same table directly, so without its own check an import is a way
* round a limit the rest of the app keeps.
*/
const document = {
format: 'flagraft.export',
version: 1,
contextFields: Array.from({ length: 26 }, (_, index) => ({
key: `field_${index}`,
type: 'string',
})),
flags: [],
}
const res = await app.inject({
method: 'POST',
url: `${targetBase}/transfer/import`,
headers: targetAuth,
payload: { document },
})
expect(res.statusCode).toBe(409)
expect(res.json<{ message: string }>().message).toMatch(/limit is 25/)
/** The whole transaction rolls back: not even the first 25 land. */
const fields = await app.inject({
method: 'GET',
url: `${targetBase}/context-fields`,
headers: targetAuth,
})
expect(fields.json<unknown[]>()).toEqual([])
await app.close()
})
it('rejects a document whose context field key the API would refuse', async () => {
const { app, rootKey } = await seeded()
const { targetAuth, targetBase } = await freshTarget(app, rootKey, 'badkey-project')
const res = await app.inject({
method: 'POST',
url: `${targetBase}/transfer/import`,
headers: targetAuth,
payload: {
document: {
format: 'flagraft.export',
version: 1,
contextFields: [{ key: 'my tenant', type: 'string' }],
flags: [],
},
},
})
expect(res.statusCode).toBe(400)
await app.close()
})
it('dryRun returns the real report and writes nothing', async () => {
const { app, rootKey } = await seeded()
const { targetAuth, targetBase } = await freshTarget(app, rootKey, 'dry-project')
const document = {
format: 'flagraft.export',
version: 1,
contextFields: [{ key: 'plan', type: 'enum', enumValues: ['free', 'pro'] }],
flags: [
{
key: 'preview',
name: 'Preview',
environments: {
production: {
enabled: true,
strategies: [
{ constraints: [{ fieldKey: 'plan', operator: 'in', values: ['pro'] }] },
],
},
},
},
],
}
const dry = await app.inject({
method: 'POST',
url: `${targetBase}/transfer/import`,
headers: targetAuth,
payload: { document, dryRun: true },
})
const { report } = dry.json<ImportBody>()
expect(report.dryRun).toBe(true)
expect(report.counts).toMatchObject({
flagsCreated: 1,
contextFieldsCreated: 1,
strategiesImported: 1,
})
/** Nothing may have landed -- neither the flag nor the context field. */
const after = await app.inject({
method: 'GET',
url: `${targetBase}/transfer/export`,
headers: targetAuth,
})
expect(after.json<ExportBody>().document.flags).toEqual([])
expect(after.json<ExportBody>().document.contextFields).toEqual([])
/** The real run then produces the same counts. */
const real = await app.inject({
method: 'POST',
url: `${targetBase}/transfer/import`,
headers: targetAuth,
payload: { document },
})
expect(real.json<ImportBody>().report.counts).toEqual(report.counts)
expect(real.json<ImportBody>().report.dryRun).toBe(false)
await app.close()
})
it('a client key cannot export or import', async () => {
const { app, auth, base, project, rootKey } = await seeded()
const envs = await app.inject({ method: 'GET', url: `${base}/environments`, headers: auth })
const envId = envs.json<{ id: string }[]>()[0].id
const clientKey = await createClientKey(app, `${rootKey}`, project.id, envId)
const clientAuth = { authorization: clientKey }
const exported = await app.inject({
method: 'GET',
url: `${base}/transfer/export`,
headers: clientAuth,
})
expect(exported.statusCode).toBeGreaterThanOrEqual(401)
const imported = await app.inject({
method: 'POST',
url: `${base}/transfer/import`,
headers: clientAuth,
payload: { document: { format: 'flagraft.export', version: 1 } },
})
expect(imported.statusCode).toBeGreaterThanOrEqual(401)
await app.close()
})
it('an editor may export but not import', async () => {
const { app, base, project, rootKey } = await seeded()
const invite = await app.inject({
method: 'POST',
url: '/api/v1/admin/users/invite',
headers: { authorization: rootKey, origin: 'https://flags.example' },
payload: { emails: ['editor-transfer@co.com'], role: 'editor', projectIds: [project.id] },
})
expect(invite.statusCode).toBe(201)
const token = invite.json<{ inviteUrl: string }[]>()[0].inviteUrl.split('/invite/')[1]
const accept = await app.inject({
method: 'POST',
url: `/api/v1/public/invite/${token}/accept`,
payload: { password: 'transfer-password-1' },
})
expect(accept.statusCode).toBe(200)
const cookies = {
flagraft_session: accept.cookies.find((c) => c.name === 'flagraft_session')!.value,
}
const exported = await app.inject({ method: 'GET', url: `${base}/transfer/export`, cookies })
expect(exported.statusCode).toBe(200)
const imported = await app.inject({
method: 'POST',
url: `${base}/transfer/import`,
cookies,
payload: { document: { format: 'flagraft.export', version: 1 } },
})
expect(imported.statusCode).toBe(403)
await app.close()
})
})