saas-platform.test.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673
  1. import assert from 'node:assert/strict';
  2. import type { AddressInfo } from 'node:net';
  3. import test from 'node:test';
  4. import express, { type ErrorRequestHandler } from 'express';
  5. import { ZodError } from 'zod';
  6. import { ApiError } from '../src/http/api-error.js';
  7. import { createLocalDemoApp } from '../src/local-app.js';
  8. import { LocalSyncJobStore } from '../src/modules/domestic-voc/local/local-sync-job.store.js';
  9. import {
  10. createAuthenticationMiddleware,
  11. type RequestAuthenticator,
  12. WorkspaceAccessService,
  13. } from '../src/modules/saas-platform/auth.js';
  14. import { LocalPlatformRepository } from '../src/modules/saas-platform/local-platform.repository.js';
  15. import { createSaasPlatformRouter } from '../src/modules/saas-platform/routes.js';
  16. import type { DomesticDataset, DomesticMetricSummary, DomesticProduct } from '../src/types/domestic-dataset.js';
  17. const emptyMetrics: DomesticMetricSummary = {
  18. gmv: 0,
  19. soldUnits: 0,
  20. transactionOrders: 0,
  21. transactionCustomers: 0,
  22. impressions: 0,
  23. clicks: 0,
  24. views: 0,
  25. visitors: 0,
  26. cartUnits: 0,
  27. orderAmount: 0,
  28. orderUnits: 0,
  29. orderCount: 0,
  30. refundAmount: 0,
  31. refundUnits: 0,
  32. refundOrders: 0,
  33. conversionRate: 0,
  34. clickThroughRate: 0,
  35. averageUnitPrice: 0,
  36. refundToGmvRate: 0,
  37. };
  38. function product(productId: string, role: DomesticProduct['role']): DomesticProduct {
  39. return {
  40. platform: 'jd',
  41. productId,
  42. productKey: `jd:${productId}`,
  43. asin: productId,
  44. role,
  45. brand: role === 'own' ? 'Demashi' : 'Competitor',
  46. title: `Product ${productId}`,
  47. model: `M-${productId}`,
  48. category1: 'Commercial appliance',
  49. category2: 'Kitchen equipment',
  50. category3: 'Oven',
  51. source: 'test',
  52. relationCount: role === 'own' ? 2 : 0,
  53. summary: emptyMetrics,
  54. trend: [],
  55. };
  56. }
  57. const dataset: DomesticDataset = {
  58. schemaVersion: 1,
  59. generatedAt: '2026-07-23T00:00:00.000Z',
  60. caseName: 'Demashi',
  61. platform: 'jd',
  62. source: {
  63. sourceFile: 'demashi-summary.json',
  64. sourceHash: 'test',
  65. sheets: [],
  66. dateRange: { start: '2026-07-01', end: '2026-07-23' },
  67. },
  68. summary: {
  69. metricRows: 0,
  70. metricProducts: 3,
  71. mappingRows: 2,
  72. relations: 2,
  73. uniqueCompetitorProducts: 2,
  74. category2Count: 1,
  75. category3Count: 1,
  76. reviewCount: 0,
  77. },
  78. dailyTotals: [],
  79. products: [product('1001', 'own'), product('1002', 'competitor'), product('1003', 'competitor')],
  80. mappingGroups: [],
  81. relations: [
  82. {
  83. relationKey: 'jd:1001:1002',
  84. ownProductKey: 'jd:1001',
  85. ownProductId: '1001',
  86. competitorProductKey: 'jd:1002',
  87. competitorProductId: '1002',
  88. competitorBrand: 'Competitor',
  89. category: 'Oven',
  90. },
  91. {
  92. relationKey: 'jd:1001:1003',
  93. ownProductKey: 'jd:1001',
  94. ownProductId: '1001',
  95. competitorProductKey: 'jd:1003',
  96. competitorProductId: '1003',
  97. competitorBrand: 'Competitor',
  98. category: 'Oven',
  99. },
  100. ],
  101. reviews: [],
  102. quality: { orphanMappings: [], mappingsWithoutCompetitor: [], brandWithoutProductId: [] },
  103. };
  104. async function listen(app: ReturnType<typeof express>) {
  105. const server = await new Promise<ReturnType<typeof app.listen>>((resolve) => {
  106. const listening = app.listen(0, '127.0.0.1', () => resolve(listening));
  107. });
  108. const address = server.address() as AddressInfo;
  109. return {
  110. baseUrl: `http://127.0.0.1:${address.port}`,
  111. close: () => new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
  112. };
  113. }
  114. async function json(response: Response): Promise<Record<string, any>> {
  115. return await response.json() as Record<string, any>;
  116. }
  117. test('local SaaS APIs cover context, cursor catalogs, workflows, and audit history', async () => {
  118. const app = createLocalDemoApp({ dataset, corsOrigins: ['http://localhost:4200'] });
  119. const server = await listen(app);
  120. try {
  121. const contextResponse = await fetch(`${server.baseUrl}/api/saas/context`);
  122. assert.equal(contextResponse.status, 200);
  123. const context = await json(contextResponse);
  124. assert.equal(context.principal.userId, 'local-admin');
  125. assert.equal(context.workspaces[0].role, 'owner');
  126. const firstProductsResponse = await fetch(`${server.baseUrl}/api/domestic-voc/products?limit=2`);
  127. assert.equal(firstProductsResponse.status, 200);
  128. const firstProducts = await json(firstProductsResponse);
  129. assert.equal(firstProducts.items.length, 2);
  130. assert.equal(typeof firstProducts.nextCursor, 'string');
  131. const secondProductsResponse = await fetch(
  132. `${server.baseUrl}/api/domestic-voc/products?limit=2&cursor=${encodeURIComponent(firstProducts.nextCursor)}`,
  133. );
  134. assert.equal(secondProductsResponse.status, 200);
  135. const secondProducts = await json(secondProductsResponse);
  136. assert.equal(secondProducts.items.length, 1);
  137. assert.equal(secondProducts.nextCursor, null);
  138. const invalidCursor = await fetch(`${server.baseUrl}/api/domestic-voc/products?cursor=not-a-cursor`);
  139. assert.equal(invalidCursor.status, 400);
  140. assert.equal((await json(invalidCursor)).error, 'invalid_cursor');
  141. const invalidActionStatus = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions?status=unknown`);
  142. assert.equal(invalidActionStatus.status, 400);
  143. const missingAnalysisTarget = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses`, {
  144. method: 'POST',
  145. headers: { 'Content-Type': 'application/json' },
  146. body: JSON.stringify({ analysisType: 'voice', targetKind: 'product' }),
  147. });
  148. assert.equal(missingAnalysisTarget.status, 400);
  149. const firstRelations = await json(await fetch(`${server.baseUrl}/api/domestic-voc/relations?limit=1`));
  150. assert.equal(firstRelations.items.length, 1);
  151. assert.equal(typeof firstRelations.nextCursor, 'string');
  152. const memberResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/members/viewer-user`, {
  153. method: 'PUT',
  154. headers: { 'Content-Type': 'application/json' },
  155. body: JSON.stringify({
  156. email: 'viewer@example.test',
  157. displayName: 'Viewer',
  158. role: 'viewer',
  159. status: 'active',
  160. }),
  161. });
  162. assert.equal(memberResponse.status, 200);
  163. assert.equal((await json(memberResponse)).member.role, 'viewer');
  164. const analysisResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses`, {
  165. method: 'POST',
  166. headers: { 'Content-Type': 'application/json' },
  167. body: JSON.stringify({ analysisType: 'voc_insight', targetKind: 'workspace', input: { platform: 'jd' } }),
  168. });
  169. assert.equal(analysisResponse.status, 202);
  170. const createdAnalysis = (await json(analysisResponse)).analysis as { id: string; status: string };
  171. assert.equal(createdAnalysis.status, 'pending');
  172. const processingResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses/${createdAnalysis.id}`, {
  173. method: 'PATCH',
  174. headers: { 'Content-Type': 'application/json' },
  175. body: JSON.stringify({ status: 'processing', startedAt: '2026-07-23T10:00:00.000Z', evidenceCount: 3 }),
  176. });
  177. assert.equal(processingResponse.status, 200);
  178. const processingAnalysis = (await json(processingResponse)).analysis as {
  179. status: string; startedAt: string | null; evidenceCount: number;
  180. };
  181. assert.equal(processingAnalysis.status, 'processing');
  182. assert.equal(processingAnalysis.startedAt, '2026-07-23T10:00:00.000Z');
  183. assert.equal(processingAnalysis.evidenceCount, 3);
  184. const completedResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses/${createdAnalysis.id}`, {
  185. method: 'PATCH',
  186. headers: { 'Content-Type': 'application/json' },
  187. body: JSON.stringify({
  188. status: 'completed',
  189. result: { findings: ['Need clearer VOC evidence links'] },
  190. evidenceCount: 5,
  191. completedAt: '2026-07-23T10:05:00.000Z',
  192. }),
  193. });
  194. assert.equal(completedResponse.status, 200);
  195. const completedAnalysis = (await json(completedResponse)).analysis as {
  196. status: string; result: Record<string, unknown> | null; completedAt: string | null;
  197. };
  198. assert.equal(completedAnalysis.status, 'completed');
  199. assert.deepEqual(completedAnalysis.result, { findings: ['Need clearer VOC evidence links'] });
  200. assert.equal(completedAnalysis.completedAt, '2026-07-23T10:05:00.000Z');
  201. const terminalAnalysisResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses/${createdAnalysis.id}`, {
  202. method: 'PATCH',
  203. headers: { 'Content-Type': 'application/json' },
  204. body: JSON.stringify({ status: 'cancelled' }),
  205. });
  206. assert.equal(terminalAnalysisResponse.status, 409);
  207. assert.equal((await json(terminalAnalysisResponse)).error, 'analysis_terminal');
  208. const analyses = await json(await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses`));
  209. assert.equal(analyses.items.length, 1);
  210. const actionResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
  211. method: 'POST',
  212. headers: { 'Content-Type': 'application/json' },
  213. body: JSON.stringify({ title: 'Verify review source', priority: 'high' }),
  214. });
  215. assert.equal(actionResponse.status, 201);
  216. const action = (await json(actionResponse)).action;
  217. const completedActionResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions/${action.id}`, {
  218. method: 'PATCH',
  219. headers: { 'Content-Type': 'application/json' },
  220. body: JSON.stringify({ status: 'completed' }),
  221. });
  222. assert.equal(completedActionResponse.status, 200);
  223. assert.equal((await json(completedActionResponse)).action.status, 'completed');
  224. const completedActions = await json(await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions?status=completed`));
  225. assert.equal(completedActions.items.length, 1);
  226. assert.equal(typeof completedActions.items[0].completedAt, 'string');
  227. const alertResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/alerts`, {
  228. method: 'POST',
  229. headers: { 'Content-Type': 'application/json' },
  230. body: JSON.stringify({ title: 'Review evidence unavailable', alertType: 'data_quality', severity: 'high' }),
  231. });
  232. assert.equal(alertResponse.status, 201);
  233. const alert = (await json(alertResponse)).alert;
  234. const resolvedAlertResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/alerts/${alert.id}`, {
  235. method: 'PATCH',
  236. headers: { 'Content-Type': 'application/json' },
  237. body: JSON.stringify({ status: 'resolved' }),
  238. });
  239. assert.equal(resolvedAlertResponse.status, 200);
  240. assert.equal(typeof (await json(resolvedAlertResponse)).alert.resolvedAt, 'string');
  241. const syncResponse = await fetch(`${server.baseUrl}/api/domestic-voc/sync`, {
  242. method: 'POST',
  243. headers: { 'Content-Type': 'application/json', 'Idempotency-Key': 'platform-audit-sync-1001' },
  244. body: JSON.stringify({ productIds: ['1001'] }),
  245. });
  246. assert.equal(syncResponse.status, 202);
  247. const auditResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/audit?limit=100`);
  248. assert.equal(auditResponse.status, 200);
  249. const auditActions = (await json(auditResponse)).items.map((item: { action: string }) => item.action);
  250. assert.ok(auditActions.includes('member.upserted'));
  251. assert.ok(auditActions.includes('analysis.created'));
  252. assert.ok(auditActions.includes('analysis.updated'));
  253. assert.ok(auditActions.includes('action.updated'));
  254. assert.ok(auditActions.includes('alert.updated'));
  255. assert.ok(auditActions.includes('sync.requested'));
  256. } finally {
  257. await server.close();
  258. }
  259. });
  260. test('action items retain validated VOC insight provenance and deduplicated evidence', async () => {
  261. const app = createLocalDemoApp({ dataset, corsOrigins: ['http://localhost:4200'] });
  262. const server = await listen(app);
  263. const headers = { 'Content-Type': 'application/json' };
  264. async function createAnalysis(analysisType: 'voice' | 'voc_insight') {
  265. const response = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses`, {
  266. method: 'POST',
  267. headers,
  268. body: JSON.stringify({ analysisType, targetKind: 'workspace', input: { source: 'action-test' } }),
  269. });
  270. assert.equal(response.status, 202);
  271. return (await json(response)).analysis as { id: string };
  272. }
  273. async function finishAnalysis(id: string, status: 'completed' | 'partial') {
  274. const processing = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses/${id}`, {
  275. method: 'PATCH', headers, body: JSON.stringify({ status: 'processing' }),
  276. });
  277. assert.equal(processing.status, 200);
  278. const terminal = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses/${id}`, {
  279. method: 'PATCH',
  280. headers,
  281. body: JSON.stringify({
  282. status,
  283. result: {
  284. insights: [
  285. { id: 'insight-1', evidenceIds: ['review-1', 'review-2'] },
  286. { id: 'insight-2', evidenceIds: ['review-3'] },
  287. ],
  288. },
  289. evidenceCount: 3,
  290. }),
  291. });
  292. assert.equal(terminal.status, 200);
  293. }
  294. async function createDecision(
  295. analysisId: string,
  296. insightId: string,
  297. reviewedEvidenceIds: string[],
  298. ): Promise<{ id: string }> {
  299. const response = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/insight-decisions`, {
  300. method: 'POST',
  301. headers,
  302. body: JSON.stringify({
  303. sourceAnalysisId: analysisId,
  304. sourceInsightId: insightId,
  305. decision: 'confirmed',
  306. reviewedEvidenceIds,
  307. comment: 'Evidence reviewed for action creation.',
  308. }),
  309. });
  310. assert.equal(response.status, 201);
  311. return (await json(response)).decision as { id: string };
  312. }
  313. try {
  314. const ordinaryResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
  315. method: 'POST', headers, body: JSON.stringify({ title: 'Ordinary action' }),
  316. });
  317. assert.equal(ordinaryResponse.status, 201);
  318. const ordinary = (await json(ordinaryResponse)).action;
  319. assert.equal(ordinary.sourceAnalysisId, null);
  320. assert.equal(ordinary.sourceInsightId, null);
  321. assert.deepEqual(ordinary.evidenceIds, []);
  322. assert.equal(ordinary.validationMetric, '');
  323. const completedInsight = await createAnalysis('voc_insight');
  324. const pendingSource = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
  325. method: 'POST',
  326. headers,
  327. body: JSON.stringify({ title: 'Pending source', sourceAnalysisId: completedInsight.id }),
  328. });
  329. assert.equal(pendingSource.status, 400);
  330. assert.equal((await json(pendingSource)).error, 'source_analysis_not_ready');
  331. await finishAnalysis(completedInsight.id, 'completed');
  332. const missingInsight = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
  333. method: 'POST',
  334. headers,
  335. body: JSON.stringify({ title: 'Missing insight', sourceAnalysisId: completedInsight.id }),
  336. });
  337. assert.equal(missingInsight.status, 400);
  338. assert.equal((await json(missingInsight)).error, 'source_insight_required');
  339. const unknownInsight = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
  340. method: 'POST',
  341. headers,
  342. body: JSON.stringify({
  343. title: 'Unknown insight',
  344. sourceAnalysisId: completedInsight.id,
  345. sourceInsightId: 'insight-unknown',
  346. evidenceIds: ['review-1'],
  347. }),
  348. });
  349. assert.equal(unknownInsight.status, 400);
  350. assert.equal((await json(unknownInsight)).error, 'source_insight_not_found');
  351. const missingEvidence = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
  352. method: 'POST',
  353. headers,
  354. body: JSON.stringify({
  355. title: 'Missing evidence',
  356. sourceAnalysisId: completedInsight.id,
  357. sourceInsightId: 'insight-1',
  358. }),
  359. });
  360. assert.equal(missingEvidence.status, 400);
  361. assert.equal((await json(missingEvidence)).error, 'source_evidence_required');
  362. const unknownEvidence = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
  363. method: 'POST',
  364. headers,
  365. body: JSON.stringify({
  366. title: 'Unknown evidence',
  367. sourceAnalysisId: completedInsight.id,
  368. sourceInsightId: 'insight-1',
  369. evidenceIds: ['review-unknown'],
  370. }),
  371. });
  372. assert.equal(unknownEvidence.status, 400);
  373. assert.equal((await json(unknownEvidence)).error, 'source_evidence_not_in_insight');
  374. const crossInsightEvidence = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
  375. method: 'POST',
  376. headers,
  377. body: JSON.stringify({
  378. title: 'Cross insight evidence',
  379. sourceAnalysisId: completedInsight.id,
  380. sourceInsightId: 'insight-1',
  381. evidenceIds: ['review-3'],
  382. }),
  383. });
  384. assert.equal(crossInsightEvidence.status, 400);
  385. assert.equal((await json(crossInsightEvidence)).error, 'source_evidence_not_in_insight');
  386. const orphanInsight = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
  387. method: 'POST',
  388. headers,
  389. body: JSON.stringify({ title: 'Orphan insight', sourceInsightId: 'insight-1' }),
  390. });
  391. assert.equal(orphanInsight.status, 400);
  392. assert.equal((await json(orphanInsight)).error, 'source_insight_orphan');
  393. const unconfirmedAction = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
  394. method: 'POST',
  395. headers,
  396. body: JSON.stringify({
  397. title: 'Improve evidence traceability',
  398. sourceAnalysisId: completedInsight.id,
  399. sourceInsightId: 'insight-1',
  400. evidenceIds: ['review-1', 'review-1', 'review-2'],
  401. validationMetric: 'Evidence coverage >= 80%',
  402. }),
  403. });
  404. assert.equal(unconfirmedAction.status, 400);
  405. assert.equal((await json(unconfirmedAction)).error, 'source_decision_required');
  406. const completedDecision = await createDecision(
  407. completedInsight.id,
  408. 'insight-1',
  409. ['review-1', 'review-2'],
  410. );
  411. const linkedPayload = {
  412. title: 'Improve evidence traceability',
  413. sourceAnalysisId: completedInsight.id,
  414. sourceInsightId: 'insight-1',
  415. sourceDecisionId: completedDecision.id,
  416. sourceKind: 'insight',
  417. evidenceIds: ['review-1', 'review-1', 'review-2'],
  418. validationMetric: 'Evidence coverage >= 80%',
  419. };
  420. const linkedResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
  421. method: 'POST', headers, body: JSON.stringify(linkedPayload),
  422. });
  423. assert.equal(linkedResponse.status, 201);
  424. const linked = (await json(linkedResponse)).action;
  425. assert.equal(linked.sourceAnalysisId, completedInsight.id);
  426. assert.equal(linked.sourceInsightId, 'insight-1');
  427. assert.equal(linked.sourceDecisionId, completedDecision.id);
  428. assert.equal(linked.sourceKind, 'insight');
  429. assert.deepEqual(linked.evidenceIds, ['review-1', 'review-2']);
  430. assert.equal(linked.validationMetric, 'Evidence coverage >= 80%');
  431. const duplicateResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
  432. method: 'POST', headers, body: JSON.stringify(linkedPayload),
  433. });
  434. assert.equal(duplicateResponse.status, 200);
  435. const duplicateBody = await json(duplicateResponse);
  436. assert.equal(duplicateBody.idempotent, true);
  437. assert.equal(duplicateBody.action.id, linked.id);
  438. const partialInsight = await createAnalysis('voc_insight');
  439. await finishAnalysis(partialInsight.id, 'partial');
  440. const partialDecision = await createDecision(partialInsight.id, 'insight-2', ['review-3']);
  441. const partialSource = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
  442. method: 'POST',
  443. headers,
  444. body: JSON.stringify({
  445. title: 'Validate partial insight',
  446. sourceAnalysisId: partialInsight.id,
  447. sourceInsightId: 'insight-2',
  448. sourceDecisionId: partialDecision.id,
  449. sourceKind: 'insight',
  450. evidenceIds: ['review-3'],
  451. }),
  452. });
  453. assert.equal(partialSource.status, 201);
  454. const voiceAnalysis = await createAnalysis('voice');
  455. await finishAnalysis(voiceAnalysis.id, 'completed');
  456. const wrongType = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
  457. method: 'POST',
  458. headers,
  459. body: JSON.stringify({ title: 'Wrong source type', sourceAnalysisId: voiceAnalysis.id }),
  460. });
  461. assert.equal(wrongType.status, 400);
  462. assert.equal((await json(wrongType)).error, 'source_analysis_not_voc_insight');
  463. const missingSource = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
  464. method: 'POST',
  465. headers,
  466. body: JSON.stringify({ title: 'Missing source', sourceAnalysisId: 'other-workspace-analysis' }),
  467. });
  468. assert.equal(missingSource.status, 400);
  469. assert.equal((await json(missingSource)).error, 'source_analysis_not_found');
  470. const tooManyEvidence = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
  471. method: 'POST',
  472. headers,
  473. body: JSON.stringify({ title: 'Too much evidence', evidenceIds: Array.from({ length: 101 }, (_, index) => `review-${index}`) }),
  474. });
  475. assert.equal(tooManyEvidence.status, 400);
  476. assert.equal((await json(tooManyEvidence)).error, 'invalid_request');
  477. const actions = await json(await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions?limit=100`));
  478. const persisted = actions.items.find((item: { id: string }) => item.id === linked.id);
  479. assert.equal(persisted.sourceAnalysisId, completedInsight.id);
  480. assert.deepEqual(persisted.evidenceIds, ['review-1', 'review-2']);
  481. const audit = await json(await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/audit?limit=100`));
  482. const entry = audit.items.find((item: { action: string; entityId: string }) => (
  483. item.action === 'action.created' && item.entityId === linked.id
  484. ));
  485. assert.equal(entry.metadata.sourceAnalysisId, completedInsight.id);
  486. assert.equal(entry.metadata.sourceInsightId, 'insight-1');
  487. assert.equal(entry.metadata.sourceDecisionId, completedDecision.id);
  488. assert.equal(entry.metadata.sourceKind, 'insight');
  489. assert.deepEqual(entry.metadata.evidenceIds, ['review-1', 'review-2']);
  490. assert.equal(entry.metadata.evidenceCount, 2);
  491. assert.equal(entry.metadata.validationMetric, 'Evidence coverage >= 80%');
  492. } finally {
  493. await server.close();
  494. }
  495. });
  496. test('viewer membership can read but cannot call write or member-management routes', async () => {
  497. const jobs = new LocalSyncJobStore(dataset);
  498. const repository = new LocalPlatformRepository(dataset, jobs, {
  499. userId: 'local-admin',
  500. email: 'local-admin@localhost',
  501. displayName: 'Local Admin',
  502. });
  503. await repository.upsertMember({
  504. workspaceId: 'demashi',
  505. userId: 'viewer-user',
  506. email: 'viewer@example.test',
  507. displayName: 'Viewer',
  508. role: 'viewer',
  509. status: 'active',
  510. });
  511. const authenticator: RequestAuthenticator = {
  512. async authenticate(request) {
  513. const userId = request.header('X-Test-User') || 'local-admin';
  514. return { userId, email: `${userId}@example.test`, displayName: userId, authMode: 'disabled' };
  515. },
  516. };
  517. const app = express();
  518. app.use(express.json());
  519. app.use('/api', createAuthenticationMiddleware(authenticator));
  520. app.use('/api/saas', createSaasPlatformRouter({
  521. repository,
  522. access: new WorkspaceAccessService(repository),
  523. }));
  524. const errorHandler: ErrorRequestHandler = (error, _request, response, _next) => {
  525. if (error instanceof ApiError) {
  526. response.status(error.status).json({ error: error.code });
  527. return;
  528. }
  529. if (error instanceof ZodError) {
  530. response.status(400).json({ error: 'invalid_request' });
  531. return;
  532. }
  533. response.status(500).json({ error: 'internal_error' });
  534. };
  535. app.use(errorHandler);
  536. const server = await listen(app);
  537. const viewerHeaders = { 'Content-Type': 'application/json', 'X-Test-User': 'viewer-user' };
  538. const ownerHeaders = { 'Content-Type': 'application/json', 'X-Test-User': 'local-admin' };
  539. try {
  540. const readable = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, { headers: viewerHeaders });
  541. assert.equal(readable.status, 200);
  542. const writes = [
  543. fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses`, {
  544. method: 'POST', headers: viewerHeaders, body: JSON.stringify({ analysisType: 'voice' }),
  545. }),
  546. fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
  547. method: 'POST', headers: viewerHeaders, body: JSON.stringify({ title: 'Forbidden' }),
  548. }),
  549. fetch(`${server.baseUrl}/api/saas/workspaces/demashi/alerts`, {
  550. method: 'POST', headers: viewerHeaders, body: JSON.stringify({ title: 'Forbidden' }),
  551. }),
  552. fetch(`${server.baseUrl}/api/saas/workspaces/demashi/members/new-user`, {
  553. method: 'PUT', headers: viewerHeaders,
  554. body: JSON.stringify({ role: 'viewer', status: 'active' }),
  555. }),
  556. ];
  557. for (const response of await Promise.all(writes)) {
  558. assert.equal(response.status, 403);
  559. assert.equal((await json(response)).error, 'workspace_permission_denied');
  560. }
  561. const lastOwnerRemoval = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/members/local-admin`, {
  562. method: 'PUT',
  563. headers: ownerHeaders,
  564. body: JSON.stringify({ role: 'analyst', status: 'active' }),
  565. });
  566. assert.equal(lastOwnerRemoval.status, 409);
  567. assert.equal((await json(lastOwnerRemoval)).error, 'workspace_requires_active_owner');
  568. const invalidAssignee = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/actions`, {
  569. method: 'POST',
  570. headers: ownerHeaders,
  571. body: JSON.stringify({ title: 'Invalid assignment', assigneeUserId: 'outside-user' }),
  572. });
  573. assert.equal(invalidAssignee.status, 400);
  574. assert.equal((await json(invalidAssignee)).error, 'assignee_not_workspace_member');
  575. const ownerAnalysis = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses`, {
  576. method: 'POST',
  577. headers: ownerHeaders,
  578. body: JSON.stringify({ analysisType: 'voc_insight', targetKind: 'workspace', input: { source: 'viewer-test' } }),
  579. });
  580. assert.equal(ownerAnalysis.status, 202);
  581. const ownerAnalysisId = (await json(ownerAnalysis)).analysis.id as string;
  582. const viewerPatch = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses/${ownerAnalysisId}`, {
  583. method: 'PATCH',
  584. headers: viewerHeaders,
  585. body: JSON.stringify({ status: 'processing' }),
  586. });
  587. assert.equal(viewerPatch.status, 403);
  588. assert.equal((await json(viewerPatch)).error, 'workspace_permission_denied');
  589. const adminMember = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/members/admin-user`, {
  590. method: 'PUT',
  591. headers: ownerHeaders,
  592. body: JSON.stringify({ role: 'admin', status: 'active' }),
  593. });
  594. assert.equal(adminMember.status, 200);
  595. const adminOwnerChange = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/members/viewer-user`, {
  596. method: 'PUT',
  597. headers: { 'Content-Type': 'application/json', 'X-Test-User': 'admin-user' },
  598. body: JSON.stringify({ role: 'owner', status: 'active' }),
  599. });
  600. assert.equal(adminOwnerChange.status, 403);
  601. assert.equal((await json(adminOwnerChange)).error, 'workspace_owner_required');
  602. const secondOwner = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/members/second-owner`, {
  603. method: 'PUT',
  604. headers: ownerHeaders,
  605. body: JSON.stringify({ role: 'owner', status: 'active' }),
  606. });
  607. assert.equal(secondOwner.status, 200);
  608. const allowedOwnerRemoval = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/members/local-admin`, {
  609. method: 'PUT',
  610. headers: ownerHeaders,
  611. body: JSON.stringify({ role: 'analyst', status: 'active' }),
  612. });
  613. assert.equal(allowedOwnerRemoval.status, 200);
  614. } finally {
  615. await server.close();
  616. }
  617. });