storage-governance-postdeploy.mjs 7.7 KB

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