@ftisindia/create-app 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/.env.example +28 -0
- package/template/README.md +51 -0
- package/template/_gitignore +6 -0
- package/template/_package.json +10 -1
- package/template/docs/FORMS.md +188 -0
- package/template/docs/FORMS_CHECKLIST.md +69 -0
- package/template/docs/REPORTS.md +255 -0
- package/template/docs/REPORTS_CHECKLIST.md +152 -0
- package/template/prisma/migrations/20260612000000_add_form_builder/migration.sql +147 -0
- package/template/prisma/migrations/20260613000000_add_report_builder/migration.sql +129 -0
- package/template/prisma/migrations/20260616000000_add_form_outbox_claimed_by/migration.sql +5 -0
- package/template/prisma/schema.prisma +289 -0
- package/template/scripts/export-openapi.ts +85 -0
- package/template/scripts/gen-form.mjs +149 -0
- package/template/scripts/push-form.ts +124 -0
- package/template/src/app.module.ts +30 -8
- package/template/src/common/dto/membership-response.dto.ts +1 -0
- package/template/src/common/dto/role-summary.dto.ts +3 -3
- package/template/src/common/dto/user-summary.dto.ts +3 -3
- package/template/src/config/env.validation.ts +28 -0
- package/template/src/config/forms.config.ts +13 -0
- package/template/src/config/index.ts +2 -0
- package/template/src/config/openapi.ts +12 -0
- package/template/src/config/reports-secret.ts +15 -0
- package/template/src/config/reports.config.ts +18 -0
- package/template/src/main.ts +3 -12
- package/template/src/modules/access-control/dto/access-control-response.dto.ts +3 -0
- package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +5 -1
- package/template/src/modules/access-control/types/permission-key.ts +27 -0
- package/template/src/modules/access-control/types/route-permission-registry.ts +183 -0
- package/template/src/modules/audit/dto/audit-response.dto.ts +7 -3
- package/template/src/modules/auth/auth.module.ts +3 -1
- package/template/src/modules/auth/dto/auth-response.dto.ts +1 -1
- package/template/src/modules/forms/application/services/file-gc.service.ts +85 -0
- package/template/src/modules/forms/application/services/forms-definitions.service.ts +137 -0
- package/template/src/modules/forms/application/services/forms-error.mapper.ts +64 -0
- package/template/src/modules/forms/application/services/forms-export.service.ts +210 -0
- package/template/src/modules/forms/application/services/forms-files.service.ts +164 -0
- package/template/src/modules/forms/application/services/forms-public.service.ts +49 -0
- package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +53 -0
- package/template/src/modules/forms/application/services/forms-submissions.service.ts +103 -0
- package/template/src/modules/forms/application/services/handlers/authenticate.action.ts +37 -0
- package/template/src/modules/forms/application/services/handlers/logging-email.handler.ts +22 -0
- package/template/src/modules/forms/application/services/handlers/send-confirmation-email.action.ts +40 -0
- package/template/src/modules/forms/application/services/handlers/webhook-delivery.transport.ts +319 -0
- package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +89 -0
- package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +131 -0
- package/template/src/modules/forms/dto/create-form-definition.dto.ts +12 -0
- package/template/src/modules/forms/dto/data-source-response.dto.ts +19 -0
- package/template/src/modules/forms/dto/export-submissions-query.dto.ts +33 -0
- package/template/src/modules/forms/dto/file-upload-response.dto.ts +24 -0
- package/template/src/modules/forms/dto/form-definition-response.dto.ts +50 -0
- package/template/src/modules/forms/dto/form-render-response.dto.ts +17 -0
- package/template/src/modules/forms/dto/list-form-definitions-query.dto.ts +10 -0
- package/template/src/modules/forms/dto/list-submissions-query.dto.ts +10 -0
- package/template/src/modules/forms/dto/public-submit-form.dto.ts +24 -0
- package/template/src/modules/forms/dto/set-public-access.dto.ts +8 -0
- package/template/src/modules/forms/dto/submission-response.dto.ts +99 -0
- package/template/src/modules/forms/dto/submit-form.dto.ts +50 -0
- package/template/src/modules/forms/dto/update-form-definition.dto.ts +12 -0
- package/template/src/modules/forms/dto/upload-file-query.dto.ts +33 -0
- package/template/src/modules/forms/dto/validate-submission.dto.ts +22 -0
- package/template/src/modules/forms/examples/abstract-submission.form.json +80 -0
- package/template/src/modules/forms/examples/login.form.json +24 -0
- package/template/src/modules/forms/examples/registration.form.json +44 -0
- package/template/src/modules/forms/forms.module.ts +228 -0
- package/template/src/modules/forms/forms.tokens.ts +6 -0
- package/template/src/modules/forms/infrastructure/audit-sink.adapter.ts +30 -0
- package/template/src/modules/forms/infrastructure/casl-forms-authorization.ts +31 -0
- package/template/src/modules/forms/infrastructure/prisma-tx-runner.ts +17 -0
- package/template/src/modules/forms/infrastructure/registry/form-extension.decorators.ts +17 -0
- package/template/src/modules/forms/infrastructure/registry/registry-bootstrap.service.ts +82 -0
- package/template/src/modules/forms/infrastructure/request-forms-context.ts +60 -0
- package/template/src/modules/forms/infrastructure/schema-check/forms-schema-check.service.ts +76 -0
- package/template/src/modules/forms/infrastructure/storage/local-disk-storage.adapter.ts +43 -0
- package/template/src/modules/forms/infrastructure/stores/index.ts +5 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-action-log.store.ts +37 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +108 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-form-definition.store.ts +147 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +156 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-submission.store.ts +164 -0
- package/template/src/modules/forms/presentation/forms-data-sources.controller.ts +58 -0
- package/template/src/modules/forms/presentation/forms-definitions.controller.ts +191 -0
- package/template/src/modules/forms/presentation/forms-files.controller.ts +79 -0
- package/template/src/modules/forms/presentation/forms-submissions.controller.ts +154 -0
- package/template/src/modules/forms/presentation/forms-upload.interceptor.ts +33 -0
- package/template/src/modules/forms/presentation/public-forms.controller.ts +51 -0
- package/template/src/modules/invitations/dto/invitation-response.dto.ts +4 -0
- package/template/src/modules/organisations/dto/organisation-response.dto.ts +1 -0
- package/template/src/modules/reports/application/services/reports-actions.service.ts +54 -0
- package/template/src/modules/reports/application/services/reports-definitions.service.ts +66 -0
- package/template/src/modules/reports/application/services/reports-error.mapper.ts +97 -0
- package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +205 -0
- package/template/src/modules/reports/application/services/reports-exports.service.ts +78 -0
- package/template/src/modules/reports/application/services/reports-queries.service.ts +35 -0
- package/template/src/modules/reports/application/services/reports-settings-reader.service.ts +49 -0
- package/template/src/modules/reports/application/services/reports-views.service.ts +79 -0
- package/template/src/modules/reports/dto/action-result-response.dto.ts +21 -0
- package/template/src/modules/reports/dto/create-report-definition.dto.ts +86 -0
- package/template/src/modules/reports/dto/create-saved-view.dto.ts +26 -0
- package/template/src/modules/reports/dto/execute-action.dto.ts +71 -0
- package/template/src/modules/reports/dto/export-job-response.dto.ts +60 -0
- package/template/src/modules/reports/dto/export-request.dto.ts +34 -0
- package/template/src/modules/reports/dto/list-reports-query.dto.ts +10 -0
- package/template/src/modules/reports/dto/list-views-query.dto.ts +17 -0
- package/template/src/modules/reports/dto/prepare-action-response.dto.ts +14 -0
- package/template/src/modules/reports/dto/prepare-action.dto.ts +27 -0
- package/template/src/modules/reports/dto/query-response.dto.ts +64 -0
- package/template/src/modules/reports/dto/query-spec.dto.ts +120 -0
- package/template/src/modules/reports/dto/report-definition-response.dto.ts +64 -0
- package/template/src/modules/reports/dto/report-meta-query.dto.ts +16 -0
- package/template/src/modules/reports/dto/report-meta-response.dto.ts +113 -0
- package/template/src/modules/reports/dto/saved-view-response.dto.ts +66 -0
- package/template/src/modules/reports/dto/update-report-definition.dto.ts +9 -0
- package/template/src/modules/reports/dto/update-saved-view.dto.ts +27 -0
- package/template/src/modules/reports/examples/abstract-review-board.report.json +54 -0
- package/template/src/modules/reports/examples/org-members.report.json +55 -0
- package/template/src/modules/reports/infrastructure/audit-sink.adapter.ts +31 -0
- package/template/src/modules/reports/infrastructure/casl-reports-authorization.ts +39 -0
- package/template/src/modules/reports/infrastructure/forms-adapter/form-report-source.adapter.ts +292 -0
- package/template/src/modules/reports/infrastructure/forms-adapter/form-row-actions.ts +171 -0
- package/template/src/modules/reports/infrastructure/forms-adapter/forms-bridge-bootstrap.service.ts +32 -0
- package/template/src/modules/reports/infrastructure/prisma-catalog.adapter.ts +95 -0
- package/template/src/modules/reports/infrastructure/prisma-query-executor.ts +103 -0
- package/template/src/modules/reports/infrastructure/prisma-snapshot-runner.ts +47 -0
- package/template/src/modules/reports/infrastructure/prisma-tx-runner.ts +18 -0
- package/template/src/modules/reports/infrastructure/registry/registry-bootstrap.service.ts +61 -0
- package/template/src/modules/reports/infrastructure/registry/report-extension.decorators.ts +14 -0
- package/template/src/modules/reports/infrastructure/reports-job-queue.adapter.ts +28 -0
- package/template/src/modules/reports/infrastructure/request-reports-context.ts +42 -0
- package/template/src/modules/reports/infrastructure/schema-check/reports-schema-check.service.ts +116 -0
- package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +92 -0
- package/template/src/modules/reports/infrastructure/stores/index.ts +5 -0
- package/template/src/modules/reports/infrastructure/stores/prisma-bulk-action-run.store.ts +89 -0
- package/template/src/modules/reports/infrastructure/stores/prisma-export-job.store.ts +93 -0
- package/template/src/modules/reports/infrastructure/stores/prisma-report-definition.store.ts +171 -0
- package/template/src/modules/reports/infrastructure/stores/prisma-row-tag.store.ts +110 -0
- package/template/src/modules/reports/infrastructure/stores/prisma-saved-view.store.ts +144 -0
- package/template/src/modules/reports/presentation/reports-actions.controller.ts +83 -0
- package/template/src/modules/reports/presentation/reports-definitions.controller.ts +156 -0
- package/template/src/modules/reports/presentation/reports-export-jobs.controller.ts +61 -0
- package/template/src/modules/reports/presentation/reports-export.controller.ts +76 -0
- package/template/src/modules/reports/presentation/reports-query.controller.ts +52 -0
- package/template/src/modules/reports/presentation/reports-views.controller.ts +140 -0
- package/template/src/modules/reports/reports-forms.module.ts +33 -0
- package/template/src/modules/reports/reports.module.ts +335 -0
- package/template/src/modules/reports/reports.tokens.ts +11 -0
- package/template/src/modules/reports/sources/org-members.source.ts +112 -0
- package/template/src/modules/settings/types/setting-definitions.ts +94 -0
- package/template/test/forms-captcha.e2e-spec.ts +163 -0
- package/template/test/forms-definitions.e2e-spec.ts +394 -0
- package/template/test/forms-export.e2e-spec.ts +390 -0
- package/template/test/forms-files.e2e-spec.ts +345 -0
- package/template/test/forms-outbox.e2e-spec.ts +570 -0
- package/template/test/forms-permission-sync.spec.ts +27 -0
- package/template/test/forms-public.e2e-spec.ts +293 -0
- package/template/test/forms-schema-check.e2e-spec.ts +65 -0
- package/template/test/forms-submissions.e2e-spec.ts +500 -0
- package/template/test/forms-throttling.e2e-spec.ts +146 -0
- package/template/test/forms-webhooks.e2e-spec.ts +403 -0
- package/template/test/jest-e2e.json +1 -0
- package/template/test/reports-advanced.e2e-spec.ts +381 -0
- package/template/test/reports-permission-sync.spec.ts +30 -0
- package/template/test/reports-query.e2e-spec.ts +402 -0
- package/template/test/reports-tiers.e2e-spec.ts +343 -0
- package/template/test/route-registry.validator.spec.ts +22 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
2
|
+
import { Test } from '@nestjs/testing';
|
|
3
|
+
import request, { type Response } from 'supertest';
|
|
4
|
+
import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
|
|
5
|
+
import { PrismaService } from '../src/database/prisma/prisma.service';
|
|
6
|
+
import { ReportsExportDispatcherService } from '../src/modules/reports/application/services/reports-export-dispatcher.service';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reports e2e — the surfaces the README/checklist claim but the basic query
|
|
10
|
+
* spec does not exercise: the §6.3 byFilter prepare/execute token protocol
|
|
11
|
+
* (drift + idempotency), the reports-OWNED async export worker + download,
|
|
12
|
+
* XLSX validity, the PII-egress audit row, shared-view partial-unique
|
|
13
|
+
* enforcement, and the indexed-tier publish lint emitting its migration
|
|
14
|
+
* snippet. All run against a real Postgres over the org-members CUSTOM source
|
|
15
|
+
* (no form builder involved).
|
|
16
|
+
*/
|
|
17
|
+
jest.setTimeout(60_000);
|
|
18
|
+
|
|
19
|
+
describe('Reports advanced lifecycle (e2e)', () => {
|
|
20
|
+
let app: INestApplication;
|
|
21
|
+
let prisma: PrismaService;
|
|
22
|
+
let dispatcher: ReportsExportDispatcherService;
|
|
23
|
+
|
|
24
|
+
beforeAll(async () => {
|
|
25
|
+
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
|
|
26
|
+
process.env.JWT_SECRET ||= 'test-jwt-secret-at-least-32-characters-long';
|
|
27
|
+
process.env.AUTH_GOOGLE_ENABLED ||= 'false';
|
|
28
|
+
process.env.NODE_ENV = 'test';
|
|
29
|
+
process.env.FORMS_OUTBOX_ENABLED = 'false';
|
|
30
|
+
// Drive the export worker manually via runOnce() for determinism.
|
|
31
|
+
process.env.REPORTS_EXPORT_WORKER_ENABLED = 'false';
|
|
32
|
+
process.env.REPORTS_EXPORT_STORAGE_DIR = './var/test-report-exports';
|
|
33
|
+
|
|
34
|
+
const { AppModule } = await import('../src/app.module');
|
|
35
|
+
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
|
36
|
+
|
|
37
|
+
app = moduleRef.createNestApplication();
|
|
38
|
+
app.useGlobalFilters(new HttpExceptionFilter());
|
|
39
|
+
app.useGlobalPipes(
|
|
40
|
+
new ValidationPipe({ forbidNonWhitelisted: true, transform: true, whitelist: true }),
|
|
41
|
+
);
|
|
42
|
+
await app.init();
|
|
43
|
+
prisma = app.get(PrismaService);
|
|
44
|
+
dispatcher = app.get(ReportsExportDispatcherService);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterAll(async () => {
|
|
48
|
+
await app.close();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('runs a byFilter bulk action via the prepare/execute token protocol, idempotently', async () => {
|
|
52
|
+
const { accessToken, orgId } = await createUserAndOrg('rep-bulk');
|
|
53
|
+
await createMemberInOrg(orgId, 'rep-bulk-b', ['reports.read']);
|
|
54
|
+
const key = uniqueKey('bulk');
|
|
55
|
+
await publishOrgMembers(accessToken, orgId, key);
|
|
56
|
+
|
|
57
|
+
const prep = await api()
|
|
58
|
+
.post(`/organisations/${orgId}/reports/${key}/actions/manageTags/prepare`)
|
|
59
|
+
.set(auth(accessToken))
|
|
60
|
+
.send({ selection: { byFilter: {} } })
|
|
61
|
+
.expect(200);
|
|
62
|
+
expect(prep.body.expectedCount).toBe(2);
|
|
63
|
+
expect(typeof prep.body.actionToken).toBe('string');
|
|
64
|
+
|
|
65
|
+
const exec = await api()
|
|
66
|
+
.post(`/organisations/${orgId}/reports/${key}/actions/manageTags`)
|
|
67
|
+
.set(auth(accessToken))
|
|
68
|
+
.send({
|
|
69
|
+
selection: { byFilter: {} },
|
|
70
|
+
actionToken: prep.body.actionToken,
|
|
71
|
+
input: { add: ['reviewed'] },
|
|
72
|
+
idempotencyKey: 'bulk-1',
|
|
73
|
+
})
|
|
74
|
+
.expect(200);
|
|
75
|
+
expect(exec.body.affectedRows).toBe(2);
|
|
76
|
+
expect(exec.body.replayed).toBe(false);
|
|
77
|
+
|
|
78
|
+
// Same idempotency key ⇒ the recorded outcome replays, no re-run.
|
|
79
|
+
const replay = await api()
|
|
80
|
+
.post(`/organisations/${orgId}/reports/${key}/actions/manageTags`)
|
|
81
|
+
.set(auth(accessToken))
|
|
82
|
+
.send({
|
|
83
|
+
selection: { byFilter: {} },
|
|
84
|
+
actionToken: prep.body.actionToken,
|
|
85
|
+
input: { add: ['reviewed'] },
|
|
86
|
+
idempotencyKey: 'bulk-1',
|
|
87
|
+
})
|
|
88
|
+
.expect(200);
|
|
89
|
+
expect(replay.body.replayed).toBe(true);
|
|
90
|
+
|
|
91
|
+
const tagged = await api()
|
|
92
|
+
.post(`/organisations/${orgId}/reports/${key}/query`)
|
|
93
|
+
.set(auth(accessToken))
|
|
94
|
+
.send({ filters: [{ column: '$tags', op: 'hasTag', value: 'reviewed' }] })
|
|
95
|
+
.expect(200);
|
|
96
|
+
expect(tagged.body.rows).toHaveLength(2);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('rejects a byFilter action whose selection drifted since prepare (409)', async () => {
|
|
100
|
+
const { accessToken, orgId } = await createUserAndOrg('rep-drift');
|
|
101
|
+
await createMemberInOrg(orgId, 'rep-drift-b', ['reports.read']);
|
|
102
|
+
const key = uniqueKey('drift');
|
|
103
|
+
await publishOrgMembers(accessToken, orgId, key);
|
|
104
|
+
|
|
105
|
+
const prep = await api()
|
|
106
|
+
.post(`/organisations/${orgId}/reports/${key}/actions/manageTags/prepare`)
|
|
107
|
+
.set(auth(accessToken))
|
|
108
|
+
.send({ selection: { byFilter: {} } })
|
|
109
|
+
.expect(200);
|
|
110
|
+
expect(prep.body.expectedCount).toBe(2);
|
|
111
|
+
|
|
112
|
+
// The selection grows between prepare and execute.
|
|
113
|
+
await createMemberInOrg(orgId, 'rep-drift-c', ['reports.read']);
|
|
114
|
+
|
|
115
|
+
const res = await api()
|
|
116
|
+
.post(`/organisations/${orgId}/reports/${key}/actions/manageTags`)
|
|
117
|
+
.set(auth(accessToken))
|
|
118
|
+
.send({
|
|
119
|
+
selection: { byFilter: {} },
|
|
120
|
+
actionToken: prep.body.actionToken,
|
|
121
|
+
input: { add: ['x'] },
|
|
122
|
+
idempotencyKey: 'drift-1',
|
|
123
|
+
})
|
|
124
|
+
.expect(409);
|
|
125
|
+
// The app envelope carries engine-typed extras under error.details.
|
|
126
|
+
expect(res.body.error.details.currentCount).toBe(3);
|
|
127
|
+
expect(res.body.error.details.code).toBe('REPORTS_SELECTION_DRIFT');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('produces an async export job, the worker streams it, and it downloads', async () => {
|
|
131
|
+
const { accessToken, orgId } = await createUserAndOrg('rep-async');
|
|
132
|
+
await createMemberInOrg(orgId, 'rep-async-b', ['reports.read']);
|
|
133
|
+
const key = uniqueKey('async');
|
|
134
|
+
await publishOrgMembers(accessToken, orgId, key);
|
|
135
|
+
|
|
136
|
+
// Force the async path: cap sync at 1 row, then export 2.
|
|
137
|
+
await api()
|
|
138
|
+
.patch(`/organisations/${orgId}/settings`)
|
|
139
|
+
.set(auth(accessToken))
|
|
140
|
+
.send({ key: 'reports.maxRowsSync', value: 1 })
|
|
141
|
+
.expect(200);
|
|
142
|
+
|
|
143
|
+
const queued = await api()
|
|
144
|
+
.post(`/organisations/${orgId}/reports/${key}/export`)
|
|
145
|
+
.set(auth(accessToken))
|
|
146
|
+
.send({ format: 'csv' })
|
|
147
|
+
.expect(202);
|
|
148
|
+
expect(queued.body.status).toBe('PENDING');
|
|
149
|
+
const jobId = queued.body.id as string;
|
|
150
|
+
|
|
151
|
+
// Drain the reports-owned worker deterministically (no forms outbox).
|
|
152
|
+
const cycle = await dispatcher.runOnce();
|
|
153
|
+
expect(cycle.done).toBeGreaterThanOrEqual(1);
|
|
154
|
+
|
|
155
|
+
const status = await api()
|
|
156
|
+
.get(`/organisations/${orgId}/reports/exports/${jobId}`)
|
|
157
|
+
.set(auth(accessToken))
|
|
158
|
+
.expect(200);
|
|
159
|
+
expect(status.body.status).toBe('DONE');
|
|
160
|
+
expect(status.body.rowCount).toBe(2);
|
|
161
|
+
expect(status.body.asOf).not.toBeNull();
|
|
162
|
+
|
|
163
|
+
const download = await api()
|
|
164
|
+
.get(`/organisations/${orgId}/reports/exports/${jobId}/download`)
|
|
165
|
+
.set(auth(accessToken))
|
|
166
|
+
.expect(200);
|
|
167
|
+
expect(download.headers['content-disposition']).toContain('attachment');
|
|
168
|
+
expect(download.text).toContain('Name');
|
|
169
|
+
expect(download.text.split(/\r?\n/).filter(Boolean).length).toBe(3); // header + 2 rows
|
|
170
|
+
|
|
171
|
+
await prisma.reportExportJob.update({
|
|
172
|
+
where: { id: jobId },
|
|
173
|
+
data: { updatedAt: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000) },
|
|
174
|
+
});
|
|
175
|
+
await expect(dispatcher.cleanupExpiredFiles()).resolves.toBeGreaterThanOrEqual(1);
|
|
176
|
+
|
|
177
|
+
const expired = await prisma.reportExportJob.findUnique({ where: { id: jobId } });
|
|
178
|
+
expect(expired?.fileId).toBeNull();
|
|
179
|
+
await api()
|
|
180
|
+
.get(`/organisations/${orgId}/reports/exports/${jobId}/download`)
|
|
181
|
+
.set(auth(accessToken))
|
|
182
|
+
.expect(404);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('streams a structurally valid XLSX synchronously', async () => {
|
|
186
|
+
const { accessToken, orgId } = await createUserAndOrg('rep-xlsx');
|
|
187
|
+
const key = uniqueKey('xlsx');
|
|
188
|
+
await publishOrgMembers(accessToken, orgId, key);
|
|
189
|
+
|
|
190
|
+
const res = await api()
|
|
191
|
+
.post(`/organisations/${orgId}/reports/${key}/export`)
|
|
192
|
+
.set(auth(accessToken))
|
|
193
|
+
.send({ format: 'xlsx' })
|
|
194
|
+
.buffer(true)
|
|
195
|
+
.parse(binaryParser)
|
|
196
|
+
.expect(200);
|
|
197
|
+
const body = res.body as Buffer;
|
|
198
|
+
expect(res.headers['content-type']).toContain('spreadsheetml');
|
|
199
|
+
// Every .xlsx is a ZIP — the local file header magic is "PK\x03\x04".
|
|
200
|
+
expect(body.subarray(0, 2).toString('latin1')).toBe('PK');
|
|
201
|
+
expect(body.byteLength).toBeGreaterThan(200);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('writes a PII-egress audit row for every export', async () => {
|
|
205
|
+
const { accessToken, orgId } = await createUserAndOrg('rep-audit');
|
|
206
|
+
const key = uniqueKey('audit');
|
|
207
|
+
await publishOrgMembers(accessToken, orgId, key);
|
|
208
|
+
|
|
209
|
+
await api()
|
|
210
|
+
.post(`/organisations/${orgId}/reports/${key}/export`)
|
|
211
|
+
.set(auth(accessToken))
|
|
212
|
+
.send({ format: 'csv' })
|
|
213
|
+
.expect(200);
|
|
214
|
+
|
|
215
|
+
const row = await pollFor(() =>
|
|
216
|
+
prisma.auditLog.findFirst({
|
|
217
|
+
where: { orgId, action: 'reports.export' },
|
|
218
|
+
orderBy: { createdAt: 'desc' },
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
expect(row).not.toBeNull();
|
|
222
|
+
const metadata = row!.metadata as Record<string, unknown>;
|
|
223
|
+
expect(metadata.reportKey).toBe(key);
|
|
224
|
+
expect(metadata.columns).toEqual(expect.arrayContaining(['displayName']));
|
|
225
|
+
expect(metadata.rowCount).toBe(1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('enforces unique shared-view names via the partial unique index', async () => {
|
|
229
|
+
const { accessToken, orgId } = await createUserAndOrg('rep-uq');
|
|
230
|
+
const key = uniqueKey('uq');
|
|
231
|
+
await publishOrgMembers(accessToken, orgId, key);
|
|
232
|
+
|
|
233
|
+
await api()
|
|
234
|
+
.post(`/organisations/${orgId}/reports/${key}/views/shared`)
|
|
235
|
+
.set(auth(accessToken))
|
|
236
|
+
.send({ name: 'Review board', spec: {} })
|
|
237
|
+
.expect(201);
|
|
238
|
+
|
|
239
|
+
await api()
|
|
240
|
+
.post(`/organisations/${orgId}/reports/${key}/views/shared`)
|
|
241
|
+
.set(auth(accessToken))
|
|
242
|
+
.send({ name: 'Review board', spec: {} })
|
|
243
|
+
.expect(409);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('fails an indexed-tier publish with the exact migration snippet attached', async () => {
|
|
247
|
+
const { accessToken, orgId } = await createUserAndOrg('rep-indexed');
|
|
248
|
+
const key = uniqueKey('indexed');
|
|
249
|
+
const def = orgMembersDefinition(key);
|
|
250
|
+
def.performanceTier = 'indexed';
|
|
251
|
+
|
|
252
|
+
// The draft saves (save-time lint does not touch the catalog)...
|
|
253
|
+
await api()
|
|
254
|
+
.post(`/organisations/${orgId}/reports`)
|
|
255
|
+
.set(auth(accessToken))
|
|
256
|
+
.send(def)
|
|
257
|
+
.expect(201);
|
|
258
|
+
|
|
259
|
+
// ...but publishing runs the catalog lint: the manifest's declared indexes
|
|
260
|
+
// do not exist, so it fails constructively with CREATE INDEX SQL (§8).
|
|
261
|
+
const res = await api()
|
|
262
|
+
.post(`/organisations/${orgId}/reports/${key}/publish`)
|
|
263
|
+
.set(auth(accessToken))
|
|
264
|
+
.expect(422);
|
|
265
|
+
expect(JSON.stringify(res.body)).toContain('CREATE INDEX');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
function api() {
|
|
271
|
+
return request(app.getHttpServer());
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function auth(token: string) {
|
|
275
|
+
return { Authorization: `Bearer ${token}` };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function uniqueSuffix() {
|
|
279
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function uniqueKey(label: string) {
|
|
283
|
+
return `${label}-${uniqueSuffix()}`.toLowerCase();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function orgMembersDefinition(key: string): Record<string, unknown> {
|
|
287
|
+
return {
|
|
288
|
+
key,
|
|
289
|
+
title: 'Organisation members',
|
|
290
|
+
source: { kind: 'custom', key: 'org-members' },
|
|
291
|
+
columns: [
|
|
292
|
+
{ id: 'displayName', header: 'Name', columnId: 'displayName', type: 'text', sortable: true, filterable: true, searchable: true },
|
|
293
|
+
{ id: 'email', header: 'Email', columnId: 'email', type: 'text', sortable: true, filterable: true },
|
|
294
|
+
{ id: 'role', header: 'Role', columnId: 'role', type: 'text', sortable: true, filterable: true },
|
|
295
|
+
{ id: 'status', header: 'Status', columnId: 'status', type: 'enum', filterable: true, enum: ['ACTIVE', 'SUSPENDED', 'REVOKED'] },
|
|
296
|
+
{ id: 'joinedAt', header: 'Joined', columnId: 'joinedAt', type: 'datetime', sortable: true },
|
|
297
|
+
],
|
|
298
|
+
defaultSort: [{ column: 'joinedAt', dir: 'desc' }],
|
|
299
|
+
search: { columns: ['displayName'] },
|
|
300
|
+
rowActions: ['manageTags'],
|
|
301
|
+
export: { formats: ['csv', 'xlsx'] },
|
|
302
|
+
performanceTier: 'live',
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function publishOrgMembers(accessToken: string, orgId: string, key: string) {
|
|
307
|
+
await api()
|
|
308
|
+
.post(`/organisations/${orgId}/reports`)
|
|
309
|
+
.set(auth(accessToken))
|
|
310
|
+
.send(orgMembersDefinition(key))
|
|
311
|
+
.expect(201);
|
|
312
|
+
await api()
|
|
313
|
+
.post(`/organisations/${orgId}/reports/${key}/publish`)
|
|
314
|
+
.set(auth(accessToken))
|
|
315
|
+
.expect(200);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function createUserAndOrg(label: string) {
|
|
319
|
+
const user = await createUser(label);
|
|
320
|
+
const org = await createOrganisation(user.accessToken, label);
|
|
321
|
+
return { ...user, orgId: org.orgId };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function createUser(label: string) {
|
|
325
|
+
const suffix = uniqueSuffix();
|
|
326
|
+
const signup = await api()
|
|
327
|
+
.post('/auth/signup')
|
|
328
|
+
.send({ email: `${label}-${suffix}@example.com`, password: 'test-password-123', displayName: label })
|
|
329
|
+
.expect(201);
|
|
330
|
+
return {
|
|
331
|
+
accessToken: signup.body.accessToken as string,
|
|
332
|
+
userId: signup.body.user.id as string,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function createOrganisation(accessToken: string, label: string) {
|
|
337
|
+
const suffix = uniqueSuffix();
|
|
338
|
+
const org = await api()
|
|
339
|
+
.post('/organisations')
|
|
340
|
+
.set(auth(accessToken))
|
|
341
|
+
.send({ name: `${label} ${suffix}`, slug: `${label}-${suffix}` })
|
|
342
|
+
.expect(201);
|
|
343
|
+
return { orgId: org.body.organisation.id as string };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function createMemberInOrg(orgId: string, label: string, permissionKeys: string[]) {
|
|
347
|
+
const user = await createUser(label);
|
|
348
|
+
const permissions = await prisma.permission.findMany({
|
|
349
|
+
where: { key: { in: permissionKeys } },
|
|
350
|
+
select: { id: true },
|
|
351
|
+
});
|
|
352
|
+
const role = await prisma.role.create({
|
|
353
|
+
data: {
|
|
354
|
+
orgId,
|
|
355
|
+
name: `${label}-${uniqueSuffix()}`,
|
|
356
|
+
permissions: { create: permissions.map((permission) => ({ permissionId: permission.id })) },
|
|
357
|
+
},
|
|
358
|
+
select: { id: true },
|
|
359
|
+
});
|
|
360
|
+
await prisma.membership.create({ data: { userId: user.userId, orgId, roleId: role.id } });
|
|
361
|
+
return user;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function pollFor<T>(fn: () => Promise<T | null>, attempts = 20): Promise<T | null> {
|
|
365
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
366
|
+
const value = await fn();
|
|
367
|
+
if (value !== null) {
|
|
368
|
+
return value;
|
|
369
|
+
}
|
|
370
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
371
|
+
}
|
|
372
|
+
return fn();
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
/** Collect a binary response body (StreamableFile) into one Buffer. */
|
|
377
|
+
function binaryParser(res: Response, cb: (err: Error | null, body: Buffer) => void): void {
|
|
378
|
+
const chunks: Buffer[] = [];
|
|
379
|
+
res.on('data', (chunk: Buffer) => chunks.push(Buffer.from(chunk)));
|
|
380
|
+
res.on('end', () => cb(null, Buffer.concat(chunks)));
|
|
381
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { REPORT_PERMISSION_KEYS } from '@ftisindia/report-builder';
|
|
2
|
+
import { permissionKeys } from '../src/modules/access-control/types/permission-key';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The template carries the report permission keys as literals (so the
|
|
6
|
+
* as-const tuple stays narrow and gen:module's insertion markers keep
|
|
7
|
+
* working); this test is the mechanical guarantee they can never drift from
|
|
8
|
+
* the engine's REPORT_PERMISSION_KEYS export — in either direction.
|
|
9
|
+
*/
|
|
10
|
+
describe('reports permission sync', () => {
|
|
11
|
+
const templateReportKeys = permissionKeys.filter(
|
|
12
|
+
(key) => key.startsWith('reports.') || key.startsWith('reportTags.'),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
it('contains every engine report permission key', () => {
|
|
16
|
+
for (const key of REPORT_PERMISSION_KEYS) {
|
|
17
|
+
expect(permissionKeys).toContain(key);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('has no report keys the engine does not declare', () => {
|
|
22
|
+
expect([...templateReportKeys].sort()).toEqual([...REPORT_PERMISSION_KEYS].sort());
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('carries the formSubmissions.update key the delegated grid verbs require', () => {
|
|
26
|
+
// Report design §6.1: editSubmission/updateStatus delegate to the form
|
|
27
|
+
// engine and are gated by this key from the FORM taxonomy.
|
|
28
|
+
expect(permissionKeys).toContain('formSubmissions.update');
|
|
29
|
+
});
|
|
30
|
+
});
|