wecom-identity-deploy.test.js 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
  1. import test from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import fs from 'node:fs';
  4. import os from 'node:os';
  5. import path from 'node:path';
  6. import crypto from 'node:crypto';
  7. import { buildIdentityFunctions } from '../server/identity/build-release.mjs';
  8. import { manage } from '../server/identity/manage.mjs';
  9. const sha = (v) => crypto.createHash('sha256').update(v).digest('hex');
  10. function setup(t) {
  11. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-identity-test-')); t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
  12. const base = { name: 'ksfGetPublicBoard', path: '/ksfGetPublicBoard', objectId: 'public-id', ACL: { '*': { read: true } }, code: 'function bootstrap(){attachWorkshopHandlers(app,{store,data:DATA});}\nfunction handler(request,response){return runDatabaseFunction("ksfGetPublicBoard",request,response,{});}' };
  13. const built = buildIdentityFunctions(base);
  14. for (const entry of built) fs.writeFileSync(path.join(dir, entry.name + '.js'), entry.code);
  15. fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify({ version: 'ksf.wecom-identity.20261103.v1', functions: built.map(({ code, ...r }) => r) }));
  16. const rows = [structuredClone(base)], calls = [];
  17. const request = async (method, route, body) => {
  18. calls.push({ method, route });
  19. if (method === 'GET' && route.includes('?')) { const name = JSON.parse(new URL('https://test' + route).searchParams.get('where')).name; return { results: structuredClone(rows.filter((r) => r.name === name)) }; }
  20. const id = route.split('/').at(-1), row = rows.find((r) => r.objectId === id);
  21. if (method === 'GET') { assert.ok(row); return structuredClone(row); }
  22. if (method === 'POST') { const created = { objectId: 'sync-id', ...body }; rows.push(created); return { objectId: created.objectId }; }
  23. if (method === 'PUT') { assert.ok(row); Object.assign(row, body); return {}; }
  24. if (method === 'DELETE') { rows.splice(rows.indexOf(row), 1); return {}; }
  25. throw Error('unexpected request');
  26. };
  27. const run = (args) => manage({ args, request, releaseDir: dir });
  28. return { dir, base, built, rows, calls, run };
  29. }
  30. test('production overlay builds exact executable entries while rejecting unknown base shapes', () => {
  31. assert.throws(() => buildIdentityFunctions({ name: 'other', code: '' }), /UNEXPECTED_PUBLIC_FUNCTION/);
  32. });
  33. test('upload hooks overlay each current production function without replacing its original business implementation', async (t) => {
  34. const f = setup(t), uploads = ['ksfStartWorkshopSession', 'ksfSaveWorkshopTask'].map((name) => ({ ...f.base, name, objectId: name + '-id', path: '/' + name, code: f.base.code.replace('"ksfGetPublicBoard"', JSON.stringify(name)) }));
  35. const result = buildIdentityFunctions(f.base, { uploadBases: uploads });
  36. assert.equal(result.length, 4); assert.deepEqual(result.map((r) => r.name).sort(), ['ksfGetPublicBoard', 'ksfSyncWecomEmployee', 'ksfStartWorkshopSession', 'ksfSaveWorkshopTask'].sort());
  37. for (const source of uploads) { const built = result.find((r) => r.name === source.name); assert.equal(built.priorSha256, sha(source.code)); assert.ok(built.code.includes('runDatabaseFunction(' + JSON.stringify(source.name))); new Function(built.code); }
  38. assert.throws(() => buildIdentityFunctions(f.base, { uploadBases: [uploads[0]] }), /UNEXPECTED_UPLOAD_BASES/);
  39. for (const entry of result) fs.writeFileSync(path.join(f.dir, entry.name + '.js'), entry.code);
  40. fs.writeFileSync(path.join(f.dir, 'manifest.json'), JSON.stringify({ version: 'ksf.wecom-identity.20261103.v1', functions: result.map(({ code, ...r }) => r) }));
  41. f.rows.push(...structuredClone(uploads));
  42. const deployed = await f.run(['deploy', '--apply', '--receipt-dir', f.dir]); assert.equal(deployed.functions, 4); assert.equal(f.rows.length, 4);
  43. await f.run(['rollback', '--receipt', deployed.receipt, '--apply']); assert.equal(f.rows.length, 3);
  44. for (const source of [f.base, ...uploads]) assert.equal(f.rows.find((r) => r.name === source.name).code, source.code);
  45. });
  46. test('function deployment is read-only by default, checks exact baseline, repeats without writes and restores old public code', async (t) => {
  47. const f = setup(t), plan = await f.run(['deploy']); assert.equal(plan.readOnly, true); assert.ok(f.calls.every((c) => c.method === 'GET'));
  48. const applied = await f.run(['deploy', '--apply', '--receipt-dir', f.dir]); assert.equal(applied.applied, true); assert.equal(f.rows.length, 2);
  49. const before = f.calls.length; assert.equal((await f.run(['deploy', '--apply', '--receipt-dir', f.dir])).unchanged, true); assert.ok(f.calls.slice(before).every((c) => c.method === 'GET'));
  50. await f.run(['rollback', '--receipt', applied.receipt]); assert.notEqual(sha(f.rows[0].code), sha(f.base.code));
  51. await f.run(['rollback', '--receipt', applied.receipt, '--apply']); assert.equal(f.rows.length, 1); assert.equal(f.rows[0].code, f.base.code);
  52. });
  53. test('unknown function changes and malformed flags stop before any writes', async (t) => {
  54. const f = setup(t); f.rows[0].code += '/*newer production revision*/';
  55. await assert.rejects(() => f.run(['deploy', '--apply', '--receipt-dir', f.dir]), { code: 'FUNCTION_BASELINE_MISMATCH' });
  56. await assert.rejects(() => f.run(['deploy', '--apply', '--unexpected']), { code: 'USAGE' });
  57. assert.ok(f.calls.every((c) => c.method === 'GET'));
  58. });
  59. test('rollback refuses concurrent edits before changing either function', async (t) => {
  60. const f = setup(t), applied = await f.run(['deploy', '--apply', '--receipt-dir', f.dir]); f.rows[0].code += '/*concurrent*/'; const before = f.calls.length;
  61. await assert.rejects(() => f.run(['rollback', '--receipt', applied.receipt, '--apply']), { code: 'CONCURRENT_FUNCTION_CHANGE' });
  62. assert.ok(f.calls.slice(before).every((c) => c.method === 'GET')); assert.equal(f.rows.length, 2);
  63. });