77 lines
4.9 KiB
JavaScript
77 lines
4.9 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { createHash, randomUUID } from 'node:crypto'
|
|
import fs from 'node:fs'
|
|
|
|
// Run in the compiled test backend image. Never upload a device configuration here.
|
|
async function main() {
|
|
const endpoint = new URL(process.env.MMAPP_TEST_S3_ENDPOINT ?? '')
|
|
assert.equal(endpoint.protocol, 'https:', 'Verified HTTPS endpoint required')
|
|
assert(!endpoint.username && !endpoint.password && !endpoint.search && !endpoint.hash, 'Endpoint must not contain credentials or query parameters')
|
|
assert.equal(process.env.MMAPP_TEST_S3_OWNED_SCOPE, 'yes', 'Explicit bucket/prefix authorization required')
|
|
const bucket = process.env.MMAPP_TEST_S3_BUCKET
|
|
assert(bucket && bucket.trim() === bucket, 'Explicit bucket required')
|
|
const accessKeyId = process.env.MMAPP_TEST_S3_ACCESS_KEY, secretAccessKey = process.env.MMAPP_TEST_S3_SECRET_KEY
|
|
assert(accessKeyId && secretAccessKey, 'Explicit test credentials required')
|
|
const region = process.env.MMAPP_TEST_S3_REGION
|
|
assert(region, 'Explicit region required')
|
|
const pathStyle = process.env.MMAPP_TEST_S3_PATH_STYLE
|
|
assert(['true', 'false'].includes(pathStyle), 'Explicit path-style choice required')
|
|
const prefix = 'mmapp-acceptance/' + randomUUID()
|
|
const keys = [prefix + '/probe-1.rsc', prefix + '/probe-2.rsc']
|
|
if (process.env.MMAPP_TEST_S3_DRY_RUN === 'true') {
|
|
console.log(JSON.stringify({ dryRun: true, networkRequests: 0, plannedKeys: keys, deviceConfigurationUploads: 0 }))
|
|
} else {
|
|
assert.equal(process.env.MMAPP_TEST_S3_RUN, 'confirmed', 'Explicit live execution required')
|
|
const output = process.env.MMAPP_TEST_REPORT
|
|
assert(output, 'Explicit report path required')
|
|
const { createS3ClientFromConfig, s3PutObject, s3GetObject, s3ListObjects, s3DeleteObject } = await import('/app/backend/dist/services/s3-backup-client.js')
|
|
const client = createS3ClientFromConfig({ endpoint: endpoint.toString(), region, bucket, prefix, accessKeyId, secretAccessKey, forcePathStyle: pathStyle === 'true' })
|
|
const report = { startedAt: new Date().toISOString(), scope: 'Compiled application S3 client; harmless synthetic bodies; dedicated UUID prefix', prefix, passed: false, checks: [], cleanup: [], deviceConfigurationUploads: 0, bounds: ['No application backup jobs, SQL catalog, native restore or UI acceptance', 'Cleanup covers current objects; retained versions/delete markers/retention policies are not inspected'] }
|
|
const attempted = []
|
|
report.plannedKeys = keys
|
|
report.attemptedKeys = attempted
|
|
const save = () => fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n')
|
|
const failure = error => ({ name: /^[\w:.-]+$/.test(error.name) ? error.name : 'Error', httpStatus: error.$metadata?.httpStatusCode ?? null })
|
|
const sha = bytes => createHash('sha256').update(bytes).digest('hex')
|
|
save()
|
|
try {
|
|
// An empty namespace is checked before recording ownership or attempting a write.
|
|
assert.equal((await s3ListObjects(client, bucket, prefix)).length, 0, 'Namespace already contains objects; no writes allowed')
|
|
report.checks.push({ operation: 'empty-prefix-preflight', passed: true }); save()
|
|
for (const key of keys) {
|
|
const body = Buffer.from('# MikroTik Manager owned S3 roundtrip probe\n# ' + key + '\n')
|
|
attempted.push(key); save()
|
|
await s3PutObject(client, bucket, key, body)
|
|
const received = await s3GetObject(client, bucket, key)
|
|
assert.equal(sha(received), sha(body), 'Object content mismatch')
|
|
report.checks.push({ operation: 'put-get-content', key, bytes: received.length, passed: true }); save()
|
|
}
|
|
const listed = (await s3ListObjects(client, bucket, prefix)).map(row => row.key).sort()
|
|
assert.deepEqual(listed, [...keys].sort(), 'Unexpected listing in owned namespace')
|
|
report.checks.push({ operation: 'list-exact-owned-keys', passed: true })
|
|
report.passed = true
|
|
} catch (error) { report.failure = failure(error) }
|
|
finally {
|
|
for (const key of attempted) {
|
|
try { await s3DeleteObject(client, bucket, key); report.cleanup.push({ key, acknowledged: true }) }
|
|
catch (error) { report.cleanup.push({ key, acknowledged: false, failure: failure(error) }); report.passed = false }
|
|
}
|
|
if (attempted.length) {
|
|
try {
|
|
const remaining = (await s3ListObjects(client, bucket, prefix)).filter(row => attempted.includes(row.key)).length
|
|
report.currentOwnedObjectsRemaining = remaining
|
|
if (remaining) report.passed = false
|
|
} catch (error) { report.cleanupReadbackFailure = failure(error); report.passed = false }
|
|
}
|
|
report.completedAt = new Date().toISOString(); save(); client.destroy()
|
|
}
|
|
console.log(JSON.stringify({ passed: report.passed, checks: report.checks.length, attemptedObjects: attempted.length, currentOwnedObjectsRemaining: report.currentOwnedObjectsRemaining ?? null }))
|
|
if (!report.passed) process.exitCode = 1
|
|
}
|
|
}
|
|
await main().catch(error => {
|
|
const name = /^[\w:.-]+$/.test(error.name) ? error.name : 'Error'
|
|
console.error('S3_ACCEPTANCE_FAILED: ' + name)
|
|
process.exitCode = 1
|
|
})
|