parse-storage-postdeploy.mjs 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import { fileURLToPath } from 'node:url';
  4. const FN_URL = process.env.SMOKE_FN_URL || 'https://server.fmode.cn/api/functions';
  5. const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster';
  6. const SESSION_TOKEN = process.env.SMOKE_SESSION_TOKEN || '';
  7. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  8. const rootDir = path.resolve(__dirname, '..', '..');
  9. const cloudFunctionsPath = path.join(rootDir, 'src', 'app', 'services', 'cloud-functions.ts');
  10. const fn = readCloudFunctionIds(cloudFunctionsPath);
  11. const probeType = 'governanceProbe';
  12. const probeId = `parse-storage-${Date.now()}`;
  13. const errors = [];
  14. let probeAssetId = '';
  15. if (!SESSION_TOKEN) {
  16. fail('Missing SMOKE_SESSION_TOKEN. Provide one test user Parse session token.');
  17. }
  18. for (const key of ['systemStorage', 'upload', 'fileAsset', 'authCredit']) {
  19. if (!fn[key]) fail(`CLOUD_FN.${key} is empty. Deploy and fill src/app/services/cloud-functions.ts before running validation.`);
  20. }
  21. if (errors.length) exitWithErrors();
  22. try {
  23. await run('authCredit.balance keeps APIG reachable', async () => {
  24. const result = await call(fn.authCredit, { action: 'balance' });
  25. assertOk(result, 'authCredit.balance should succeed');
  26. assert(result?.data?.apigId === '6pFf6EAdKT', `APIG id must stay 6pFf6EAdKT: ${summarize(result)}`);
  27. assert(Number.isFinite(Number(result?.data?.balance)), 'authCredit.balance must return numeric balance');
  28. });
  29. await run('systemStorage writes VideoWorkflowEntity', async () => {
  30. const upsert = await call(fn.systemStorage, {
  31. action: 'upsert',
  32. entityType: probeType,
  33. entityId: probeId,
  34. status: 'active',
  35. data: {
  36. marker: probeId,
  37. source: 'parse-storage-postdeploy',
  38. },
  39. });
  40. assertOk(upsert, 'systemStorage.upsert should succeed');
  41. assert(upsert?.data?.data?.marker === probeId, `systemStorage.upsert should return probe payload: ${summarize(upsert)}`);
  42. });
  43. await run('systemStorage reads VideoWorkflowEntity', async () => {
  44. const get = await call(fn.systemStorage, { action: 'get', entityType: probeType, entityId: probeId });
  45. assertOk(get, 'systemStorage.get should succeed');
  46. assert(get?.data?.data?.marker === probeId, `systemStorage.get should return the probe payload: ${summarize(get)}`);
  47. });
  48. await run('systemStorage writes VideoWorkflowAudit detail string', async () => {
  49. const audit = await call(fn.systemStorage, {
  50. action: 'audit',
  51. auditAction: 'parse-storage-postdeploy',
  52. entityType: probeType,
  53. entityId: probeId,
  54. summary: 'Parse storage postdeploy probe',
  55. detail: { marker: probeId },
  56. });
  57. assertOk(audit, 'systemStorage.audit should succeed');
  58. assert(audit?.data?.detail?.marker === probeId, `systemStorage.audit should return parsed detail: ${summarize(audit)}`);
  59. });
  60. await run('upload issues user-partition qiniu key', async () => {
  61. const upload = await call(fn.upload, {
  62. action: 'createUploadToken',
  63. filename: 'parse-storage-postdeploy-probe.txt',
  64. mimeType: 'text/plain',
  65. kind: 'probe',
  66. bizId: probeId,
  67. });
  68. assertOk(upload, 'upload.createUploadToken should succeed');
  69. const qiniuKey = String(upload?.data?.key || '');
  70. assert(qiniuKey.startsWith('users/'), `upload key must start with users/: ${summarize(upload)}`);
  71. const registered = await call(fn.fileAsset, {
  72. action: 'register',
  73. qiniuKey,
  74. url: upload?.data?.url || '',
  75. bucket: upload?.data?.bucket || 'nova-repos',
  76. mimeType: 'text/plain',
  77. kind: 'export',
  78. sizeBytes: 0,
  79. sourceModule: 'parse-storage-postdeploy',
  80. bizType: probeType,
  81. bizId: probeId,
  82. metadata: { marker: probeId },
  83. });
  84. assertOk(registered, 'fileAsset.register should succeed');
  85. probeAssetId = String(registered?.data?.assetId || registered?.data?.id || '');
  86. assert(probeAssetId, `fileAsset.register must return assetId: ${summarize(registered)}`);
  87. assert(registered?.data?.qiniuKey === qiniuKey, `fileAsset.register must preserve qiniuKey: ${summarize(registered)}`);
  88. });
  89. await run('fileAsset reads VideoWorkflowFileAsset', async () => {
  90. const get = await call(fn.fileAsset, { action: 'get', assetId: probeAssetId });
  91. assertOk(get, 'fileAsset.get should succeed');
  92. assert(get?.data?.metadata?.marker === probeId, `fileAsset.get should return probe metadata: ${summarize(get)}`);
  93. });
  94. } finally {
  95. if (probeAssetId) {
  96. await run('cleanup VideoWorkflowFileAsset', async () => {
  97. const deleted = await call(fn.fileAsset, { action: 'delete', assetId: probeAssetId });
  98. assertOk(deleted, 'fileAsset.delete cleanup should succeed');
  99. });
  100. }
  101. await run('cleanup VideoWorkflowEntity', async () => {
  102. const deleted = await call(fn.systemStorage, { action: 'delete', entityType: probeType, entityId: probeId });
  103. assertOk(deleted, 'systemStorage.delete cleanup should succeed');
  104. });
  105. }
  106. if (errors.length) exitWithErrors();
  107. console.log('Parse storage postdeploy validation passed');
  108. async function run(name, task) {
  109. try {
  110. await task();
  111. console.log(`OK ${name}`);
  112. } catch (error) {
  113. errors.push(`${name}: ${error?.message || error}`);
  114. console.error(`FAIL ${name}: ${error?.message || error}`);
  115. }
  116. }
  117. async function call(id, body) {
  118. let lastResult = null;
  119. let lastError = null;
  120. for (let attempt = 1; attempt <= 4; attempt += 1) {
  121. try {
  122. const response = await fetch(FN_URL, {
  123. method: 'POST',
  124. headers: {
  125. 'Content-Type': 'application/json',
  126. 'X-Parse-Application-Id': APP_ID,
  127. },
  128. body: JSON.stringify({ id, _ApplicationId: APP_ID, sessionToken: SESSION_TOKEN, ...body }),
  129. });
  130. const text = await response.text();
  131. let result;
  132. try {
  133. result = JSON.parse(text);
  134. } catch {
  135. result = { code: response.status, success: false, error: text };
  136. }
  137. lastResult = result;
  138. if (attempt < 4 && isRetryableResult(result)) {
  139. await sleep(600 * attempt);
  140. continue;
  141. }
  142. return result;
  143. } catch (error) {
  144. lastError = error;
  145. if (attempt < 4 && isRetryableMessage(error?.message || '')) {
  146. await sleep(600 * attempt);
  147. continue;
  148. }
  149. throw error;
  150. }
  151. }
  152. if (lastResult) return lastResult;
  153. throw lastError || new Error('fetch failed');
  154. }
  155. function assertOk(result, message) {
  156. assert(Number(result?.code || 0) === 200 && result?.success !== false, `${message}: ${summarize(result)}`);
  157. }
  158. function assert(condition, message) {
  159. if (!condition) throw new Error(message);
  160. }
  161. function fail(message) {
  162. errors.push(message);
  163. }
  164. function exitWithErrors() {
  165. for (const error of errors) console.error(`FAIL ${error}`);
  166. process.exit(1);
  167. }
  168. function summarize(result) {
  169. return JSON.stringify(result, (key, value) => key === 'token' ? '<redacted>' : value).slice(0, 700);
  170. }
  171. function isRetryableResult(result) {
  172. const message = `${result?.error || ''} ${result?.message || ''}`;
  173. return Number(result?.code || 0) >= 500 || isRetryableMessage(message);
  174. }
  175. function isRetryableMessage(message) {
  176. return /fetch failed|Failed to fetch|NetworkError|Load failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message || '');
  177. }
  178. function sleep(ms) {
  179. return new Promise((resolve) => setTimeout(resolve, ms));
  180. }
  181. function readCloudFunctionIds(filePath) {
  182. const text = fs.readFileSync(filePath, 'utf8');
  183. const ids = {};
  184. const pattern = /^\s*([a-zA-Z][a-zA-Z0-9_]*)\s*:\s*'([^']*)'/gm;
  185. let match;
  186. while ((match = pattern.exec(text))) {
  187. ids[match[1]] = match[2];
  188. }
  189. return ids;
  190. }