@scryan7371/sdr-security 0.1.1 → 0.1.2

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 (127) hide show
  1. package/README.md +175 -13
  2. package/dist/api/contracts.d.ts +12 -0
  3. package/dist/api/index.d.ts +1 -0
  4. package/dist/api/index.js +1 -0
  5. package/dist/api/migrations/1739520000000-create-password-reset-tokens.d.ts +9 -0
  6. package/dist/api/migrations/1739520000000-create-password-reset-tokens.js +42 -0
  7. package/dist/api/migrations/index.d.ts +2 -1
  8. package/dist/api/migrations/index.js +4 -1
  9. package/dist/api/migrations/migrations.test.d.ts +1 -0
  10. package/dist/api/migrations/migrations.test.js +134 -0
  11. package/dist/api/notification-workflows.d.ts +35 -0
  12. package/dist/api/notification-workflows.js +23 -0
  13. package/dist/api/notification-workflows.test.d.ts +1 -0
  14. package/dist/api/notification-workflows.test.js +66 -0
  15. package/dist/api/validation.test.d.ts +1 -0
  16. package/dist/api/validation.test.js +20 -0
  17. package/dist/app/client.d.ts +17 -2
  18. package/dist/app/client.js +38 -11
  19. package/dist/app/client.test.d.ts +1 -0
  20. package/dist/app/client.test.js +132 -0
  21. package/dist/index.test.d.ts +1 -0
  22. package/dist/index.test.js +10 -0
  23. package/dist/integration/database.integration.test.d.ts +1 -0
  24. package/dist/integration/database.integration.test.js +158 -0
  25. package/dist/nest/contracts.d.ts +24 -0
  26. package/dist/nest/contracts.js +2 -0
  27. package/dist/nest/dto/auth.dto.d.ts +27 -0
  28. package/dist/nest/dto/auth.dto.js +99 -0
  29. package/dist/nest/dto/workflows.dto.d.ts +16 -0
  30. package/dist/nest/dto/workflows.dto.js +58 -0
  31. package/dist/nest/entities/app-user.entity.d.ts +11 -0
  32. package/dist/nest/entities/app-user.entity.js +64 -0
  33. package/dist/nest/entities/password-reset-token.entity.d.ts +8 -0
  34. package/dist/nest/entities/password-reset-token.entity.js +49 -0
  35. package/dist/nest/entities/refresh-token.entity.d.ts +8 -0
  36. package/dist/nest/entities/refresh-token.entity.js +49 -0
  37. package/dist/nest/entities/security-role.entity.d.ts +6 -0
  38. package/dist/nest/entities/security-role.entity.js +39 -0
  39. package/dist/nest/entities/security-user-role.entity.d.ts +5 -0
  40. package/dist/nest/entities/security-user-role.entity.js +34 -0
  41. package/dist/nest/index.d.ts +18 -0
  42. package/dist/nest/index.js +34 -0
  43. package/dist/nest/index.test.d.ts +1 -0
  44. package/dist/nest/index.test.js +14 -0
  45. package/dist/nest/security-admin.guard.d.ts +4 -0
  46. package/dist/nest/security-admin.guard.js +25 -0
  47. package/dist/nest/security-admin.guard.test.d.ts +1 -0
  48. package/dist/nest/security-admin.guard.test.js +24 -0
  49. package/dist/nest/security-auth.constants.d.ts +1 -0
  50. package/dist/nest/security-auth.constants.js +4 -0
  51. package/dist/nest/security-auth.controller.d.ts +53 -0
  52. package/dist/nest/security-auth.controller.js +179 -0
  53. package/dist/nest/security-auth.controller.test.d.ts +1 -0
  54. package/dist/nest/security-auth.controller.test.js +91 -0
  55. package/dist/nest/security-auth.module.d.ts +9 -0
  56. package/dist/nest/security-auth.module.js +68 -0
  57. package/dist/nest/security-auth.options.d.ts +8 -0
  58. package/dist/nest/security-auth.options.js +2 -0
  59. package/dist/nest/security-auth.service.d.ts +59 -0
  60. package/dist/nest/security-auth.service.js +269 -0
  61. package/dist/nest/security-auth.service.test.d.ts +1 -0
  62. package/dist/nest/security-auth.service.test.js +245 -0
  63. package/dist/nest/security-jwt.guard.d.ts +7 -0
  64. package/dist/nest/security-jwt.guard.js +46 -0
  65. package/dist/nest/security-jwt.guard.test.d.ts +1 -0
  66. package/dist/nest/security-jwt.guard.test.js +51 -0
  67. package/dist/nest/security-modules.test.d.ts +1 -0
  68. package/dist/nest/security-modules.test.js +61 -0
  69. package/dist/nest/security-workflows.controller.d.ts +72 -0
  70. package/dist/nest/security-workflows.controller.js +187 -0
  71. package/dist/nest/security-workflows.controller.test.d.ts +1 -0
  72. package/dist/nest/security-workflows.controller.test.js +87 -0
  73. package/dist/nest/security-workflows.module.d.ts +9 -0
  74. package/dist/nest/security-workflows.module.js +59 -0
  75. package/dist/nest/security-workflows.service.d.ts +67 -0
  76. package/dist/nest/security-workflows.service.js +200 -0
  77. package/dist/nest/security-workflows.service.test.d.ts +1 -0
  78. package/dist/nest/security-workflows.service.test.js +173 -0
  79. package/dist/nest/swagger.d.ts +2 -0
  80. package/dist/nest/swagger.js +16 -0
  81. package/dist/nest/swagger.test.d.ts +1 -0
  82. package/dist/nest/swagger.test.js +21 -0
  83. package/dist/nest/tokens.d.ts +1 -0
  84. package/dist/nest/tokens.js +4 -0
  85. package/package.json +45 -4
  86. package/src/api/contracts.ts +11 -0
  87. package/src/api/index.ts +1 -0
  88. package/src/api/migrations/1739520000000-create-password-reset-tokens.ts +57 -0
  89. package/src/api/migrations/index.ts +3 -0
  90. package/src/api/migrations/migrations.test.ts +208 -0
  91. package/src/api/notification-workflows.test.ts +81 -0
  92. package/src/api/notification-workflows.ts +45 -0
  93. package/src/api/validation.test.ts +21 -0
  94. package/src/app/client.test.ts +159 -0
  95. package/src/app/client.ts +73 -12
  96. package/src/index.test.ts +9 -0
  97. package/src/integration/database.integration.test.ts +205 -0
  98. package/src/nest/contracts.ts +25 -0
  99. package/src/nest/dto/auth.dto.ts +54 -0
  100. package/src/nest/dto/workflows.dto.ts +29 -0
  101. package/src/nest/entities/app-user.entity.ts +31 -0
  102. package/src/nest/entities/password-reset-token.entity.ts +27 -0
  103. package/src/nest/entities/refresh-token.entity.ts +22 -0
  104. package/src/nest/entities/security-role.entity.ts +16 -0
  105. package/src/nest/entities/security-user-role.entity.ts +13 -0
  106. package/src/nest/index.test.ts +20 -0
  107. package/src/nest/index.ts +18 -0
  108. package/src/nest/security-admin.guard.test.ts +31 -0
  109. package/src/nest/security-admin.guard.ts +21 -0
  110. package/src/nest/security-auth.constants.ts +1 -0
  111. package/src/nest/security-auth.controller.test.ts +132 -0
  112. package/src/nest/security-auth.controller.ts +152 -0
  113. package/src/nest/security-auth.module.ts +63 -0
  114. package/src/nest/security-auth.options.ts +8 -0
  115. package/src/nest/security-auth.service.test.ts +337 -0
  116. package/src/nest/security-auth.service.ts +319 -0
  117. package/src/nest/security-jwt.guard.test.ts +65 -0
  118. package/src/nest/security-jwt.guard.ts +47 -0
  119. package/src/nest/security-modules.test.ts +79 -0
  120. package/src/nest/security-workflows.controller.test.ts +119 -0
  121. package/src/nest/security-workflows.controller.ts +149 -0
  122. package/src/nest/security-workflows.module.ts +54 -0
  123. package/src/nest/security-workflows.service.test.ts +232 -0
  124. package/src/nest/security-workflows.service.ts +215 -0
  125. package/src/nest/swagger.test.ts +27 -0
  126. package/src/nest/swagger.ts +18 -0
  127. package/src/nest/tokens.ts +1 -0
@@ -0,0 +1,232 @@
1
+ import { NotFoundException } from "@nestjs/common";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { SecurityWorkflowsService } from "./security-workflows.service";
4
+
5
+ const makeRepo = () => ({
6
+ update: vi.fn(async () => ({ affected: 1 })),
7
+ findOne: vi.fn(),
8
+ find: vi.fn(async (): Promise<Array<Record<string, unknown>>> => []),
9
+ save: vi.fn(async (value: any) => value),
10
+ create: vi.fn((value: any) => value),
11
+ delete: vi.fn(async () => ({ affected: 1 })),
12
+ createQueryBuilder: vi.fn(),
13
+ });
14
+
15
+ const makeNotifier = () => ({
16
+ sendAdminsUserEmailVerified: vi.fn(async () => undefined),
17
+ sendUserAccountApproved: vi.fn(async () => undefined),
18
+ });
19
+
20
+ const makeUser = () => ({
21
+ id: "user-1",
22
+ email: "user@example.com",
23
+ firstName: "A",
24
+ lastName: "B",
25
+ isActive: true,
26
+ });
27
+
28
+ const setup = () => {
29
+ const usersRepo = makeRepo();
30
+ const rolesRepo = makeRepo();
31
+ const userRolesRepo = makeRepo();
32
+ const notifier = makeNotifier();
33
+
34
+ const service = new SecurityWorkflowsService(
35
+ usersRepo as never,
36
+ rolesRepo as never,
37
+ userRolesRepo as never,
38
+ notifier as never,
39
+ );
40
+
41
+ return { service, usersRepo, rolesRepo, userRolesRepo, notifier };
42
+ };
43
+
44
+ describe("SecurityWorkflowsService", () => {
45
+ beforeEach(() => {
46
+ vi.clearAllMocks();
47
+ });
48
+
49
+ it("marks email verified and notifies admins", async () => {
50
+ const { service, usersRepo, userRolesRepo, notifier } = setup();
51
+ usersRepo.findOne.mockResolvedValue(makeUser());
52
+
53
+ const getRawMany = vi
54
+ .fn()
55
+ .mockResolvedValue([{ email: "admin@example.com" }]);
56
+ const qb = {
57
+ innerJoin: vi.fn().mockReturnThis(),
58
+ where: vi.fn().mockReturnThis(),
59
+ andWhere: vi.fn().mockReturnThis(),
60
+ select: vi.fn().mockReturnThis(),
61
+ getRawMany,
62
+ };
63
+ userRolesRepo.createQueryBuilder.mockReturnValue(qb);
64
+
65
+ const result = await service.markEmailVerifiedAndNotifyAdmins("user-1");
66
+
67
+ expect(result).toEqual({
68
+ success: true,
69
+ notified: true,
70
+ adminEmails: ["admin@example.com"],
71
+ });
72
+ expect(notifier.sendAdminsUserEmailVerified).toHaveBeenCalled();
73
+ });
74
+
75
+ it("returns not-notified when no admins are present", async () => {
76
+ const { service, usersRepo, userRolesRepo } = setup();
77
+ usersRepo.findOne.mockResolvedValue(makeUser());
78
+
79
+ const qb = {
80
+ innerJoin: vi.fn().mockReturnThis(),
81
+ where: vi.fn().mockReturnThis(),
82
+ andWhere: vi.fn().mockReturnThis(),
83
+ select: vi.fn().mockReturnThis(),
84
+ getRawMany: vi.fn().mockResolvedValue([]),
85
+ };
86
+ userRolesRepo.createQueryBuilder.mockReturnValue(qb);
87
+
88
+ await expect(
89
+ service.markEmailVerifiedAndNotifyAdmins("user-1"),
90
+ ).resolves.toEqual({ success: true, notified: false, adminEmails: [] });
91
+ });
92
+
93
+ it("throws when user is missing during verification flow", async () => {
94
+ const { service, usersRepo } = setup();
95
+ usersRepo.findOne.mockResolvedValue(null);
96
+
97
+ await expect(
98
+ service.markEmailVerifiedAndNotifyAdmins("missing"),
99
+ ).rejects.toBeInstanceOf(NotFoundException);
100
+ });
101
+
102
+ it("handles admin approval notifications", async () => {
103
+ const { service, usersRepo, notifier } = setup();
104
+ usersRepo.findOne.mockResolvedValue(makeUser());
105
+
106
+ await expect(
107
+ service.setAdminApprovalAndNotifyUser("user-1", false),
108
+ ).resolves.toEqual({ success: true, notified: false });
109
+
110
+ await expect(
111
+ service.setAdminApprovalAndNotifyUser("user-1", true),
112
+ ).resolves.toEqual({ success: true, notified: true });
113
+
114
+ expect(notifier.sendUserAccountApproved).toHaveBeenCalledWith({
115
+ email: "user@example.com",
116
+ firstName: "A",
117
+ });
118
+ });
119
+
120
+ it("throws when approval target user is missing", async () => {
121
+ const { service, usersRepo } = setup();
122
+ usersRepo.findOne.mockResolvedValue(null);
123
+
124
+ await expect(
125
+ service.setAdminApprovalAndNotifyUser("missing", true),
126
+ ).rejects.toBeInstanceOf(NotFoundException);
127
+ });
128
+
129
+ it("manages role catalog and protected role removal", async () => {
130
+ const { service, rolesRepo, userRolesRepo } = setup();
131
+ rolesRepo.find.mockResolvedValue([
132
+ { roleKey: "ADMIN", description: null, isSystem: true },
133
+ ]);
134
+
135
+ await expect(service.listRoles()).resolves.toEqual([
136
+ { role: "ADMIN", description: null, isSystem: true },
137
+ ]);
138
+
139
+ rolesRepo.findOne.mockResolvedValue(null);
140
+ rolesRepo.find
141
+ .mockResolvedValueOnce([])
142
+ .mockResolvedValueOnce([
143
+ { roleKey: "COACH", description: null, isSystem: false },
144
+ ]);
145
+ await service.createRole("coach", " Coach ");
146
+ expect(rolesRepo.save).toHaveBeenCalled();
147
+
148
+ rolesRepo.findOne.mockResolvedValue({
149
+ id: "r1",
150
+ roleKey: "ADMIN",
151
+ isSystem: true,
152
+ });
153
+ await expect(service.removeRole("ADMIN")).resolves.toEqual({
154
+ success: false,
155
+ });
156
+
157
+ rolesRepo.findOne.mockResolvedValue({
158
+ id: "r2",
159
+ roleKey: "COACH",
160
+ isSystem: false,
161
+ });
162
+ await expect(service.removeRole("COACH")).resolves.toEqual({
163
+ success: true,
164
+ });
165
+ expect(userRolesRepo.delete).toHaveBeenCalledWith({ roleId: "r2" });
166
+ });
167
+
168
+ it("gets and sets user roles", async () => {
169
+ const { service, usersRepo, userRolesRepo, rolesRepo } = setup();
170
+ usersRepo.findOne.mockResolvedValue(makeUser());
171
+ userRolesRepo.find.mockResolvedValue([{ roleId: "r1" }]);
172
+ rolesRepo.find.mockResolvedValue([{ id: "r1", roleKey: "ADMIN" }]);
173
+
174
+ await expect(service.getUserRoles("user-1")).resolves.toEqual({
175
+ userId: "user-1",
176
+ roles: ["ADMIN"],
177
+ });
178
+
179
+ rolesRepo.find
180
+ .mockResolvedValueOnce([{ roleKey: "ADMIN" }])
181
+ .mockResolvedValueOnce([{ id: "r1", roleKey: "ADMIN" }]);
182
+ await expect(
183
+ service.setUserRoles("user-1", ["admin", "admin"]),
184
+ ).resolves.toEqual({
185
+ userId: "user-1",
186
+ roles: ["ADMIN"],
187
+ });
188
+ expect(userRolesRepo.save).toHaveBeenCalled();
189
+ });
190
+
191
+ it("assigns and removes role from user", async () => {
192
+ const { service, usersRepo, userRolesRepo, rolesRepo } = setup();
193
+ usersRepo.findOne.mockResolvedValue(makeUser());
194
+
195
+ userRolesRepo.find.mockResolvedValue([]);
196
+ rolesRepo.find.mockResolvedValue([]);
197
+ await service.assignRoleToUser("user-1", "coach");
198
+
199
+ userRolesRepo.find.mockResolvedValue([{ roleId: "r1" }]);
200
+ rolesRepo.find
201
+ .mockResolvedValueOnce([{ id: "r1", roleKey: "COACH" }])
202
+ .mockResolvedValueOnce([]);
203
+ await service.removeRoleFromUser("user-1", "coach");
204
+
205
+ expect(userRolesRepo.delete).toHaveBeenCalledWith({ userId: "user-1" });
206
+ });
207
+
208
+ it("sets user active state", async () => {
209
+ const { service, usersRepo } = setup();
210
+ await expect(service.setUserActive("user-1", false)).resolves.toEqual({
211
+ success: true,
212
+ userId: "user-1",
213
+ active: false,
214
+ });
215
+ expect(usersRepo.update).toHaveBeenCalledWith(
216
+ { id: "user-1" },
217
+ { isActive: false },
218
+ );
219
+ });
220
+
221
+ it("throws when role operations target missing user", async () => {
222
+ const { service, usersRepo } = setup();
223
+ usersRepo.findOne.mockResolvedValue(null);
224
+
225
+ await expect(service.getUserRoles("missing")).rejects.toBeInstanceOf(
226
+ NotFoundException,
227
+ );
228
+ await expect(
229
+ service.setUserRoles("missing", ["ADMIN"]),
230
+ ).rejects.toBeInstanceOf(NotFoundException);
231
+ });
232
+ });
@@ -0,0 +1,215 @@
1
+ import { Inject, Injectable, NotFoundException } from "@nestjs/common";
2
+ import { InjectRepository } from "@nestjs/typeorm";
3
+ import { In, Repository } from "typeorm";
4
+ import { ADMIN_ROLE } from "../api/contracts";
5
+ import { normalizeRoleName } from "../api/roles";
6
+ import { AppUserEntity } from "./entities/app-user.entity";
7
+ import { SecurityRoleEntity } from "./entities/security-role.entity";
8
+ import { SecurityUserRoleEntity } from "./entities/security-user-role.entity";
9
+ import { SecurityWorkflowNotifier } from "./contracts";
10
+ import { SECURITY_WORKFLOW_NOTIFIER } from "./tokens";
11
+
12
+ @Injectable()
13
+ export class SecurityWorkflowsService {
14
+ constructor(
15
+ @InjectRepository(AppUserEntity)
16
+ private readonly usersRepo: Repository<AppUserEntity>,
17
+ @InjectRepository(SecurityRoleEntity)
18
+ private readonly rolesRepo: Repository<SecurityRoleEntity>,
19
+ @InjectRepository(SecurityUserRoleEntity)
20
+ private readonly userRolesRepo: Repository<SecurityUserRoleEntity>,
21
+ @Inject(SECURITY_WORKFLOW_NOTIFIER)
22
+ private readonly notifier: SecurityWorkflowNotifier,
23
+ ) {}
24
+
25
+ async markEmailVerifiedAndNotifyAdmins(userId: string) {
26
+ await this.usersRepo.update(
27
+ { id: userId },
28
+ { emailVerifiedAt: new Date(), emailVerificationToken: null },
29
+ );
30
+
31
+ const user = await this.usersRepo.findOne({ where: { id: userId } });
32
+ if (!user) {
33
+ throw new NotFoundException("User not found");
34
+ }
35
+
36
+ const adminEmails = await this.listAdminEmails();
37
+ if (adminEmails.length === 0) {
38
+ return { success: true as const, notified: false as const, adminEmails };
39
+ }
40
+
41
+ await this.notifier.sendAdminsUserEmailVerified({
42
+ adminEmails,
43
+ user: {
44
+ id: user.id,
45
+ email: user.email,
46
+ firstName: user.firstName,
47
+ lastName: user.lastName,
48
+ },
49
+ });
50
+
51
+ return { success: true as const, notified: true as const, adminEmails };
52
+ }
53
+
54
+ async setAdminApprovalAndNotifyUser(userId: string, approved: boolean) {
55
+ await this.usersRepo.update(
56
+ { id: userId },
57
+ { adminApprovedAt: approved ? new Date() : null },
58
+ );
59
+
60
+ const user = await this.usersRepo.findOne({ where: { id: userId } });
61
+ if (!user) {
62
+ throw new NotFoundException("User not found");
63
+ }
64
+
65
+ if (!approved) {
66
+ return { success: true as const, notified: false as const };
67
+ }
68
+
69
+ await this.notifier.sendUserAccountApproved({
70
+ email: user.email,
71
+ firstName: user.firstName,
72
+ });
73
+
74
+ return { success: true as const, notified: true as const };
75
+ }
76
+
77
+ async listAdminEmails() {
78
+ const rows = await this.userRolesRepo
79
+ .createQueryBuilder("userRole")
80
+ .innerJoin("security_role", "role", "role.id = userRole.role_id")
81
+ .innerJoin("app_user", "user", "user.id = userRole.user_id")
82
+ .where("role.role_key = :roleKey", { roleKey: ADMIN_ROLE })
83
+ .andWhere("user.is_active = :isActive", { isActive: true })
84
+ .select("DISTINCT user.email", "email")
85
+ .getRawMany<{ email: string }>();
86
+
87
+ return rows.map((row) => row.email).filter(Boolean);
88
+ }
89
+
90
+ async listRoles() {
91
+ const roles = await this.rolesRepo.find({ order: { roleKey: "ASC" } });
92
+ return roles.map((role) => ({
93
+ role: role.roleKey,
94
+ description: role.description,
95
+ isSystem: role.isSystem,
96
+ }));
97
+ }
98
+
99
+ async createRole(roleName: string, description?: string | null) {
100
+ const roleKey = normalizeRoleName(roleName);
101
+ let role = await this.rolesRepo.findOne({ where: { roleKey } });
102
+ if (!role) {
103
+ role = this.rolesRepo.create({
104
+ roleKey,
105
+ description: description?.trim() || null,
106
+ isSystem: roleKey === ADMIN_ROLE,
107
+ });
108
+ await this.rolesRepo.save(role);
109
+ } else if (description !== undefined) {
110
+ role.description = description?.trim() || null;
111
+ await this.rolesRepo.save(role);
112
+ }
113
+
114
+ return this.listRoles();
115
+ }
116
+
117
+ async removeRole(roleName: string) {
118
+ const roleKey = normalizeRoleName(roleName);
119
+ const role = await this.rolesRepo.findOne({ where: { roleKey } });
120
+ if (!role || role.isSystem || role.roleKey === ADMIN_ROLE) {
121
+ return { success: false as const };
122
+ }
123
+
124
+ await this.userRolesRepo.delete({ roleId: role.id });
125
+ await this.rolesRepo.delete({ id: role.id });
126
+ return { success: true as const };
127
+ }
128
+
129
+ async getUserRoles(userId: string) {
130
+ await this.assertUserExists(userId);
131
+ const assignments = await this.userRolesRepo.find({ where: { userId } });
132
+ if (assignments.length === 0) {
133
+ return { userId, roles: [] as string[] };
134
+ }
135
+ const roleIds = assignments.map((assignment) => assignment.roleId);
136
+ const roles = await this.rolesRepo.find({
137
+ where: { id: In(roleIds) },
138
+ order: { roleKey: "ASC" },
139
+ });
140
+ return { userId, roles: roles.map((role) => role.roleKey) };
141
+ }
142
+
143
+ async setUserRoles(userId: string, roleNames: string[]) {
144
+ await this.assertUserExists(userId);
145
+ const normalized = [...new Set(roleNames.map(normalizeRoleName))];
146
+ await this.ensureRoles(normalized);
147
+ const roles = normalized.length
148
+ ? await this.rolesRepo.find({ where: { roleKey: In(normalized) } })
149
+ : [];
150
+
151
+ await this.userRolesRepo.delete({ userId });
152
+ if (roles.length > 0) {
153
+ await this.userRolesRepo.save(
154
+ roles.map((role) =>
155
+ this.userRolesRepo.create({
156
+ userId,
157
+ roleId: role.id,
158
+ }),
159
+ ),
160
+ );
161
+ }
162
+ return { userId, roles: normalized };
163
+ }
164
+
165
+ async assignRoleToUser(userId: string, roleName: string) {
166
+ const existing = await this.getUserRoles(userId);
167
+ const nextRoles = [
168
+ ...new Set([...existing.roles, normalizeRoleName(roleName)]),
169
+ ];
170
+ return this.setUserRoles(userId, nextRoles);
171
+ }
172
+
173
+ async removeRoleFromUser(userId: string, roleName: string) {
174
+ const normalized = normalizeRoleName(roleName);
175
+ const existing = await this.getUserRoles(userId);
176
+ const nextRoles = existing.roles.filter((role) => role !== normalized);
177
+ return this.setUserRoles(userId, nextRoles);
178
+ }
179
+
180
+ async setUserActive(userId: string, active: boolean) {
181
+ await this.usersRepo.update({ id: userId }, { isActive: active });
182
+ return { success: true as const, userId, active };
183
+ }
184
+
185
+ private async assertUserExists(userId: string) {
186
+ const user = await this.usersRepo.findOne({ where: { id: userId } });
187
+ if (!user) {
188
+ throw new NotFoundException("User not found");
189
+ }
190
+ return user;
191
+ }
192
+
193
+ private async ensureRoles(roleKeys: string[]) {
194
+ if (roleKeys.length === 0) {
195
+ return;
196
+ }
197
+ const existing = await this.rolesRepo.find({
198
+ where: { roleKey: In(roleKeys) },
199
+ });
200
+ const existingSet = new Set(existing.map((role) => role.roleKey));
201
+ const missing = roleKeys.filter((roleKey) => !existingSet.has(roleKey));
202
+ if (missing.length === 0) {
203
+ return;
204
+ }
205
+ await this.rolesRepo.save(
206
+ missing.map((roleKey) =>
207
+ this.rolesRepo.create({
208
+ roleKey,
209
+ description: null,
210
+ isSystem: roleKey === ADMIN_ROLE,
211
+ }),
212
+ ),
213
+ );
214
+ }
215
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { setupSecuritySwagger } from "./swagger";
3
+ import { SwaggerModule } from "@nestjs/swagger";
4
+
5
+ describe("setupSecuritySwagger", () => {
6
+ it("creates swagger document and mounts UI", () => {
7
+ const app = {} as any;
8
+ const document = { openapi: "3.0.0" } as any;
9
+
10
+ const createSpy = vi
11
+ .spyOn(SwaggerModule, "createDocument")
12
+ .mockReturnValue(document);
13
+ const setupSpy = vi.spyOn(SwaggerModule, "setup").mockImplementation(() => {
14
+ return;
15
+ });
16
+
17
+ const result = setupSecuritySwagger(app, "docs/custom-security");
18
+
19
+ expect(createSpy).toHaveBeenCalled();
20
+ expect(setupSpy).toHaveBeenCalledWith(
21
+ "docs/custom-security",
22
+ app,
23
+ document,
24
+ );
25
+ expect(result).toBe(document);
26
+ });
27
+ });
@@ -0,0 +1,18 @@
1
+ import { INestApplication } from "@nestjs/common";
2
+ import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
3
+
4
+ export const setupSecuritySwagger = (
5
+ app: INestApplication,
6
+ path = "docs/security",
7
+ ) => {
8
+ const config = new DocumentBuilder()
9
+ .setTitle("Security API")
10
+ .setDescription("Shared auth and security workflow endpoints")
11
+ .setVersion("1.0")
12
+ .addBearerAuth()
13
+ .build();
14
+
15
+ const document = SwaggerModule.createDocument(app, config);
16
+ SwaggerModule.setup(path, app, document);
17
+ return document;
18
+ };
@@ -0,0 +1 @@
1
+ export const SECURITY_WORKFLOW_NOTIFIER = Symbol("SECURITY_WORKFLOW_NOTIFIER");