@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,402 @@
|
|
|
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
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reports end-to-end (@ftisindia/report-builder) over the `org-members`
|
|
9
|
+
* CUSTOM source — the Phase-1 standalone milestone (report design §14): a
|
|
10
|
+
* report that filters/sorts/searches/paginates at constant cost with ZERO
|
|
11
|
+
* form-builder involvement. Creating an org seeds the owner's membership, so
|
|
12
|
+
* the source has real rows to query.
|
|
13
|
+
*/
|
|
14
|
+
// Cold ts-jest compile of the full app graph + boot (DI scan, schema checks,
|
|
15
|
+
// Prisma connect) exceeds jest's 5s default; each test also runs many
|
|
16
|
+
// sequential HTTP + DB calls.
|
|
17
|
+
jest.setTimeout(60_000);
|
|
18
|
+
|
|
19
|
+
describe('Reports query lifecycle (e2e)', () => {
|
|
20
|
+
let app: INestApplication;
|
|
21
|
+
let prisma: PrismaService;
|
|
22
|
+
|
|
23
|
+
beforeAll(async () => {
|
|
24
|
+
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
|
|
25
|
+
process.env.JWT_SECRET ||= 'test-jwt-secret-at-least-32-characters-long';
|
|
26
|
+
process.env.AUTH_GOOGLE_ENABLED ||= 'false';
|
|
27
|
+
process.env.AUTH_EMAIL_PASSWORD_ENABLED ||= 'true';
|
|
28
|
+
process.env.NODE_ENV = 'test';
|
|
29
|
+
process.env.FORMS_OUTBOX_ENABLED = 'false';
|
|
30
|
+
process.env.REPORTS_SCHEMA_CHECK = 'on';
|
|
31
|
+
|
|
32
|
+
const { AppModule } = await import('../src/app.module');
|
|
33
|
+
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
|
34
|
+
|
|
35
|
+
app = moduleRef.createNestApplication();
|
|
36
|
+
app.useGlobalFilters(new HttpExceptionFilter());
|
|
37
|
+
app.useGlobalPipes(
|
|
38
|
+
new ValidationPipe({ forbidNonWhitelisted: true, transform: true, whitelist: true }),
|
|
39
|
+
);
|
|
40
|
+
await app.init();
|
|
41
|
+
prisma = app.get(PrismaService);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterAll(async () => {
|
|
45
|
+
await app.close();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('publishes a custom-source report and queries member rows (the standalone milestone)', async () => {
|
|
49
|
+
const { accessToken, orgId } = await createUserAndOrg('rep-pub');
|
|
50
|
+
const key = uniqueKey('members');
|
|
51
|
+
|
|
52
|
+
const draft = await api()
|
|
53
|
+
.post(`/organisations/${orgId}/reports`)
|
|
54
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
55
|
+
.send(orgMembersDefinition(key))
|
|
56
|
+
.expect(201);
|
|
57
|
+
expect(draft.body.status).toBe('DRAFT');
|
|
58
|
+
expect(draft.body.version).toBe(1);
|
|
59
|
+
|
|
60
|
+
await api()
|
|
61
|
+
.post(`/organisations/${orgId}/reports/${key}/publish`)
|
|
62
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
63
|
+
.expect(200)
|
|
64
|
+
.expect((res) => expect(res.body.status).toBe('PUBLISHED'));
|
|
65
|
+
|
|
66
|
+
const result = await api()
|
|
67
|
+
.post(`/organisations/${orgId}/reports/${key}/query`)
|
|
68
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
69
|
+
.send({ pageSize: 50 })
|
|
70
|
+
.expect(200);
|
|
71
|
+
|
|
72
|
+
expect(result.body.rows).toHaveLength(1); // the owner
|
|
73
|
+
const row = result.body.rows[0];
|
|
74
|
+
expect(typeof row.$id).toBe('string');
|
|
75
|
+
expect(Array.isArray(row.$tags)).toBe(true);
|
|
76
|
+
expect(row.role).toBe('Owner');
|
|
77
|
+
expect(row.status).toBe('ACTIVE');
|
|
78
|
+
expect(typeof row.displayName).toBe('string');
|
|
79
|
+
expect(result.body.meta.reportVersion).toBe(1);
|
|
80
|
+
expect(result.body.pageInfo.hasMore).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('paginates members at constant cost with keyset cursors', async () => {
|
|
84
|
+
const { accessToken, orgId } = await createUserAndOrg('rep-page');
|
|
85
|
+
await createMemberInOrg(orgId, 'rep-page-b', ['reports.read']);
|
|
86
|
+
await createMemberInOrg(orgId, 'rep-page-c', ['reports.read']);
|
|
87
|
+
const key = uniqueKey('page');
|
|
88
|
+
await publishOrgMembers(accessToken, orgId, key);
|
|
89
|
+
|
|
90
|
+
// Three members; walk one row per page via the signed cursor.
|
|
91
|
+
const seen = new Set<string>();
|
|
92
|
+
let cursor: string | null = null;
|
|
93
|
+
for (let page = 0; page < 3; page += 1) {
|
|
94
|
+
const res: Response = await api()
|
|
95
|
+
.post(`/organisations/${orgId}/reports/${key}/query`)
|
|
96
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
97
|
+
.send({ pageSize: 1, sort: [{ column: 'displayName', dir: 'asc' }], cursor })
|
|
98
|
+
.expect(200);
|
|
99
|
+
expect(res.body.rows).toHaveLength(1);
|
|
100
|
+
seen.add(res.body.rows[0].$id);
|
|
101
|
+
cursor = res.body.pageInfo.nextCursor;
|
|
102
|
+
expect(res.body.pageInfo.hasMore).toBe(page < 2);
|
|
103
|
+
}
|
|
104
|
+
expect(seen.size).toBe(3); // every page distinct — no OFFSET skips/dupes
|
|
105
|
+
expect(cursor).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('filters and sorts through the typed compiler', async () => {
|
|
109
|
+
const { accessToken, orgId } = await createUserAndOrg('rep-filter');
|
|
110
|
+
const key = uniqueKey('filter');
|
|
111
|
+
await publishOrgMembers(accessToken, orgId, key);
|
|
112
|
+
|
|
113
|
+
await api()
|
|
114
|
+
.post(`/organisations/${orgId}/reports/${key}/query`)
|
|
115
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
116
|
+
.send({ filters: [{ column: 'status', op: 'eq', value: 'ACTIVE' }] })
|
|
117
|
+
.expect(200)
|
|
118
|
+
.expect((res) => expect(res.body.rows.length).toBeGreaterThanOrEqual(1));
|
|
119
|
+
|
|
120
|
+
// A typed-operator violation is a 400 BEFORE any SQL runs (§4).
|
|
121
|
+
await api()
|
|
122
|
+
.post(`/organisations/${orgId}/reports/${key}/query`)
|
|
123
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
124
|
+
.send({ filters: [{ column: 'joinedAt', op: 'contains', value: 'x' }] })
|
|
125
|
+
.expect(400);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('describes itself through the meta endpoint', async () => {
|
|
129
|
+
const { accessToken, orgId } = await createUserAndOrg('rep-meta');
|
|
130
|
+
const key = uniqueKey('meta');
|
|
131
|
+
await publishOrgMembers(accessToken, orgId, key);
|
|
132
|
+
|
|
133
|
+
const meta = await api()
|
|
134
|
+
.get(`/organisations/${orgId}/reports/${key}/meta`)
|
|
135
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
136
|
+
.expect(200);
|
|
137
|
+
|
|
138
|
+
expect(meta.body.reportVersion).toBe(1);
|
|
139
|
+
expect(meta.body.searchEnabled).toBe(true);
|
|
140
|
+
expect(meta.body.rowActions.map((a: { name: string }) => a.name)).toContain('manageTags');
|
|
141
|
+
const status = meta.body.columns.find((c: { id: string }) => c.id === 'status');
|
|
142
|
+
expect(status.operators).toEqual(expect.arrayContaining(['eq', 'in']));
|
|
143
|
+
expect(status.operators).not.toContain('contains');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('gates queries on reports.read', async () => {
|
|
147
|
+
const { accessToken, orgId } = await createUserAndOrg('rep-perm');
|
|
148
|
+
const key = uniqueKey('perm');
|
|
149
|
+
await publishOrgMembers(accessToken, orgId, key);
|
|
150
|
+
|
|
151
|
+
const outsider = await createMemberInOrg(orgId, 'rep-perm-no', ['reportTags.manage']);
|
|
152
|
+
await api()
|
|
153
|
+
.post(`/organisations/${orgId}/reports/${key}/query`)
|
|
154
|
+
.set('Authorization', `Bearer ${outsider.accessToken}`)
|
|
155
|
+
.send({})
|
|
156
|
+
.expect(403);
|
|
157
|
+
|
|
158
|
+
const reader = await createMemberInOrg(orgId, 'rep-perm-yes', ['reports.read']);
|
|
159
|
+
await api()
|
|
160
|
+
.post(`/organisations/${orgId}/reports/${key}/query`)
|
|
161
|
+
.set('Authorization', `Bearer ${reader.accessToken}`)
|
|
162
|
+
.send({})
|
|
163
|
+
.expect(200);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('saves a personal view and reports its compatibility', async () => {
|
|
167
|
+
const { accessToken, orgId } = await createUserAndOrg('rep-view');
|
|
168
|
+
const key = uniqueKey('view');
|
|
169
|
+
await publishOrgMembers(accessToken, orgId, key);
|
|
170
|
+
|
|
171
|
+
await api()
|
|
172
|
+
.post(`/organisations/${orgId}/reports/${key}/views`)
|
|
173
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
174
|
+
.send({ name: 'Active only', spec: { filters: [{ column: 'status', op: 'eq', value: 'ACTIVE' }] } })
|
|
175
|
+
.expect(201);
|
|
176
|
+
|
|
177
|
+
const views = await api()
|
|
178
|
+
.get(`/organisations/${orgId}/reports/${key}/views`)
|
|
179
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
180
|
+
.expect(200);
|
|
181
|
+
const list = Array.isArray(views.body) ? views.body : views.body.items;
|
|
182
|
+
expect(list).toHaveLength(1);
|
|
183
|
+
expect(list[0].compatibility.status).toBe('ok');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('tags rows through the manageTags row action and filters on $tags', async () => {
|
|
187
|
+
const { accessToken, orgId } = await createUserAndOrg('rep-tag');
|
|
188
|
+
const key = uniqueKey('tag');
|
|
189
|
+
await publishOrgMembers(accessToken, orgId, key);
|
|
190
|
+
|
|
191
|
+
const first = await api()
|
|
192
|
+
.post(`/organisations/${orgId}/reports/${key}/query`)
|
|
193
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
194
|
+
.send({})
|
|
195
|
+
.expect(200);
|
|
196
|
+
const rowId = first.body.rows[0].$id;
|
|
197
|
+
|
|
198
|
+
await api()
|
|
199
|
+
.post(`/organisations/${orgId}/reports/${key}/actions/manageTags`)
|
|
200
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
201
|
+
.send({ selection: { byIds: [rowId] }, input: { add: ['shortlisted'] } })
|
|
202
|
+
.expect(200);
|
|
203
|
+
|
|
204
|
+
const tagged = await api()
|
|
205
|
+
.post(`/organisations/${orgId}/reports/${key}/query`)
|
|
206
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
207
|
+
.send({ filters: [{ column: '$tags', op: 'hasTag', value: 'shortlisted' }] })
|
|
208
|
+
.expect(200);
|
|
209
|
+
expect(tagged.body.rows).toHaveLength(1);
|
|
210
|
+
expect(tagged.body.rows[0].$tags).toContain('shortlisted');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('isolates report query, export, and byIds actions across organisations', async () => {
|
|
214
|
+
const orgA = await createUserAndOrg('rep-ten-a');
|
|
215
|
+
const orgB = await createUserAndOrg('rep-ten-b');
|
|
216
|
+
const keyA = uniqueKey('ten-a');
|
|
217
|
+
const keyB = uniqueKey('ten-b');
|
|
218
|
+
await publishOrgMembers(orgA.accessToken, orgA.orgId, keyA);
|
|
219
|
+
await publishOrgMembers(orgB.accessToken, orgB.orgId, keyB);
|
|
220
|
+
|
|
221
|
+
const orgBRows = await api()
|
|
222
|
+
.post(`/organisations/${orgB.orgId}/reports/${keyB}/query`)
|
|
223
|
+
.set(auth(orgB.accessToken))
|
|
224
|
+
.send({})
|
|
225
|
+
.expect(200);
|
|
226
|
+
const orgBRowId = orgBRows.body.rows[0].$id as string;
|
|
227
|
+
|
|
228
|
+
await api()
|
|
229
|
+
.post(`/organisations/${orgB.orgId}/reports/${keyB}/query`)
|
|
230
|
+
.set(auth(orgA.accessToken))
|
|
231
|
+
.send({})
|
|
232
|
+
.expect(403);
|
|
233
|
+
|
|
234
|
+
await api()
|
|
235
|
+
.post(`/organisations/${orgB.orgId}/reports/${keyB}/export`)
|
|
236
|
+
.set(auth(orgA.accessToken))
|
|
237
|
+
.send({ format: 'csv' })
|
|
238
|
+
.expect(403);
|
|
239
|
+
|
|
240
|
+
await api()
|
|
241
|
+
.post(`/organisations/${orgB.orgId}/reports/${keyB}/actions/manageTags`)
|
|
242
|
+
.set(auth(orgA.accessToken))
|
|
243
|
+
.send({ selection: { byIds: [orgBRowId] }, input: { add: ['cross-org-leak'] } })
|
|
244
|
+
.expect(403);
|
|
245
|
+
|
|
246
|
+
const crossOrgIdSelection = await api()
|
|
247
|
+
.post(`/organisations/${orgA.orgId}/reports/${keyA}/actions/manageTags`)
|
|
248
|
+
.set(auth(orgA.accessToken))
|
|
249
|
+
.send({ selection: { byIds: [orgBRowId] }, input: { add: ['cross-org-leak'] } })
|
|
250
|
+
.expect(200);
|
|
251
|
+
expect(crossOrgIdSelection.body.affectedRows).toBe(0);
|
|
252
|
+
|
|
253
|
+
const orgBTagged = await api()
|
|
254
|
+
.post(`/organisations/${orgB.orgId}/reports/${keyB}/query`)
|
|
255
|
+
.set(auth(orgB.accessToken))
|
|
256
|
+
.send({ filters: [{ column: '$tags', op: 'hasTag', value: 'cross-org-leak' }] })
|
|
257
|
+
.expect(200);
|
|
258
|
+
expect(orgBTagged.body.rows).toHaveLength(0);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('streams a snapshot-consistent CSV export, gated by reports.export', async () => {
|
|
262
|
+
const { accessToken, orgId } = await createUserAndOrg('rep-export');
|
|
263
|
+
const key = uniqueKey('export');
|
|
264
|
+
await publishOrgMembers(accessToken, orgId, key);
|
|
265
|
+
|
|
266
|
+
const csv = await api()
|
|
267
|
+
.post(`/organisations/${orgId}/reports/${key}/export`)
|
|
268
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
269
|
+
.send({ format: 'csv' })
|
|
270
|
+
.expect(200);
|
|
271
|
+
expect(csv.headers['content-disposition']).toContain('attachment');
|
|
272
|
+
expect(csv.text).toContain('Name'); // the displayName header
|
|
273
|
+
expect(csv.text.split(/\r?\n/).filter(Boolean).length).toBeGreaterThanOrEqual(2);
|
|
274
|
+
|
|
275
|
+
// A member without reports.export cannot egress (never implied by read).
|
|
276
|
+
const reader = await createMemberInOrg(orgId, 'rep-export-no', ['reports.read']);
|
|
277
|
+
await api()
|
|
278
|
+
.post(`/organisations/${orgId}/reports/${key}/export`)
|
|
279
|
+
.set('Authorization', `Bearer ${reader.accessToken}`)
|
|
280
|
+
.send({ format: 'csv' })
|
|
281
|
+
.expect(403);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('rejects a definition that smuggles SQL into a column path (the §2.1 boundary)', async () => {
|
|
285
|
+
const { accessToken, orgId } = await createUserAndOrg('rep-sql');
|
|
286
|
+
const def = orgMembersDefinition(uniqueKey('sql'));
|
|
287
|
+
(def.columns as Array<Record<string, unknown>>).push({
|
|
288
|
+
id: 'evil',
|
|
289
|
+
header: 'Evil',
|
|
290
|
+
path: "name'); DROP TABLE \"User\"; --",
|
|
291
|
+
type: 'text',
|
|
292
|
+
});
|
|
293
|
+
await api()
|
|
294
|
+
.post(`/organisations/${orgId}/reports`)
|
|
295
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
296
|
+
.send(def)
|
|
297
|
+
.expect(422);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ── helpers (mirrors the forms e2e harness) ─────────────────────────────────
|
|
301
|
+
|
|
302
|
+
function api() {
|
|
303
|
+
return request(app.getHttpServer());
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function auth(token: string) {
|
|
307
|
+
return { Authorization: `Bearer ${token}` };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function uniqueSuffix() {
|
|
311
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function uniqueKey(label: string) {
|
|
315
|
+
return `${label}-${uniqueSuffix()}`.toLowerCase();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function orgMembersDefinition(key: string): Record<string, unknown> {
|
|
319
|
+
return {
|
|
320
|
+
key,
|
|
321
|
+
title: 'Organisation members',
|
|
322
|
+
source: { kind: 'custom', key: 'org-members' },
|
|
323
|
+
columns: [
|
|
324
|
+
{ id: 'displayName', header: 'Name', columnId: 'displayName', type: 'text', sortable: true, filterable: true, searchable: true },
|
|
325
|
+
{ id: 'email', header: 'Email', columnId: 'email', type: 'text', sortable: true, filterable: true },
|
|
326
|
+
{ id: 'role', header: 'Role', columnId: 'role', type: 'text', sortable: true, filterable: true },
|
|
327
|
+
{ id: 'status', header: 'Status', columnId: 'status', type: 'enum', filterable: true, enum: ['ACTIVE', 'SUSPENDED', 'REVOKED'] },
|
|
328
|
+
{ id: 'joinedAt', header: 'Joined', columnId: 'joinedAt', type: 'datetime', sortable: true },
|
|
329
|
+
],
|
|
330
|
+
defaultSort: [{ column: 'joinedAt', dir: 'desc' }],
|
|
331
|
+
search: { columns: ['displayName'] },
|
|
332
|
+
rowActions: ['manageTags'],
|
|
333
|
+
export: { formats: ['csv', 'xlsx'] },
|
|
334
|
+
performanceTier: 'live',
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function publishOrgMembers(accessToken: string, orgId: string, key: string) {
|
|
339
|
+
await api()
|
|
340
|
+
.post(`/organisations/${orgId}/reports`)
|
|
341
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
342
|
+
.send(orgMembersDefinition(key))
|
|
343
|
+
.expect(201);
|
|
344
|
+
await api()
|
|
345
|
+
.post(`/organisations/${orgId}/reports/${key}/publish`)
|
|
346
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
347
|
+
.expect(200);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function createUserAndOrg(label: string) {
|
|
351
|
+
const user = await createUser(label);
|
|
352
|
+
const org = await createOrganisation(user.accessToken, label);
|
|
353
|
+
return { ...user, orgId: org.orgId, membershipId: org.membershipId };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function createUser(label: string) {
|
|
357
|
+
const suffix = uniqueSuffix();
|
|
358
|
+
const signup = await api()
|
|
359
|
+
.post('/auth/signup')
|
|
360
|
+
.send({ email: `${label}-${suffix}@example.com`, password: 'test-password-123', displayName: label })
|
|
361
|
+
.expect(201);
|
|
362
|
+
return {
|
|
363
|
+
accessToken: signup.body.accessToken as string,
|
|
364
|
+
userId: signup.body.user.id as string,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function createOrganisation(accessToken: string, label: string) {
|
|
369
|
+
const suffix = uniqueSuffix();
|
|
370
|
+
const org = await api()
|
|
371
|
+
.post('/organisations')
|
|
372
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
373
|
+
.send({ name: `${label} ${suffix}`, slug: `${label}-${suffix}` })
|
|
374
|
+
.expect(201);
|
|
375
|
+
return {
|
|
376
|
+
orgId: org.body.organisation.id as string,
|
|
377
|
+
membershipId: org.body.membership.id as string,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function createMemberInOrg(orgId: string, label: string, permissionKeys: string[]) {
|
|
382
|
+
const user = await createUser(label);
|
|
383
|
+
const permissions = await prisma.permission.findMany({
|
|
384
|
+
where: { key: { in: permissionKeys } },
|
|
385
|
+
select: { id: true },
|
|
386
|
+
});
|
|
387
|
+
expect(permissions).toHaveLength(permissionKeys.length);
|
|
388
|
+
const role = await prisma.role.create({
|
|
389
|
+
data: {
|
|
390
|
+
orgId,
|
|
391
|
+
name: `${label}-${uniqueSuffix()}`,
|
|
392
|
+
permissions: { create: permissions.map((permission) => ({ permissionId: permission.id })) },
|
|
393
|
+
},
|
|
394
|
+
select: { id: true },
|
|
395
|
+
});
|
|
396
|
+
const membership = await prisma.membership.create({
|
|
397
|
+
data: { userId: user.userId, orgId, roleId: role.id },
|
|
398
|
+
select: { id: true, roleId: true },
|
|
399
|
+
});
|
|
400
|
+
return { ...user, membershipId: membership.id, roleId: membership.roleId };
|
|
401
|
+
}
|
|
402
|
+
});
|