@ftisindia/create-app 0.1.4 → 0.1.6
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 +31 -0
- package/template/README.md +61 -0
- package/template/_gitignore +6 -0
- package/template/_package.json +6 -0
- package/template/docs/FORMS.md +169 -0
- package/template/docs/FORMS_CHECKLIST.md +61 -0
- package/template/docs/REPORTS.md +246 -0
- package/template/docs/REPORTS_CHECKLIST.md +97 -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/schema.prisma +285 -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 +29 -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/app.config.ts +6 -1
- package/template/src/config/env.validation.ts +45 -0
- package/template/src/config/forms.config.ts +12 -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 +16 -0
- package/template/src/main.ts +16 -12
- package/template/src/modules/access-control/access-control.module.ts +2 -1
- 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 +35 -0
- package/template/src/modules/access-control/presentation/current-access-control.controller.ts +40 -0
- 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.handler.ts +41 -0
- package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +109 -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 +226 -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 +133 -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/application/services/organisations.service.ts +67 -1
- package/template/src/modules/organisations/dto/organisation-response.dto.ts +52 -0
- package/template/src/modules/organisations/presentation/organisations.controller.ts +25 -3
- 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 +124 -0
- package/template/src/modules/reports/application/services/reports-exports.service.ts +74 -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 +79 -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-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 +309 -0
- package/template/test/forms-permission-sync.spec.ts +27 -0
- package/template/test/forms-public.e2e-spec.ts +269 -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-webhooks.e2e-spec.ts +261 -0
- package/template/test/frontend-bootstrap.spec.ts +181 -0
- package/template/test/reports-advanced.e2e-spec.ts +368 -0
- package/template/test/reports-permission-sync.spec.ts +30 -0
- package/template/test/reports-query.e2e-spec.ts +350 -0
- package/template/test/reports-tiers.e2e-spec.ts +257 -0
- package/template/test/route-registry.validator.spec.ts +34 -0
- package/template/test/security.e2e-spec.ts +134 -2
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
2
|
+
import { Test } from '@nestjs/testing';
|
|
3
|
+
import { DataSourceRegistry } from '@ftisindia/form-builder';
|
|
4
|
+
import request from 'supertest';
|
|
5
|
+
import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
|
|
6
|
+
import { PrismaService } from '../src/database/prisma/prisma.service';
|
|
7
|
+
import { FileGcService } from '../src/modules/forms/application/services/file-gc.service';
|
|
8
|
+
|
|
9
|
+
const PDF_BYTES = Buffer.from('%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF');
|
|
10
|
+
const PNG_BYTES = Buffer.from([
|
|
11
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
describe('Forms file uploads (e2e)', () => {
|
|
15
|
+
let app: INestApplication;
|
|
16
|
+
let prisma: PrismaService;
|
|
17
|
+
let owner: { accessToken: string; userId: string; orgId: string };
|
|
18
|
+
let formKey: string;
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
|
|
22
|
+
process.env.JWT_SECRET ||= 'test-jwt-secret-at-least-16-chars';
|
|
23
|
+
process.env.AUTH_GOOGLE_ENABLED ||= 'false';
|
|
24
|
+
process.env.AUTH_EMAIL_PASSWORD_ENABLED ||= 'true';
|
|
25
|
+
process.env.NODE_ENV = 'test';
|
|
26
|
+
process.env.FORMS_OUTBOX_ENABLED = 'false';
|
|
27
|
+
process.env.FORMS_FILE_STORAGE_DIR = './var/test-uploads';
|
|
28
|
+
|
|
29
|
+
const { AppModule } = await import('../src/app.module');
|
|
30
|
+
const moduleRef = await Test.createTestingModule({
|
|
31
|
+
imports: [AppModule],
|
|
32
|
+
}).compile();
|
|
33
|
+
|
|
34
|
+
app = moduleRef.createNestApplication();
|
|
35
|
+
app.useGlobalFilters(new HttpExceptionFilter());
|
|
36
|
+
app.useGlobalPipes(
|
|
37
|
+
new ValidationPipe({
|
|
38
|
+
forbidNonWhitelisted: true,
|
|
39
|
+
transform: true,
|
|
40
|
+
whitelist: true,
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
await app.init();
|
|
44
|
+
prisma = app.get(PrismaService);
|
|
45
|
+
|
|
46
|
+
// The abstract-like definition binds a select to this source; register the
|
|
47
|
+
// stub BEFORE creating the definition so save-time lint passes.
|
|
48
|
+
app.get(DataSourceRegistry).register({
|
|
49
|
+
key: 'conference-tracks',
|
|
50
|
+
fetch: () => Promise.resolve([
|
|
51
|
+
{ value: 'ai', label: 'Artificial Intelligence' },
|
|
52
|
+
{ value: 'ml', label: 'Machine Learning' },
|
|
53
|
+
]),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
owner = await createUserAndOrg('files-owner');
|
|
57
|
+
formKey = await publishDefinition(abstractDefinition(uniqueFormKey('abstract')));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterAll(async () => {
|
|
61
|
+
await app.close();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('rejects mismatched content (415) and accepts a sniffed PDF', async () => {
|
|
65
|
+
await request(app.getHttpServer())
|
|
66
|
+
.post(`/organisations/${owner.orgId}/forms/${formKey}/files`)
|
|
67
|
+
.query({ field: 'manuscript' })
|
|
68
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
69
|
+
.attach('file', PNG_BYTES, 'sneaky.pdf')
|
|
70
|
+
.expect(415);
|
|
71
|
+
|
|
72
|
+
const upload = await request(app.getHttpServer())
|
|
73
|
+
.post(`/organisations/${owner.orgId}/forms/${formKey}/files`)
|
|
74
|
+
.query({ field: 'manuscript' })
|
|
75
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
76
|
+
.attach('file', PDF_BYTES, 'manuscript.pdf');
|
|
77
|
+
|
|
78
|
+
expect([200, 201]).toContain(upload.status);
|
|
79
|
+
expect(upload.body.fileId).toBeTruthy();
|
|
80
|
+
expect(upload.body.mimeType).toBe('application/pdf');
|
|
81
|
+
expect(upload.body.status).toBe('TEMPORARY');
|
|
82
|
+
expect(upload.body.checksum).toMatch(/^[0-9a-f]{64}$/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('validates uploads against the requested version or draft submission stamp', async () => {
|
|
86
|
+
const key = await publishDefinition(abstractDefinition(uniqueFormKey('versioned-file')));
|
|
87
|
+
const draft = await request(app.getHttpServer())
|
|
88
|
+
.post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
|
|
89
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
90
|
+
.send({ mode: 'draft', data: { title: 'Versioned upload', track: 'ai' } })
|
|
91
|
+
.expect(200);
|
|
92
|
+
|
|
93
|
+
const createdV2 = await request(app.getHttpServer())
|
|
94
|
+
.post(`/organisations/${owner.orgId}/forms`)
|
|
95
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
96
|
+
.send({ definition: abstractDefinition(key, ['image/png']) })
|
|
97
|
+
.expect(201);
|
|
98
|
+
await request(app.getHttpServer())
|
|
99
|
+
.post(
|
|
100
|
+
`/organisations/${owner.orgId}/forms/${key}/versions/${createdV2.body.version as number}/publish`,
|
|
101
|
+
)
|
|
102
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
103
|
+
.expect(200);
|
|
104
|
+
|
|
105
|
+
await request(app.getHttpServer())
|
|
106
|
+
.post(`/organisations/${owner.orgId}/forms/${key}/files`)
|
|
107
|
+
.query({ field: 'manuscript' })
|
|
108
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
109
|
+
.attach('file', PDF_BYTES, 'manuscript.pdf')
|
|
110
|
+
.expect(415);
|
|
111
|
+
|
|
112
|
+
const pngUpload = await request(app.getHttpServer())
|
|
113
|
+
.post(`/organisations/${owner.orgId}/forms/${key}/files`)
|
|
114
|
+
.query({ field: 'manuscript', version: createdV2.body.version as number })
|
|
115
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
116
|
+
.attach('file', PNG_BYTES, 'manuscript.png');
|
|
117
|
+
expect([200, 201]).toContain(pngUpload.status);
|
|
118
|
+
|
|
119
|
+
await request(app.getHttpServer())
|
|
120
|
+
.post(`/organisations/${owner.orgId}/forms/${key}/files`)
|
|
121
|
+
.query({
|
|
122
|
+
field: 'manuscript',
|
|
123
|
+
submissionId: draft.body.submissionId as string,
|
|
124
|
+
version: createdV2.body.version as number,
|
|
125
|
+
})
|
|
126
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
127
|
+
.attach('file', PNG_BYTES, 'manuscript.png')
|
|
128
|
+
.expect(409);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('links the referenced file to the submission (TEMPORARY -> LINKED)', async () => {
|
|
132
|
+
const fileId = await uploadPdf(owner.accessToken);
|
|
133
|
+
|
|
134
|
+
const submitted = await request(app.getHttpServer())
|
|
135
|
+
.post(`/organisations/${owner.orgId}/forms/${formKey}/submissions`)
|
|
136
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
137
|
+
.send({
|
|
138
|
+
mode: 'submit',
|
|
139
|
+
data: { title: 'On Linking Files', track: 'ai', manuscript: { fileId } },
|
|
140
|
+
})
|
|
141
|
+
.expect(200);
|
|
142
|
+
|
|
143
|
+
const submissionId = submitted.body.submissionId as string;
|
|
144
|
+
expect(submissionId).toBeTruthy();
|
|
145
|
+
|
|
146
|
+
const row = await prisma.uploadedFile.findUnique({ where: { id: fileId } });
|
|
147
|
+
expect(row?.status).toBe('LINKED');
|
|
148
|
+
expect(row?.submissionId).toBe(submissionId);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('rejects another member referencing a file they did not upload (IDOR)', async () => {
|
|
152
|
+
const fileId = await uploadPdf(owner.accessToken);
|
|
153
|
+
const member = await createMemberInOrg(owner.orgId, 'files-member', [
|
|
154
|
+
'formSubmissions.create',
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
const response = await request(app.getHttpServer())
|
|
158
|
+
.post(`/organisations/${owner.orgId}/forms/${formKey}/submissions`)
|
|
159
|
+
.set('Authorization', `Bearer ${member.accessToken}`)
|
|
160
|
+
.send({
|
|
161
|
+
mode: 'submit',
|
|
162
|
+
data: { title: 'Stolen Reference', track: 'ml', manuscript: { fileId } },
|
|
163
|
+
})
|
|
164
|
+
.expect(422);
|
|
165
|
+
|
|
166
|
+
const errors = response.body.error.details.errors as Array<{ code: string; path: string }>;
|
|
167
|
+
expect(errors.some((error) => error.code === 'FILE_REFERENCE')).toBe(true);
|
|
168
|
+
|
|
169
|
+
// The file is untouched.
|
|
170
|
+
const row = await prisma.uploadedFile.findUnique({ where: { id: fileId } });
|
|
171
|
+
expect(row?.status).toBe('TEMPORARY');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('garbage-collects orphaned TEMPORARY uploads past the TTL', async () => {
|
|
175
|
+
const fileId = await uploadPdf(owner.accessToken);
|
|
176
|
+
|
|
177
|
+
await prisma.uploadedFile.update({
|
|
178
|
+
where: { id: fileId },
|
|
179
|
+
data: { createdAt: new Date(Date.now() - 48 * 60 * 60 * 1000) },
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const removed = await app.get(FileGcService).runOnce();
|
|
183
|
+
expect(removed).toBeGreaterThanOrEqual(1);
|
|
184
|
+
|
|
185
|
+
const row = await prisma.uploadedFile.findUnique({ where: { id: fileId } });
|
|
186
|
+
expect(row).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('refuses to flip a definition with file fields to public access', async () => {
|
|
190
|
+
const response = await request(app.getHttpServer())
|
|
191
|
+
.post(`/organisations/${owner.orgId}/forms/${formKey}/versions/1/public-access`)
|
|
192
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
193
|
+
.send({ access: 'public' })
|
|
194
|
+
.expect(422);
|
|
195
|
+
|
|
196
|
+
const issues = response.body.error.details.issues as Array<{ code: string }>;
|
|
197
|
+
expect(issues.map((issue) => issue.code)).toContain('PUBLIC_FILE_FIELD');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
function uniqueSuffix() {
|
|
203
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function uniqueFormKey(label: string) {
|
|
207
|
+
return `${label}-${uniqueSuffix()}`.toLowerCase();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function abstractDefinition(
|
|
211
|
+
key: string,
|
|
212
|
+
accept: string[] = ['application/pdf'],
|
|
213
|
+
): Record<string, unknown> {
|
|
214
|
+
return {
|
|
215
|
+
key,
|
|
216
|
+
version: 1,
|
|
217
|
+
title: 'Scientific abstract submission',
|
|
218
|
+
fields: [
|
|
219
|
+
{
|
|
220
|
+
type: 'text',
|
|
221
|
+
name: 'title',
|
|
222
|
+
label: 'Abstract title',
|
|
223
|
+
validators: { required: true, maxLength: 200 },
|
|
224
|
+
reportable: true,
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
type: 'select',
|
|
228
|
+
name: 'track',
|
|
229
|
+
label: 'Conference track',
|
|
230
|
+
dataSource: { key: 'conference-tracks' },
|
|
231
|
+
validators: { required: true },
|
|
232
|
+
reportable: true,
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
type: 'file',
|
|
236
|
+
name: 'manuscript',
|
|
237
|
+
label: 'Full manuscript (PDF)',
|
|
238
|
+
accept,
|
|
239
|
+
maxSizeMb: 5,
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
actions: {
|
|
243
|
+
submit: ['validateAll', 'persist'],
|
|
244
|
+
saveDraft: ['persistDraft'],
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function uploadPdf(accessToken: string): Promise<string> {
|
|
250
|
+
const upload = await request(app.getHttpServer())
|
|
251
|
+
.post(`/organisations/${owner.orgId}/forms/${formKey}/files`)
|
|
252
|
+
.query({ field: 'manuscript' })
|
|
253
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
254
|
+
.attach('file', PDF_BYTES, 'manuscript.pdf');
|
|
255
|
+
|
|
256
|
+
expect([200, 201]).toContain(upload.status);
|
|
257
|
+
return upload.body.fileId as string;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function publishDefinition(definition: Record<string, unknown>): Promise<string> {
|
|
261
|
+
const created = await request(app.getHttpServer())
|
|
262
|
+
.post(`/organisations/${owner.orgId}/forms`)
|
|
263
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
264
|
+
.send({ definition })
|
|
265
|
+
.expect(201);
|
|
266
|
+
|
|
267
|
+
await request(app.getHttpServer())
|
|
268
|
+
.post(
|
|
269
|
+
`/organisations/${owner.orgId}/forms/${created.body.key as string}/versions/${created.body.version as number}/publish`,
|
|
270
|
+
)
|
|
271
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
272
|
+
.expect(200);
|
|
273
|
+
|
|
274
|
+
return created.body.key as string;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function createUser(label: string) {
|
|
278
|
+
const suffix = uniqueSuffix();
|
|
279
|
+
const signup = await request(app.getHttpServer())
|
|
280
|
+
.post('/auth/signup')
|
|
281
|
+
.send({
|
|
282
|
+
email: `${label}-${suffix}@example.com`,
|
|
283
|
+
password: 'test-password-123',
|
|
284
|
+
displayName: label,
|
|
285
|
+
})
|
|
286
|
+
.expect(201);
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
accessToken: signup.body.accessToken as string,
|
|
290
|
+
userId: signup.body.user.id as string,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function createUserAndOrg(label: string) {
|
|
295
|
+
const user = await createUser(label);
|
|
296
|
+
const suffix = uniqueSuffix();
|
|
297
|
+
const org = await request(app.getHttpServer())
|
|
298
|
+
.post('/organisations')
|
|
299
|
+
.set('Authorization', `Bearer ${user.accessToken}`)
|
|
300
|
+
.send({
|
|
301
|
+
name: `${label} ${suffix}`,
|
|
302
|
+
slug: `${label}-${suffix}`,
|
|
303
|
+
})
|
|
304
|
+
.expect(201);
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
...user,
|
|
308
|
+
orgId: org.body.organisation.id as string,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function createMemberInOrg(orgId: string, label: string, permissionKeys: string[]) {
|
|
313
|
+
const user = await createUser(label);
|
|
314
|
+
const permissions = await prisma.permission.findMany({
|
|
315
|
+
where: { key: { in: permissionKeys } },
|
|
316
|
+
select: { id: true },
|
|
317
|
+
});
|
|
318
|
+
expect(permissions).toHaveLength(permissionKeys.length);
|
|
319
|
+
|
|
320
|
+
const role = await prisma.role.create({
|
|
321
|
+
data: {
|
|
322
|
+
orgId,
|
|
323
|
+
name: `${label}-${uniqueSuffix()}`,
|
|
324
|
+
permissions: {
|
|
325
|
+
create: permissions.map((permission) => ({ permissionId: permission.id })),
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
select: { id: true },
|
|
329
|
+
});
|
|
330
|
+
const membership = await prisma.membership.create({
|
|
331
|
+
data: {
|
|
332
|
+
userId: user.userId,
|
|
333
|
+
orgId,
|
|
334
|
+
roleId: role.id,
|
|
335
|
+
},
|
|
336
|
+
select: { id: true, roleId: true },
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
...user,
|
|
341
|
+
membershipId: membership.id,
|
|
342
|
+
roleId: membership.roleId,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
});
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
2
|
+
import { Test } from '@nestjs/testing';
|
|
3
|
+
import { ActionRegistry, OutboxHandlerRegistry } from '@ftisindia/form-builder';
|
|
4
|
+
import request from 'supertest';
|
|
5
|
+
import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
|
|
6
|
+
import { PrismaService } from '../src/database/prisma/prisma.service';
|
|
7
|
+
import { AuditService } from '../src/modules/audit/application/services/audit.service';
|
|
8
|
+
import { OutboxDispatcherService } from '../src/modules/forms/application/services/outbox-dispatcher.service';
|
|
9
|
+
|
|
10
|
+
describe('Forms transactional outbox (e2e)', () => {
|
|
11
|
+
let app: INestApplication;
|
|
12
|
+
let prisma: PrismaService;
|
|
13
|
+
let dispatcher: OutboxDispatcherService;
|
|
14
|
+
let owner: { accessToken: string; userId: string; orgId: string };
|
|
15
|
+
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
|
|
18
|
+
process.env.JWT_SECRET ||= 'test-jwt-secret-at-least-16-chars';
|
|
19
|
+
process.env.AUTH_GOOGLE_ENABLED ||= 'false';
|
|
20
|
+
process.env.AUTH_EMAIL_PASSWORD_ENABLED ||= 'true';
|
|
21
|
+
process.env.NODE_ENV = 'test';
|
|
22
|
+
process.env.FORMS_OUTBOX_ENABLED = 'false';
|
|
23
|
+
process.env.FORMS_FILE_STORAGE_DIR = './var/test-uploads';
|
|
24
|
+
|
|
25
|
+
const { AppModule } = await import('../src/app.module');
|
|
26
|
+
const moduleRef = await Test.createTestingModule({
|
|
27
|
+
imports: [AppModule],
|
|
28
|
+
}).compile();
|
|
29
|
+
|
|
30
|
+
app = moduleRef.createNestApplication();
|
|
31
|
+
app.useGlobalFilters(new HttpExceptionFilter());
|
|
32
|
+
app.useGlobalPipes(
|
|
33
|
+
new ValidationPipe({
|
|
34
|
+
forbidNonWhitelisted: true,
|
|
35
|
+
transform: true,
|
|
36
|
+
whitelist: true,
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
await app.init();
|
|
40
|
+
prisma = app.get(PrismaService);
|
|
41
|
+
dispatcher = app.get(OutboxDispatcherService);
|
|
42
|
+
|
|
43
|
+
// Test-only extensions: a handler type that always throws, and a
|
|
44
|
+
// post-commit action that enqueues a job for it with a tight retry budget.
|
|
45
|
+
app.get(OutboxHandlerRegistry).register({
|
|
46
|
+
type: 'boom',
|
|
47
|
+
handle: () => {
|
|
48
|
+
return Promise.reject(new Error('boom handler failure'));
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
app.get(ActionRegistry).register({
|
|
52
|
+
name: 'enqueueBoom',
|
|
53
|
+
kind: 'post-commit',
|
|
54
|
+
execute: async (ctx) => {
|
|
55
|
+
await ctx.enqueue({ type: 'boom', payload: {}, maxAttempts: 2 });
|
|
56
|
+
return { queued: true };
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Park stray PENDING jobs left by earlier suites/runs so runOnce() cycles
|
|
61
|
+
// in this suite deterministically claim the jobs created here (the claim
|
|
62
|
+
// batch is ordered oldest-first and capped).
|
|
63
|
+
await prisma.formOutboxJob.updateMany({
|
|
64
|
+
where: { status: { in: ['PENDING', 'PROCESSING'] } },
|
|
65
|
+
data: { status: 'FAILED', lastError: 'parked by forms-outbox e2e setup' },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
owner = await createUserAndOrg('outbox-owner');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterAll(async () => {
|
|
72
|
+
await app.close();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('enqueues atomically with the submission and delivers via runOnce()', async () => {
|
|
76
|
+
const key = await publishDefinition(registrationDefinition(uniqueFormKey('outbox-email')));
|
|
77
|
+
|
|
78
|
+
const response = await request(app.getHttpServer())
|
|
79
|
+
.post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
|
|
80
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
81
|
+
.send({
|
|
82
|
+
mode: 'submit',
|
|
83
|
+
data: { fullName: 'Outbox Tester', email: 'outbox@example.com', ticketType: 'standard' },
|
|
84
|
+
})
|
|
85
|
+
.expect(200);
|
|
86
|
+
|
|
87
|
+
const submissionId = response.body.submissionId as string;
|
|
88
|
+
expect(submissionId).toBeTruthy();
|
|
89
|
+
|
|
90
|
+
const job = await prisma.formOutboxJob.findFirst({
|
|
91
|
+
where: { idempotencyKey: { contains: submissionId } },
|
|
92
|
+
});
|
|
93
|
+
expect(job).not.toBeNull();
|
|
94
|
+
expect(job?.status).toBe('PENDING');
|
|
95
|
+
expect(job?.type).toBe('email');
|
|
96
|
+
|
|
97
|
+
await dispatcher.runOnce();
|
|
98
|
+
|
|
99
|
+
const delivered = await prisma.formOutboxJob.findUnique({ where: { id: job!.id } });
|
|
100
|
+
expect(delivered?.status).toBe('DONE');
|
|
101
|
+
|
|
102
|
+
const auditRow = await prisma.auditLog.findFirst({
|
|
103
|
+
where: { action: 'forms.outbox.delivered', targetId: job!.id },
|
|
104
|
+
});
|
|
105
|
+
expect(auditRow).not.toBeNull();
|
|
106
|
+
expect(auditRow?.orgId).toBe(owner.orgId);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('retries a failing handler with backoff, then parks the job at maxAttempts', async () => {
|
|
110
|
+
const key = uniqueFormKey('outbox-boom');
|
|
111
|
+
await publishDefinition({
|
|
112
|
+
...registrationDefinition(key),
|
|
113
|
+
actions: {
|
|
114
|
+
submit: ['validateAll', 'persist', 'enqueueBoom'],
|
|
115
|
+
saveDraft: ['persistDraft'],
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const response = await request(app.getHttpServer())
|
|
120
|
+
.post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
|
|
121
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
122
|
+
.send({
|
|
123
|
+
mode: 'submit',
|
|
124
|
+
data: { fullName: 'Boom Tester', email: 'boom@example.com', ticketType: 'vip' },
|
|
125
|
+
})
|
|
126
|
+
.expect(200);
|
|
127
|
+
|
|
128
|
+
const submissionId = response.body.submissionId as string;
|
|
129
|
+
const job = await prisma.formOutboxJob.findFirst({
|
|
130
|
+
where: { type: 'boom', idempotencyKey: { contains: submissionId } },
|
|
131
|
+
});
|
|
132
|
+
expect(job).not.toBeNull();
|
|
133
|
+
expect(job?.maxAttempts).toBe(2);
|
|
134
|
+
|
|
135
|
+
// First attempt fails -> retried: back to PENDING with a future runAfter.
|
|
136
|
+
await dispatcher.runOnce();
|
|
137
|
+
const retried = await prisma.formOutboxJob.findUnique({ where: { id: job!.id } });
|
|
138
|
+
expect(retried?.status).toBe('PENDING');
|
|
139
|
+
expect(retried?.attempts).toBe(1);
|
|
140
|
+
expect(retried?.runAfter).not.toBeNull();
|
|
141
|
+
expect(retried!.runAfter!.getTime()).toBeGreaterThan(Date.now());
|
|
142
|
+
|
|
143
|
+
// Make the retry due, then fail again -> parked as FAILED at maxAttempts.
|
|
144
|
+
await prisma.formOutboxJob.update({
|
|
145
|
+
where: { id: job!.id },
|
|
146
|
+
data: { runAfter: new Date(Date.now() - 1000) },
|
|
147
|
+
});
|
|
148
|
+
await dispatcher.runOnce();
|
|
149
|
+
|
|
150
|
+
const parked = await prisma.formOutboxJob.findUnique({ where: { id: job!.id } });
|
|
151
|
+
expect(parked?.status).toBe('FAILED');
|
|
152
|
+
expect(parked?.attempts).toBe(2);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('fails immediately when no handler is registered for the job type', async () => {
|
|
156
|
+
const orphan = await prisma.formOutboxJob.create({
|
|
157
|
+
data: {
|
|
158
|
+
type: `no-handler-${uniqueSuffix()}`,
|
|
159
|
+
payload: {},
|
|
160
|
+
status: 'PENDING',
|
|
161
|
+
attempts: 0,
|
|
162
|
+
maxAttempts: 5,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await dispatcher.runOnce();
|
|
167
|
+
|
|
168
|
+
const failed = await prisma.formOutboxJob.findUnique({ where: { id: orphan.id } });
|
|
169
|
+
expect(failed?.status).toBe('FAILED');
|
|
170
|
+
expect(failed?.attempts).toBe(1);
|
|
171
|
+
expect(failed?.lastError).toMatch(/No handler registered/);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('does not retry a delivered job when the success audit write fails', async () => {
|
|
175
|
+
const job = await prisma.formOutboxJob.create({
|
|
176
|
+
data: {
|
|
177
|
+
type: 'email',
|
|
178
|
+
payload: { to: 'audit-failure@example.com', template: 'test' },
|
|
179
|
+
status: 'PENDING',
|
|
180
|
+
attempts: 0,
|
|
181
|
+
maxAttempts: 3,
|
|
182
|
+
orgId: owner.orgId,
|
|
183
|
+
actorUserId: owner.userId,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
const audit = app.get(AuditService);
|
|
187
|
+
const spy = jest.spyOn(audit, 'write').mockRejectedValueOnce(new Error('audit down'));
|
|
188
|
+
|
|
189
|
+
await dispatcher.runOnce();
|
|
190
|
+
|
|
191
|
+
const delivered = await prisma.formOutboxJob.findUnique({ where: { id: job.id } });
|
|
192
|
+
expect(delivered?.status).toBe('DONE');
|
|
193
|
+
expect(delivered?.attempts).toBe(0);
|
|
194
|
+
expect(delivered?.lastError).toBeNull();
|
|
195
|
+
spy.mockRestore();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('reclaims stale PROCESSING jobs', async () => {
|
|
199
|
+
const staleTime = new Date(Date.now() - 10 * 60 * 1000);
|
|
200
|
+
const job = await prisma.formOutboxJob.create({
|
|
201
|
+
data: {
|
|
202
|
+
type: 'email',
|
|
203
|
+
payload: { to: 'stale@example.com', template: 'test' },
|
|
204
|
+
status: 'PROCESSING',
|
|
205
|
+
attempts: 0,
|
|
206
|
+
maxAttempts: 3,
|
|
207
|
+
orgId: owner.orgId,
|
|
208
|
+
actorUserId: owner.userId,
|
|
209
|
+
createdAt: staleTime,
|
|
210
|
+
updatedAt: staleTime,
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await dispatcher.runOnce();
|
|
215
|
+
|
|
216
|
+
const delivered = await prisma.formOutboxJob.findUnique({ where: { id: job.id } });
|
|
217
|
+
expect(delivered?.status).toBe('DONE');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
function uniqueSuffix() {
|
|
223
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function uniqueFormKey(label: string) {
|
|
227
|
+
return `${label}-${uniqueSuffix()}`.toLowerCase();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function registrationDefinition(key: string): Record<string, unknown> {
|
|
231
|
+
return {
|
|
232
|
+
key,
|
|
233
|
+
version: 1,
|
|
234
|
+
title: 'Event registration',
|
|
235
|
+
fields: [
|
|
236
|
+
{
|
|
237
|
+
type: 'text',
|
|
238
|
+
name: 'fullName',
|
|
239
|
+
label: 'Full name',
|
|
240
|
+
validators: { required: true, maxLength: 120 },
|
|
241
|
+
reportable: true,
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
type: 'email',
|
|
245
|
+
name: 'email',
|
|
246
|
+
label: 'Email address',
|
|
247
|
+
validators: { required: true },
|
|
248
|
+
reportable: true,
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
type: 'select',
|
|
252
|
+
name: 'ticketType',
|
|
253
|
+
label: 'Ticket type',
|
|
254
|
+
validators: { required: true, options: ['standard', 'student', 'vip'] },
|
|
255
|
+
reportable: true,
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
actions: {
|
|
259
|
+
submit: ['validateAll', 'persist', 'sendConfirmationEmail'],
|
|
260
|
+
saveDraft: ['persistDraft'],
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function publishDefinition(definition: Record<string, unknown>): Promise<string> {
|
|
266
|
+
const created = await request(app.getHttpServer())
|
|
267
|
+
.post(`/organisations/${owner.orgId}/forms`)
|
|
268
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
269
|
+
.send({ definition })
|
|
270
|
+
.expect(201);
|
|
271
|
+
|
|
272
|
+
await request(app.getHttpServer())
|
|
273
|
+
.post(
|
|
274
|
+
`/organisations/${owner.orgId}/forms/${created.body.key as string}/versions/${created.body.version as number}/publish`,
|
|
275
|
+
)
|
|
276
|
+
.set('Authorization', `Bearer ${owner.accessToken}`)
|
|
277
|
+
.expect(200);
|
|
278
|
+
|
|
279
|
+
return created.body.key as string;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function createUserAndOrg(label: string) {
|
|
283
|
+
const suffix = uniqueSuffix();
|
|
284
|
+
const signup = await request(app.getHttpServer())
|
|
285
|
+
.post('/auth/signup')
|
|
286
|
+
.send({
|
|
287
|
+
email: `${label}-${suffix}@example.com`,
|
|
288
|
+
password: 'test-password-123',
|
|
289
|
+
displayName: label,
|
|
290
|
+
})
|
|
291
|
+
.expect(201);
|
|
292
|
+
|
|
293
|
+
const accessToken = signup.body.accessToken as string;
|
|
294
|
+
const org = await request(app.getHttpServer())
|
|
295
|
+
.post('/organisations')
|
|
296
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
297
|
+
.send({
|
|
298
|
+
name: `${label} ${suffix}`,
|
|
299
|
+
slug: `${label}-${suffix}`,
|
|
300
|
+
})
|
|
301
|
+
.expect(201);
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
accessToken,
|
|
305
|
+
userId: signup.body.user.id as string,
|
|
306
|
+
orgId: org.body.organisation.id as string,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { FORM_PERMISSION_KEYS } from '@ftisindia/form-builder';
|
|
2
|
+
import { permissionKeys } from '../src/modules/access-control/types/permission-key';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The template carries the form permission keys as literals (so the as-const
|
|
6
|
+
* tuple stays narrow and gen:module's insertion markers keep working); this
|
|
7
|
+
* test is the mechanical guarantee they can never drift from the engine's
|
|
8
|
+
* FORM_PERMISSION_KEYS export — in either direction.
|
|
9
|
+
*/
|
|
10
|
+
describe('forms permission sync', () => {
|
|
11
|
+
const templateFormKeys = permissionKeys.filter(
|
|
12
|
+
(key) =>
|
|
13
|
+
key.startsWith('forms.') ||
|
|
14
|
+
key.startsWith('formSubmissions.') ||
|
|
15
|
+
key.startsWith('formDataSources.'),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
it('contains every engine form permission key', () => {
|
|
19
|
+
for (const key of FORM_PERMISSION_KEYS) {
|
|
20
|
+
expect(permissionKeys).toContain(key);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('has no form keys the engine does not declare', () => {
|
|
25
|
+
expect([...templateFormKeys].sort()).toEqual([...FORM_PERMISSION_KEYS].sort());
|
|
26
|
+
});
|
|
27
|
+
});
|