Files
2026-09-09 14:47:29 +08:00

202 lines
12 KiB
JavaScript

import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { inventory, hash, objectUrl, verifyPublicObject, uploadObject, main } from './upload.mjs'
import { configuredDatabase, validateCosConfig, safeError, withDeadline } from './config.mjs'
function fixture(t) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'tang-cos-test-'))
t.after(() => fs.rmSync(directory, { recursive: true }))
fs.mkdirSync(path.join(directory, 'native/tang-detective'), { recursive: true })
fs.mkdirSync(path.join(directory, 'build'))
fs.writeFileSync(path.join(directory, 'native/tang-detective/image.jpg'), 'existing-image')
fs.writeFileSync(path.join(directory, 'build/tang-detective-source-manifest.json'), JSON.stringify({ files: [
{ path: 'image.jpg', bytes: 14, sha256: hash('existing-image') },
] }))
return directory
}
test('inventory creates content-addressed keys without changing source files', t => {
const project = fixture(t)
const input = inventory(project)
assert.equal(input.entries.length, 1)
assert.equal(input.entries[0].objectKey, `tang-detective/season-01/media-v1/${hash('existing-image')}.jpg`)
assert.equal(fs.readFileSync(path.join(project, 'native/tang-detective/image.jpg'), 'utf8'), 'existing-image')
})
test('inventory refuses changed bytes, unrecorded files, and symlinks', t => {
const project = fixture(t)
const source = path.join(project, 'native/tang-detective')
fs.writeFileSync(path.join(source, 'image.jpg'), 'different-data')
assert.throws(() => inventory(project), /SOURCE_BYTES_CHANGED/)
fs.writeFileSync(path.join(source, 'image.jpg'), 'existing-image')
fs.writeFileSync(path.join(source, 'extra.mp3'), 'audio')
assert.throws(() => inventory(project), /SOURCE_FILE_SET_CHANGED/)
fs.unlinkSync(path.join(source, 'extra.mp3'))
fs.symlinkSync(path.join(source, 'image.jpg'), path.join(source, 'linked.jpg'))
assert.throws(() => inventory(project), /SOURCE_SYMLINK_NOT_ALLOWED/)
})
test('database config never disables certificate or hostname verification', t => {
const server = fs.mkdtempSync(path.join(os.tmpdir(), 'tang-cos-db-test-'))
t.after(() => fs.rmSync(server, { recursive: true }))
fs.mkdirSync(path.join(server, 'config'))
const values = { hostname: 'db.example.invalid', hostport: '3306', database: 'test', username: 'test', password: 'fake-not-secret', prefix: 'zyt_' }
fs.writeFileSync(path.join(server, 'config/database.php'), Object.entries(values).map(([key, value]) => `env('database.${key}', '${value}')`).join('\n'))
const config = configuredDatabase(server, {})
assert.equal(config.options.ssl.rejectUnauthorized, true)
assert.equal(config.options.ssl.verifyIdentity, true)
assert.equal(config.options.multipleStatements, false)
fs.writeFileSync(path.join(server, '.env'), 'unparsed')
assert.throws(() => configuredDatabase(server, {}), /SERVER_ENV_REQUIRES_NATIVE_RUNTIME/)
})
test('destination rejects insecure/signed URLs and missing credentials', () => {
const config = { bucket: 'example-12345', region: 'ap-guangzhou', access_key: 'test', secret_key: 'test' }
assert.equal(validateCosConfig(config, 'qcloud').baseUrl, 'https://example-12345.cos.ap-guangzhou.myqcloud.com')
for (const domain of ['http://example.invalid', 'https://user:password@example.invalid', 'https://example.invalid/?token=test']) {
assert.throws(() => validateCosConfig({ ...config, domain }, 'qcloud'), /COS_REQUIRES_UNSIGNED_HTTPS_BASE_URL/)
}
assert.throws(() => validateCosConfig(config, 'local'), /CONFIGURED_DRIVER_IS_NOT_COS/)
assert.throws(() => validateCosConfig({ ...config, secret_key: '' }, 'qcloud'), /COS_CREDENTIALS_MISSING/)
})
test('object URLs only use namespaced content-addressed keys', () => {
const key = `tang-detective/season-01/media-v1/${hash('image')}.jpg`
assert.equal(objectUrl('https://example.invalid/prefix', key), `https://example.invalid/prefix/${key}`)
assert.throws(() => objectUrl('https://example.invalid', '../unrelated.jpg'), /UNSAFE_OBJECT_KEY/)
assert.throws(() => objectUrl('http://example.invalid', key), /UNSAFE_OBJECT_URL/)
})
test('public verification checks complete bytes, MIME, hash and audio Range', async () => {
const bytes = Buffer.from('original-audio')
const entry = { url: 'https://example.invalid/file.mp3', bytes: bytes.length, sha256: hash(bytes), kind: 'audio', contentType: 'audio/mpeg' }
const calls = []
const verified = await verifyPublicObject(entry, async (url, options) => {
calls.push(options)
assert.equal(options.redirect, 'error')
assert.equal(options.headers?.Authorization, undefined)
return new Response(bytes, options.headers?.Range ? { status: 206, headers: { 'content-range': `bytes 0-13/14` } }
: { status: 200, headers: { 'content-type': 'audio/mpeg' } })
})
assert.equal(verified.remoteVerifiedSha256, entry.sha256)
assert.equal(verified.rangeVerified, true)
assert.equal(calls.length, 2)
})
test('private objects, oversize bodies, corrupt data and missing Range cannot activate a manifest', async () => {
const entry = { url: 'https://example.invalid/file.mp3', bytes: 4, sha256: hash('good'), kind: 'audio', contentType: 'audio/mpeg' }
await assert.rejects(verifyPublicObject(entry, async () => new Response('denied', { status: 403 })), /PUBLIC_MEDIA_GET_FAILED/)
await assert.rejects(verifyPublicObject(entry, async () => new Response('too-large')), /REMOTE_BODY_TOO_LARGE/)
await assert.rejects(verifyPublicObject(entry, async () => new Response('oops')), /PUBLIC_MEDIA_HASH_MISMATCH/)
await assert.rejects(verifyPublicObject(entry, async () => new Response('good', { headers: { 'content-type': 'audio/mpeg' } })), /PUBLIC_MEDIA_RANGE_FAILED/)
})
test('safe errors never leak raw connection or signed-URL text', () => {
assert.equal(safeError({ message: 'mysql://user:password@database/' }), 'REDACTED_OPERATION_ERROR')
assert.equal(safeError({ code: 'HANDSHAKE_SSL_ERROR', message: 'secret' }), 'HANDSHAKE_SSL_ERROR')
assert.equal(safeError({ message: 'https://bucket/?secret=test' }), 'REDACTED_OPERATION_ERROR')
})
test('uploader only creates missing objects with checksums and never changes permissions', async () => {
const body = Buffer.from('good')
const entry = { bytes: body.length, sha256: hash(body), objectKey: `tang-detective/season-01/media-v1/${hash(body)}.jpg`, contentType: 'image/jpeg' }
const destination = { bucket: 'example-12345', region: 'ap-guangzhou' }
let put = null
const cos = { headObject: async () => { throw { statusCode: 404 } }, putObject: async params => { put = params } }
const result = await uploadObject(cos, destination, entry, body)
assert.equal(result.action, 'uploaded')
assert.equal(put.Headers['x-cos-forbid-overwrite'], 'true')
assert.equal(put.Headers['x-cos-meta-sha256'], entry.sha256)
assert.equal(put.Headers['Content-MD5'], 'dV+FwnI7s5OBxzeaYEFg2A==')
assert.equal(put.ACL, undefined)
assert.equal(put.Headers['x-cos-acl'], undefined)
assert.equal(put.ContentType, 'image/jpeg')
})
test('uploader reuses exact owned objects; mismatches and denied HEAD never trigger writes', async () => {
const entry = { bytes: 4, sha256: hash('good'), objectKey: 'test', contentType: 'image/jpeg' }
let writes = 0
const cos = { headObject: async () => ({ headers: { 'content-length': '4', 'x-cos-meta-sha256': hash('good') } }), putObject: async () => { writes++ } }
assert.equal((await uploadObject(cos, {}, entry, Buffer.from('good'))).action, 'reused-identical')
cos.headObject = async () => ({ headers: { 'content-length': '4', 'x-cos-meta-sha256': hash('evil') } })
await assert.rejects(uploadObject(cos, {}, entry, Buffer.from('good')), /EXISTING_OBJECT_NOT_OWNED_OR_DIFFERENT/)
cos.headObject = async () => { throw { statusCode: 403 } }
await assert.rejects(uploadObject(cos, {}, entry, Buffer.from('good')))
assert.equal(writes, 0)
})
test('database deadline aborts a stalled task-owned operation and clears its timer', async () => {
let aborts = 0
await assert.rejects(withDeadline(new Promise(() => {}), 5, () => { aborts++ }, 'DATABASE_QUERY_TIMEOUT'), /DATABASE_QUERY_TIMEOUT/)
assert.equal(aborts, 1)
assert.equal(await withDeadline(Promise.resolve('done'), 5, () => { aborts++ }, 'DATABASE_QUERY_TIMEOUT'), 'done')
assert.equal(aborts, 1)
})
const fakeConfig = () => ({ bucket: 'example-12345', region: 'ap-guangzhou', baseUrl: 'https://example.invalid',
access_key: 'FAKE_ACCESS_MUST_STAY_IN_MEMORY', secret_key: 'FAKE_SECRET_MUST_STAY_IN_MEMORY' })
const readReceipt = project => JSON.parse(fs.readFileSync(path.join(project, 'build/tang-detective-cos-upload-receipt.json')))
test('a configuration failure creates a fresh failed attempt and archives previous success', async t => {
const project = fixture(t)
const previous = { complete: true, runId: 'previous-run', objects: [] }
fs.writeFileSync(path.join(project, 'build/tang-detective-cos-upload-receipt.json'), JSON.stringify(previous))
await assert.rejects(main('upload', { project, log: () => {}, readConfig: async () => { throw { code: 'HANDSHAKE_SSL_ERROR' } } }))
const latest = readReceipt(project)
assert.equal(latest.complete, false)
assert.notEqual(latest.runId, previous.runId)
assert.equal(latest.phase, 'configuration')
assert.equal(latest.error, 'HANDSHAKE_SSL_ERROR')
assert.deepEqual(latest.objects, [])
const archived = fs.readdirSync(path.join(project, 'build/tang-detective-cos-upload-attempts'))
assert.equal(archived.length, 2)
assert.equal(fs.existsSync(path.join(project, 'build/tang-detective-cos-manifest.json')), false)
})
test('SDK initialization failure is recorded without exposing credentials', async t => {
const project = fixture(t)
await assert.rejects(main('upload', { project, readConfig: async () => fakeConfig(), log: () => {},
createCos: () => { throw new Error('signed-url-or-secret-detail') } }))
const latest = readReceipt(project)
assert.equal(latest.complete, false)
assert.equal(latest.phase, 'sdk-initialization')
assert.equal(latest.error, 'REDACTED_OPERATION_ERROR')
assert.equal(JSON.stringify(latest).includes('MUST_STAY_IN_MEMORY'), false)
})
test('lost PUT response persists unknown outcome and only performs read-only reconciliation', async t => {
const project = fixture(t)
let puts = 0
let heads = 0
await assert.rejects(main('upload', { project, readConfig: async () => fakeConfig(), log: () => {}, createCos: () => ({
headObject: async () => { heads++; throw { statusCode: heads === 1 ? 404 : 403 } },
putObject: async () => {
puts++
assert.equal(readReceipt(project).objects[0].uploaded, 'unknown')
throw { code: 'ETIMEDOUT' }
},
}) }))
const latest = readReceipt(project)
assert.equal(latest.complete, false)
assert.equal(latest.objects[0].uploaded, 'unknown')
assert.equal(heads, 2)
assert.equal(puts, 1)
assert.equal(fs.existsSync(path.join(project, 'build/tang-detective-cos-manifest.json')), false)
})
test('only a fully verified upload writes the activation manifest; no secret values reach artifacts', async t => {
const project = fixture(t)
await main('upload', { project, readConfig: async () => fakeConfig(), log: () => {}, createCos: () => ({
headObject: async () => { throw { statusCode: 404 } }, putObject: async () => {},
}), verify: async entry => ({ remoteVerifiedSha256: entry.sha256, rangeVerified: null, verifiedAt: 'test-only' }) })
const latest = readReceipt(project)
const manifest = JSON.parse(fs.readFileSync(path.join(project, 'build/tang-detective-cos-manifest.json')))
assert.equal(latest.complete, true)
assert.equal(manifest.entries.length, 1)
assert.equal(manifest.entries[0].remoteVerifiedSha256, hash('existing-image'))
assert.equal(JSON.stringify([latest, manifest]).includes('MUST_STAY_IN_MEMORY'), false)
})