import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const FN_URL = process.env.SMOKE_FN_URL || 'https://server.fmode.cn/api/functions'; const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster'; const TOKEN_A = process.env.STORAGE_GOVERNANCE_SESSION_A || process.env.SMOKE_SESSION_TOKEN_A || ''; const TOKEN_B = process.env.STORAGE_GOVERNANCE_SESSION_B || process.env.SMOKE_SESSION_TOKEN_B || ''; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..', '..'); const cloudFunctionsPath = path.join(rootDir, 'src', 'app', 'services', 'cloud-functions.ts'); const fn = readCloudFunctionIds(cloudFunctionsPath); const probeType = 'governanceProbe'; const probeId = `postdeploy-${Date.now()}`; const errors = []; let probeQiniuKey = ''; let probeAssetId = ''; if (!TOKEN_A || !TOKEN_B) { fail('Missing STORAGE_GOVERNANCE_SESSION_A and STORAGE_GOVERNANCE_SESSION_B. Provide two different test user Parse session tokens.'); } for (const key of ['systemStorage', 'upload', 'authCredit', 'fileAsset']) { if (!fn[key]) fail(`CLOUD_FN.${key} is empty. Deploy and fill src/app/services/cloud-functions.ts before running postdeploy validation.`); } if (errors.length) exitWithErrors(); await run('authCredit.balance user A', async () => { const result = await call(fn.authCredit, { action: 'balance' }, TOKEN_A); assertOk(result, 'authCredit.balance should succeed for user A'); assert(Number.isFinite(Number(result?.data?.balance)), 'authCredit.balance must return numeric balance'); }); await run('systemStorage user A write/read/delete', async () => { const upsert = await call(fn.systemStorage, { action: 'upsert', entityType: probeType, entityId: probeId, data: { marker: probeId, owner: 'A', createdBy: 'storage-governance-postdeploy' }, status: 'active', }, TOKEN_A); assertOk(upsert, 'systemStorage.upsert should succeed for user A'); const get = await call(fn.systemStorage, { action: 'get', entityType: probeType, entityId: probeId }, TOKEN_A); assertOk(get, 'systemStorage.get should return user A probe'); assert(get?.data?.data?.marker === probeId || get?.data?.marker === probeId, 'systemStorage.get should return the probe payload'); }); await run('systemStorage cross-account isolation', async () => { const result = await call(fn.systemStorage, { action: 'get', entityType: probeType, entityId: probeId }, TOKEN_B); const data = result?.data; const hasAProbe = data?.data?.marker === probeId || data?.marker === probeId; assert(!hasAProbe, 'user B must not read user A governance probe'); }); await run('upload token user partition', async () => { const result = await call(fn.upload, { action: 'createUploadToken', filename: 'postdeploy-probe.txt', mimeType: 'text/plain', kind: 'probe', bizId: probeId, }, TOKEN_A); assertOk(result, 'upload token should succeed for user A'); const key = String(result?.data?.key || ''); assert(key.startsWith('users/'), `upload key must start with users/, got: ${key || '(empty)'}`); probeQiniuKey = key; }); await run('fileAsset register/read/delete user A', async () => { assert(probeQiniuKey, 'upload token key is required before registering file asset metadata'); const register = await call(fn.fileAsset, { action: 'register', qiniuKey: probeQiniuKey, url: '', bucket: 'postdeploy-probe', mimeType: 'text/plain', kind: 'export', sizeBytes: 0, sourceModule: 'storage-governance-postdeploy', bizType: probeType, bizId: probeId, metadata: { marker: probeId }, }, TOKEN_A); assertOk(register, 'fileAsset.register should create VideoWorkflowFileAsset metadata'); probeAssetId = String(register?.data?.assetId || register?.data?.id || ''); assert(probeAssetId, `fileAsset.register must return assetId: ${JSON.stringify(register).slice(0, 500)}`); assert(register?.data?.qiniuKey === probeQiniuKey, 'fileAsset.register must preserve qiniuKey'); const get = await call(fn.fileAsset, { action: 'get', assetId: probeAssetId }, TOKEN_A); assertOk(get, 'fileAsset.get should return registered metadata'); assert(get?.data?.qiniuKey === probeQiniuKey, 'fileAsset.get must return the registered qiniuKey'); const deleted = await call(fn.fileAsset, { action: 'delete', assetId: probeAssetId }, TOKEN_A); assertOk(deleted, 'fileAsset.delete should soft-delete temporary metadata'); }); await run('fileAsset stats user A', async () => { const result = await call(fn.fileAsset, { action: 'stats' }, TOKEN_A); assertOk(result, 'fileAsset.stats should succeed for user A'); assert(Number.isFinite(Number(result?.data?.total ?? 0)), 'fileAsset.stats must return numeric total'); }); await run('cleanup user A probe', async () => { const result = await call(fn.systemStorage, { action: 'delete', entityType: probeType, entityId: probeId }, TOKEN_A); assertOk(result, 'systemStorage.delete should clean up user A probe'); }); if (errors.length) exitWithErrors(); console.log('Storage governance postdeploy validation passed'); async function run(name, task) { try { await task(); console.log(`OK ${name}`); } catch (error) { errors.push(`${name}: ${error?.message || error}`); console.error(`FAIL ${name}: ${error?.message || error}`); if (name !== 'cleanup user A probe') { if (probeAssetId) { await call(fn.fileAsset, { action: 'delete', assetId: probeAssetId }, TOKEN_A).catch(() => null); } await call(fn.systemStorage, { action: 'delete', entityType: probeType, entityId: probeId }, TOKEN_A).catch(() => null); } } } async function call(id, body, sessionToken) { let lastResult = null; let lastError = null; for (let attempt = 1; attempt <= 4; attempt += 1) { try { const response = await fetch(FN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': APP_ID, }, body: JSON.stringify({ id, _ApplicationId: APP_ID, sessionToken, ...body }), }); const text = await response.text(); let result; try { result = JSON.parse(text); } catch { result = { code: response.status, success: false, error: text }; } lastResult = result; if (attempt < 4 && isRetryableResult(result)) { await sleep(600 * attempt); continue; } return result; } catch (error) { lastError = error; if (attempt < 4 && isRetryableMessage(error?.message || '')) { await sleep(600 * attempt); continue; } throw error; } } if (lastResult) return lastResult; throw lastError || new Error('fetch failed'); } function assertOk(result, message) { assert(Number(result?.code || 0) === 200 && result?.success !== false, `${message}: ${JSON.stringify(result).slice(0, 500)}`); } function assert(condition, message) { if (!condition) throw new Error(message); } function fail(message) { errors.push(message); } function exitWithErrors() { for (const error of errors) console.error(`FAIL ${error}`); process.exit(1); } function readCloudFunctionIds(filePath) { const text = fs.readFileSync(filePath, 'utf8'); const ids = {}; const pattern = /^\s*([a-zA-Z][a-zA-Z0-9_]*)\s*:\s*'([^']*)'/gm; let match; while ((match = pattern.exec(text))) { ids[match[1]] = match[2]; } return ids; } function isRetryableResult(result) { const message = `${result?.error || ''} ${result?.message || ''}`; return Number(result?.code || 0) >= 500 || isRetryableMessage(message); } function isRetryableMessage(message) { return /fetch failed|Failed to fetch|NetworkError|Load failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message || ''); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }