/** * ICD Test Runner — CRUD: Team Workflows (v0.31.2) * Team-scoped workflow lifecycle via /teams/:teamId/workflows routes. * Tests ownership isolation: workflows created via team routes are * accessible only through team routes, and cross-team access is denied. */ (function () { 'use strict'; var T = window.ICD; if (!T) return; if (!T.crud) T.crud = {}; T.crud.teamWorkflows = async function (testTag) { // Requires admin user (who is also team admin for fixtures.team) if (T.user.role !== 'admin') return; var team = T.fixtures && T.fixtures.team; if (!team) { await T.test('crud', 'team-workflows', 'SKIP — no fixture team', async function () { T.assert(false, 'fixtures.team not provisioned — run fixtures first'); }); return; } var teamId = team.id; var wfId = null; var wfSlug = testTag.toLowerCase().replace(/[^a-z0-9-]/g, '-') + '-twf'; // ── Create via team route ── await T.test('crud', 'team-workflows', 'POST /teams/:teamId/workflows (create)', async function () { var d = await T.apiPost('/teams/' + teamId + '/workflows', { name: testTag + '-team-workflow', slug: wfSlug, description: 'Team workflow CRUD test', entry_mode: 'team_only' }); T.assertShape(d, T.S.workflow, 'team workflow'); T.assert(d.slug === wfSlug, 'slug mismatch: expected ' + wfSlug + ', got ' + d.slug); T.assert(d.team_id === teamId, 'team_id should be ' + teamId + ', got ' + d.team_id); wfId = d.id; T.registerCleanup(function () { if (wfId) return T.safeDelete('/workflows/' + wfId); }); }); if (!wfId) return; // ── Read via team route ── await T.test('crud', 'team-workflows', 'GET /teams/:teamId/workflows/:id (read)', async function () { var d = await T.apiGet('/teams/' + teamId + '/workflows/' + wfId); T.assertShape(d, T.S.workflow, 'team workflow'); T.assert(d.id === wfId, 'id mismatch'); T.assert(d.team_id === teamId, 'team_id mismatch'); }); // ── List via team route ── await T.test('crud', 'team-workflows', 'GET /teams/:teamId/workflows (list)', async function () { var d = await T.apiGet('/teams/' + teamId + '/workflows'); T.assertHasKey(d, 'data', '/workflows'); T.assert(Array.isArray(d.data), 'data should be array'); var found = d.data.find(function (w) { return w.id === wfId; }); T.assert(found, 'team workflow should appear in team list'); }); // ── Update via team route ── await T.test('crud', 'team-workflows', 'PATCH /teams/:teamId/workflows/:id (update)', async function () { var d = await T.apiPatch('/teams/' + teamId + '/workflows/' + wfId, { description: 'Updated by team admin', entry_mode: 'public_link' }); T.assert(typeof d === 'object', 'expected object'); }); // ── Stage CRUD via team routes ── var stageIds = []; await T.test('crud', 'team-workflows', 'POST .../stages (create stage #1)', async function () { var d = await T.apiPost('/teams/' + teamId + '/workflows/' + wfId + '/stages', { name: 'Team Intake', ordinal: 0, history_mode: 'full', stage_mode: 'chat_only' }); T.assertShape(d, T.S.workflowStage, 'stage'); T.assert(d.name === 'Team Intake', 'name mismatch'); stageIds.push(d.id); }); await T.test('crud', 'team-workflows', 'POST .../stages (create stage #2)', async function () { var d = await T.apiPost('/teams/' + teamId + '/workflows/' + wfId + '/stages', { name: 'Team Review', ordinal: 1, history_mode: 'summary', stage_mode: 'review' }); T.assertShape(d, T.S.workflowStage, 'stage'); stageIds.push(d.id); }); await T.test('crud', 'team-workflows', 'GET .../stages (list)', async function () { var d = await T.apiGet('/teams/' + teamId + '/workflows/' + wfId + '/stages'); T.assertHasKey(d, 'data', '/stages'); T.assert(d.data.length >= 2, 'expected at least 2 stages, got ' + d.data.length); }); await T.test('crud', 'team-workflows', 'PUT .../stages/:sid (update)', async function () { var d = await T.apiPut('/teams/' + teamId + '/workflows/' + wfId + '/stages/' + stageIds[0], { name: 'Team Intake (updated)', ordinal: 0, history_mode: 'full', stage_mode: 'form_chat', form_template: { fields: [{ key: 'name', type: 'text', label: 'Name', required: true }] } }); T.assert(typeof d === 'object', 'expected object'); }); if (stageIds.length >= 2) { await T.test('crud', 'team-workflows', 'PATCH .../stages/reorder', async function () { var reversed = stageIds.slice().reverse(); var d = await T.apiPatch('/teams/' + teamId + '/workflows/' + wfId + '/stages/reorder', { ordered_ids: reversed }); T.assert(typeof d === 'object', 'expected object'); }); // Restore order await T.apiPatch('/teams/' + teamId + '/workflows/' + wfId + '/stages/reorder', { ordered_ids: stageIds }); } // ── Activate + Publish via team route ── var versionNum = null; await T.test('crud', 'team-workflows', 'PATCH activate + POST publish', async function () { await T.apiPatch('/teams/' + teamId + '/workflows/' + wfId, { is_active: true }); var v = await T.apiPost('/teams/' + teamId + '/workflows/' + wfId + '/publish', {}); T.assertShape(v, T.S.workflowVersion, 'version'); T.assertHasKey(v, 'snapshot', 'version'); versionNum = v.version_number; }); await T.test('crud', 'team-workflows', 'GET .../versions/:v (read)', async function () { T.assert(versionNum !== null, 'no version number from publish'); var d = await T.apiGet('/teams/' + teamId + '/workflows/' + wfId + '/versions/' + versionNum); T.assertShape(d, T.S.workflowVersion, 'version'); T.assert(d.version_number === versionNum, 'version should be ' + versionNum); }); // ── Ownership isolation: cross-team access denied ── await T.test('crud', 'team-workflows', 'GET /teams/fake-id/workflows/:id → 403', async function () { var d = await T.authFetch(await T.getAuthToken(), 'GET', '/teams/00000000-0000-0000-0000-000000000099/workflows/' + wfId, null); // Should be 403 (not a team member) or 404 T.assert(d._status === 403 || d._status === 404, 'cross-team access should be denied, got ' + d._status); }); // ── Stage delete via team route ── if (stageIds.length >= 2) { await T.test('crud', 'team-workflows', 'DELETE .../stages/:sid', async function () { await T.apiDelete('/teams/' + teamId + '/workflows/' + wfId + '/stages/' + stageIds.pop()); }); } // ── Workflow delete via team route ── await T.test('crud', 'team-workflows', 'DELETE /teams/:teamId/workflows/:id', async function () { await T.apiDelete('/teams/' + teamId + '/workflows/' + wfId); wfId = null; }); }; })();