import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { mkdtemp, mkdir, readFile, rm, stat, symlink, writeFile, } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { test } from 'node:test'; import qiniu from 'qiniu'; import { loadConfig, normalizeRelativePath, packageRoot, SyncError, } from '../src/config.mjs'; import { buildPlan, qiniuEtag } from '../src/files.mjs'; import { safeFailure, syncPlan } from '../src/sync.mjs'; import { createQiniuClient } from '../src/qiniu-client.mjs'; const jpeg = Buffer.from([ 255, 216, 255, 224, 0, 16, 74, 70, 73, 70, 0, 1, 0, 0, 0, 0, ]); async function fixture(context) { const dir = await mkdtemp(join(tmpdir(), 'doodle-sync-')); context.after(() => rm(dir, { recursive: true, force: true })); const source = join(dir, 'source'); await mkdir(join(source, 'assets/previews/math'), { recursive: true }); await mkdir(join(source, 'user-avatars')); await mkdir(join(source, 'content')); await writeFile(join(source, 'assets/previews/math/中文 图.jpg'), jpeg); await writeFile( join(source, 'user-avatars/u.png'), Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 0]), ); await writeFile(join(source, 'content/page-config.json'), '{"home":null}'); await writeFile(join(source, '.DS_Store'), 'ignored'); const envFile = join(dir, '.env'); await writeFile( envFile, `SOURCE_DIR=./source\nQINIU_AK=test-ak-never-log\nQINIU_SK=test-sk-never-log\nQINIU_BUCKET=test-public\nQINIU_PUBLIC_DOMAIN=https://cdn.example.test\n`, ); const config = await loadConfig({ env: {}, envFile }); const plan = await buildPlan(config); return { dir, source, envFile, config, plan }; } function fakeClient() { const objects = new Map(); const calls = []; return { calls, objects, async bucketInfo(bucket) { calls.push(['info', bucket]); return { private: bucket === 'test-private' ? 1 : 0 }; }, async stat(bucket, key) { calls.push(['stat', bucket, key]); return objects.get(`${bucket}/${key}`) ?? null; }, async upload(file, buffer) { calls.push(['upload', file.key]); const id = `${file.bucket}/${file.key}`; if (objects.has(id)) throw new SyncError('QINIU_REQUEST_FAILED', 'conflict', 614); objects.set(id, { hash: qiniuEtag(buffer), fsize: buffer.length }); }, }; } const noSleep = async () => {}; test('配置:相对源路径按 env 文件定位,环境变量覆盖,拒绝非法前缀/域名/数值', async (context) => { const fixtureData = await fixture(context); assert.equal(fixtureData.config.sourceDir, fixtureData.source); assert.equal(fixtureData.config.prefix, 'doodle/'); const override = await loadConfig({ env: { QINIU_KEY_PREFIX: '' }, envFile: fixtureData.envFile, }); assert.equal(override.prefix, ''); for (const env of [ { SYNC_CONCURRENCY: '0' }, { SYNC_RETRIES: '-1' }, { QINIU_PUBLIC_DOMAIN: 'https://user:secret@host.test' }, { QINIU_KEY_PREFIX: '../escape' }, { QINIU_KEY_PREFIX: '/absolute' }, ]) await assert.rejects( loadConfig({ env, envFile: fixtureData.envFile }), SyncError, ); for (const path of ['../x', 'a/../../b', '/x', 'a\\b', 'a//b', 'x\0']) assert.throws(() => normalizeRelativePath(path), SyncError); }); test('ETag:固定向量覆盖空文件、普通内容与 4 MiB 分块边界', () => { assert.equal(qiniuEtag(Buffer.alloc(0)), 'Fto5o-5ea0sNMlW_75VgGJCv2AcJ'); assert.equal( qiniuEtag(Buffer.from('hello')), 'Fqr0xh3cxeii2r7eDztILNmuqUNN', ); // 固定多块期望值由独立 SHA-1 实现生成,避免只用同一函数自我验证。 assert.equal( qiniuEtag(Buffer.alloc(4194304)), 'FivMvS848VwT631aif2dhfWV4jvD', ); assert.equal( qiniuEtag(Buffer.alloc(4194305)), 'lhCFgki5yzon0rjN9uJusf6qtsF6', ); }); test('预演:不调用七牛,不包含 AK/SK,保留目录/摘要头像与预览共用公开空间', async (context) => { const { config, plan } = await fixture(context); const report = await syncPlan(config, plan, { client: new Proxy( {}, { get() { throw new Error('禁止网络'); }, }, ), }); assert.equal(report.ok, true); assert.equal(report.summary.planned, 3); const avatar = report.files.find((file) => file.relativePath.startsWith('user-avatars/'), ); assert.equal(avatar.visibility, 'public'); assert.equal(avatar.bucket, 'test-public'); assert.equal( avatar.publicUrl, 'https://cdn.example.test/doodle/user-avatars/u.png', ); assert.ok(report.files.every((file) => file.bucket === avatar.bucket)); const preview = report.files.find((file) => file.relativePath.startsWith('assets/'), ); assert.equal(preview.key, 'doodle/assets/previews/math/中文 图.jpg'); assert.match(preview.publicUrl, /%E4%B8%AD%E6%96%87%20%E5%9B%BE/); assert.equal(preview.mimeType, 'image/jpeg'); assert.equal(preview.sha256.length, 64); assert.ok(!JSON.stringify(report).includes(config.ak)); assert.ok(!JSON.stringify(report).includes(config.sk)); assert.equal( (await buildPlan(config, ['assets/previews'])).files.length, 1, ); }); test('同步:上传后逐对象校验,重跑跳过,相同 key 的不同内容绝不覆盖', async (context) => { const { config, plan } = await fixture(context); const client = fakeClient(); const first = await syncPlan(config, plan, { apply: true, client }); assert.equal(first.summary.uploaded, 3); assert.equal(first.ok, true); assert.equal(client.objects.size, 3); const second = await syncPlan(config, plan, { apply: true, client }); assert.equal(second.summary.skipped, 3); const item = plan.files[0]; client.objects.set(`${item.bucket}/${item.key}`, { hash: 'different', fsize: item.size, }); const third = await syncPlan(config, plan, { apply: true, client }); assert.equal(third.summary.conflict, 1); assert.equal(third.summary.skipped, 2); assert.equal(third.ok, false); assert.equal( client.objects.get(`${item.bucket}/${item.key}`).hash, 'different', ); assert.equal(client.calls.filter(([op]) => op === 'upload').length, 3); }); test('全批预检查:缺少凭据/空间、目标空间实际私有时不上传任何文件', async (context) => { const { config, plan } = await fixture(context); for (const changed of [ { ...config, ak: '' }, { ...config, publicBucket: '' }, ]) { const client = fakeClient(); const changedPlan = await buildPlan(changed); const result = await syncPlan(changed, changedPlan, { apply: true, client, }); assert.equal(result.ok, false); assert.equal(client.objects.size, 0); } const missing = { ...plan, files: plan.files.map((file) => file.relativePath.startsWith('user-avatars/') ? { ...file, bucket: null } : file, ), }; const client = fakeClient(); assert.equal( (await syncPlan(config, missing, { apply: true, client })).error.code, 'BUCKET_REQUIRED', ); client.bucketInfo = async () => ({ private: 1 }); assert.equal( (await syncPlan(config, plan, { apply: true, client })).error.code, 'BUCKET_VISIBILITY_MISMATCH', ); assert.equal(client.objects.size, 0); }); test('瞬时错误重试:上传成功但响应丢失时按 614 和远端摘要恢复,不覆盖', async (context) => { const { config, plan } = await fixture(context); const client = fakeClient(); const normalUpload = client.upload; let lost = false; client.upload = async (file, buffer) => { await normalUpload(file, buffer); if (!lost) { lost = true; throw new SyncError('QINIU_REQUEST_FAILED', 'lost', 0, true); } }; const result = await syncPlan(config, plan, { apply: true, client, sleep: noSleep, }); assert.equal(result.ok, true); assert.equal(result.summary.uploaded, 3); assert.equal(client.objects.size, 3); }); test('源文件变化、远端摘要错误会失败;普通异常和 SDK 凭据不会写进报告', async (context) => { const { config, plan, source } = await fixture(context); await writeFile( join(source, plan.files[0].relativePath), Buffer.concat([jpeg, Buffer.from('changed')]), ); const client = fakeClient(); const result = await syncPlan(config, plan, { apply: true, client }); assert.equal(result.summary.failed, 1); assert.equal(result.files[0].error.code, 'SOURCE_CHANGED'); const other = fakeClient(); other.stat = async () => null; const verify = await syncPlan( config, { ...plan, files: [plan.files[1]] }, { apply: true, client: other }, ); assert.equal(verify.files[0].error.code, 'VERIFY_FAILED'); assert.deepEqual(safeFailure(new Error(`${config.ak} ${config.sk}`)), { code: 'LOCAL_OPERATION_FAILED', statusCode: null, }); }); test('扫描:拒绝软链接、超限和损坏图片,保留空普通文件', async (context) => { const { config, source } = await fixture(context); await assert.rejects(buildPlan({ ...config, maxFileBytes: 1 }), SyncError); await writeFile(join(source, 'empty.txt'), ''); assert.ok( (await buildPlan(config)).files.some( (file) => file.relativePath === 'empty.txt' && file.size === 0, ), ); await writeFile( join(source, 'assets/previews/math/bad.jpg'), 'denied', ); await assert.rejects(buildPlan(config), { code: 'INVALID_IMAGE' }); await rm(join(source, 'assets/previews/math/bad.jpg')); await symlink(join(source, 'content'), join(source, 'link')); await assert.rejects(buildPlan(config), { code: 'SYMLINK_NOT_ALLOWED' }); }); test('官方 SDK 适配:HTTPS、明确 key 的 insertOnly token、MIME/CRC 和安全错误映射', async (context) => { const { config, plan } = await fixture(context); const file = plan.files[0]; let token, policy, extraValue; const sdk = { ...qiniu, rs: { ...qiniu.rs, BucketManager: class { constructor(_mac, conf) { assert.equal(conf.useHttpsDomain, true); } async getBucketInfo() { return { resp: { statusCode: 200 }, data: { private: 0 } }; } async stat() { return { resp: { statusCode: 612 }, data: { error: 'ignored' }, }; } }, }, form_up: { ...qiniu.form_up, FormUploader: class { async put(value, key, _buffer, extra) { token = value; extraValue = extra; return { resp: { statusCode: 200 }, data: { key, hash: file.etag }, }; } }, }, }; const client = createQiniuClient(config, sdk); assert.equal(await client.stat(file.bucket, file.key), null); assert.equal((await client.bucketInfo(file.bucket)).private, 0); await client.upload(file, jpeg); policy = JSON.parse( Buffer.from(token.split(':')[2], 'base64url').toString(), ); assert.equal(policy.insertOnly, 1); assert.equal(policy.scope, `${file.bucket}:${file.key}`); assert.equal(extraValue.mimeType, file.mimeType); assert.equal(extraValue.checkCrc, true); sdk.rs.BucketManager = class { async stat() { throw Object.assign(new Error(config.sk), { resp: { statusCode: 401 }, }); } }; await assert.rejects( createQiniuClient(config, sdk).stat(file.bucket, file.key), (error) => error.statusCode === 401 && !error.retryable && !error.message.includes(config.sk), ); }); test('CLI:默认只预演,报告独占且权限 0600;禁止报告写入源目录', async (context) => { const { dir, envFile, source } = await fixture(context); const reportPath = join(dir, 'report.json'); const cli = resolve(packageRoot, 'src/cli.mjs'); const result = execFileSync( process.execPath, [cli, '--env', envFile, '--report', reportPath], { encoding: 'utf8', env: { PATH: process.env.PATH } }, ); assert.equal(JSON.parse(result).mode, 'dry-run'); assert.equal( JSON.parse(await readFile(reportPath, 'utf8')).summary.planned, 3, ); assert.equal((await stat(reportPath)).mode & 0o777, 0o600); const deniedReport = join(dir, 'no-credentials.json'); assert.throws(() => execFileSync( process.execPath, [cli, '--env', envFile, '--apply', '--report', deniedReport], { stdio: 'pipe', env: { PATH: process.env.PATH, QINIU_AK: '', QINIU_SK: '' }, }, ), ); const denied = JSON.parse(await readFile(deniedReport, 'utf8')); assert.equal(denied.error.code, 'CREDENTIALS_REQUIRED'); assert.equal(denied.summary.not_attempted, 3); assert.throws(() => execFileSync( process.execPath, [cli, '--env', envFile, '--report', reportPath], { stdio: 'pipe' }, ), ); assert.throws(() => execFileSync( process.execPath, [cli, '--env', envFile, '--report', join(source, 'report.json')], { stdio: 'pipe' }, ), ); });