tests/bundle-semver-validation.test.ts
Rama main · réplica de lectura del repositorio original.
import { randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { BASE_URL, createDirectApiKeyWithBindings, getSupabaseClient, resetAndSeedAppData, resetAppData, SEMVER_ORG_ID, SEMVER_STRIPE_CUSTOMER_ID, USER_ID } from './test-utils.ts'
const id = randomUUID()
const APPNAME = `com.bundle.semver.${id}`
const semverApiKey = randomUUID()
let headers: Record<string, string>
let semverApiKeyId: number | null = null
beforeAll(async () => {
// Use dedicated semver test org and stripe info for isolation
await resetAndSeedAppData(APPNAME, {
orgId: SEMVER_ORG_ID,
userId: USER_ID,
stripeCustomerId: SEMVER_STRIPE_CUSTOMER_ID,
})
const apiKey = await createDirectApiKeyWithBindings({
key: semverApiKey,
name: `semver-test-${id}`,
orgId: SEMVER_ORG_ID,
roleName: 'org_super_admin',
appId: APPNAME,
appRoleName: 'app_admin',
})
if (!apiKey.key)
throw new Error('Failed to create semver API key')
semverApiKeyId = apiKey.id
headers = {
'Content-Type': 'application/json',
'Authorization': apiKey.key,
}
})
afterAll(async () => {
if (semverApiKeyId !== null) {
await getSupabaseClient()
.from('apikeys')
.delete()
.eq('id', semverApiKeyId)
.throwOnError()
}
// Clean up all versions created during tests
await getSupabaseClient()
.from('app_versions')
.delete()
.eq('app_id', APPNAME)
await resetAppData(APPNAME)
})
const validVersions = [
'1.0.0',
'2.1.0',
'3.2.1',
'0.0.0',
'10.20.30',
'1.0.0-0',
'1.0.0-123',
'1.0.0-1.2.3',
'1.0.0-1a',
'1.0.0-a1',
'1.0.0-alpha',
'1.0.0-alpha.1',
'1.0.0-alpha-1',
'1.0.0-alpha-.-beta',
'1.0.0+456',
'1.0.0+build',
'1.0.0+new-build',
'1.0.0+build.1',
'1.0.0+build.1a',
'1.0.0+build.a1',
'1.0.0+build.alpha',
'1.0.0+build.alpha.beta',
'1.0.0-alpha+build',
'1.0.0-beta.2+build.123',
'10.2.2',
'1.1.2-prerelease+meta',
'1.1.2+meta',
'1.1.2+meta-valid',
'1.0.0-beta',
'1.0.0-alpha.beta',
'1.0.0-alpha.beta.1',
'1.0.0-alpha0.valid',
'1.0.0-alpha.0valid',
'1.0.0-alpha-a.b-c-somethinglong+build.1-aef.1-its-okay',
'1.0.0-rc.1+build.1',
'2.0.0-rc.1+build.123',
'1.2.3-beta',
'10.2.2-beta',
'1.2.3-DEV-SNAPSHOT',
'1.2.3-SNAPSHOT-123',
'2.0.0',
'1.1.7',
'2.0.0+build.1848',
'2.0.1-alpha.1227',
'1.0.0-alpha+beta',
'1.2.3----RC-SNAPSHOT.12.9.1--.12+788',
'1.2.3----R-S.12.9.1--.12+meta',
'1.2.3----RC-SNAPSHOT.12.9.1--.12',
'1.0.0+0.build.1-rc.10000aaa-kk-0.1',
'1.0.0-0A.is.legal',
`1.0.0-alpha.${'a'.repeat(100)}`,
]
const invalidVersions = [
// Leading 'v' or 'V' (npm-style versions not allowed)
{ version: 'v1.2.3', reason: 'leading v' },
{ version: 'V1.2.3', reason: 'leading V' },
{ version: 'v1.0.0', reason: 'leading v' },
{ version: 'V2.0.0', reason: 'leading V' },
// Missing parts
{ version: '1', reason: 'missing minor and patch' },
{ version: '1.2', reason: 'missing patch' },
{ version: '1.2.', reason: 'incomplete version' },
{ version: '1..3', reason: 'missing minor' },
// Invalid characters
{ version: 'hello, world', reason: 'non-numeric' },
{ version: 'xyz', reason: 'non-numeric' },
{ version: '1.2.3.4', reason: 'too many parts' },
{ version: 'a.b.c', reason: 'non-numeric parts' },
// Leading zeros (not allowed in semver)
{ version: '01.0.0', reason: 'leading zero in major' },
{ version: '1.02.0', reason: 'leading zero in minor' },
{ version: '1.0.03', reason: 'leading zero in patch' },
// Empty or whitespace - will be caught by missing_version check first
{ version: ' ', reason: 'whitespace' },
{ version: ' 1.0.0', reason: 'leading whitespace' },
{ version: '1.0.0 ', reason: 'trailing whitespace' },
{ version: ' 1.0.0 ', reason: 'surrounding whitespace' },
// Invalid prerelease formats
{ version: '1.0.0-', reason: 'empty prerelease' },
{ version: '1.0.0-..', reason: 'invalid prerelease' },
{ version: '1.0.0-01', reason: 'leading zero in numeric prerelease' },
// Invalid build metadata
{ version: '1.0.0+', reason: 'empty build metadata' },
{ version: '1.0.0+_build', reason: 'underscore in build metadata' },
// Negative numbers
{ version: '-1.0.0', reason: 'negative major' },
{ version: '1.-1.0', reason: 'negative minor' },
{ version: '1.0.-1', reason: 'negative patch' },
// Special characters
{ version: '1.0.0!', reason: 'special character' },
{ version: '1.0.0@build', reason: 'at sign instead of plus' },
{ version: '1.0.0#alpha', reason: 'hash instead of dash' },
// Ranges (not allowed)
{ version: '>=1.0.0', reason: 'semver range' },
{ version: '^1.0.0', reason: 'semver range' },
{ version: '~1.0.0', reason: 'semver range' },
{ version: '*', reason: 'wildcard' },
{ version: '1.x', reason: 'wildcard' },
{ version: '1.*.0', reason: 'wildcard' },
// npm-specific formats (not allowed)
{ version: 'latest', reason: 'npm tag' },
{ version: 'next', reason: 'npm tag' },
// Other edge cases
{ version: '1.0.0 - 2.0.0', reason: 'range' },
{ version: '1.0.0 || 2.0.0', reason: 'logical OR' },
{ version: '1.0.0\x00', reason: 'null character' },
{ version: '1.0.0\n', reason: 'newline' },
]
function postBundle(version: string) {
return fetch(`${BASE_URL}/bundle`, {
method: 'POST',
headers,
body: JSON.stringify({
app_id: APPNAME,
checksum: 'a1b2c3d4e5f6789abcdef123456789abcdef123456789abcdef123456789abcd',
version,
external_url: 'https://example.com/test.zip',
}),
})
}
describe('[POST] /bundle - Semver Validation', () => {
// Clean up any existing versions before running tests
beforeAll(async () => {
await getSupabaseClient()
.from('app_versions')
.delete()
.eq('app_id', APPNAME)
})
it.each(validVersions)('should accept valid semver: %s', async (version) => {
const response = await postBundle(version)
expect(response.status).toBe(200)
const data = await response.json() as { status: string, bundle: any }
expect(data.status).toBe('success')
expect(data.bundle.name).toBe(version)
// Cleanup: delete the created version for the next test
await getSupabaseClient()
.from('app_versions')
.delete()
.eq('app_id', APPNAME)
.eq('name', version)
})
it.concurrent.each(invalidVersions)('should reject invalid semver: "$version" ($reason)', async ({ version }) => {
const response = await postBundle(version)
expect(response.status).toBe(400)
const data = await response.json() as { error: string }
expect(data.error).toBe('invalid_version_format')
})
})