@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.
- package/README.md +216 -13
- package/dist/api/contracts.d.ts +12 -2
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/migrations/1739500000000-create-security-identity.d.ts +1 -1
- package/dist/api/migrations/1739500000000-create-security-identity.js +9 -35
- package/dist/api/migrations/1739510000000-create-security-roles.d.ts +1 -1
- package/dist/api/migrations/1739510000000-create-security-roles.js +1 -67
- package/dist/api/migrations/1739515000000-create-security-user-roles.d.ts +9 -0
- package/dist/api/migrations/1739515000000-create-security-user-roles.js +39 -0
- package/dist/api/migrations/1739520000000-create-password-reset-tokens.d.ts +9 -0
- package/dist/api/migrations/1739520000000-create-password-reset-tokens.js +42 -0
- package/dist/api/migrations/1739530000000-create-security-user.d.ts +9 -0
- package/dist/api/migrations/1739530000000-create-security-user.js +41 -0
- package/dist/api/migrations/index.d.ts +4 -2
- package/dist/api/migrations/index.js +10 -4
- package/dist/api/migrations/migrations.test.d.ts +1 -0
- package/dist/api/migrations/migrations.test.js +88 -0
- package/dist/api/notification-workflows.d.ts +31 -0
- package/dist/api/notification-workflows.js +22 -0
- package/dist/api/notification-workflows.test.d.ts +1 -0
- package/dist/api/notification-workflows.test.js +63 -0
- package/dist/api/validation.test.d.ts +1 -0
- package/dist/api/validation.test.js +20 -0
- package/dist/app/client.d.ts +17 -4
- package/dist/app/client.js +38 -11
- package/dist/app/client.test.d.ts +1 -0
- package/dist/app/client.test.js +130 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +10 -0
- package/dist/integration/database.integration.test.d.ts +1 -0
- package/dist/integration/database.integration.test.js +158 -0
- package/dist/nest/contracts.d.ts +21 -0
- package/dist/nest/contracts.js +2 -0
- package/dist/nest/dto/auth.dto.d.ts +25 -0
- package/dist/nest/dto/auth.dto.js +89 -0
- package/dist/nest/dto/workflows.dto.d.ts +16 -0
- package/dist/nest/dto/workflows.dto.js +58 -0
- package/dist/nest/entities/app-user.entity.d.ts +4 -0
- package/dist/nest/entities/app-user.entity.js +29 -0
- package/dist/nest/entities/password-reset-token.entity.d.ts +8 -0
- package/dist/nest/entities/password-reset-token.entity.js +49 -0
- package/dist/nest/entities/refresh-token.entity.d.ts +8 -0
- package/dist/nest/entities/refresh-token.entity.js +49 -0
- package/dist/nest/entities/security-role.entity.d.ts +6 -0
- package/dist/nest/entities/security-role.entity.js +39 -0
- package/dist/nest/entities/security-user-role.entity.d.ts +5 -0
- package/dist/nest/entities/security-user-role.entity.js +34 -0
- package/dist/nest/entities/security-user.entity.d.ts +9 -0
- package/dist/nest/entities/security-user.entity.js +54 -0
- package/dist/nest/index.d.ts +19 -0
- package/dist/nest/index.js +35 -0
- package/dist/nest/index.test.d.ts +1 -0
- package/dist/nest/index.test.js +14 -0
- package/dist/nest/security-admin.guard.d.ts +4 -0
- package/dist/nest/security-admin.guard.js +25 -0
- package/dist/nest/security-admin.guard.test.d.ts +1 -0
- package/dist/nest/security-admin.guard.test.js +24 -0
- package/dist/nest/security-auth.constants.d.ts +1 -0
- package/dist/nest/security-auth.constants.js +4 -0
- package/dist/nest/security-auth.controller.d.ts +51 -0
- package/dist/nest/security-auth.controller.js +177 -0
- package/dist/nest/security-auth.controller.test.d.ts +1 -0
- package/dist/nest/security-auth.controller.test.js +87 -0
- package/dist/nest/security-auth.module.d.ts +9 -0
- package/dist/nest/security-auth.module.js +70 -0
- package/dist/nest/security-auth.options.d.ts +8 -0
- package/dist/nest/security-auth.options.js +2 -0
- package/dist/nest/security-auth.service.d.ts +60 -0
- package/dist/nest/security-auth.service.js +299 -0
- package/dist/nest/security-auth.service.test.d.ts +1 -0
- package/dist/nest/security-auth.service.test.js +249 -0
- package/dist/nest/security-jwt.guard.d.ts +7 -0
- package/dist/nest/security-jwt.guard.js +46 -0
- package/dist/nest/security-jwt.guard.test.d.ts +1 -0
- package/dist/nest/security-jwt.guard.test.js +51 -0
- package/dist/nest/security-modules.test.d.ts +1 -0
- package/dist/nest/security-modules.test.js +61 -0
- package/dist/nest/security-workflows.controller.d.ts +72 -0
- package/dist/nest/security-workflows.controller.js +187 -0
- package/dist/nest/security-workflows.controller.test.d.ts +1 -0
- package/dist/nest/security-workflows.controller.test.js +87 -0
- package/dist/nest/security-workflows.module.d.ts +9 -0
- package/dist/nest/security-workflows.module.js +61 -0
- package/dist/nest/security-workflows.service.d.ts +69 -0
- package/dist/nest/security-workflows.service.js +203 -0
- package/dist/nest/security-workflows.service.test.d.ts +1 -0
- package/dist/nest/security-workflows.service.test.js +178 -0
- package/dist/nest/swagger.d.ts +2 -0
- package/dist/nest/swagger.js +16 -0
- package/dist/nest/swagger.test.d.ts +1 -0
- package/dist/nest/swagger.test.js +21 -0
- package/dist/nest/tokens.d.ts +1 -0
- package/dist/nest/tokens.js +4 -0
- package/package.json +45 -4
- package/src/api/contracts.ts +11 -2
- package/src/api/index.ts +1 -0
- package/src/api/migrations/1739500000000-create-security-identity.ts +11 -50
- package/src/api/migrations/1739510000000-create-security-roles.ts +2 -89
- package/src/api/migrations/1739515000000-create-security-user-roles.ts +49 -0
- package/src/api/migrations/1739520000000-create-password-reset-tokens.ts +57 -0
- package/src/api/migrations/1739530000000-create-security-user.ts +51 -0
- package/src/api/migrations/index.ts +9 -3
- package/src/api/migrations/migrations.test.ts +145 -0
- package/src/api/notification-workflows.test.ts +78 -0
- package/src/api/notification-workflows.ts +38 -0
- package/src/api/validation.test.ts +21 -0
- package/src/app/client.test.ts +157 -0
- package/src/app/client.ts +74 -18
- package/src/index.test.ts +9 -0
- package/src/integration/database.integration.test.ts +205 -0
- package/src/nest/contracts.ts +20 -0
- package/src/nest/dto/auth.dto.ts +48 -0
- package/src/nest/dto/workflows.dto.ts +29 -0
- package/src/nest/entities/app-user.entity.ts +10 -0
- package/src/nest/entities/password-reset-token.entity.ts +27 -0
- package/src/nest/entities/refresh-token.entity.ts +22 -0
- package/src/nest/entities/security-role.entity.ts +16 -0
- package/src/nest/entities/security-user-role.entity.ts +13 -0
- package/src/nest/entities/security-user.entity.ts +25 -0
- package/src/nest/index.test.ts +20 -0
- package/src/nest/index.ts +19 -0
- package/src/nest/security-admin.guard.test.ts +31 -0
- package/src/nest/security-admin.guard.ts +21 -0
- package/src/nest/security-auth.constants.ts +1 -0
- package/src/nest/security-auth.controller.test.ts +128 -0
- package/src/nest/security-auth.controller.ts +148 -0
- package/src/nest/security-auth.module.ts +65 -0
- package/src/nest/security-auth.options.ts +8 -0
- package/src/nest/security-auth.service.test.ts +368 -0
- package/src/nest/security-auth.service.ts +356 -0
- package/src/nest/security-jwt.guard.test.ts +65 -0
- package/src/nest/security-jwt.guard.ts +47 -0
- package/src/nest/security-modules.test.ts +79 -0
- package/src/nest/security-workflows.controller.test.ts +119 -0
- package/src/nest/security-workflows.controller.ts +149 -0
- package/src/nest/security-workflows.module.ts +56 -0
- package/src/nest/security-workflows.service.test.ts +238 -0
- package/src/nest/security-workflows.service.ts +220 -0
- package/src/nest/swagger.test.ts +27 -0
- package/src/nest/swagger.ts +18 -0
- package/src/nest/tokens.ts +1 -0
- package/dist/api/migrations/1739490000000-add-google-subject-to-user.d.ts +0 -5
- package/dist/api/migrations/1739490000000-add-google-subject-to-user.js +0 -14
- 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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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 }>(
|
|
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>("/
|
|
130
|
+
listRoles: () => request<RoleCatalogResponse>("/security/workflows/roles"),
|
|
115
131
|
|
|
116
132
|
createRole: (payload: { role: string; description?: string | null }) =>
|
|
117
|
-
request<RoleCatalogResponse>("/
|
|
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>(`/
|
|
139
|
+
request<UserRolesResponse>(`/security/workflows/users/${userId}/roles`),
|
|
124
140
|
|
|
125
141
|
setUserRoles: (userId: string, roles: string[]) =>
|
|
126
|
-
request<UserRolesResponse>(`/
|
|
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,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
|
+
}
|