@scryan7371/sdr-security 0.1.2 → 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 +48 -7
- package/dist/api/contracts.d.ts +0 -2
- 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.js +1 -1
- 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 +3 -2
- package/dist/api/migrations/index.js +7 -4
- package/dist/api/migrations/migrations.test.js +37 -83
- package/dist/api/notification-workflows.d.ts +0 -4
- package/dist/api/notification-workflows.js +0 -1
- package/dist/api/notification-workflows.test.js +1 -4
- package/dist/app/client.d.ts +0 -2
- package/dist/app/client.test.js +0 -2
- package/dist/nest/contracts.d.ts +0 -3
- package/dist/nest/dto/auth.dto.d.ts +0 -2
- package/dist/nest/dto/auth.dto.js +0 -10
- package/dist/nest/entities/app-user.entity.d.ts +0 -7
- package/dist/nest/entities/app-user.entity.js +0 -35
- 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 +1 -0
- package/dist/nest/index.js +1 -0
- package/dist/nest/security-auth.controller.d.ts +0 -2
- package/dist/nest/security-auth.controller.js +0 -2
- package/dist/nest/security-auth.controller.test.js +0 -4
- package/dist/nest/security-auth.module.js +2 -0
- package/dist/nest/security-auth.service.d.ts +5 -4
- package/dist/nest/security-auth.service.js +81 -51
- package/dist/nest/security-auth.service.test.js +45 -41
- package/dist/nest/security-workflows.module.js +2 -0
- package/dist/nest/security-workflows.service.d.ts +4 -2
- package/dist/nest/security-workflows.service.js +19 -16
- package/dist/nest/security-workflows.service.test.js +29 -24
- package/package.json +3 -3
- package/src/api/contracts.ts +0 -2
- 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 +1 -1
- package/src/api/migrations/1739530000000-create-security-user.ts +51 -0
- package/src/api/migrations/index.ts +6 -3
- package/src/api/migrations/migrations.test.ts +48 -111
- package/src/api/notification-workflows.test.ts +1 -4
- package/src/api/notification-workflows.ts +1 -8
- package/src/app/client.test.ts +0 -2
- package/src/app/client.ts +1 -6
- package/src/nest/contracts.ts +1 -6
- package/src/nest/dto/auth.dto.ts +0 -6
- package/src/nest/entities/app-user.entity.ts +0 -21
- package/src/nest/entities/security-user.entity.ts +25 -0
- package/src/nest/index.ts +1 -0
- package/src/nest/security-auth.controller.test.ts +0 -4
- package/src/nest/security-auth.controller.ts +0 -4
- package/src/nest/security-auth.module.ts +2 -0
- package/src/nest/security-auth.service.test.ts +74 -43
- package/src/nest/security-auth.service.ts +88 -51
- package/src/nest/security-workflows.module.ts +2 -0
- package/src/nest/security-workflows.service.test.ts +31 -25
- package/src/nest/security-workflows.service.ts +18 -13
- 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
package/README.md
CHANGED
|
@@ -41,8 +41,8 @@ import { EmailService } from "./notifications/email.service";
|
|
|
41
41
|
adminEmails,
|
|
42
42
|
user,
|
|
43
43
|
),
|
|
44
|
-
sendUserAccountApproved: ({ email
|
|
45
|
-
emailService.sendAccountApproved(email
|
|
44
|
+
sendUserAccountApproved: ({ email }) =>
|
|
45
|
+
emailService.sendAccountApproved(email),
|
|
46
46
|
}),
|
|
47
47
|
inject: [EmailService],
|
|
48
48
|
},
|
|
@@ -52,6 +52,51 @@ import { EmailService } from "./notifications/email.service";
|
|
|
52
52
|
export class AppModule {}
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
### User Table Ownership Model
|
|
56
|
+
|
|
57
|
+
Consuming apps keep ownership of their own `app_user` table. `sdr-security`
|
|
58
|
+
stores security/auth state in its own tables and links them by user id.
|
|
59
|
+
|
|
60
|
+
- App-owned table:
|
|
61
|
+
- `app_user` (at minimum: `id`, `email`, plus any app-specific columns)
|
|
62
|
+
- `sdr-security` tables:
|
|
63
|
+
- `security_user` (password hash, verified/approved/active flags)
|
|
64
|
+
- `security_identity` (provider links such as Google subject)
|
|
65
|
+
- `security_role`, `security_user_role`
|
|
66
|
+
- `refresh_token`
|
|
67
|
+
- `security_password_reset_token`
|
|
68
|
+
|
|
69
|
+
Link key:
|
|
70
|
+
|
|
71
|
+
- `security_* .user_id` -> `app_user.id`
|
|
72
|
+
|
|
73
|
+
This lets each app evolve its user schema independently while reusing the same
|
|
74
|
+
security workflows, guards, controllers, and migrations.
|
|
75
|
+
|
|
76
|
+
Typical app query pattern is a join when you need security state:
|
|
77
|
+
|
|
78
|
+
```sql
|
|
79
|
+
SELECT u.id, u.email, su.is_active, su.admin_approved_at, su.email_verified_at
|
|
80
|
+
FROM app_user u
|
|
81
|
+
LEFT JOIN security_user su ON su.user_id = u.id
|
|
82
|
+
WHERE u.id = $1;
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Nest/TypeORM equivalent:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const row = await usersRepo
|
|
89
|
+
.createQueryBuilder("user")
|
|
90
|
+
.leftJoin("security_user", "securityUser", "securityUser.user_id = user.id")
|
|
91
|
+
.select("user.id", "id")
|
|
92
|
+
.addSelect("user.email", "email")
|
|
93
|
+
.addSelect("securityUser.is_active", "isActive")
|
|
94
|
+
.addSelect("securityUser.admin_approved_at", "adminApprovedAt")
|
|
95
|
+
.addSelect("securityUser.email_verified_at", "emailVerifiedAt")
|
|
96
|
+
.where("user.id = :id", { id: userId })
|
|
97
|
+
.getRawOne();
|
|
98
|
+
```
|
|
99
|
+
|
|
55
100
|
Optional Swagger setup in consuming app:
|
|
56
101
|
|
|
57
102
|
```ts
|
|
@@ -96,8 +141,6 @@ await sdrSecurity.notifyAdminsOnEmailVerified({
|
|
|
96
141
|
user: {
|
|
97
142
|
id: user.id,
|
|
98
143
|
email: user.email,
|
|
99
|
-
firstName: user.firstName,
|
|
100
|
-
lastName: user.lastName,
|
|
101
144
|
},
|
|
102
145
|
listAdminEmails: () => usersService.listAdminEmails(),
|
|
103
146
|
notifyAdmins: ({ adminEmails, user }) =>
|
|
@@ -108,10 +151,8 @@ await sdrSecurity.notifyUserOnAdminApproval({
|
|
|
108
151
|
approved: body.approved,
|
|
109
152
|
user: {
|
|
110
153
|
email: user.email,
|
|
111
|
-
firstName: user.firstName,
|
|
112
154
|
},
|
|
113
|
-
notifyUser: ({ email
|
|
114
|
-
emailService.sendAccountApproved(email, firstName),
|
|
155
|
+
notifyUser: ({ email }) => emailService.sendAccountApproved(email),
|
|
115
156
|
});
|
|
116
157
|
```
|
|
117
158
|
|
package/dist/api/contracts.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export declare class CreateSecurityIdentity1739500000000 {
|
|
2
2
|
name: string;
|
|
3
3
|
up(queryRunner: {
|
|
4
|
-
query: (sql: string
|
|
4
|
+
query: (sql: string) => Promise<unknown>;
|
|
5
5
|
}): Promise<void>;
|
|
6
6
|
down(queryRunner: {
|
|
7
7
|
query: (sql: string) => Promise<unknown>;
|
|
@@ -4,13 +4,10 @@ exports.CreateSecurityIdentity1739500000000 = void 0;
|
|
|
4
4
|
class CreateSecurityIdentity1739500000000 {
|
|
5
5
|
name = "CreateSecurityIdentity1739500000000";
|
|
6
6
|
async up(queryRunner) {
|
|
7
|
-
const
|
|
8
|
-
const userSchema = getSafeIdentifier(process.env.USER_TABLE_SCHEMA, "public");
|
|
9
|
-
const userTableRef = `"${userSchema}"."${userTable}"`;
|
|
10
|
-
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
|
|
7
|
+
const userTableRef = getUserTableReference();
|
|
11
8
|
await queryRunner.query(`
|
|
12
9
|
CREATE TABLE IF NOT EXISTS "security_identity" (
|
|
13
|
-
"id" uuid PRIMARY KEY NOT NULL DEFAULT
|
|
10
|
+
"id" uuid PRIMARY KEY NOT NULL DEFAULT uuidv7(),
|
|
14
11
|
"user_id" varchar NOT NULL,
|
|
15
12
|
"provider" varchar NOT NULL,
|
|
16
13
|
"provider_subject" varchar NOT NULL,
|
|
@@ -21,36 +18,6 @@ class CreateSecurityIdentity1739500000000 {
|
|
|
21
18
|
`);
|
|
22
19
|
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_security_identity_provider_subject" ON "security_identity" ("provider", "provider_subject")`);
|
|
23
20
|
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_security_identity_user_provider" ON "security_identity" ("user_id", "provider")`);
|
|
24
|
-
const hasGoogleSubjectColumn = (await queryRunner.query(`
|
|
25
|
-
SELECT 1
|
|
26
|
-
FROM information_schema.columns
|
|
27
|
-
WHERE table_schema = $1
|
|
28
|
-
AND table_name = $2
|
|
29
|
-
AND column_name = 'google_subject'
|
|
30
|
-
LIMIT 1
|
|
31
|
-
`, [userSchema, userTable]));
|
|
32
|
-
if (hasGoogleSubjectColumn.length > 0) {
|
|
33
|
-
await queryRunner.query(`
|
|
34
|
-
INSERT INTO "security_identity" (
|
|
35
|
-
"user_id",
|
|
36
|
-
"provider",
|
|
37
|
-
"provider_subject",
|
|
38
|
-
"created_at",
|
|
39
|
-
"updated_at"
|
|
40
|
-
)
|
|
41
|
-
SELECT
|
|
42
|
-
"id",
|
|
43
|
-
'google',
|
|
44
|
-
"google_subject",
|
|
45
|
-
now(),
|
|
46
|
-
now()
|
|
47
|
-
FROM ${userTableRef}
|
|
48
|
-
WHERE "google_subject" IS NOT NULL
|
|
49
|
-
ON CONFLICT ("provider", "provider_subject") DO NOTHING
|
|
50
|
-
`);
|
|
51
|
-
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_app_user_google_subject"`);
|
|
52
|
-
await queryRunner.query(`ALTER TABLE ${userTableRef} DROP COLUMN IF EXISTS "google_subject"`);
|
|
53
|
-
}
|
|
54
21
|
}
|
|
55
22
|
async down(queryRunner) {
|
|
56
23
|
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_security_identity_user_provider"`);
|
|
@@ -66,3 +33,10 @@ const getSafeIdentifier = (value, fallback) => {
|
|
|
66
33
|
}
|
|
67
34
|
return resolved;
|
|
68
35
|
};
|
|
36
|
+
const getUserTableReference = () => {
|
|
37
|
+
const table = getSafeIdentifier(process.env.USER_TABLE, "app_user");
|
|
38
|
+
const schema = process.env.USER_TABLE_SCHEMA
|
|
39
|
+
? getSafeIdentifier(process.env.USER_TABLE_SCHEMA, "public")
|
|
40
|
+
: "public";
|
|
41
|
+
return `"${schema}"."${table}"`;
|
|
42
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export declare class CreateSecurityRoles1739510000000 {
|
|
2
2
|
name: string;
|
|
3
3
|
up(queryRunner: {
|
|
4
|
-
query: (sql: string
|
|
4
|
+
query: (sql: string) => Promise<unknown>;
|
|
5
5
|
}): Promise<void>;
|
|
6
6
|
down(queryRunner: {
|
|
7
7
|
query: (sql: string) => Promise<unknown>;
|
|
@@ -4,13 +4,9 @@ exports.CreateSecurityRoles1739510000000 = void 0;
|
|
|
4
4
|
class CreateSecurityRoles1739510000000 {
|
|
5
5
|
name = "CreateSecurityRoles1739510000000";
|
|
6
6
|
async up(queryRunner) {
|
|
7
|
-
const userTable = getSafeIdentifier(process.env.USER_TABLE, "app_user");
|
|
8
|
-
const userSchema = getSafeIdentifier(process.env.USER_TABLE_SCHEMA, "public");
|
|
9
|
-
const userTableRef = `"${userSchema}"."${userTable}"`;
|
|
10
|
-
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
|
|
11
7
|
await queryRunner.query(`
|
|
12
8
|
CREATE TABLE IF NOT EXISTS "security_role" (
|
|
13
|
-
"id" uuid PRIMARY KEY NOT NULL DEFAULT
|
|
9
|
+
"id" uuid PRIMARY KEY NOT NULL DEFAULT uuidv7(),
|
|
14
10
|
"role_key" varchar NOT NULL,
|
|
15
11
|
"description" text,
|
|
16
12
|
"is_system" boolean NOT NULL DEFAULT false,
|
|
@@ -20,76 +16,14 @@ class CreateSecurityRoles1739510000000 {
|
|
|
20
16
|
`);
|
|
21
17
|
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_security_role_key" ON "security_role" ("role_key")`);
|
|
22
18
|
await queryRunner.query(`
|
|
23
|
-
CREATE TABLE IF NOT EXISTS "security_user_role" (
|
|
24
|
-
"id" uuid PRIMARY KEY NOT NULL DEFAULT uuid_generate_v4(),
|
|
25
|
-
"user_id" varchar NOT NULL,
|
|
26
|
-
"role_id" uuid NOT NULL,
|
|
27
|
-
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
28
|
-
CONSTRAINT "FK_security_user_role_user_id" FOREIGN KEY ("user_id") REFERENCES ${userTableRef} ("id") ON DELETE CASCADE,
|
|
29
|
-
CONSTRAINT "FK_security_user_role_role_id" FOREIGN KEY ("role_id") REFERENCES "security_role" ("id") ON DELETE CASCADE
|
|
30
|
-
)
|
|
31
|
-
`);
|
|
32
|
-
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_security_user_role_user_role" ON "security_user_role" ("user_id", "role_id")`);
|
|
33
|
-
await queryRunner.query(`
|
|
34
19
|
INSERT INTO "security_role" ("role_key", "description", "is_system", "created_at", "updated_at")
|
|
35
20
|
VALUES ('ADMIN', 'Administrative access', true, now(), now())
|
|
36
21
|
ON CONFLICT ("role_key") DO NOTHING
|
|
37
22
|
`);
|
|
38
|
-
const hasRoleColumn = (await queryRunner.query(`
|
|
39
|
-
SELECT 1
|
|
40
|
-
FROM information_schema.columns
|
|
41
|
-
WHERE table_schema = $1
|
|
42
|
-
AND table_name = $2
|
|
43
|
-
AND column_name = 'role'
|
|
44
|
-
LIMIT 1
|
|
45
|
-
`, [userSchema, userTable]));
|
|
46
|
-
if (hasRoleColumn.length > 0) {
|
|
47
|
-
await queryRunner.query(`
|
|
48
|
-
INSERT INTO "security_role" ("role_key", "description", "is_system", "created_at", "updated_at")
|
|
49
|
-
SELECT DISTINCT
|
|
50
|
-
CASE
|
|
51
|
-
WHEN UPPER(TRIM("role")) = 'ADMINISTRATOR' THEN 'ADMIN'
|
|
52
|
-
ELSE UPPER(TRIM("role"))
|
|
53
|
-
END AS "role_key",
|
|
54
|
-
NULL,
|
|
55
|
-
false,
|
|
56
|
-
now(),
|
|
57
|
-
now()
|
|
58
|
-
FROM ${userTableRef}
|
|
59
|
-
WHERE "role" IS NOT NULL
|
|
60
|
-
AND LENGTH(TRIM("role")) > 0
|
|
61
|
-
ON CONFLICT ("role_key") DO NOTHING
|
|
62
|
-
`);
|
|
63
|
-
await queryRunner.query(`
|
|
64
|
-
INSERT INTO "security_user_role" ("user_id", "role_id", "created_at")
|
|
65
|
-
SELECT
|
|
66
|
-
u."id" AS "user_id",
|
|
67
|
-
r."id" AS "role_id",
|
|
68
|
-
now()
|
|
69
|
-
FROM ${userTableRef} u
|
|
70
|
-
INNER JOIN "security_role" r ON r."role_key" = CASE
|
|
71
|
-
WHEN UPPER(TRIM(u."role")) = 'ADMINISTRATOR' THEN 'ADMIN'
|
|
72
|
-
ELSE UPPER(TRIM(u."role"))
|
|
73
|
-
END
|
|
74
|
-
WHERE u."role" IS NOT NULL
|
|
75
|
-
AND LENGTH(TRIM(u."role")) > 0
|
|
76
|
-
ON CONFLICT ("user_id", "role_id") DO NOTHING
|
|
77
|
-
`);
|
|
78
|
-
await queryRunner.query(`ALTER TABLE ${userTableRef} DROP COLUMN IF EXISTS "role"`);
|
|
79
|
-
}
|
|
80
23
|
}
|
|
81
24
|
async down(queryRunner) {
|
|
82
|
-
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_security_user_role_user_role"`);
|
|
83
|
-
await queryRunner.query(`DROP TABLE IF EXISTS "security_user_role"`);
|
|
84
25
|
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_security_role_key"`);
|
|
85
26
|
await queryRunner.query(`DROP TABLE IF EXISTS "security_role"`);
|
|
86
27
|
}
|
|
87
28
|
}
|
|
88
29
|
exports.CreateSecurityRoles1739510000000 = CreateSecurityRoles1739510000000;
|
|
89
|
-
const getSafeIdentifier = (value, fallback) => {
|
|
90
|
-
const resolved = value?.trim() || fallback;
|
|
91
|
-
if (!resolved || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(resolved)) {
|
|
92
|
-
throw new Error(`Invalid SQL identifier: ${resolved}`);
|
|
93
|
-
}
|
|
94
|
-
return resolved;
|
|
95
|
-
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CreateSecurityUserRoles1739515000000 = void 0;
|
|
4
|
+
class CreateSecurityUserRoles1739515000000 {
|
|
5
|
+
name = "CreateSecurityUserRoles1739515000000";
|
|
6
|
+
async up(queryRunner) {
|
|
7
|
+
const userTableRef = getUserTableReference();
|
|
8
|
+
await queryRunner.query(`
|
|
9
|
+
CREATE TABLE IF NOT EXISTS "security_user_role" (
|
|
10
|
+
"id" uuid PRIMARY KEY NOT NULL DEFAULT uuidv7(),
|
|
11
|
+
"user_id" varchar NOT NULL,
|
|
12
|
+
"role_id" uuid NOT NULL,
|
|
13
|
+
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
14
|
+
CONSTRAINT "FK_security_user_role_user_id" FOREIGN KEY ("user_id") REFERENCES ${userTableRef} ("id") ON DELETE CASCADE,
|
|
15
|
+
CONSTRAINT "FK_security_user_role_role_id" FOREIGN KEY ("role_id") REFERENCES "security_role" ("id") ON DELETE CASCADE
|
|
16
|
+
)
|
|
17
|
+
`);
|
|
18
|
+
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_security_user_role_user_role" ON "security_user_role" ("user_id", "role_id")`);
|
|
19
|
+
}
|
|
20
|
+
async down(queryRunner) {
|
|
21
|
+
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_security_user_role_user_role"`);
|
|
22
|
+
await queryRunner.query(`DROP TABLE IF EXISTS "security_user_role"`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
exports.CreateSecurityUserRoles1739515000000 = CreateSecurityUserRoles1739515000000;
|
|
26
|
+
const getUserTableReference = () => {
|
|
27
|
+
const table = getSafeIdentifier(process.env.USER_TABLE, "app_user");
|
|
28
|
+
const schema = process.env.USER_TABLE_SCHEMA
|
|
29
|
+
? getSafeIdentifier(process.env.USER_TABLE_SCHEMA, "public")
|
|
30
|
+
: "public";
|
|
31
|
+
return `"${schema}"."${table}"`;
|
|
32
|
+
};
|
|
33
|
+
const getSafeIdentifier = (value, fallback) => {
|
|
34
|
+
const resolved = value?.trim() || fallback;
|
|
35
|
+
if (!resolved || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(resolved)) {
|
|
36
|
+
throw new Error(`Invalid SQL identifier: ${resolved}`);
|
|
37
|
+
}
|
|
38
|
+
return resolved;
|
|
39
|
+
};
|
|
@@ -7,7 +7,7 @@ class CreatePasswordResetTokens1739520000000 {
|
|
|
7
7
|
const userTableRef = getUserTableReference();
|
|
8
8
|
await queryRunner.query(`
|
|
9
9
|
CREATE TABLE IF NOT EXISTS "security_password_reset_token" (
|
|
10
|
-
"id" uuid PRIMARY KEY NOT NULL DEFAULT
|
|
10
|
+
"id" uuid PRIMARY KEY NOT NULL DEFAULT uuidv7(),
|
|
11
11
|
"user_id" varchar NOT NULL,
|
|
12
12
|
"token" varchar NOT NULL,
|
|
13
13
|
"expires_at" timestamptz NOT NULL,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CreateSecurityUser1739530000000 = void 0;
|
|
4
|
+
class CreateSecurityUser1739530000000 {
|
|
5
|
+
name = "CreateSecurityUser1739530000000";
|
|
6
|
+
async up(queryRunner) {
|
|
7
|
+
const userTableRef = getUserTableReference();
|
|
8
|
+
await queryRunner.query(`
|
|
9
|
+
CREATE TABLE IF NOT EXISTS "security_user" (
|
|
10
|
+
"user_id" varchar PRIMARY KEY NOT NULL,
|
|
11
|
+
"password_hash" varchar NOT NULL,
|
|
12
|
+
"email_verified_at" timestamptz,
|
|
13
|
+
"email_verification_token" varchar,
|
|
14
|
+
"admin_approved_at" timestamptz,
|
|
15
|
+
"is_active" boolean NOT NULL DEFAULT true,
|
|
16
|
+
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
17
|
+
CONSTRAINT "FK_security_user_user_id" FOREIGN KEY ("user_id") REFERENCES ${userTableRef} ("id") ON DELETE CASCADE
|
|
18
|
+
)
|
|
19
|
+
`);
|
|
20
|
+
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_security_user_email_verification_token" ON "security_user" ("email_verification_token") WHERE "email_verification_token" IS NOT NULL`);
|
|
21
|
+
}
|
|
22
|
+
async down(queryRunner) {
|
|
23
|
+
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_security_user_email_verification_token"`);
|
|
24
|
+
await queryRunner.query(`DROP TABLE IF EXISTS "security_user"`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
exports.CreateSecurityUser1739530000000 = CreateSecurityUser1739530000000;
|
|
28
|
+
const getSafeIdentifier = (value, fallback) => {
|
|
29
|
+
const resolved = value?.trim() || fallback;
|
|
30
|
+
if (!resolved || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(resolved)) {
|
|
31
|
+
throw new Error(`Invalid SQL identifier: ${resolved}`);
|
|
32
|
+
}
|
|
33
|
+
return resolved;
|
|
34
|
+
};
|
|
35
|
+
const getUserTableReference = () => {
|
|
36
|
+
const table = getSafeIdentifier(process.env.USER_TABLE, "app_user");
|
|
37
|
+
const schema = process.env.USER_TABLE_SCHEMA
|
|
38
|
+
? getSafeIdentifier(process.env.USER_TABLE_SCHEMA, "public")
|
|
39
|
+
: "public";
|
|
40
|
+
return `"${schema}"."${table}"`;
|
|
41
|
+
};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { AddRefreshTokens1700000000001 } from "./1700000000001-add-refresh-tokens";
|
|
2
|
-
import { AddGoogleSubjectToUser1739490000000 } from "./1739490000000-add-google-subject-to-user";
|
|
3
2
|
import { CreateSecurityIdentity1739500000000 } from "./1739500000000-create-security-identity";
|
|
4
3
|
import { CreateSecurityRoles1739510000000 } from "./1739510000000-create-security-roles";
|
|
4
|
+
import { CreateSecurityUserRoles1739515000000 } from "./1739515000000-create-security-user-roles";
|
|
5
5
|
import { CreatePasswordResetTokens1739520000000 } from "./1739520000000-create-password-reset-tokens";
|
|
6
|
+
import { CreateSecurityUser1739530000000 } from "./1739530000000-create-security-user";
|
|
6
7
|
export declare const securityMigrations: (typeof AddRefreshTokens1700000000001)[];
|
|
7
|
-
export { AddRefreshTokens1700000000001,
|
|
8
|
+
export { AddRefreshTokens1700000000001, CreateSecurityIdentity1739500000000, CreateSecurityRoles1739510000000, CreateSecurityUserRoles1739515000000, CreatePasswordResetTokens1739520000000, CreateSecurityUser1739530000000, };
|
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.CreatePasswordResetTokens1739520000000 = exports.
|
|
3
|
+
exports.CreateSecurityUser1739530000000 = exports.CreatePasswordResetTokens1739520000000 = exports.CreateSecurityUserRoles1739515000000 = exports.CreateSecurityRoles1739510000000 = exports.CreateSecurityIdentity1739500000000 = exports.AddRefreshTokens1700000000001 = exports.securityMigrations = void 0;
|
|
4
4
|
const _1700000000001_add_refresh_tokens_1 = require("./1700000000001-add-refresh-tokens");
|
|
5
5
|
Object.defineProperty(exports, "AddRefreshTokens1700000000001", { enumerable: true, get: function () { return _1700000000001_add_refresh_tokens_1.AddRefreshTokens1700000000001; } });
|
|
6
|
-
const _1739490000000_add_google_subject_to_user_1 = require("./1739490000000-add-google-subject-to-user");
|
|
7
|
-
Object.defineProperty(exports, "AddGoogleSubjectToUser1739490000000", { enumerable: true, get: function () { return _1739490000000_add_google_subject_to_user_1.AddGoogleSubjectToUser1739490000000; } });
|
|
8
6
|
const _1739500000000_create_security_identity_1 = require("./1739500000000-create-security-identity");
|
|
9
7
|
Object.defineProperty(exports, "CreateSecurityIdentity1739500000000", { enumerable: true, get: function () { return _1739500000000_create_security_identity_1.CreateSecurityIdentity1739500000000; } });
|
|
10
8
|
const _1739510000000_create_security_roles_1 = require("./1739510000000-create-security-roles");
|
|
11
9
|
Object.defineProperty(exports, "CreateSecurityRoles1739510000000", { enumerable: true, get: function () { return _1739510000000_create_security_roles_1.CreateSecurityRoles1739510000000; } });
|
|
10
|
+
const _1739515000000_create_security_user_roles_1 = require("./1739515000000-create-security-user-roles");
|
|
11
|
+
Object.defineProperty(exports, "CreateSecurityUserRoles1739515000000", { enumerable: true, get: function () { return _1739515000000_create_security_user_roles_1.CreateSecurityUserRoles1739515000000; } });
|
|
12
12
|
const _1739520000000_create_password_reset_tokens_1 = require("./1739520000000-create-password-reset-tokens");
|
|
13
13
|
Object.defineProperty(exports, "CreatePasswordResetTokens1739520000000", { enumerable: true, get: function () { return _1739520000000_create_password_reset_tokens_1.CreatePasswordResetTokens1739520000000; } });
|
|
14
|
+
const _1739530000000_create_security_user_1 = require("./1739530000000-create-security-user");
|
|
15
|
+
Object.defineProperty(exports, "CreateSecurityUser1739530000000", { enumerable: true, get: function () { return _1739530000000_create_security_user_1.CreateSecurityUser1739530000000; } });
|
|
14
16
|
exports.securityMigrations = [
|
|
15
17
|
_1700000000001_add_refresh_tokens_1.AddRefreshTokens1700000000001,
|
|
16
|
-
_1739490000000_add_google_subject_to_user_1.AddGoogleSubjectToUser1739490000000,
|
|
17
18
|
_1739500000000_create_security_identity_1.CreateSecurityIdentity1739500000000,
|
|
18
19
|
_1739510000000_create_security_roles_1.CreateSecurityRoles1739510000000,
|
|
20
|
+
_1739515000000_create_security_user_roles_1.CreateSecurityUserRoles1739515000000,
|
|
19
21
|
_1739520000000_create_password_reset_tokens_1.CreatePasswordResetTokens1739520000000,
|
|
22
|
+
_1739530000000_create_security_user_1.CreateSecurityUser1739530000000,
|
|
20
23
|
];
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const vitest_1 = require("vitest");
|
|
4
4
|
const _1700000000001_add_refresh_tokens_1 = require("./1700000000001-add-refresh-tokens");
|
|
5
|
-
const _1739490000000_add_google_subject_to_user_1 = require("./1739490000000-add-google-subject-to-user");
|
|
6
5
|
const _1739500000000_create_security_identity_1 = require("./1739500000000-create-security-identity");
|
|
7
6
|
const _1739510000000_create_security_roles_1 = require("./1739510000000-create-security-roles");
|
|
7
|
+
const _1739515000000_create_security_user_roles_1 = require("./1739515000000-create-security-user-roles");
|
|
8
8
|
const _1739520000000_create_password_reset_tokens_1 = require("./1739520000000-create-password-reset-tokens");
|
|
9
|
+
const _1739530000000_create_security_user_1 = require("./1739530000000-create-security-user");
|
|
9
10
|
const index_1 = require("./index");
|
|
10
11
|
const originalEnv = { ...process.env };
|
|
11
12
|
(0, vitest_1.afterEach)(() => {
|
|
@@ -14,10 +15,17 @@ const originalEnv = { ...process.env };
|
|
|
14
15
|
});
|
|
15
16
|
(0, vitest_1.describe)("security migrations", () => {
|
|
16
17
|
(0, vitest_1.it)("exports migration list", () => {
|
|
17
|
-
(0, vitest_1.expect)(index_1.securityMigrations.length).toBe(
|
|
18
|
-
(0, vitest_1.expect)(index_1.securityMigrations
|
|
18
|
+
(0, vitest_1.expect)(index_1.securityMigrations.length).toBe(6);
|
|
19
|
+
(0, vitest_1.expect)(index_1.securityMigrations).toEqual([
|
|
20
|
+
_1700000000001_add_refresh_tokens_1.AddRefreshTokens1700000000001,
|
|
21
|
+
_1739500000000_create_security_identity_1.CreateSecurityIdentity1739500000000,
|
|
22
|
+
_1739510000000_create_security_roles_1.CreateSecurityRoles1739510000000,
|
|
23
|
+
_1739515000000_create_security_user_roles_1.CreateSecurityUserRoles1739515000000,
|
|
24
|
+
_1739520000000_create_password_reset_tokens_1.CreatePasswordResetTokens1739520000000,
|
|
25
|
+
_1739530000000_create_security_user_1.CreateSecurityUser1739530000000,
|
|
26
|
+
]);
|
|
19
27
|
});
|
|
20
|
-
(0, vitest_1.it)("runs
|
|
28
|
+
(0, vitest_1.it)("runs refresh token migration up/down", async () => {
|
|
21
29
|
const query = vitest_1.vi.fn().mockResolvedValue(undefined);
|
|
22
30
|
const migration = new _1700000000001_add_refresh_tokens_1.AddRefreshTokens1700000000001();
|
|
23
31
|
await migration.up({ query });
|
|
@@ -25,93 +33,29 @@ const originalEnv = { ...process.env };
|
|
|
25
33
|
(0, vitest_1.expect)(query).toHaveBeenCalledWith(vitest_1.expect.stringContaining('CREATE TABLE "refresh_token"'));
|
|
26
34
|
(0, vitest_1.expect)(query).toHaveBeenCalledWith(vitest_1.expect.stringContaining('DROP TABLE "refresh_token"'));
|
|
27
35
|
});
|
|
28
|
-
(0, vitest_1.it)("
|
|
29
|
-
process.env.USER_TABLE = "users";
|
|
30
|
-
process.env.USER_TABLE_SCHEMA = "security";
|
|
31
|
-
const query = vitest_1.vi.fn().mockResolvedValue(undefined);
|
|
32
|
-
await new _1700000000001_add_refresh_tokens_1.AddRefreshTokens1700000000001().up({ query });
|
|
33
|
-
(0, vitest_1.expect)(query).toHaveBeenCalledWith(vitest_1.expect.stringContaining('REFERENCES "security"."users" ("id")'));
|
|
34
|
-
});
|
|
35
|
-
(0, vitest_1.it)("throws for invalid identifiers in refresh token migration", async () => {
|
|
36
|
-
process.env.USER_TABLE = "bad-name;drop";
|
|
36
|
+
(0, vitest_1.it)("runs security identity migration up/down", async () => {
|
|
37
37
|
const query = vitest_1.vi.fn().mockResolvedValue(undefined);
|
|
38
|
-
await (0, vitest_1.expect)(new _1700000000001_add_refresh_tokens_1.AddRefreshTokens1700000000001().up({ query })).rejects.toThrow("Invalid SQL identifier");
|
|
39
|
-
});
|
|
40
|
-
(0, vitest_1.it)("keeps legacy google subject migration as no-op", async () => {
|
|
41
|
-
const migration = new _1739490000000_add_google_subject_to_user_1.AddGoogleSubjectToUser1739490000000();
|
|
42
|
-
await (0, vitest_1.expect)(migration.up()).resolves.toBeUndefined();
|
|
43
|
-
await (0, vitest_1.expect)(migration.down()).resolves.toBeUndefined();
|
|
44
|
-
});
|
|
45
|
-
(0, vitest_1.it)("runs security identity migration path with google_subject present", async () => {
|
|
46
|
-
const query = vitest_1.vi
|
|
47
|
-
.fn()
|
|
48
|
-
.mockResolvedValueOnce(undefined)
|
|
49
|
-
.mockResolvedValueOnce(undefined)
|
|
50
|
-
.mockResolvedValueOnce(undefined)
|
|
51
|
-
.mockResolvedValueOnce(undefined)
|
|
52
|
-
.mockResolvedValueOnce([{ "?column?": 1 }])
|
|
53
|
-
.mockResolvedValueOnce(undefined)
|
|
54
|
-
.mockResolvedValueOnce(undefined)
|
|
55
|
-
.mockResolvedValueOnce(undefined);
|
|
56
38
|
const migration = new _1739500000000_create_security_identity_1.CreateSecurityIdentity1739500000000();
|
|
57
39
|
await migration.up({ query });
|
|
58
40
|
await migration.down({ query });
|
|
59
41
|
(0, vitest_1.expect)(query).toHaveBeenCalledWith(vitest_1.expect.stringContaining('CREATE TABLE IF NOT EXISTS "security_identity"'));
|
|
60
42
|
(0, vitest_1.expect)(query).toHaveBeenCalledWith(vitest_1.expect.stringContaining('DROP TABLE IF EXISTS "security_identity"'));
|
|
61
43
|
});
|
|
62
|
-
(0, vitest_1.it)("
|
|
63
|
-
const query = vitest_1.vi
|
|
64
|
-
.fn()
|
|
65
|
-
.mockResolvedValueOnce(undefined)
|
|
66
|
-
.mockResolvedValueOnce(undefined)
|
|
67
|
-
.mockResolvedValueOnce(undefined)
|
|
68
|
-
.mockResolvedValueOnce(undefined)
|
|
69
|
-
.mockResolvedValueOnce([]);
|
|
70
|
-
await new _1739500000000_create_security_identity_1.CreateSecurityIdentity1739500000000().up({ query });
|
|
71
|
-
(0, vitest_1.expect)(query).not.toHaveBeenCalledWith(vitest_1.expect.stringContaining('DROP COLUMN IF EXISTS "google_subject"'));
|
|
72
|
-
});
|
|
73
|
-
(0, vitest_1.it)("throws for invalid identifiers in identity migration", async () => {
|
|
74
|
-
process.env.USER_TABLE_SCHEMA = "bad-schema!";
|
|
44
|
+
(0, vitest_1.it)("runs security role migration up/down", async () => {
|
|
75
45
|
const query = vitest_1.vi.fn().mockResolvedValue(undefined);
|
|
76
|
-
await (0, vitest_1.expect)(new _1739500000000_create_security_identity_1.CreateSecurityIdentity1739500000000().up({ query })).rejects.toThrow("Invalid SQL identifier");
|
|
77
|
-
});
|
|
78
|
-
(0, vitest_1.it)("runs security roles migration path with legacy role column", async () => {
|
|
79
|
-
const query = vitest_1.vi
|
|
80
|
-
.fn()
|
|
81
|
-
.mockResolvedValueOnce(undefined)
|
|
82
|
-
.mockResolvedValueOnce(undefined)
|
|
83
|
-
.mockResolvedValueOnce(undefined)
|
|
84
|
-
.mockResolvedValueOnce(undefined)
|
|
85
|
-
.mockResolvedValueOnce(undefined)
|
|
86
|
-
.mockResolvedValueOnce(undefined)
|
|
87
|
-
.mockResolvedValueOnce([{ "?column?": 1 }])
|
|
88
|
-
.mockResolvedValueOnce(undefined)
|
|
89
|
-
.mockResolvedValueOnce(undefined)
|
|
90
|
-
.mockResolvedValueOnce(undefined);
|
|
91
46
|
const migration = new _1739510000000_create_security_roles_1.CreateSecurityRoles1739510000000();
|
|
92
47
|
await migration.up({ query });
|
|
93
48
|
await migration.down({ query });
|
|
94
49
|
(0, vitest_1.expect)(query).toHaveBeenCalledWith(vitest_1.expect.stringContaining('CREATE TABLE IF NOT EXISTS "security_role"'));
|
|
95
|
-
(0, vitest_1.expect)(query).toHaveBeenCalledWith(vitest_1.expect.stringContaining('
|
|
96
|
-
(0, vitest_1.expect)(query).toHaveBeenCalledWith(vitest_1.expect.stringContaining('DROP TABLE IF EXISTS "security_user_role"'));
|
|
97
|
-
});
|
|
98
|
-
(0, vitest_1.it)("skips legacy role backfill when role column absent", async () => {
|
|
99
|
-
const query = vitest_1.vi
|
|
100
|
-
.fn()
|
|
101
|
-
.mockResolvedValueOnce(undefined)
|
|
102
|
-
.mockResolvedValueOnce(undefined)
|
|
103
|
-
.mockResolvedValueOnce(undefined)
|
|
104
|
-
.mockResolvedValueOnce(undefined)
|
|
105
|
-
.mockResolvedValueOnce(undefined)
|
|
106
|
-
.mockResolvedValueOnce(undefined)
|
|
107
|
-
.mockResolvedValueOnce([]);
|
|
108
|
-
await new _1739510000000_create_security_roles_1.CreateSecurityRoles1739510000000().up({ query });
|
|
109
|
-
(0, vitest_1.expect)(query).not.toHaveBeenCalledWith(vitest_1.expect.stringContaining('DROP COLUMN IF EXISTS "role"'));
|
|
50
|
+
(0, vitest_1.expect)(query).toHaveBeenCalledWith(vitest_1.expect.stringContaining('DROP TABLE IF EXISTS "security_role"'));
|
|
110
51
|
});
|
|
111
|
-
(0, vitest_1.it)("
|
|
112
|
-
process.env.USER_TABLE = "bad-name*";
|
|
52
|
+
(0, vitest_1.it)("runs security user role migration up/down", async () => {
|
|
113
53
|
const query = vitest_1.vi.fn().mockResolvedValue(undefined);
|
|
114
|
-
|
|
54
|
+
const migration = new _1739515000000_create_security_user_roles_1.CreateSecurityUserRoles1739515000000();
|
|
55
|
+
await migration.up({ query });
|
|
56
|
+
await migration.down({ query });
|
|
57
|
+
(0, vitest_1.expect)(query).toHaveBeenCalledWith(vitest_1.expect.stringContaining('CREATE TABLE IF NOT EXISTS "security_user_role"'));
|
|
58
|
+
(0, vitest_1.expect)(query).toHaveBeenCalledWith(vitest_1.expect.stringContaining('DROP TABLE IF EXISTS "security_user_role"'));
|
|
115
59
|
});
|
|
116
60
|
(0, vitest_1.it)("runs password reset token migration up/down", async () => {
|
|
117
61
|
const query = vitest_1.vi.fn().mockResolvedValue(undefined);
|
|
@@ -121,14 +65,24 @@ const originalEnv = { ...process.env };
|
|
|
121
65
|
(0, vitest_1.expect)(query).toHaveBeenCalledWith(vitest_1.expect.stringContaining('CREATE TABLE IF NOT EXISTS "security_password_reset_token"'));
|
|
122
66
|
(0, vitest_1.expect)(query).toHaveBeenCalledWith(vitest_1.expect.stringContaining('DROP TABLE IF EXISTS "security_password_reset_token"'));
|
|
123
67
|
});
|
|
124
|
-
(0, vitest_1.it)("
|
|
68
|
+
(0, vitest_1.it)("runs security user migration up/down", async () => {
|
|
125
69
|
const query = vitest_1.vi.fn().mockResolvedValue(undefined);
|
|
126
|
-
|
|
127
|
-
|
|
70
|
+
const migration = new _1739530000000_create_security_user_1.CreateSecurityUser1739530000000();
|
|
71
|
+
await migration.up({ query });
|
|
72
|
+
await migration.down({ query });
|
|
73
|
+
(0, vitest_1.expect)(query).toHaveBeenCalledWith(vitest_1.expect.stringContaining('CREATE TABLE IF NOT EXISTS "security_user"'));
|
|
74
|
+
(0, vitest_1.expect)(query).toHaveBeenCalledWith(vitest_1.expect.stringContaining('DROP TABLE IF EXISTS "security_user"'));
|
|
75
|
+
});
|
|
76
|
+
(0, vitest_1.it)("uses user schema/table env safely", async () => {
|
|
77
|
+
process.env.USER_TABLE = "users";
|
|
78
|
+
process.env.USER_TABLE_SCHEMA = "security";
|
|
79
|
+
const query = vitest_1.vi.fn().mockResolvedValue(undefined);
|
|
80
|
+
await new _1739530000000_create_security_user_1.CreateSecurityUser1739530000000().up({ query });
|
|
81
|
+
(0, vitest_1.expect)(query).toHaveBeenCalledWith(vitest_1.expect.stringContaining('REFERENCES "security"."users" ("id")'));
|
|
128
82
|
});
|
|
129
|
-
(0, vitest_1.it)("throws for invalid identifiers
|
|
130
|
-
process.env.
|
|
83
|
+
(0, vitest_1.it)("throws for invalid identifiers", async () => {
|
|
84
|
+
process.env.USER_TABLE = "bad-name!";
|
|
131
85
|
const query = vitest_1.vi.fn().mockResolvedValue(undefined);
|
|
132
|
-
await (0, vitest_1.expect)(new
|
|
86
|
+
await (0, vitest_1.expect)(new _1739515000000_create_security_user_roles_1.CreateSecurityUserRoles1739515000000().up({ query })).rejects.toThrow("Invalid SQL identifier");
|
|
133
87
|
});
|
|
134
88
|
});
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
export type VerificationNotificationUser = {
|
|
2
2
|
id: string;
|
|
3
3
|
email: string;
|
|
4
|
-
firstName?: string | null;
|
|
5
|
-
lastName?: string | null;
|
|
6
4
|
};
|
|
7
5
|
export declare const notifyAdminsOnEmailVerified: (params: {
|
|
8
6
|
user: VerificationNotificationUser;
|
|
@@ -22,11 +20,9 @@ export declare const notifyUserOnAdminApproval: (params: {
|
|
|
22
20
|
approved: boolean;
|
|
23
21
|
user: {
|
|
24
22
|
email: string;
|
|
25
|
-
firstName?: string | null;
|
|
26
23
|
};
|
|
27
24
|
notifyUser: (payload: {
|
|
28
25
|
email: string;
|
|
29
|
-
firstName?: string | null;
|
|
30
26
|
}) => Promise<void>;
|
|
31
27
|
}) => Promise<{
|
|
32
28
|
notified: false;
|
|
@@ -12,8 +12,6 @@ const notification_workflows_1 = require("./notification-workflows");
|
|
|
12
12
|
user: {
|
|
13
13
|
id: "user-1",
|
|
14
14
|
email: "user@example.com",
|
|
15
|
-
firstName: "User",
|
|
16
|
-
lastName: "One",
|
|
17
15
|
},
|
|
18
16
|
listAdminEmails,
|
|
19
17
|
notifyAdmins,
|
|
@@ -44,13 +42,12 @@ const notification_workflows_1 = require("./notification-workflows");
|
|
|
44
42
|
const notifyUser = vitest_1.vi.fn().mockResolvedValue(undefined);
|
|
45
43
|
const result = await (0, notification_workflows_1.notifyUserOnAdminApproval)({
|
|
46
44
|
approved: true,
|
|
47
|
-
user: { email: "user@example.com"
|
|
45
|
+
user: { email: "user@example.com" },
|
|
48
46
|
notifyUser,
|
|
49
47
|
});
|
|
50
48
|
(0, vitest_1.expect)(result).toEqual({ notified: true });
|
|
51
49
|
(0, vitest_1.expect)(notifyUser).toHaveBeenCalledWith({
|
|
52
50
|
email: "user@example.com",
|
|
53
|
-
firstName: "User",
|
|
54
51
|
});
|
|
55
52
|
});
|
|
56
53
|
(0, vitest_1.it)("skips user notification when approval is false", async () => {
|