@scryan7371/sdr-security 0.1.1 → 0.1.3

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 (145) hide show
  1. package/README.md +216 -13
  2. package/dist/api/contracts.d.ts +12 -2
  3. package/dist/api/index.d.ts +1 -0
  4. package/dist/api/index.js +1 -0
  5. package/dist/api/migrations/1739500000000-create-security-identity.d.ts +1 -1
  6. package/dist/api/migrations/1739500000000-create-security-identity.js +9 -35
  7. package/dist/api/migrations/1739510000000-create-security-roles.d.ts +1 -1
  8. package/dist/api/migrations/1739510000000-create-security-roles.js +1 -67
  9. package/dist/api/migrations/1739515000000-create-security-user-roles.d.ts +9 -0
  10. package/dist/api/migrations/1739515000000-create-security-user-roles.js +39 -0
  11. package/dist/api/migrations/1739520000000-create-password-reset-tokens.d.ts +9 -0
  12. package/dist/api/migrations/1739520000000-create-password-reset-tokens.js +42 -0
  13. package/dist/api/migrations/1739530000000-create-security-user.d.ts +9 -0
  14. package/dist/api/migrations/1739530000000-create-security-user.js +41 -0
  15. package/dist/api/migrations/index.d.ts +4 -2
  16. package/dist/api/migrations/index.js +10 -4
  17. package/dist/api/migrations/migrations.test.d.ts +1 -0
  18. package/dist/api/migrations/migrations.test.js +88 -0
  19. package/dist/api/notification-workflows.d.ts +31 -0
  20. package/dist/api/notification-workflows.js +22 -0
  21. package/dist/api/notification-workflows.test.d.ts +1 -0
  22. package/dist/api/notification-workflows.test.js +63 -0
  23. package/dist/api/validation.test.d.ts +1 -0
  24. package/dist/api/validation.test.js +20 -0
  25. package/dist/app/client.d.ts +17 -4
  26. package/dist/app/client.js +38 -11
  27. package/dist/app/client.test.d.ts +1 -0
  28. package/dist/app/client.test.js +130 -0
  29. package/dist/index.test.d.ts +1 -0
  30. package/dist/index.test.js +10 -0
  31. package/dist/integration/database.integration.test.d.ts +1 -0
  32. package/dist/integration/database.integration.test.js +158 -0
  33. package/dist/nest/contracts.d.ts +21 -0
  34. package/dist/nest/contracts.js +2 -0
  35. package/dist/nest/dto/auth.dto.d.ts +25 -0
  36. package/dist/nest/dto/auth.dto.js +89 -0
  37. package/dist/nest/dto/workflows.dto.d.ts +16 -0
  38. package/dist/nest/dto/workflows.dto.js +58 -0
  39. package/dist/nest/entities/app-user.entity.d.ts +4 -0
  40. package/dist/nest/entities/app-user.entity.js +29 -0
  41. package/dist/nest/entities/password-reset-token.entity.d.ts +8 -0
  42. package/dist/nest/entities/password-reset-token.entity.js +49 -0
  43. package/dist/nest/entities/refresh-token.entity.d.ts +8 -0
  44. package/dist/nest/entities/refresh-token.entity.js +49 -0
  45. package/dist/nest/entities/security-role.entity.d.ts +6 -0
  46. package/dist/nest/entities/security-role.entity.js +39 -0
  47. package/dist/nest/entities/security-user-role.entity.d.ts +5 -0
  48. package/dist/nest/entities/security-user-role.entity.js +34 -0
  49. package/dist/nest/entities/security-user.entity.d.ts +9 -0
  50. package/dist/nest/entities/security-user.entity.js +54 -0
  51. package/dist/nest/index.d.ts +19 -0
  52. package/dist/nest/index.js +35 -0
  53. package/dist/nest/index.test.d.ts +1 -0
  54. package/dist/nest/index.test.js +14 -0
  55. package/dist/nest/security-admin.guard.d.ts +4 -0
  56. package/dist/nest/security-admin.guard.js +25 -0
  57. package/dist/nest/security-admin.guard.test.d.ts +1 -0
  58. package/dist/nest/security-admin.guard.test.js +24 -0
  59. package/dist/nest/security-auth.constants.d.ts +1 -0
  60. package/dist/nest/security-auth.constants.js +4 -0
  61. package/dist/nest/security-auth.controller.d.ts +51 -0
  62. package/dist/nest/security-auth.controller.js +177 -0
  63. package/dist/nest/security-auth.controller.test.d.ts +1 -0
  64. package/dist/nest/security-auth.controller.test.js +87 -0
  65. package/dist/nest/security-auth.module.d.ts +9 -0
  66. package/dist/nest/security-auth.module.js +70 -0
  67. package/dist/nest/security-auth.options.d.ts +8 -0
  68. package/dist/nest/security-auth.options.js +2 -0
  69. package/dist/nest/security-auth.service.d.ts +60 -0
  70. package/dist/nest/security-auth.service.js +299 -0
  71. package/dist/nest/security-auth.service.test.d.ts +1 -0
  72. package/dist/nest/security-auth.service.test.js +249 -0
  73. package/dist/nest/security-jwt.guard.d.ts +7 -0
  74. package/dist/nest/security-jwt.guard.js +46 -0
  75. package/dist/nest/security-jwt.guard.test.d.ts +1 -0
  76. package/dist/nest/security-jwt.guard.test.js +51 -0
  77. package/dist/nest/security-modules.test.d.ts +1 -0
  78. package/dist/nest/security-modules.test.js +61 -0
  79. package/dist/nest/security-workflows.controller.d.ts +72 -0
  80. package/dist/nest/security-workflows.controller.js +187 -0
  81. package/dist/nest/security-workflows.controller.test.d.ts +1 -0
  82. package/dist/nest/security-workflows.controller.test.js +87 -0
  83. package/dist/nest/security-workflows.module.d.ts +9 -0
  84. package/dist/nest/security-workflows.module.js +61 -0
  85. package/dist/nest/security-workflows.service.d.ts +69 -0
  86. package/dist/nest/security-workflows.service.js +203 -0
  87. package/dist/nest/security-workflows.service.test.d.ts +1 -0
  88. package/dist/nest/security-workflows.service.test.js +178 -0
  89. package/dist/nest/swagger.d.ts +2 -0
  90. package/dist/nest/swagger.js +16 -0
  91. package/dist/nest/swagger.test.d.ts +1 -0
  92. package/dist/nest/swagger.test.js +21 -0
  93. package/dist/nest/tokens.d.ts +1 -0
  94. package/dist/nest/tokens.js +4 -0
  95. package/package.json +45 -4
  96. package/src/api/contracts.ts +11 -2
  97. package/src/api/index.ts +1 -0
  98. package/src/api/migrations/1739500000000-create-security-identity.ts +11 -50
  99. package/src/api/migrations/1739510000000-create-security-roles.ts +2 -89
  100. package/src/api/migrations/1739515000000-create-security-user-roles.ts +49 -0
  101. package/src/api/migrations/1739520000000-create-password-reset-tokens.ts +57 -0
  102. package/src/api/migrations/1739530000000-create-security-user.ts +51 -0
  103. package/src/api/migrations/index.ts +9 -3
  104. package/src/api/migrations/migrations.test.ts +145 -0
  105. package/src/api/notification-workflows.test.ts +78 -0
  106. package/src/api/notification-workflows.ts +38 -0
  107. package/src/api/validation.test.ts +21 -0
  108. package/src/app/client.test.ts +157 -0
  109. package/src/app/client.ts +74 -18
  110. package/src/index.test.ts +9 -0
  111. package/src/integration/database.integration.test.ts +205 -0
  112. package/src/nest/contracts.ts +20 -0
  113. package/src/nest/dto/auth.dto.ts +48 -0
  114. package/src/nest/dto/workflows.dto.ts +29 -0
  115. package/src/nest/entities/app-user.entity.ts +10 -0
  116. package/src/nest/entities/password-reset-token.entity.ts +27 -0
  117. package/src/nest/entities/refresh-token.entity.ts +22 -0
  118. package/src/nest/entities/security-role.entity.ts +16 -0
  119. package/src/nest/entities/security-user-role.entity.ts +13 -0
  120. package/src/nest/entities/security-user.entity.ts +25 -0
  121. package/src/nest/index.test.ts +20 -0
  122. package/src/nest/index.ts +19 -0
  123. package/src/nest/security-admin.guard.test.ts +31 -0
  124. package/src/nest/security-admin.guard.ts +21 -0
  125. package/src/nest/security-auth.constants.ts +1 -0
  126. package/src/nest/security-auth.controller.test.ts +128 -0
  127. package/src/nest/security-auth.controller.ts +148 -0
  128. package/src/nest/security-auth.module.ts +65 -0
  129. package/src/nest/security-auth.options.ts +8 -0
  130. package/src/nest/security-auth.service.test.ts +368 -0
  131. package/src/nest/security-auth.service.ts +356 -0
  132. package/src/nest/security-jwt.guard.test.ts +65 -0
  133. package/src/nest/security-jwt.guard.ts +47 -0
  134. package/src/nest/security-modules.test.ts +79 -0
  135. package/src/nest/security-workflows.controller.test.ts +119 -0
  136. package/src/nest/security-workflows.controller.ts +149 -0
  137. package/src/nest/security-workflows.module.ts +56 -0
  138. package/src/nest/security-workflows.service.test.ts +238 -0
  139. package/src/nest/security-workflows.service.ts +220 -0
  140. package/src/nest/swagger.test.ts +27 -0
  141. package/src/nest/swagger.ts +18 -0
  142. package/src/nest/tokens.ts +1 -0
  143. package/dist/api/migrations/1739490000000-add-google-subject-to-user.d.ts +0 -5
  144. package/dist/api/migrations/1739490000000-add-google-subject-to-user.js +0 -14
  145. package/src/api/migrations/1739490000000-add-google-subject-to-user.ts +0 -12
@@ -0,0 +1,157 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createSecurityClient } from "./client";
3
+
4
+ type MockResponse = { ok: boolean; status: number; body: unknown };
5
+
6
+ const makeFetch = (responses: MockResponse[]) => {
7
+ const fn = vi.fn();
8
+ responses.forEach((response) => {
9
+ fn.mockResolvedValueOnce({
10
+ ok: response.ok,
11
+ status: response.status,
12
+ text: async () =>
13
+ response.body === undefined ? "" : JSON.stringify(response.body),
14
+ });
15
+ });
16
+ return fn;
17
+ };
18
+
19
+ describe("createSecurityClient", () => {
20
+ it("sends auth header and JSON body", async () => {
21
+ const fetchImpl = makeFetch([
22
+ { ok: true, status: 200, body: { success: true } },
23
+ ]);
24
+ const client = createSecurityClient({
25
+ baseUrl: "https://api.example.com",
26
+ getAccessToken: () => "token-1",
27
+ fetchImpl: fetchImpl as unknown as typeof fetch,
28
+ });
29
+
30
+ await client.register({
31
+ email: "user@example.com",
32
+ password: "Secret123",
33
+ });
34
+
35
+ expect(fetchImpl).toHaveBeenCalledWith(
36
+ "https://api.example.com/security/auth/register",
37
+ expect.objectContaining({
38
+ method: "POST",
39
+ headers: expect.objectContaining({
40
+ Authorization: "Bearer token-1",
41
+ "Content-Type": "application/json",
42
+ }),
43
+ }),
44
+ );
45
+ });
46
+
47
+ it("omits auth header when token is missing", async () => {
48
+ const fetchImpl = makeFetch([
49
+ { ok: true, status: 200, body: { success: true } },
50
+ ]);
51
+ const client = createSecurityClient({
52
+ baseUrl: "https://api.example.com",
53
+ getAccessToken: () => null,
54
+ fetchImpl: fetchImpl as unknown as typeof fetch,
55
+ });
56
+
57
+ await client.logout({});
58
+
59
+ const headers = (
60
+ fetchImpl.mock.calls[0][1] as { headers: Record<string, string> }
61
+ ).headers;
62
+ expect(headers.Authorization).toBeUndefined();
63
+ });
64
+
65
+ it("throws server-provided error message", async () => {
66
+ const fetchImpl = makeFetch([
67
+ { ok: false, status: 400, body: { message: "bad request" } },
68
+ ]);
69
+ const client = createSecurityClient({
70
+ baseUrl: "https://api.example.com",
71
+ getAccessToken: () => "token",
72
+ fetchImpl: fetchImpl as unknown as typeof fetch,
73
+ });
74
+
75
+ await expect(client.login({ email: "x", password: "y" })).rejects.toThrow(
76
+ "bad request",
77
+ );
78
+ });
79
+
80
+ it("throws fallback error when server does not return message", async () => {
81
+ const fetchImpl = makeFetch([
82
+ { ok: false, status: 503, body: { detail: "down" } },
83
+ ]);
84
+ const client = createSecurityClient({
85
+ baseUrl: "https://api.example.com",
86
+ getAccessToken: () => "token",
87
+ fetchImpl: fetchImpl as unknown as typeof fetch,
88
+ });
89
+
90
+ await expect(client.listRoles()).rejects.toThrow("Request failed: 503");
91
+ });
92
+
93
+ it("encodes URL params correctly", async () => {
94
+ const fetchImpl = makeFetch([
95
+ { ok: true, status: 200, body: { success: true } },
96
+ { ok: true, status: 200, body: { success: true } },
97
+ { ok: true, status: 200, body: { success: true } },
98
+ ]);
99
+ const client = createSecurityClient({
100
+ baseUrl: "https://api.example.com",
101
+ getAccessToken: () => "token",
102
+ fetchImpl: fetchImpl as unknown as typeof fetch,
103
+ });
104
+
105
+ await client.verifyEmail("a b+c");
106
+ await client.removeRole("ADMIN TEAM");
107
+ await client.removeRoleFromUser("u1", "ADMIN TEAM");
108
+
109
+ expect(fetchImpl.mock.calls[0][0]).toContain("token=a%20b%2Bc");
110
+ expect(fetchImpl.mock.calls[1][0]).toContain("roles/ADMIN%20TEAM");
111
+ expect(fetchImpl.mock.calls[2][0]).toContain("roles/ADMIN%20TEAM");
112
+ });
113
+
114
+ it("supports auth and workflow operations", async () => {
115
+ const responses = Array.from({ length: 18 }, () => ({
116
+ ok: true,
117
+ status: 200,
118
+ body: { success: true },
119
+ }));
120
+ const fetchImpl = makeFetch(responses);
121
+ const client = createSecurityClient({
122
+ baseUrl: "https://api.example.com",
123
+ getAccessToken: () => "token",
124
+ fetchImpl: fetchImpl as unknown as typeof fetch,
125
+ });
126
+
127
+ await client.login({ email: "u@e.com", password: "Secret123" });
128
+ await client.loginWithGoogle({ idToken: "id-token" });
129
+ await client.refresh({ refreshToken: "rt" });
130
+ await client.revoke({ refreshToken: "rt" });
131
+ await client.changePassword({ currentPassword: "a", newPassword: "b" });
132
+ await client.forgotPassword("u@e.com");
133
+ await client.resetPassword({ token: "t", newPassword: "Secret123" });
134
+ await client.requestPhoneVerification();
135
+ await client.verifyPhone("123456");
136
+ await client.getMyRoles();
137
+ await client.listRoles();
138
+ await client.createRole({ role: "COACH", description: null });
139
+ await client.getUserRoles("u1");
140
+ await client.setUserRoles("u1", ["ADMIN"]);
141
+ await client.approveUser("u1", true);
142
+ await client.setUserActive("u1", true);
143
+ await client.assignRoleToUser("u1", "ADMIN");
144
+ await client.removeRoleFromUser("u1", "ADMIN");
145
+
146
+ expect(fetchImpl).toHaveBeenCalledTimes(18);
147
+ expect(fetchImpl.mock.calls.map((c) => c[0])).toEqual(
148
+ expect.arrayContaining([
149
+ "https://api.example.com/auth/login/google",
150
+ "https://api.example.com/auth/revoke",
151
+ "https://api.example.com/auth/request-phone-verification",
152
+ "https://api.example.com/auth/verify-phone",
153
+ "https://api.example.com/security/workflows/users/u1/admin-approval",
154
+ ]),
155
+ );
156
+ });
157
+ });
package/src/app/client.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import type {
2
+ AdminNotificationResponse,
2
3
  AuthResponse,
3
4
  DebugCodeResponse,
4
5
  DebugTokenResponse,
6
+ GenericSuccessResponse,
5
7
  RoleCatalogResponse,
6
8
  RegisterResponse,
9
+ UserActiveResponse,
7
10
  UserRolesResponse,
8
11
  } from "../api/contracts";
9
12
 
@@ -49,19 +52,14 @@ export const createSecurityClient = (options: SecurityClientOptions) => {
49
52
  };
50
53
 
51
54
  return {
52
- register: (payload: {
53
- email: string;
54
- password: string;
55
- firstName?: string;
56
- lastName?: string;
57
- }) =>
58
- request<RegisterResponse>("/auth/register", {
55
+ register: (payload: { email: string; password: string }) =>
56
+ request<RegisterResponse>("/security/auth/register", {
59
57
  method: "POST",
60
58
  body: JSON.stringify(payload),
61
59
  }),
62
60
 
63
61
  login: (payload: { email: string; password: string }) =>
64
- request<AuthResponse>("/auth/login", {
62
+ request<AuthResponse>("/security/auth/login", {
65
63
  method: "POST",
66
64
  body: JSON.stringify(payload),
67
65
  }),
@@ -73,7 +71,7 @@ export const createSecurityClient = (options: SecurityClientOptions) => {
73
71
  }),
74
72
 
75
73
  refresh: (payload: { refreshToken: string }) =>
76
- request<AuthResponse>("/auth/refresh", {
74
+ request<AuthResponse>("/security/auth/refresh", {
77
75
  method: "POST",
78
76
  body: JSON.stringify(payload),
79
77
  }),
@@ -85,18 +83,36 @@ export const createSecurityClient = (options: SecurityClientOptions) => {
85
83
  }),
86
84
 
87
85
  logout: (payload: { refreshToken?: string }) =>
88
- request<{ success: true }>("/auth/logout", {
86
+ request<{ success: true }>("/security/auth/logout", {
89
87
  method: "POST",
90
88
  body: JSON.stringify(payload),
91
89
  }),
92
90
 
93
- requestEmailVerification: () =>
94
- request<DebugTokenResponse>("/auth/request-email-verification", {
91
+ changePassword: (payload: {
92
+ currentPassword: string;
93
+ newPassword: string;
94
+ }) =>
95
+ request<GenericSuccessResponse>("/security/auth/change-password", {
95
96
  method: "POST",
97
+ body: JSON.stringify(payload),
96
98
  }),
97
99
 
98
100
  verifyEmail: (token: string) =>
99
- request<{ success: true }>(`/auth/verify-email?token=${token}`),
101
+ request<{ success: true }>(
102
+ `/security/auth/verify-email?token=${encodeURIComponent(token)}`,
103
+ ),
104
+
105
+ forgotPassword: (email: string) =>
106
+ request<GenericSuccessResponse>("/security/auth/forgot-password", {
107
+ method: "POST",
108
+ body: JSON.stringify({ email }),
109
+ }),
110
+
111
+ resetPassword: (payload: { token: string; newPassword: string }) =>
112
+ request<GenericSuccessResponse>("/security/auth/reset-password", {
113
+ method: "POST",
114
+ body: JSON.stringify(payload),
115
+ }),
100
116
 
101
117
  requestPhoneVerification: () =>
102
118
  request<DebugCodeResponse>("/auth/request-phone-verification", {
@@ -109,23 +125,63 @@ export const createSecurityClient = (options: SecurityClientOptions) => {
109
125
  body: JSON.stringify({ code }),
110
126
  }),
111
127
 
112
- getMyRoles: () => request<UserRolesResponse>("/auth/me/roles"),
128
+ getMyRoles: () => request<UserRolesResponse>("/security/auth/me/roles"),
113
129
 
114
- listRoles: () => request<RoleCatalogResponse>("/admin/roles"),
130
+ listRoles: () => request<RoleCatalogResponse>("/security/workflows/roles"),
115
131
 
116
132
  createRole: (payload: { role: string; description?: string | null }) =>
117
- request<RoleCatalogResponse>("/admin/roles", {
133
+ request<RoleCatalogResponse>("/security/workflows/roles", {
118
134
  method: "POST",
119
135
  body: JSON.stringify(payload),
120
136
  }),
121
137
 
122
138
  getUserRoles: (userId: string) =>
123
- request<UserRolesResponse>(`/admin/users/${userId}/roles`),
139
+ request<UserRolesResponse>(`/security/workflows/users/${userId}/roles`),
124
140
 
125
141
  setUserRoles: (userId: string, roles: string[]) =>
126
- request<UserRolesResponse>(`/admin/users/${userId}/roles`, {
142
+ request<UserRolesResponse>(`/security/workflows/users/${userId}/roles`, {
127
143
  method: "PUT",
128
144
  body: JSON.stringify({ roles }),
129
145
  }),
146
+
147
+ approveUser: (userId: string, approved: boolean) =>
148
+ request<AdminNotificationResponse>(
149
+ `/security/workflows/users/${userId}/admin-approval`,
150
+ {
151
+ method: "PATCH",
152
+ body: JSON.stringify({ approved }),
153
+ },
154
+ ),
155
+
156
+ setUserActive: (userId: string, active: boolean) =>
157
+ request<UserActiveResponse>(
158
+ `/security/workflows/users/${userId}/active`,
159
+ {
160
+ method: "PATCH",
161
+ body: JSON.stringify({ active }),
162
+ },
163
+ ),
164
+
165
+ removeRole: (role: string) =>
166
+ request<{ success: boolean }>(
167
+ `/security/workflows/roles/${encodeURIComponent(role)}`,
168
+ {
169
+ method: "DELETE",
170
+ },
171
+ ),
172
+
173
+ assignRoleToUser: (userId: string, role: string) =>
174
+ request<UserRolesResponse>(`/security/workflows/users/${userId}/roles`, {
175
+ method: "POST",
176
+ body: JSON.stringify({ role }),
177
+ }),
178
+
179
+ removeRoleFromUser: (userId: string, role: string) =>
180
+ request<UserRolesResponse>(
181
+ `/security/workflows/users/${userId}/roles/${encodeURIComponent(role)}`,
182
+ {
183
+ method: "DELETE",
184
+ },
185
+ ),
130
186
  };
131
187
  };
@@ -0,0 +1,9 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { api, app } from "./index";
3
+
4
+ describe("package exports", () => {
5
+ it("exposes api and app modules", () => {
6
+ expect(api.ADMIN_ROLE).toBe("ADMIN");
7
+ expect(typeof app.createSecurityClient).toBe("function");
8
+ });
9
+ });
@@ -0,0 +1,205 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
4
+ import { Client, ClientConfig } from "pg";
5
+ import { securityMigrations } from "../api/migrations";
6
+
7
+ type QueryRunnerLike = {
8
+ query: (sql: string, params?: unknown[]) => Promise<unknown>;
9
+ };
10
+
11
+ const loadEnvFile = (filepath: string) => {
12
+ if (!existsSync(filepath)) {
13
+ return;
14
+ }
15
+
16
+ const file = readFileSync(filepath, "utf8");
17
+ for (const line of file.split(/\r?\n/)) {
18
+ const trimmed = line.trim();
19
+ if (!trimmed || trimmed.startsWith("#")) {
20
+ continue;
21
+ }
22
+
23
+ const index = trimmed.indexOf("=");
24
+ if (index <= 0) {
25
+ continue;
26
+ }
27
+
28
+ const rawKey = trimmed.slice(0, index).trim();
29
+ const key = rawKey.startsWith("export ")
30
+ ? rawKey.slice("export ".length).trim()
31
+ : rawKey;
32
+ const rawValue = trimmed.slice(index + 1).trim();
33
+ const unquoted =
34
+ (rawValue.startsWith('"') && rawValue.endsWith('"')) ||
35
+ (rawValue.startsWith("'") && rawValue.endsWith("'"))
36
+ ? rawValue.slice(1, -1)
37
+ : rawValue;
38
+
39
+ if (!process.env[key]) {
40
+ process.env[key] = unquoted;
41
+ }
42
+ }
43
+ };
44
+
45
+ const loadDotEnvDefaults = () => {
46
+ const cwd = process.cwd();
47
+ const fileDir = __dirname;
48
+ const projectRoot = path.resolve(fileDir, "../..");
49
+
50
+ const candidates = [
51
+ path.join(cwd, ".env.test"),
52
+ path.join(cwd, ".env.dev"),
53
+ path.join(projectRoot, ".env.test"),
54
+ path.join(projectRoot, ".env.dev"),
55
+ ];
56
+
57
+ for (const candidate of candidates) {
58
+ loadEnvFile(candidate);
59
+ }
60
+ };
61
+
62
+ const resolveDbConfig = (): ClientConfig | null => {
63
+ const connectionString =
64
+ process.env.SECURITY_TEST_DATABASE_URL ?? process.env.DATABASE_URL;
65
+
66
+ if (connectionString) {
67
+ return { connectionString };
68
+ }
69
+
70
+ if (!process.env.DB_HOST || !process.env.DB_USER || !process.env.DB_NAME) {
71
+ return null;
72
+ }
73
+
74
+ return {
75
+ host: process.env.DB_HOST,
76
+ port: process.env.DB_PORT ? Number(process.env.DB_PORT) : 5432,
77
+ user: process.env.DB_USER,
78
+ password: process.env.DB_PASSWORD,
79
+ database: process.env.DB_NAME,
80
+ };
81
+ };
82
+
83
+ loadDotEnvDefaults();
84
+ const dbConfig = resolveDbConfig();
85
+ const missingRequiredDbVars = ["DB_HOST", "DB_USER", "DB_NAME"].filter(
86
+ (key) => !process.env[key],
87
+ );
88
+ const keepSchemaForDebug = process.env.SECURITY_TEST_KEEP_SCHEMA === "true";
89
+ const fixedSchemaName = process.env.SECURITY_TEST_SCHEMA?.trim() || "";
90
+ const resetSchemaBeforeRun = process.env.SECURITY_TEST_RESET_SCHEMA !== "false";
91
+
92
+ describe("database integration", () => {
93
+ const previousUserTable = process.env.USER_TABLE;
94
+ const previousUserSchema = process.env.USER_TABLE_SCHEMA;
95
+
96
+ const schema = fixedSchemaName || `sdr_security_it_${Date.now()}`;
97
+ let client: Client;
98
+ let runner: QueryRunnerLike;
99
+
100
+ beforeAll(async () => {
101
+ if (!dbConfig) {
102
+ throw new Error(
103
+ `Database integration tests require DB env. Missing: ${missingRequiredDbVars.join(", ") || "unknown"}`,
104
+ );
105
+ }
106
+
107
+ client = new Client(dbConfig as ClientConfig);
108
+ await client.connect();
109
+
110
+ runner = {
111
+ query: async (sql: string, params?: unknown[]) => {
112
+ const result = await client.query(sql, params as any[] | undefined);
113
+ return result.rows;
114
+ },
115
+ };
116
+
117
+ if (resetSchemaBeforeRun) {
118
+ await client.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
119
+ }
120
+ await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
121
+ await client.query(`SET search_path TO "${schema}", public`);
122
+ await client.query(`
123
+ CREATE TABLE IF NOT EXISTS "${schema}"."app_user" (
124
+ "id" varchar PRIMARY KEY NOT NULL,
125
+ "email" varchar NOT NULL
126
+ )
127
+ `);
128
+
129
+ process.env.USER_TABLE = "app_user";
130
+ process.env.USER_TABLE_SCHEMA = schema;
131
+
132
+ for (const Migration of securityMigrations) {
133
+ await new Migration().up(runner as never);
134
+ }
135
+ });
136
+
137
+ afterAll(async () => {
138
+ if (!client) {
139
+ return;
140
+ }
141
+
142
+ if (!keepSchemaForDebug) {
143
+ for (const Migration of [...securityMigrations].reverse()) {
144
+ await new Migration().down(runner as never);
145
+ }
146
+ await client.query(`DROP TABLE IF EXISTS "${schema}"."app_user"`);
147
+ await client.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
148
+ }
149
+ await client.end();
150
+
151
+ process.env.USER_TABLE = previousUserTable;
152
+ process.env.USER_TABLE_SCHEMA = previousUserSchema;
153
+ });
154
+
155
+ it("creates expected security tables and indexes", async () => {
156
+ const tables = await client.query(
157
+ `
158
+ SELECT table_name
159
+ FROM information_schema.tables
160
+ WHERE table_schema = $1
161
+ AND table_name = ANY($2)
162
+ `,
163
+ [
164
+ schema,
165
+ [
166
+ "refresh_token",
167
+ "security_identity",
168
+ "security_role",
169
+ "security_user_role",
170
+ "security_password_reset_token",
171
+ ],
172
+ ],
173
+ );
174
+
175
+ expect(tables.rows).toHaveLength(5);
176
+
177
+ const adminRole = await client.query(
178
+ `SELECT role_key FROM "${schema}"."security_role" WHERE role_key = 'ADMIN' LIMIT 1`,
179
+ );
180
+ expect(adminRole.rows).toHaveLength(1);
181
+
182
+ const index = await client.query(
183
+ `
184
+ SELECT indexname
185
+ FROM pg_indexes
186
+ WHERE schemaname = $1
187
+ AND indexname = 'IDX_security_user_role_user_role'
188
+ `,
189
+ [schema],
190
+ );
191
+
192
+ expect(index.rows).toHaveLength(1);
193
+ });
194
+
195
+ it("prints schema name for debugging", () => {
196
+ console.log(`[sdr-security test:db] schema=${schema}`);
197
+ console.log(
198
+ `[sdr-security test:db] keepSchema=${keepSchemaForDebug ? "true" : "false"}`,
199
+ );
200
+ console.log(
201
+ `[sdr-security test:db] resetSchemaBeforeRun=${resetSchemaBeforeRun ? "true" : "false"}`,
202
+ );
203
+ expect(schema.length).toBeGreaterThan(0);
204
+ });
205
+ });
@@ -0,0 +1,20 @@
1
+ export type SecurityWorkflowUser = {
2
+ id: string;
3
+ email: string;
4
+ };
5
+
6
+ export type SecurityWorkflowNotifier = {
7
+ sendEmailVerification?: (params: {
8
+ email: string;
9
+ token: string;
10
+ }) => Promise<void>;
11
+ sendPasswordReset?: (params: {
12
+ email: string;
13
+ token: string;
14
+ }) => Promise<void>;
15
+ sendAdminsUserEmailVerified: (params: {
16
+ adminEmails: string[];
17
+ user: SecurityWorkflowUser;
18
+ }) => Promise<void>;
19
+ sendUserAccountApproved: (params: { email: string }) => Promise<void>;
20
+ };
@@ -0,0 +1,48 @@
1
+ import { ApiProperty } from "@nestjs/swagger";
2
+
3
+ export class RegisterDto {
4
+ @ApiProperty({ example: "user@example.com" })
5
+ email!: string;
6
+
7
+ @ApiProperty({ example: "StrongPass1" })
8
+ password!: string;
9
+ }
10
+
11
+ export class LoginDto {
12
+ @ApiProperty({ example: "user@example.com" })
13
+ email!: string;
14
+
15
+ @ApiProperty({ example: "StrongPass1" })
16
+ password!: string;
17
+ }
18
+
19
+ export class RefreshDto {
20
+ @ApiProperty({ example: "refresh-token" })
21
+ refreshToken!: string;
22
+ }
23
+
24
+ export class LogoutDto {
25
+ @ApiProperty({ required: false, example: "refresh-token" })
26
+ refreshToken?: string;
27
+ }
28
+
29
+ export class ChangePasswordDto {
30
+ @ApiProperty({ example: "OldPass1" })
31
+ currentPassword!: string;
32
+
33
+ @ApiProperty({ example: "NewPass1" })
34
+ newPassword!: string;
35
+ }
36
+
37
+ export class ForgotPasswordDto {
38
+ @ApiProperty({ example: "user@example.com" })
39
+ email!: string;
40
+ }
41
+
42
+ export class ResetPasswordDto {
43
+ @ApiProperty({ example: "reset-token" })
44
+ token!: string;
45
+
46
+ @ApiProperty({ example: "NewPass1" })
47
+ newPassword!: string;
48
+ }
@@ -0,0 +1,29 @@
1
+ import { ApiProperty } from "@nestjs/swagger";
2
+
3
+ export class SetAdminApprovalDto {
4
+ @ApiProperty({ example: true })
5
+ approved!: boolean;
6
+ }
7
+
8
+ export class SetUserActiveDto {
9
+ @ApiProperty({ example: false })
10
+ active!: boolean;
11
+ }
12
+
13
+ export class CreateRoleDto {
14
+ @ApiProperty({ example: "COACH" })
15
+ role!: string;
16
+
17
+ @ApiProperty({ required: false, nullable: true, example: "Coaching access" })
18
+ description?: string | null;
19
+ }
20
+
21
+ export class SetUserRolesDto {
22
+ @ApiProperty({ type: [String], example: ["ADMIN", "COACH"] })
23
+ roles!: string[];
24
+ }
25
+
26
+ export class AssignRoleDto {
27
+ @ApiProperty({ example: "COACH" })
28
+ role!: string;
29
+ }
@@ -0,0 +1,10 @@
1
+ import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
2
+
3
+ @Entity({ name: "app_user" })
4
+ export class AppUserEntity {
5
+ @PrimaryGeneratedColumn("uuid")
6
+ id!: string;
7
+
8
+ @Column({ type: "varchar" })
9
+ email!: string;
10
+ }
@@ -0,0 +1,27 @@
1
+ import {
2
+ Column,
3
+ CreateDateColumn,
4
+ Entity,
5
+ PrimaryGeneratedColumn,
6
+ } from "typeorm";
7
+
8
+ @Entity({ name: "security_password_reset_token" })
9
+ export class PasswordResetTokenEntity {
10
+ @PrimaryGeneratedColumn("uuid")
11
+ id!: string;
12
+
13
+ @Column({ type: "varchar", name: "user_id" })
14
+ userId!: string;
15
+
16
+ @Column({ type: "varchar", unique: true })
17
+ token!: string;
18
+
19
+ @Column({ type: "timestamptz", name: "expires_at" })
20
+ expiresAt!: Date;
21
+
22
+ @Column({ type: "timestamptz", name: "used_at", nullable: true })
23
+ usedAt!: Date | null;
24
+
25
+ @CreateDateColumn({ name: "created_at" })
26
+ createdAt!: Date;
27
+ }
@@ -0,0 +1,22 @@
1
+ import { Column, CreateDateColumn, Entity, PrimaryColumn } from "typeorm";
2
+
3
+ @Entity({ name: "refresh_token" })
4
+ export class RefreshTokenEntity {
5
+ @PrimaryColumn({ type: "varchar" })
6
+ id!: string;
7
+
8
+ @Column({ type: "varchar", name: "token_hash" })
9
+ tokenHash!: string;
10
+
11
+ @Column({ type: "timestamptz", name: "expires_at" })
12
+ expiresAt!: Date;
13
+
14
+ @Column({ type: "timestamptz", name: "revoked_at", nullable: true })
15
+ revokedAt!: Date | null;
16
+
17
+ @Column({ type: "varchar", name: "userId", nullable: true })
18
+ userId!: string | null;
19
+
20
+ @CreateDateColumn({ name: "created_at" })
21
+ createdAt!: Date;
22
+ }