gengxin
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
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)
|
||||
})
|
||||
Reference in New Issue
Block a user