@ftisindia/create-app 0.1.3 → 0.1.5

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.
Files changed (114) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +6 -0
  3. package/template/README.md +11 -1
  4. package/template/_package.json +0 -2
  5. package/template/docs/API_REFERENCE.md +13 -0
  6. package/template/docs/OAUTH.md +7 -3
  7. package/template/scripts/gen-module.mjs +2 -0
  8. package/template/src/app.module.ts +16 -22
  9. package/template/src/common/dto/error-response.dto.ts +3 -3
  10. package/template/src/common/dto/membership-response.dto.ts +26 -14
  11. package/template/src/common/dto/mutation-response.dto.ts +1 -1
  12. package/template/src/common/dto/pagination-query.dto.ts +37 -0
  13. package/template/src/common/dto/role-summary.dto.ts +5 -5
  14. package/template/src/common/dto/user-summary.dto.ts +6 -6
  15. package/template/src/common/filters/http-exception.filter.ts +9 -19
  16. package/template/src/common/swagger/api-error-responses.ts +12 -12
  17. package/template/src/config/app.config.ts +8 -3
  18. package/template/src/config/auth.config.ts +3 -3
  19. package/template/src/config/database.config.ts +3 -3
  20. package/template/src/config/env.validation.ts +78 -40
  21. package/template/src/config/index.ts +5 -5
  22. package/template/src/config/rbac.config.ts +3 -3
  23. package/template/src/database/prisma/prisma-transaction.ts +1 -1
  24. package/template/src/database/prisma/prisma.module.ts +2 -2
  25. package/template/src/database/prisma/prisma.service.ts +3 -6
  26. package/template/src/main.ts +24 -11
  27. package/template/src/modules/access-control/access-control.module.ts +11 -10
  28. package/template/src/modules/access-control/application/role-permission-policy.ts +71 -0
  29. package/template/src/modules/access-control/application/route-registry.validator.ts +34 -63
  30. package/template/src/modules/access-control/application/services/ability.factory.ts +5 -9
  31. package/template/src/modules/access-control/application/services/access-control.service.ts +78 -85
  32. package/template/src/modules/access-control/application/services/permission.guard.ts +16 -21
  33. package/template/src/modules/access-control/application/services/rbac-cache.service.ts +7 -9
  34. package/template/src/modules/access-control/dto/access-control-response.dto.ts +32 -20
  35. package/template/src/modules/access-control/dto/create-role.dto.ts +6 -6
  36. package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +31 -0
  37. package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +3 -10
  38. package/template/src/modules/access-control/dto/update-role.dto.ts +6 -6
  39. package/template/src/modules/access-control/presentation/access-control.controller.ts +69 -74
  40. package/template/src/modules/access-control/presentation/current-access-control.controller.ts +40 -0
  41. package/template/src/modules/access-control/presentation/permissions.decorator.ts +3 -3
  42. package/template/src/modules/access-control/presentation/public.decorator.ts +2 -2
  43. package/template/src/modules/access-control/types/permission-key.ts +19 -19
  44. package/template/src/modules/access-control/types/route-permission-registry.ts +76 -76
  45. package/template/src/modules/audit/application/services/audit.service.ts +7 -7
  46. package/template/src/modules/audit/audit.module.ts +4 -4
  47. package/template/src/modules/audit/dto/audit-response.dto.ts +18 -18
  48. package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +14 -14
  49. package/template/src/modules/audit/presentation/audit.controller.ts +17 -23
  50. package/template/src/modules/auth/application/services/auth.service.ts +147 -110
  51. package/template/src/modules/auth/application/services/password.service.ts +2 -2
  52. package/template/src/modules/auth/application/services/token.service.ts +20 -21
  53. package/template/src/modules/auth/auth.module.ts +20 -47
  54. package/template/src/modules/auth/dto/auth-response.dto.ts +9 -10
  55. package/template/src/modules/auth/dto/login.dto.ts +4 -4
  56. package/template/src/modules/auth/dto/logout.dto.ts +1 -1
  57. package/template/src/modules/auth/dto/oauth-exchange.dto.ts +4 -5
  58. package/template/src/modules/auth/dto/refresh-token.dto.ts +4 -5
  59. package/template/src/modules/auth/dto/signup.dto.ts +5 -11
  60. package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +6 -14
  61. package/template/src/modules/auth/infrastructure/passport/google-oauth-state.store.ts +98 -0
  62. package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +21 -30
  63. package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +3 -3
  64. package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +11 -11
  65. package/template/src/modules/auth/presentation/auth.controller.ts +45 -45
  66. package/template/src/modules/auth/presentation/current-user.decorator.ts +3 -5
  67. package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +5 -10
  68. package/template/src/modules/health/dto/health-response.dto.ts +5 -5
  69. package/template/src/modules/health/health.module.ts +2 -2
  70. package/template/src/modules/health/presentation/health.controller.ts +13 -13
  71. package/template/src/modules/invitations/application/services/invitations.service.ts +127 -176
  72. package/template/src/modules/invitations/dto/accept-invitation.dto.ts +6 -7
  73. package/template/src/modules/invitations/dto/create-invitation.dto.ts +14 -15
  74. package/template/src/modules/invitations/dto/invitation-response.dto.ts +37 -29
  75. package/template/src/modules/invitations/dto/invitation-token.dto.ts +4 -4
  76. package/template/src/modules/invitations/invitations.module.ts +5 -5
  77. package/template/src/modules/invitations/presentation/invitations.controller.ts +61 -63
  78. package/template/src/modules/memberships/application/services/memberships.service.ts +70 -84
  79. package/template/src/modules/memberships/dto/transfer-owner.dto.ts +4 -4
  80. package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +2 -2
  81. package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +2 -2
  82. package/template/src/modules/memberships/dto/update-membership-role.dto.ts +4 -4
  83. package/template/src/modules/memberships/dto/update-membership-status.dto.ts +3 -3
  84. package/template/src/modules/memberships/memberships.module.ts +4 -4
  85. package/template/src/modules/memberships/presentation/memberships.controller.ts +83 -99
  86. package/template/src/modules/organisations/application/services/organisations.service.ts +87 -23
  87. package/template/src/modules/organisations/dto/create-organisation.dto.ts +6 -13
  88. package/template/src/modules/organisations/dto/organisation-response.dto.ts +65 -14
  89. package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +4 -7
  90. package/template/src/modules/organisations/organisations.module.ts +5 -5
  91. package/template/src/modules/organisations/presentation/organisations.controller.ts +31 -18
  92. package/template/src/modules/organisations/types/default-organisation-data.ts +3 -9
  93. package/template/src/modules/request-context/application/services/request-context.service.ts +15 -7
  94. package/template/src/modules/request-context/presentation/org-scope.guard.ts +4 -9
  95. package/template/src/modules/request-context/presentation/request-context.interceptor.ts +4 -9
  96. package/template/src/modules/request-context/presentation/request-context.middleware.ts +7 -8
  97. package/template/src/modules/request-context/request-context.module.ts +7 -7
  98. package/template/src/modules/request-context/types/request-context.ts +2 -2
  99. package/template/src/modules/sample/application/services/sample.service.ts +10 -8
  100. package/template/src/modules/sample/dto/sample-echo.dto.ts +3 -3
  101. package/template/src/modules/sample/dto/sample-response.dto.ts +12 -12
  102. package/template/src/modules/sample/presentation/sample.controller.ts +25 -42
  103. package/template/src/modules/sample/sample.module.ts +4 -4
  104. package/template/src/modules/settings/application/services/settings.service.ts +15 -27
  105. package/template/src/modules/settings/dto/setting-response.dto.ts +9 -9
  106. package/template/src/modules/settings/dto/update-setting.dto.ts +5 -5
  107. package/template/src/modules/settings/presentation/settings.controller.ts +29 -35
  108. package/template/src/modules/settings/settings.module.ts +5 -5
  109. package/template/src/modules/settings/types/setting-definitions.ts +49 -33
  110. package/template/test/auth-refresh.spec.ts +90 -0
  111. package/template/test/frontend-bootstrap.spec.ts +181 -0
  112. package/template/test/role-permission-policy.spec.ts +94 -0
  113. package/template/test/route-registry.validator.spec.ts +12 -0
  114. package/template/test/security.e2e-spec.ts +134 -2
@@ -0,0 +1,90 @@
1
+ import { UnauthorizedException } from '@nestjs/common';
2
+ import { AuthService } from '../src/modules/auth/application/services/auth.service';
3
+
4
+ describe('AuthService.refresh', () => {
5
+ it('commits refresh-token family revocation when a concurrent replay loses the claim', async () => {
6
+ const existingToken = {
7
+ id: 'old-token-id',
8
+ userId: 'user-id',
9
+ revokedAt: null,
10
+ replacedByTokenId: null,
11
+ expiresAt: new Date(Date.now() + 60_000),
12
+ user: {
13
+ id: 'user-id',
14
+ email: 'owner@example.com',
15
+ mobile: null,
16
+ displayName: null,
17
+ isActive: true,
18
+ },
19
+ };
20
+ const tx = {
21
+ refreshToken: {
22
+ updateMany: jest
23
+ .fn()
24
+ .mockResolvedValueOnce({ count: 0 })
25
+ .mockResolvedValueOnce({ count: 2 }),
26
+ findUnique: jest
27
+ .fn()
28
+ .mockResolvedValueOnce({ replacedByTokenId: 'next-token-id' })
29
+ .mockResolvedValueOnce({ replacedByTokenId: 'next-token-id' })
30
+ .mockResolvedValueOnce({ replacedByTokenId: null }),
31
+ },
32
+ auditLog: {
33
+ create: jest.fn().mockResolvedValue({}),
34
+ },
35
+ };
36
+ const prisma = {
37
+ refreshToken: {
38
+ findUnique: jest.fn().mockResolvedValue(existingToken),
39
+ },
40
+ $transaction: jest.fn((callback: (txClient: typeof tx) => unknown) =>
41
+ Promise.resolve(callback(tx)),
42
+ ),
43
+ };
44
+ const tokenService = {
45
+ hashRefreshToken: jest.fn().mockReturnValue('refresh-token-hash'),
46
+ createRefreshToken: jest.fn(),
47
+ signAccessToken: jest.fn(),
48
+ getAccessTokenTtlSeconds: jest.fn(),
49
+ };
50
+ const requestContext = {
51
+ getIpAddress: jest.fn(),
52
+ getUserAgent: jest.fn(),
53
+ };
54
+
55
+ const service = new AuthService(
56
+ {} as never,
57
+ {} as never,
58
+ prisma as never,
59
+ requestContext as never,
60
+ tokenService as never,
61
+ );
62
+
63
+ await expect(
64
+ service.refresh({ refreshToken: 'stale-refresh-token-value' }),
65
+ ).rejects.toBeInstanceOf(UnauthorizedException);
66
+
67
+ expect(tx.refreshToken.updateMany).toHaveBeenNthCalledWith(
68
+ 2,
69
+ expect.objectContaining({
70
+ where: {
71
+ id: {
72
+ in: ['old-token-id', 'next-token-id'],
73
+ },
74
+ },
75
+ data: {
76
+ revokedAt: expect.any(Date),
77
+ },
78
+ }),
79
+ );
80
+ expect(tx.auditLog.create).toHaveBeenCalledWith(
81
+ expect.objectContaining({
82
+ data: expect.objectContaining({
83
+ action: 'auth.refresh.reuse_detected',
84
+ targetId: 'old-token-id',
85
+ }),
86
+ }),
87
+ );
88
+ expect(tokenService.createRefreshToken).not.toHaveBeenCalled();
89
+ });
90
+ });
@@ -0,0 +1,181 @@
1
+ import { MembershipStatus, OrganisationStatus } from '@prisma/client';
2
+ import { validate, parseCorsOrigins } from '../src/config/env.validation';
3
+ import { CurrentAccessControlController } from '../src/modules/access-control/presentation/current-access-control.controller';
4
+ import { OrganisationsService } from '../src/modules/organisations/application/services/organisations.service';
5
+
6
+ describe('frontend bootstrap endpoints', () => {
7
+ it('returns the current effective access-control context without internal cache fields', async () => {
8
+ const controller = new CurrentAccessControlController({
9
+ getContext: jest.fn().mockResolvedValue({
10
+ cacheKey: 'rbac:user-1:org-1',
11
+ userId: 'user-1',
12
+ orgId: 'org-1',
13
+ membershipId: 'membership-1',
14
+ membershipStatus: MembershipStatus.ACTIVE,
15
+ roleId: 'role-1',
16
+ isOwner: false,
17
+ isBillingContact: true,
18
+ permissionKeys: ['organisations.read'],
19
+ }),
20
+ } as never);
21
+
22
+ await expect(
23
+ controller.me(
24
+ {
25
+ id: 'user-1',
26
+ email: 'user@example.com',
27
+ mobile: null,
28
+ displayName: 'User',
29
+ isActive: true,
30
+ },
31
+ 'org-1',
32
+ ),
33
+ ).resolves.toEqual({
34
+ orgId: 'org-1',
35
+ membershipId: 'membership-1',
36
+ roleId: 'role-1',
37
+ isOwner: false,
38
+ isBillingContact: true,
39
+ permissionKeys: ['organisations.read'],
40
+ });
41
+ });
42
+
43
+ it('queries only active current-user memberships in active organisations', async () => {
44
+ const prisma = {
45
+ membership: {
46
+ findMany: jest.fn().mockResolvedValue([
47
+ {
48
+ id: 'membership-1',
49
+ roleId: 'role-1',
50
+ isOwner: true,
51
+ isBillingContact: true,
52
+ organisation: {
53
+ id: 'org-1',
54
+ name: 'Acme Operations',
55
+ slug: 'acme',
56
+ status: OrganisationStatus.ACTIVE,
57
+ },
58
+ role: {
59
+ id: 'role-1',
60
+ name: 'Owner',
61
+ description: null,
62
+ isSystemSeeded: true,
63
+ },
64
+ },
65
+ {
66
+ id: 'membership-2',
67
+ roleId: 'role-2',
68
+ isOwner: false,
69
+ isBillingContact: false,
70
+ organisation: {
71
+ id: 'org-2',
72
+ name: 'Beta Operations',
73
+ slug: 'beta',
74
+ status: OrganisationStatus.ACTIVE,
75
+ },
76
+ role: {
77
+ id: 'role-2',
78
+ name: 'Viewer',
79
+ description: null,
80
+ isSystemSeeded: true,
81
+ },
82
+ },
83
+ ]),
84
+ },
85
+ };
86
+ const service = new OrganisationsService({} as never, prisma as never, {} as never);
87
+
88
+ await expect(
89
+ service.listMine(
90
+ {
91
+ id: 'user-1',
92
+ email: 'user@example.com',
93
+ mobile: null,
94
+ displayName: 'User',
95
+ isActive: true,
96
+ },
97
+ { limit: 1 },
98
+ ),
99
+ ).resolves.toEqual({
100
+ items: [
101
+ {
102
+ id: 'org-1',
103
+ name: 'Acme Operations',
104
+ slug: 'acme',
105
+ status: OrganisationStatus.ACTIVE,
106
+ membershipId: 'membership-1',
107
+ roleId: 'role-1',
108
+ role: {
109
+ id: 'role-1',
110
+ name: 'Owner',
111
+ description: null,
112
+ isSystemSeeded: true,
113
+ },
114
+ isOwner: true,
115
+ isBillingContact: true,
116
+ },
117
+ ],
118
+ nextCursor: 'membership-1',
119
+ });
120
+
121
+ expect(prisma.membership.findMany).toHaveBeenCalledWith(
122
+ expect.objectContaining({
123
+ where: {
124
+ userId: 'user-1',
125
+ status: MembershipStatus.ACTIVE,
126
+ organisation: {
127
+ status: OrganisationStatus.ACTIVE,
128
+ },
129
+ },
130
+ take: 2,
131
+ }),
132
+ );
133
+ });
134
+ });
135
+
136
+ describe('CORS environment config', () => {
137
+ it('is disabled by default', () => {
138
+ const env = validate(baseEnv());
139
+
140
+ expect(env.CORS_ENABLED).toBe(false);
141
+ expect(env.CORS_ORIGINS).toBe('');
142
+ expect(env.CORS_CREDENTIALS).toBe(false);
143
+ });
144
+
145
+ it('accepts enabled CORS with exact allowed origins', () => {
146
+ const env = validate({
147
+ ...baseEnv(),
148
+ CORS_ENABLED: 'true',
149
+ CORS_ORIGINS: 'http://localhost:5173, https://app.example.com',
150
+ CORS_CREDENTIALS: 'true',
151
+ });
152
+
153
+ expect(env.CORS_ENABLED).toBe(true);
154
+ expect(env.CORS_CREDENTIALS).toBe(true);
155
+ expect(parseCorsOrigins(env.CORS_ORIGINS)).toEqual([
156
+ 'http://localhost:5173',
157
+ 'https://app.example.com',
158
+ ]);
159
+ });
160
+
161
+ it('rejects wildcard origins when credentials are enabled', () => {
162
+ expect(() =>
163
+ validate({
164
+ ...baseEnv(),
165
+ CORS_ENABLED: 'true',
166
+ CORS_ORIGINS: '*',
167
+ CORS_CREDENTIALS: 'true',
168
+ }),
169
+ ).toThrow(/CORS_ORIGINS cannot include \*/u);
170
+ });
171
+ });
172
+
173
+ function baseEnv() {
174
+ return {
175
+ NODE_ENV: 'test',
176
+ PORT: '3000',
177
+ DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/app_test',
178
+ JWT_SECRET: 'test-jwt-secret-at-least-32-characters',
179
+ AUTH_GOOGLE_ENABLED: 'false',
180
+ };
181
+ }
@@ -0,0 +1,94 @@
1
+ import { ForbiddenException } from '@nestjs/common';
2
+ import {
3
+ assertPermissionChangeWithinActor,
4
+ assertRoleWithinActorPermissions,
5
+ } from '../src/modules/access-control/application/role-permission-policy';
6
+
7
+ describe('assertRoleWithinActorPermissions', () => {
8
+ it('allows owners to assign any role', () => {
9
+ expect(() =>
10
+ assertRoleWithinActorPermissions({
11
+ actorIsOwner: true,
12
+ actorPermissionKeys: [],
13
+ rolePermissionKeys: ['billing.manage', 'roles.manage'],
14
+ }),
15
+ ).not.toThrow();
16
+ });
17
+
18
+ it('allows a role whose permissions are a subset of the actor', () => {
19
+ expect(() =>
20
+ assertRoleWithinActorPermissions({
21
+ actorIsOwner: false,
22
+ actorPermissionKeys: ['users.read', 'users.update', 'roles.manage'],
23
+ rolePermissionKeys: ['users.read', 'users.update'],
24
+ }),
25
+ ).not.toThrow();
26
+ });
27
+
28
+ it('rejects a role carrying a permission the actor does not hold', () => {
29
+ expect(() =>
30
+ assertRoleWithinActorPermissions({
31
+ actorIsOwner: false,
32
+ actorPermissionKeys: ['users.read'],
33
+ rolePermissionKeys: ['users.read', 'billing.manage'],
34
+ }),
35
+ ).toThrow(/billing\.manage/u);
36
+ });
37
+ });
38
+
39
+ describe('assertPermissionChangeWithinActor', () => {
40
+ it('allows owners to make any change', () => {
41
+ expect(() =>
42
+ assertPermissionChangeWithinActor({
43
+ actorIsOwner: true,
44
+ actorPermissionKeys: [],
45
+ previousPermissionKeys: ['users.read'],
46
+ nextPermissionKeys: ['users.read', 'billing.manage'],
47
+ }),
48
+ ).not.toThrow();
49
+ });
50
+
51
+ it('allows adding and removing permissions the actor holds', () => {
52
+ expect(() =>
53
+ assertPermissionChangeWithinActor({
54
+ actorIsOwner: false,
55
+ actorPermissionKeys: ['users.read', 'users.update'],
56
+ previousPermissionKeys: ['users.read'],
57
+ nextPermissionKeys: ['users.update'],
58
+ }),
59
+ ).not.toThrow();
60
+ });
61
+
62
+ it('rejects adding a permission the actor does not hold', () => {
63
+ expect(() =>
64
+ assertPermissionChangeWithinActor({
65
+ actorIsOwner: false,
66
+ actorPermissionKeys: ['users.read'],
67
+ previousPermissionKeys: ['users.read'],
68
+ nextPermissionKeys: ['users.read', 'billing.manage'],
69
+ }),
70
+ ).toThrow(/billing\.manage/u);
71
+ });
72
+
73
+ it('rejects revoking a permission the actor does not hold', () => {
74
+ expect(() =>
75
+ assertPermissionChangeWithinActor({
76
+ actorIsOwner: false,
77
+ actorPermissionKeys: ['users.read'],
78
+ previousPermissionKeys: ['users.read', 'billing.manage'],
79
+ nextPermissionKeys: ['users.read'],
80
+ }),
81
+ ).toThrow(ForbiddenException);
82
+ });
83
+
84
+ it('allows an unchanged set even when it contains permissions the actor lacks', () => {
85
+ expect(() =>
86
+ assertPermissionChangeWithinActor({
87
+ actorIsOwner: false,
88
+ actorPermissionKeys: [],
89
+ previousPermissionKeys: ['billing.manage'],
90
+ nextPermissionKeys: ['billing.manage'],
91
+ }),
92
+ ).not.toThrow();
93
+ });
94
+ });
@@ -1,9 +1,11 @@
1
1
  import { Controller, Get } from '@nestjs/common';
2
2
  import { MetadataScanner, Reflector } from '@nestjs/core';
3
3
  import { AccessControlController } from '../src/modules/access-control/presentation/access-control.controller';
4
+ import { CurrentAccessControlController } from '../src/modules/access-control/presentation/current-access-control.controller';
4
5
  import { RequirePermissions } from '../src/modules/access-control/presentation/permissions.decorator';
5
6
  import { RouteRegistryValidator } from '../src/modules/access-control/application/route-registry.validator';
6
7
  import { PermissionKey } from '../src/modules/access-control/types/permission-key';
8
+ import { routePermissionRegistry } from '../src/modules/access-control/types/route-permission-registry';
7
9
  import { AuditController } from '../src/modules/audit/presentation/audit.controller';
8
10
  import { AuthController } from '../src/modules/auth/presentation/auth.controller';
9
11
  import { InvitationsController } from '../src/modules/invitations/presentation/invitations.controller';
@@ -14,6 +16,7 @@ import { SettingsController } from '../src/modules/settings/presentation/setting
14
16
 
15
17
  const controllers = [
16
18
  AccessControlController,
19
+ CurrentAccessControlController,
17
20
  AuditController,
18
21
  AuthController,
19
22
  InvitationsController,
@@ -28,6 +31,15 @@ describe('RouteRegistryValidator', () => {
28
31
  expect(() => createValidator(controllers).onApplicationBootstrap()).not.toThrow();
29
32
  });
30
33
 
34
+ it('does not register the current access-control bootstrap route as permission-gated', () => {
35
+ expect(routePermissionRegistry).not.toContainEqual(
36
+ expect.objectContaining({
37
+ method: 'GET',
38
+ path: '/organisations/:orgId/access-control/me',
39
+ }),
40
+ );
41
+ });
42
+
31
43
  it('fails when a permission-gated route is missing from the registry', () => {
32
44
  class DriftController {
33
45
  probe() {
@@ -72,7 +72,101 @@ describe('Security invariants (e2e)', () => {
72
72
  .expect(403);
73
73
  });
74
74
 
75
+ it('requires authentication for frontend bootstrap endpoints', async () => {
76
+ const { orgId } = await createUserAndOrg('bootstrap-auth');
77
+
78
+ await request(app.getHttpServer()).get('/organisations/mine').expect(401);
79
+ await request(app.getHttpServer()).get(`/organisations/${orgId}/access-control/me`).expect(401);
80
+ });
81
+
82
+ it('lists only active current-user organisations with cursor pagination shape', async () => {
83
+ const first = await createUserAndOrg('mine-active');
84
+ const second = await createOrganisation(first.accessToken, 'mine-suspended');
85
+
86
+ await prisma.membership.update({
87
+ where: { id: second.membershipId },
88
+ data: { status: MembershipStatus.SUSPENDED },
89
+ });
90
+
91
+ const response = await request(app.getHttpServer())
92
+ .get('/organisations/mine')
93
+ .set('Authorization', `Bearer ${first.accessToken}`)
94
+ .expect(200);
95
+
96
+ expect(response.body).toEqual({
97
+ items: [
98
+ expect.objectContaining({
99
+ id: first.orgId,
100
+ membershipId: first.membershipId,
101
+ roleId: expect.any(String),
102
+ role: expect.objectContaining({ name: 'Owner' }),
103
+ isOwner: true,
104
+ isBillingContact: true,
105
+ }),
106
+ ],
107
+ nextCursor: null,
108
+ });
109
+ });
110
+
111
+ it('returns effective access-control context for an active member without roles.read', async () => {
112
+ const owner = await createUserAndOrg('access-me-owner');
113
+ const member = await createMemberInOrg(owner.orgId, 'access-me-member');
114
+
115
+ const response = await request(app.getHttpServer())
116
+ .get(`/organisations/${owner.orgId}/access-control/me`)
117
+ .set('Authorization', `Bearer ${member.accessToken}`)
118
+ .expect(200);
119
+
120
+ expect(response.body).toEqual({
121
+ orgId: owner.orgId,
122
+ membershipId: member.membershipId,
123
+ roleId: member.roleId,
124
+ isOwner: false,
125
+ isBillingContact: false,
126
+ permissionKeys: [],
127
+ });
128
+ });
129
+
130
+ it('denies access-control context to non-members', async () => {
131
+ const first = await createUserAndOrg('access-me-a');
132
+ const second = await createUserAndOrg('access-me-b');
133
+
134
+ await request(app.getHttpServer())
135
+ .get(`/organisations/${second.orgId}/access-control/me`)
136
+ .set('Authorization', `Bearer ${first.accessToken}`)
137
+ .expect(403);
138
+ });
139
+
140
+ it.each([MembershipStatus.SUSPENDED, MembershipStatus.REVOKED])(
141
+ 'denies access-control context for %s memberships',
142
+ async (status) => {
143
+ const owner = await createUserAndOrg(`access-me-${status.toLowerCase()}`);
144
+ const member = await createMemberInOrg(owner.orgId, `member-${status.toLowerCase()}`);
145
+
146
+ await prisma.membership.update({
147
+ where: { id: member.membershipId },
148
+ data: { status },
149
+ });
150
+
151
+ await request(app.getHttpServer())
152
+ .get(`/organisations/${owner.orgId}/access-control/me`)
153
+ .set('Authorization', `Bearer ${member.accessToken}`)
154
+ .expect(403);
155
+ },
156
+ );
157
+
75
158
  async function createUserAndOrg(label: string) {
159
+ const user = await createUser(label);
160
+ const org = await createOrganisation(user.accessToken, label);
161
+
162
+ return {
163
+ ...user,
164
+ orgId: org.orgId,
165
+ membershipId: org.membershipId,
166
+ };
167
+ }
168
+
169
+ async function createUser(label: string) {
76
170
  const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
77
171
  const signup = await request(app.getHttpServer())
78
172
  .post('/auth/signup')
@@ -83,7 +177,14 @@ describe('Security invariants (e2e)', () => {
83
177
  })
84
178
  .expect(201);
85
179
 
86
- const accessToken = signup.body.accessToken as string;
180
+ return {
181
+ accessToken: signup.body.accessToken as string,
182
+ userId: signup.body.user.id as string,
183
+ };
184
+ }
185
+
186
+ async function createOrganisation(accessToken: string, label: string) {
187
+ const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
87
188
  const org = await request(app.getHttpServer())
88
189
  .post('/organisations')
89
190
  .set('Authorization', `Bearer ${accessToken}`)
@@ -94,9 +195,40 @@ describe('Security invariants (e2e)', () => {
94
195
  .expect(201);
95
196
 
96
197
  return {
97
- accessToken,
98
198
  orgId: org.body.organisation.id as string,
99
199
  membershipId: org.body.membership.id as string,
100
200
  };
101
201
  }
202
+
203
+ async function createMemberInOrg(orgId: string, label: string) {
204
+ const user = await createUser(label);
205
+ const role = await prisma.role.findUniqueOrThrow({
206
+ where: {
207
+ orgId_name: {
208
+ orgId,
209
+ name: 'Viewer',
210
+ },
211
+ },
212
+ select: {
213
+ id: true,
214
+ },
215
+ });
216
+ const membership = await prisma.membership.create({
217
+ data: {
218
+ userId: user.userId,
219
+ orgId,
220
+ roleId: role.id,
221
+ },
222
+ select: {
223
+ id: true,
224
+ roleId: true,
225
+ },
226
+ });
227
+
228
+ return {
229
+ ...user,
230
+ membershipId: membership.id,
231
+ roleId: membership.roleId,
232
+ };
233
+ }
102
234
  });