@things-factory/auth-base 10.1.215 → 10.1.223
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/config/config.development.js +2 -4
- package/config/config.production.js +1 -2
- package/dist-client/actions/auth.d.ts +1 -0
- package/dist-client/actions/auth.js +6 -0
- package/dist-client/actions/auth.js.map +1 -1
- package/dist-client/reducers/auth.d.ts +8 -0
- package/dist-client/reducers/auth.js +8 -2
- package/dist-client/reducers/auth.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/constants/error-code.d.ts +1 -0
- package/dist-server/constants/error-code.js +2 -1
- package/dist-server/constants/error-code.js.map +1 -1
- package/dist-server/controllers/auth.d.ts +0 -1
- package/dist-server/controllers/auth.js +0 -1
- package/dist-server/controllers/auth.js.map +1 -1
- package/dist-server/controllers/password-setup.d.ts +33 -0
- package/dist-server/controllers/password-setup.js +77 -0
- package/dist-server/controllers/password-setup.js.map +1 -0
- package/dist-server/controllers/reset-password.d.ts +8 -1
- package/dist-server/controllers/reset-password.js +21 -11
- package/dist-server/controllers/reset-password.js.map +1 -1
- package/dist-server/controllers/signin.js +10 -0
- package/dist-server/controllers/signin.js.map +1 -1
- package/dist-server/index.d.ts +1 -0
- package/dist-server/index.js +2 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/middlewares/jwt-authenticate-middleware.js +8 -25
- package/dist-server/middlewares/jwt-authenticate-middleware.js.map +1 -1
- package/dist-server/router/auth-public-process-router.js +3 -41
- package/dist-server/router/auth-public-process-router.js.map +1 -1
- package/dist-server/service/access-request/access-request.js +2 -2
- package/dist-server/service/access-request/access-request.js.map +1 -1
- package/dist-server/service/audit/audit-kinds.js +1 -0
- package/dist-server/service/audit/audit-kinds.js.map +1 -1
- package/dist-server/service/audit/audit-subscriber.d.ts +1 -7
- package/dist-server/service/audit/audit-subscriber.js +2 -33
- package/dist-server/service/audit/audit-subscriber.js.map +1 -1
- package/dist-server/service/audit/index.d.ts +1 -1
- package/dist-server/service/index.d.ts +2 -4
- package/dist-server/service/index.js +22 -27
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/open-fields.js +0 -2
- package/dist-server/service/open-fields.js.map +1 -1
- package/dist-server/service/role/role-mutation.js +5 -0
- package/dist-server/service/role/role-mutation.js.map +1 -1
- package/dist-server/service/role/role-types.d.ts +1 -0
- package/dist-server/service/role/role-types.js +4 -0
- package/dist-server/service/role/role-types.js.map +1 -1
- package/dist-server/service/role/role.d.ts +10 -0
- package/dist-server/service/role/role.js +5 -0
- package/dist-server/service/role/role.js.map +1 -1
- package/dist-server/service/role-template/role-template-gap-resolver.d.ts +2 -2
- package/dist-server/service/role-template/role-template-gap-resolver.js +7 -13
- package/dist-server/service/role-template/role-template-gap-resolver.js.map +1 -1
- package/dist-server/service/role-template/role-template-mutation.d.ts +7 -4
- package/dist-server/service/role-template/role-template-mutation.js +15 -15
- package/dist-server/service/role-template/role-template-mutation.js.map +1 -1
- package/dist-server/service/role-template/role-template-types.d.ts +3 -3
- package/dist-server/service/role-template/role-template-types.js.map +1 -1
- package/dist-server/service/role-template/role-template.d.ts +14 -15
- package/dist-server/service/role-template/role-template.js +8 -8
- package/dist-server/service/role-template/role-template.js.map +1 -1
- package/dist-server/service/user/user-mutation.d.ts +27 -6
- package/dist-server/service/user/user-mutation.js +26 -114
- package/dist-server/service/user/user-mutation.js.map +1 -1
- package/dist-server/service/user/user-query.d.ts +3 -4
- package/dist-server/service/user/user-query.js +7 -32
- package/dist-server/service/user/user-query.js.map +1 -1
- package/dist-server/service/user/user-types.d.ts +6 -2
- package/dist-server/service/user/user-types.js +20 -9
- package/dist-server/service/user/user-types.js.map +1 -1
- package/dist-server/service/user/user.js +17 -8
- package/dist-server/service/user/user.js.map +1 -1
- package/dist-server/service/verification-token/verification-token.d.ts +1 -0
- package/dist-server/service/verification-token/verification-token.js +1 -0
- package/dist-server/service/verification-token/verification-token.js.map +1 -1
- package/dist-server/templates/reset-password-email.d.ts +10 -3
- package/dist-server/templates/reset-password-email.js +8 -4
- package/dist-server/templates/reset-password-email.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/dist-server/utils/verification-token-rule.js +3 -1
- package/dist-server/utils/verification-token-rule.js.map +1 -1
- package/package.json +2 -2
- package/tests/add-user-gates.test.ts +34 -0
- package/tests/list-inheritance-db.test.ts +0 -15
- package/tests/lock-recovery.test.ts +2 -2
- package/tests/open-doors.test.ts +2 -5
- package/tests/password-reset-rule-db.test.ts +17 -16
- package/tests/password-setup-db.test.ts +213 -0
- package/tests/picture-reducer.test.ts +25 -0
- package/tests/role-template-gap-db.test.ts +58 -7
- package/tests/role-template-seed-db.test.ts +5 -4
- package/tests/verification-token-db.test.ts +12 -7
- package/translations/en.json +2 -2
- package/translations/es.json +2 -2
- package/translations/ja.json +2 -2
- package/translations/ko.json +2 -2
- package/translations/ms.json +2 -2
- package/translations/zh.json +2 -2
- package/dist-server/controllers/invitation.d.ts +0 -75
- package/dist-server/controllers/invitation.js +0 -195
- package/dist-server/controllers/invitation.js.map +0 -1
- package/dist-server/controllers/utils/make-invitation-token.d.ts +0 -1
- package/dist-server/controllers/utils/make-invitation-token.js +0 -9
- package/dist-server/controllers/utils/make-invitation-token.js.map +0 -1
- package/dist-server/controllers/utils/save-invitation-token.d.ts +0 -5
- package/dist-server/controllers/utils/save-invitation-token.js +0 -12
- package/dist-server/controllers/utils/save-invitation-token.js.map +0 -1
- package/dist-server/service/invitation/index.d.ts +0 -5
- package/dist-server/service/invitation/index.js +0 -9
- package/dist-server/service/invitation/index.js.map +0 -1
- package/dist-server/service/invitation/invitation-mutation.d.ts +0 -20
- package/dist-server/service/invitation/invitation-mutation.js +0 -109
- package/dist-server/service/invitation/invitation-mutation.js.map +0 -1
- package/dist-server/service/invitation/invitation-query.d.ts +0 -23
- package/dist-server/service/invitation/invitation-query.js +0 -89
- package/dist-server/service/invitation/invitation-query.js.map +0 -1
- package/dist-server/service/invitation/invitation-types.d.ts +0 -5
- package/dist-server/service/invitation/invitation-types.js +0 -21
- package/dist-server/service/invitation/invitation-types.js.map +0 -1
- package/dist-server/service/invitation/invitation.d.ts +0 -26
- package/dist-server/service/invitation/invitation.js +0 -142
- package/dist-server/service/invitation/invitation.js.map +0 -1
- package/dist-server/templates/invitation-email.d.ts +0 -6
- package/dist-server/templates/invitation-email.js +0 -82
- package/dist-server/templates/invitation-email.js.map +0 -1
- package/dist-server/utils/invitation-state.d.ts +0 -52
- package/dist-server/utils/invitation-state.js +0 -76
- package/dist-server/utils/invitation-state.js.map +0 -1
- package/tests/invitation-db.test.ts +0 -416
- package/tests/invitation-route.test.ts +0 -116
- package/tests/invitation-state.test.ts +0 -92
|
@@ -27,7 +27,7 @@ const crypto_1 = require("crypto");
|
|
|
27
27
|
* and a rule that reads it would extend a link by touching the row. `expiresAt` is written once,
|
|
28
28
|
* with the token.
|
|
29
29
|
*
|
|
30
|
-
* A row without `expiresAt` reads as **expired
|
|
30
|
+
* A row without `expiresAt` reads as **expired**. Such rows
|
|
31
31
|
* were written before this existed, with the token in plain text; refusing them is also how links
|
|
32
32
|
* already sent stop working when this ships.
|
|
33
33
|
*
|
|
@@ -39,6 +39,8 @@ const crypto_1 = require("crypto");
|
|
|
39
39
|
const HOUR = 60 * 60 * 1000;
|
|
40
40
|
exports.VERIFICATION_TOKEN_LIFETIME_MS = Object.freeze({
|
|
41
41
|
'password-reset': 1 * HOUR,
|
|
42
|
+
/* Handed over by mail or by hand, so it lives as long as an invitation did (ADR-0161 decision 3). */
|
|
43
|
+
'password-setup': 48 * HOUR,
|
|
42
44
|
unlock: 1 * HOUR,
|
|
43
45
|
activation: 48 * HOUR
|
|
44
46
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"verification-token-rule.js","sourceRoot":"","sources":["../../server/utils/verification-token-rule.ts"],"names":[],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"verification-token-rule.js","sourceRoot":"","sources":["../../server/utils/verification-token-rule.ts"],"names":[],"mappings":";;;AA6CA,sDAEC;AAQD,gEAQC;AAMD,0DAYC;AAjFD,mCAAmC;AAEnC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,MAAM,IAAI,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;AAEd,QAAA,8BAA8B,GAAqC,MAAM,CAAC,MAAM,CAAC;IAC5F,gBAAgB,EAAE,CAAC,GAAG,IAAI;IAC1B,qGAAqG;IACrG,gBAAgB,EAAE,EAAE,GAAG,IAAI;IAC3B,MAAM,EAAE,CAAC,GAAG,IAAI;IAChB,UAAU,EAAE,EAAE,GAAG,IAAI;CACtB,CAAC,CAAA;AAEF,6CAA6C;AAC7C,SAAgB,qBAAqB,CAAC,KAAa;IACjD,OAAO,IAAA,mBAAU,EAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AACjE,CAAC;AAED;;;;;GAKG;AACH,SAAgB,0BAA0B,CAAC,IAAY,EAAE,MAAY,IAAI,IAAI,EAAE;IAC7E,MAAM,QAAQ,GAAG,sCAA8B,CAAC,IAAI,CAAC,CAAA;IAErD,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,KAAK,CAAC,4BAA4B,IAAI,mBAAmB,CAAC,CAAA;IACtE,CAAC;IAED,OAAO,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,QAAQ,CAAC,CAAA;AAC3C,CAAC;AAKD,0GAA0G;AAC1G,SAAgB,uBAAuB,CAAC,MAA+B,EAAE,MAAY,IAAI,IAAI,EAAE;IAC7F,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC;QACvB,OAAO,KAAK,CAAA;IACd,CAAC;IAED,MAAM,EAAE,GAAG,MAAM,CAAC,SAAS,YAAY,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAA;IAE3F,IAAI,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC;QAC/B,OAAO,KAAK,CAAA;IACd,CAAC;IAED,OAAO,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,CAAA;AACrC,CAAC","sourcesContent":["import { createHash } from 'crypto'\n\n/**\n * How long a mailed link lives, and what the table keeps of it. (Pure rule.)\n *\n * Password reset, unlock and sign-up confirmation each mail a link that carries a token, and the\n * token is the whole credential: whoever holds the link resets the password or unlocks the account.\n * Before 2026-09-24 those tokens never expired, were stored as written and were found by that same\n * value — so a reset mail from last year still changed a password today, and a copy of the table\n * (a backup, a replica, a dump handed to support) was a set of live reset links.\n *\n * ── The lifetimes ──────────────────────────────────────────────────────────\n *\n * password-reset 1 hour hands the account over at once, so short\n * unlock 1 hour the same\n * activation 48 hours the person may read the mail late; it only confirms an address\n *\n * Decided with the architect, 2026-09-24 (`docs/design/auth-rebuild-questions-2026-09-24.md` Q4).\n *\n * ── Expiry is its own column ───────────────────────────────────────────────\n * Not derived from `createdAt` or `updatedAt`: `updatedAt` moves every time the row is saved again,\n * and a rule that reads it would extend a link by touching the row. `expiresAt` is written once,\n * with the token.\n *\n * A row without `expiresAt` reads as **expired**. Such rows\n * were written before this existed, with the token in plain text; refusing them is also how links\n * already sent stop working when this ships.\n *\n * ── What is stored is a hash ───────────────────────────────────────────────\n * sha256 of the token. The token is 16 random bytes, so there is nothing for a slow hash to protect\n * — it guards against guessing, and there is no guessing a random 128-bit value. The point is only\n * that the table no longer holds anything a link can be made from.\n */\n\nconst HOUR = 60 * 60 * 1000\n\nexport const VERIFICATION_TOKEN_LIFETIME_MS: Readonly<Record<string, number>> = Object.freeze({\n 'password-reset': 1 * HOUR,\n /* Handed over by mail or by hand, so it lives as long as an invitation did (ADR-0161 decision 3). */\n 'password-setup': 48 * HOUR,\n unlock: 1 * HOUR,\n activation: 48 * HOUR\n})\n\n/** The value the table keeps for a token. */\nexport function hashVerificationToken(token: string): string {\n return createHash('sha256').update(token, 'utf8').digest('hex')\n}\n\n/**\n * When a token of this type, issued at `now`, stops working.\n *\n * Throws for a type with no lifetime here — a new kind of mailed link has to say how long it lives\n * rather than inherit forever.\n */\nexport function verificationTokenExpiresAt(type: string, now: Date = new Date()): Date {\n const lifetime = VERIFICATION_TOKEN_LIFETIME_MS[type]\n\n if (!lifetime) {\n throw new Error(`verification token type '${type}' has no lifetime`)\n }\n\n return new Date(now.getTime() + lifetime)\n}\n\n/** The parts of a stored token this rule reads. */\nexport type VerificationTokenRecord = { expiresAt?: Date | string | null } | null | undefined\n\n/** May this token still be used, as of `now`? Absence of an expiry refuses; the expiry itself refuses. */\nexport function isVerificationTokenLive(record: VerificationTokenRecord, now: Date = new Date()): boolean {\n if (!record?.expiresAt) {\n return false\n }\n\n const at = record.expiresAt instanceof Date ? record.expiresAt : new Date(record.expiresAt)\n\n if (Number.isNaN(at.getTime())) {\n return false\n }\n\n return now.getTime() < at.getTime()\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/auth-base",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.223",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -50,5 +50,5 @@
|
|
|
50
50
|
"passport-jwt": "^4.0.0",
|
|
51
51
|
"passport-local": "^1.0.0"
|
|
52
52
|
},
|
|
53
|
-
"gitHead": "
|
|
53
|
+
"gitHead": "868f2026b8373371ad04f42b0a097964d0d0ed0f"
|
|
54
54
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { buildSchema } from 'type-graphql'
|
|
2
|
+
|
|
3
|
+
import { loadCompiled } from './compiled'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The questions the user popups ask are gated as the actions they lead to.
|
|
7
|
+
*
|
|
8
|
+
* `checkUserExistence` is asked only by the add-user popup, before `inviteUser` / `createUser`
|
|
9
|
+
* (each `user:mutation`, owner granted). It asked `user:query` alone, so a domain owner without that role was refused
|
|
10
|
+
* the lookup and the popup took the refusal for "no account" - and set out to create an account for someone who
|
|
11
|
+
* had one. `mayIssuePasswordSetupLink` answers for the setup link button, so it stands where `issuePasswordSetupLink` does.
|
|
12
|
+
*/
|
|
13
|
+
const { schema: declared } = loadCompiled('service/index')
|
|
14
|
+
const { privilegeDirectiveResolver, privilegeOfField } = loadCompiled('service/privilege/privilege-directive')
|
|
15
|
+
|
|
16
|
+
function gateOf(field: string) {
|
|
17
|
+
const gate = privilegeOfField('Query', field)
|
|
18
|
+
return [gate.known, gate['gate']?.category, gate['gate']?.privilege, gate['gate']?.owner]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('what the user popups ask', () => {
|
|
22
|
+
beforeAll(async () => {
|
|
23
|
+
const schema = await buildSchema({ resolvers: declared.resolverClasses, validate: false })
|
|
24
|
+
privilegeDirectiveResolver(schema, { modules: [] })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('★ checkUserExistence asks what adding a user asks - user:mutation, or the owner', () => {
|
|
28
|
+
expect(gateOf('checkUserExistence')).toEqual([true, 'user', 'mutation', true])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('mayIssuePasswordSetupLink asks what issuing asks', () => {
|
|
32
|
+
expect(gateOf('mayIssuePasswordSetupLink')).toEqual([true, 'user', 'mutation', true])
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -19,9 +19,6 @@ const { Appliance } = loadCompiled('service/appliance/appliance')
|
|
|
19
19
|
const { ApplianceQuery } = loadCompiled('service/appliance/appliance-query')
|
|
20
20
|
const { Application } = loadCompiled('service/application/application')
|
|
21
21
|
const { ApplicationQuery } = loadCompiled('service/application/application-query')
|
|
22
|
-
const { Invitation } = loadCompiled('service/invitation/invitation')
|
|
23
|
-
const { InvitationStatus } = loadCompiled('utils/invitation-state')
|
|
24
|
-
const { InvitationQuery } = loadCompiled('service/invitation/invitation-query')
|
|
25
22
|
const { Partner } = loadCompiled('service/partner/partner')
|
|
26
23
|
const { PartnerQuery } = loadCompiled('service/partner/partner-query')
|
|
27
24
|
|
|
@@ -101,18 +98,6 @@ const LISTS: [string, () => Promise<any>, (params: any, context: any) => Promise
|
|
|
101
98
|
}),
|
|
102
99
|
(params, context) => new ApplicationQuery().applications(params, context)
|
|
103
100
|
],
|
|
104
|
-
[
|
|
105
|
-
'invitations',
|
|
106
|
-
() =>
|
|
107
|
-
getRepository(Invitation).save({
|
|
108
|
-
email: 'lee@acme.z',
|
|
109
|
-
domain: parent,
|
|
110
|
-
status: InvitationStatus.PENDING,
|
|
111
|
-
token: 'parent-token',
|
|
112
|
-
expiresAt: new Date(Date.now() + 3600_000)
|
|
113
|
-
}),
|
|
114
|
-
(params, context) => new InvitationQuery().invitations(params, context)
|
|
115
|
-
],
|
|
116
101
|
[
|
|
117
102
|
'partners',
|
|
118
103
|
() => getRepository(Partner).save({ domain: parent, partnerDomain, requestedAt: new Date() }),
|
|
@@ -70,7 +70,7 @@ describe('재설정 메일 경로 (PASSWORD_RESET)', () => {
|
|
|
70
70
|
const user = await lockedUser()
|
|
71
71
|
await giveToken(user.id, 'tok', VerificationTokenType.PASSWORD_RESET)
|
|
72
72
|
|
|
73
|
-
await resetPassword('tok', NEW, context)
|
|
73
|
+
await resetPassword('tok', NEW, 'kim@example.com', context)
|
|
74
74
|
|
|
75
75
|
const after = await getRepository(User).findOneBy({ id: user.id })
|
|
76
76
|
expect(User.verify(after.password, NEW, after.salt)).toBe(true)
|
|
@@ -84,7 +84,7 @@ describe('재설정 메일 경로 (PASSWORD_RESET)', () => {
|
|
|
84
84
|
const user = await lockedUser()
|
|
85
85
|
await giveToken(user.id, 'tok', VerificationTokenType.PASSWORD_RESET)
|
|
86
86
|
|
|
87
|
-
await resetPassword('tok', NEW, context)
|
|
87
|
+
await resetPassword('tok', NEW, 'kim@example.com', context)
|
|
88
88
|
|
|
89
89
|
await expect(signin({ username: 'kim', password: NEW })).rejects.toThrow()
|
|
90
90
|
})
|
package/tests/open-doors.test.ts
CHANGED
|
@@ -81,8 +81,7 @@ describe('the doors that were closed', () => {
|
|
|
81
81
|
* domainUserRoleRegister makes a domain, its roles and its users. `domainRegister`
|
|
82
82
|
* beside it makes only a domain and is super-user only
|
|
83
83
|
* loginHistories everybody's sign-ins - now the audit's, behind `audit:query` (ADR-0141)
|
|
84
|
-
*
|
|
85
|
-
* sendInvitation / cancelInvitation / inviteUser
|
|
84
|
+
* inviteUser
|
|
86
85
|
*/
|
|
87
86
|
const CLOSED = {
|
|
88
87
|
Mutation: [
|
|
@@ -92,11 +91,9 @@ describe('the doors that were closed', () => {
|
|
|
92
91
|
'deleteAuthProvider',
|
|
93
92
|
'deleteAuthProviders',
|
|
94
93
|
'domainUserRoleRegister',
|
|
95
|
-
'sendInvitation',
|
|
96
|
-
'cancelInvitation',
|
|
97
94
|
'inviteUser'
|
|
98
95
|
],
|
|
99
|
-
Query: ['auditRecords', 'auditPeople', 'auditDoors', 'auditHealth'
|
|
96
|
+
Query: ['auditRecords', 'auditPeople', 'auditDoors', 'auditHealth']
|
|
100
97
|
}
|
|
101
98
|
|
|
102
99
|
it.each(Object.entries(CLOSED).flatMap(([t, fields]) => fields.map(f => [t, f])))('%s.%s', (typeName, field) => {
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Whose password a domain may take back - by issuing a password setup link (ADR-0161).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* of
|
|
7
|
-
*
|
|
4
|
+
* The reset asked only that the person belong to the asking domain. A person has one password for every domain, so a
|
|
5
|
+
* user manager of any domain could take the password of someone who also works elsewhere, of another domain's owner,
|
|
6
|
+
* and of the super user (the system domain's owner) the moment that person was a member here. It was the default
|
|
7
|
+
* password then; it is a setup link now, and the same people are refused.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { config } from '@things-factory/env'
|
|
11
|
-
|
|
12
10
|
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
13
11
|
import { loadCompiled } from './compiled'
|
|
14
12
|
|
|
@@ -17,7 +15,6 @@ const { UserMutation } = loadCompiled('service/user/user-mutation')
|
|
|
17
15
|
const { UserQuery } = loadCompiled('service/user/user-query')
|
|
18
16
|
const { DomainOwner } = loadCompiled('service/domain-owner/domain-owner')
|
|
19
17
|
|
|
20
|
-
const DEFAULT_PASSWORD = 'Default!234'
|
|
21
18
|
const OWN_PASSWORD = 'Mine!5678'
|
|
22
19
|
|
|
23
20
|
let here: any
|
|
@@ -40,15 +37,20 @@ async function makeUser(domains: any[], extra: any = {}) {
|
|
|
40
37
|
}
|
|
41
38
|
|
|
42
39
|
function context(domain: any) {
|
|
43
|
-
return {
|
|
40
|
+
return {
|
|
41
|
+
state: { domain, tx: undefined },
|
|
42
|
+
t: (key: string) => key,
|
|
43
|
+
headers: {},
|
|
44
|
+
request: { protocol: 'https', host: 'acme.example.com' }
|
|
45
|
+
} as any
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
function reset(user: any, domain: any) {
|
|
47
|
-
return new UserMutation().
|
|
49
|
+
return new UserMutation().issuePasswordSetupLink(user.username, false, context(domain))
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
function mayReset(user: any, domain: any) {
|
|
51
|
-
return new UserQuery().
|
|
53
|
+
return new UserQuery().mayIssuePasswordSetupLink(user.id, context(domain))
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
async function stillHasOwnPassword(user: any) {
|
|
@@ -61,13 +63,11 @@ beforeAll(async () => {
|
|
|
61
63
|
})
|
|
62
64
|
|
|
63
65
|
afterAll(async () => {
|
|
64
|
-
config.set('password', undefined)
|
|
65
66
|
await closeAuthDatabase()
|
|
66
67
|
})
|
|
67
68
|
|
|
68
69
|
beforeEach(async () => {
|
|
69
70
|
await resetAuthDatabase()
|
|
70
|
-
config.set('password', { defaultPassword: DEFAULT_PASSWORD })
|
|
71
71
|
|
|
72
72
|
const domainRepository = getRepository(Domain)
|
|
73
73
|
here = await domainRepository.save({ name: 'Plant A', subdomain: 'plant-a' })
|
|
@@ -76,14 +76,15 @@ beforeEach(async () => {
|
|
|
76
76
|
})
|
|
77
77
|
|
|
78
78
|
describe('a person wholly of this domain', () => {
|
|
79
|
-
it('is
|
|
79
|
+
it('is issued a link and waits for a password, and the screen offers it', async () => {
|
|
80
80
|
const user = await makeUser([here])
|
|
81
81
|
|
|
82
82
|
expect(await mayReset(user, here)).toBe(true)
|
|
83
|
-
await expect(reset(user, here)).resolves.
|
|
83
|
+
await expect(reset(user, here)).resolves.toMatchObject({ link: expect.stringContaining('/auth/reset-password#') })
|
|
84
84
|
|
|
85
85
|
const read = await getRepository(User).findOneBy({ id: user.id })
|
|
86
|
-
expect(
|
|
86
|
+
expect(read.status).toBe('password_reset_required')
|
|
87
|
+
expect(await stillHasOwnPassword(user)).toBe(false)
|
|
87
88
|
})
|
|
88
89
|
})
|
|
89
90
|
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An administrator never sets anybody's password (ADR-0161).
|
|
3
|
+
*
|
|
4
|
+
* It used to be the installation's default password - one value everyone handed one knew, with no end, for every
|
|
5
|
+
* account an administrator made or reset. Now such an account waits for its password, and the person sets it through
|
|
6
|
+
* a one-time link that is mailed or handed over: 48 hours, hashed at rest, asking for the address it was for, and the
|
|
7
|
+
* only live one. Issuing it ends the sessions opened before.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { authDataSource, closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
11
|
+
import { loadCompiled } from './compiled'
|
|
12
|
+
|
|
13
|
+
const jwt = require('jsonwebtoken')
|
|
14
|
+
|
|
15
|
+
const { User } = loadCompiled('service/user/user')
|
|
16
|
+
const { UserMutation } = loadCompiled('service/user/user-mutation')
|
|
17
|
+
const { VerificationToken } = loadCompiled('service/verification-token/verification-token')
|
|
18
|
+
const { signin } = loadCompiled('controllers/signin')
|
|
19
|
+
const { resetPassword } = loadCompiled('controllers/reset-password')
|
|
20
|
+
const { hashVerificationToken } = loadCompiled('utils/verification-token-rule')
|
|
21
|
+
|
|
22
|
+
const OWN_PASSWORD = 'Mine!5678'
|
|
23
|
+
const NEW_PASSWORD = 'N3w!Passw0rd'
|
|
24
|
+
const HOUR = 60 * 60 * 1000
|
|
25
|
+
|
|
26
|
+
let here: any
|
|
27
|
+
|
|
28
|
+
function context(tx?: any) {
|
|
29
|
+
return {
|
|
30
|
+
state: { domain: here, tx },
|
|
31
|
+
t: (key: string) => key,
|
|
32
|
+
headers: {},
|
|
33
|
+
request: { protocol: 'https', host: 'acme.example.com' }
|
|
34
|
+
} as any
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function makeActive(email = 'kim@example.com') {
|
|
38
|
+
const salt = User.generateSalt()
|
|
39
|
+
return await getRepository(User).save({
|
|
40
|
+
username: email,
|
|
41
|
+
email,
|
|
42
|
+
name: 'Kim',
|
|
43
|
+
salt,
|
|
44
|
+
password: User.encode(OWN_PASSWORD, salt),
|
|
45
|
+
status: 'activated',
|
|
46
|
+
domains: [here]
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function create(email = 'lee@example.com') {
|
|
51
|
+
return new UserMutation().createUser({ username: email, email, name: 'Lee', userType: 'user' }, context())
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function issue(user: any, mail = false, tx?: any) {
|
|
55
|
+
return new UserMutation().issuePasswordSetupLink(user.username, mail, context(tx))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const tokenOf = (link: string) => link.split('#')[1]
|
|
59
|
+
const reload = (id: string) => getRepository(User).findOneBy({ id })
|
|
60
|
+
const setPassword = (token: string, email: string, password = NEW_PASSWORD) =>
|
|
61
|
+
resetPassword(token, password, email, { t: (key: string) => key })
|
|
62
|
+
|
|
63
|
+
beforeAll(async () => {
|
|
64
|
+
await openAuthDatabase()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
afterAll(async () => {
|
|
68
|
+
await closeAuthDatabase()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
beforeEach(async () => {
|
|
72
|
+
await resetAuthDatabase()
|
|
73
|
+
here = await getRepository(Domain).save({ name: 'Plant A', subdomain: 'plant-a' })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('an account an administrator makes', () => {
|
|
77
|
+
it('★ waits for its password, which nobody was given', async () => {
|
|
78
|
+
const made = await create()
|
|
79
|
+
const read = await reload(made.id)
|
|
80
|
+
|
|
81
|
+
expect(read.status).toBe('password_reset_required')
|
|
82
|
+
expect(read.password).toBeTruthy()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('★ does not sign in, and a guess against it is not counted', async () => {
|
|
86
|
+
const made = await create()
|
|
87
|
+
|
|
88
|
+
await expect(signin({ username: made.username, password: 'anything' })).rejects.toMatchObject({
|
|
89
|
+
errorCode: 'password-setup-pending'
|
|
90
|
+
})
|
|
91
|
+
expect((await reload(made.id)).failCount ?? 0).toBe(0)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('★ is refused on every request too', async () => {
|
|
95
|
+
const made = await create()
|
|
96
|
+
|
|
97
|
+
await expect(User.checkAuth({ username: made.username, email: made.email })).rejects.toMatchObject({
|
|
98
|
+
errorCode: 'password-setup-pending'
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('is not opened by activating it', async () => {
|
|
103
|
+
const made = await create()
|
|
104
|
+
|
|
105
|
+
await expect(new UserMutation().activateUser(made.username, context())).rejects.toMatchObject({
|
|
106
|
+
code: 'password-setup-pending'
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('the setup link', () => {
|
|
112
|
+
it('★ is handed back once, kept only as a hash, and lives 48 hours', async () => {
|
|
113
|
+
const made = await create()
|
|
114
|
+
const before = Date.now()
|
|
115
|
+
|
|
116
|
+
const issued = await issue(made)
|
|
117
|
+
|
|
118
|
+
expect(issued.link).toMatch(/^https?:\/\/acme\.example\.com\/auth\/reset-password#[0-9a-f]{32}$/)
|
|
119
|
+
const stored = await getRepository(VerificationToken).findOneBy({ userId: made.id })
|
|
120
|
+
expect(stored.type).toBe('password-setup')
|
|
121
|
+
expect(stored.token).toBe(hashVerificationToken(tokenOf(issued.link)))
|
|
122
|
+
expect(stored.token).not.toBe(tokenOf(issued.link))
|
|
123
|
+
expect(new Date(stored.expiresAt).getTime() - before).toBeGreaterThanOrEqual(48 * HOUR - 1000)
|
|
124
|
+
expect(new Date(stored.expiresAt).getTime() - before).toBeLessThanOrEqual(48 * HOUR + 5000)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('★ opens the account with the address it was for, and then signs in', async () => {
|
|
128
|
+
const made = await create()
|
|
129
|
+
const { link } = await issue(made)
|
|
130
|
+
|
|
131
|
+
await setPassword(tokenOf(link), 'LEE@example.com ')
|
|
132
|
+
|
|
133
|
+
expect((await reload(made.id)).status).toBe('activated')
|
|
134
|
+
await expect(signin({ username: made.username, password: NEW_PASSWORD })).resolves.toMatchObject({
|
|
135
|
+
token: expect.any(String)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('★ is not enough without the address it was for', async () => {
|
|
140
|
+
const made = await create()
|
|
141
|
+
const { link } = await issue(made)
|
|
142
|
+
|
|
143
|
+
await expect(setPassword(tokenOf(link), 'someone@else.example')).rejects.toThrow()
|
|
144
|
+
await expect(setPassword(tokenOf(link), '')).rejects.toThrow()
|
|
145
|
+
expect((await reload(made.id)).status).toBe('password_reset_required')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('works once', async () => {
|
|
149
|
+
const made = await create()
|
|
150
|
+
const { link } = await issue(made)
|
|
151
|
+
|
|
152
|
+
await setPassword(tokenOf(link), made.email)
|
|
153
|
+
await expect(setPassword(tokenOf(link), made.email, 'An0ther!Pass')).rejects.toThrow()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('★ issued again, retires the last one', async () => {
|
|
157
|
+
const made = await create()
|
|
158
|
+
const first = await issue(made)
|
|
159
|
+
const second = await issue(made)
|
|
160
|
+
|
|
161
|
+
await expect(setPassword(tokenOf(first.link), made.email)).rejects.toThrow()
|
|
162
|
+
await setPassword(tokenOf(second.link), made.email)
|
|
163
|
+
expect((await reload(made.id)).status).toBe('activated')
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('taking back the password of an account in use', () => {
|
|
168
|
+
it('★ stops the old password and ends the sessions opened before', async () => {
|
|
169
|
+
const kim = await makeActive()
|
|
170
|
+
const { token: before } = await signin({ username: kim.username, password: OWN_PASSWORD })
|
|
171
|
+
await expect(User.checkAuth(jwt.decode(before))).resolves.toBeTruthy()
|
|
172
|
+
|
|
173
|
+
const { link } = await issue(kim)
|
|
174
|
+
|
|
175
|
+
await expect(signin({ username: kim.username, password: OWN_PASSWORD })).rejects.toMatchObject({
|
|
176
|
+
errorCode: 'password-setup-pending'
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
await setPassword(tokenOf(link), kim.email)
|
|
180
|
+
|
|
181
|
+
/* Open again - but the session from before stays ended: its generation has passed. */
|
|
182
|
+
await expect(User.checkAuth(jwt.decode(before))).rejects.toThrow()
|
|
183
|
+
const { token: after } = await signin({ username: kim.username, password: NEW_PASSWORD })
|
|
184
|
+
await expect(User.checkAuth(jwt.decode(after))).resolves.toBeTruthy()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('★ by mail - a mail that does not go out changes nothing', async () => {
|
|
188
|
+
const kim = await makeActive()
|
|
189
|
+
|
|
190
|
+
await expect(
|
|
191
|
+
authDataSource().transaction(async (tx: any) => await issue(kim, true, tx))
|
|
192
|
+
).rejects.toMatchObject({ code: 'password-setup-mail-not-sent' })
|
|
193
|
+
|
|
194
|
+
const read = await reload(kim.id)
|
|
195
|
+
expect(read.status).toBe('activated')
|
|
196
|
+
expect(User.verify(read.password, OWN_PASSWORD, read.salt)).toBe(true)
|
|
197
|
+
expect(await getRepository(VerificationToken).findOneBy({ userId: kim.id })).toBeNull()
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
describe('a forgotten password', () => {
|
|
202
|
+
it('asks for the address as well, through the same page', async () => {
|
|
203
|
+
const kim = await makeActive()
|
|
204
|
+
const { makeVerificationToken } = loadCompiled('controllers/utils/make-verification-token')
|
|
205
|
+
const { saveVerificationToken } = loadCompiled('controllers/utils/save-verification-token')
|
|
206
|
+
const token = makeVerificationToken()
|
|
207
|
+
await saveVerificationToken(kim.id, token, 'password-reset')
|
|
208
|
+
|
|
209
|
+
await expect(setPassword(token, 'someone@else.example')).rejects.toThrow()
|
|
210
|
+
await setPassword(token, kim.email)
|
|
211
|
+
await expect(signin({ username: kim.username, password: NEW_PASSWORD })).resolves.toBeTruthy()
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { SET_PICTURE, SET_PROFILE } from '../client/actions/auth'
|
|
2
|
+
import auth from '../client/reducers/auth'
|
|
3
|
+
|
|
4
|
+
/** The signed-in person's picture, which the header's avatar draws (`auth.picture`). */
|
|
5
|
+
describe('the signed-in person\'s picture', () => {
|
|
6
|
+
const picture = { picture: '/attachment/face.png', left: 0, top: 0, zoom: 1 }
|
|
7
|
+
|
|
8
|
+
it('starts with none - the avatar draws a monogram', () => {
|
|
9
|
+
expect(auth(undefined, { type: '@@INIT' }).picture).toBeNull()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('★ is set by SET_PICTURE and survives a later profile', () => {
|
|
13
|
+
let state = auth(undefined, { type: SET_PICTURE, picture })
|
|
14
|
+
state = auth(state, { type: SET_PROFILE, user: { name: '남상혁' } })
|
|
15
|
+
|
|
16
|
+
expect(state.picture).toEqual(picture)
|
|
17
|
+
expect(state.user).toEqual({ name: '남상혁' })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('a missing picture clears it', () => {
|
|
21
|
+
const state = auth(auth(undefined, { type: SET_PICTURE, picture }), { type: SET_PICTURE, picture: undefined })
|
|
22
|
+
|
|
23
|
+
expect(state.picture).toBeNull()
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What a role lacks against
|
|
2
|
+
* What a role lacks against its base template - against a real database (ADR-0075 decision 1, amended 2026-10-26:
|
|
3
|
+
* a role keeps one base template, `Role.templateId`, and is never paired by name).
|
|
3
4
|
*
|
|
4
5
|
* A template is a seed and nothing pushes its later grants into roles already standing. When
|
|
5
6
|
* operato-plant's templates gained ai-assistant, the roles in miratek stayed without it and only
|
|
@@ -14,12 +15,16 @@ const { Role } = loadCompiled('service/role/role')
|
|
|
14
15
|
const { Privilege } = loadCompiled('service/privilege/privilege')
|
|
15
16
|
const { User } = loadCompiled('service/user/user')
|
|
16
17
|
const { RoleTemplateMutation } = loadCompiled('service/role-template/role-template-mutation')
|
|
18
|
+
const { RoleMutation } = loadCompiled('service/role/role-mutation')
|
|
17
19
|
const { RoleTemplateGapResolver } = loadCompiled('service/role-template/role-template-gap-resolver')
|
|
18
20
|
const { registerRoleTemplate } = loadCompiled('service/role-template/role-template')
|
|
19
21
|
const { registerRoleTask } = loadCompiled('service/role-template/role-task')
|
|
20
22
|
|
|
21
23
|
const mutation = new RoleTemplateMutation()
|
|
22
|
-
const gapOf = (role: any
|
|
24
|
+
const gapOf = (role: any) => new RoleTemplateGapResolver().templateGap(role)
|
|
25
|
+
const reread = (role: any) => getRepository(Role).findOneBy({ id: role.id })
|
|
26
|
+
/* The role screen's base template field saves through `updateRole`. */
|
|
27
|
+
const baseOn = (role: any, templateId: string | null) => new RoleMutation().updateRole(role.id, { templateId } as any, { ...context(), t: (key: string) => key } as any)
|
|
23
28
|
const coverageOf = (role: any) => new RoleTemplateGapResolver().taskCoverage(role)
|
|
24
29
|
|
|
25
30
|
let domain: any
|
|
@@ -107,13 +112,52 @@ describe('the gap between a role and its template', () => {
|
|
|
107
112
|
expect((await gapOf(role)).missingCount).toBe(0)
|
|
108
113
|
})
|
|
109
114
|
|
|
110
|
-
it('★
|
|
115
|
+
it('★ a role made under a name of its own is still based on the template', async () => {
|
|
111
116
|
declareTemplate(['query'])
|
|
112
117
|
const role = await mutation.createRoleFromTemplate('plant.qi', 'NIGHT-INSPECTOR', context())
|
|
118
|
+
declareTemplate(['query', 'mutation'])
|
|
119
|
+
|
|
120
|
+
expect((await reread(role)).templateId).toBe('plant.qi')
|
|
121
|
+
expect(await gapOf(await reread(role))).toMatchObject({ templateId: 'plant.qi', missingCount: 1 })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('★ is null for a role that is based on no template, even when a template carries its name', async () => {
|
|
125
|
+
declareTemplate(['query'])
|
|
126
|
+
const role = await getRepository(Role).save({ domain, name: 'QUALITY-INSPECTOR', privileges: [] })
|
|
113
127
|
|
|
114
128
|
expect(await gapOf(role)).toBeNull()
|
|
115
129
|
})
|
|
116
130
|
|
|
131
|
+
it('★ refuses to top up a role based on another template, and changes nothing', async () => {
|
|
132
|
+
declareTemplate(['query'])
|
|
133
|
+
registerRoleTemplate({ id: 'plant.qm', name: 'QUALITY-MANAGER', grants: [{ category: 'board', axes: ['query', 'mutation'] }] })
|
|
134
|
+
const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
|
|
135
|
+
|
|
136
|
+
await expect(mutation.createRoleFromTemplate('plant.qm', 'QUALITY-INSPECTOR', context())).rejects.toThrow(/based on role template "plant.qi", not "plant.qm"/)
|
|
137
|
+
|
|
138
|
+
const stored = await getRepository(Role).findOne({ where: { id: role.id }, relations: ['privileges'] })
|
|
139
|
+
expect(stored.templateId).toBe('plant.qi')
|
|
140
|
+
expect(stored.privileges.map((p: any) => p.name)).toEqual(['query'])
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('★ tops up a standing role that has no base and makes the template its base', async () => {
|
|
144
|
+
declareTemplate(['query', 'mutation'])
|
|
145
|
+
const role = await getRepository(Role).save({ domain, name: 'QUALITY-INSPECTOR', privileges: [] })
|
|
146
|
+
|
|
147
|
+
await mutation.createRoleFromTemplate('plant.qi', undefined, context())
|
|
148
|
+
|
|
149
|
+
expect((await reread(role)).templateId).toBe('plant.qi')
|
|
150
|
+
expect((await gapOf(await reread(role))).missingCount).toBe(0)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('★ is null when the base is a template this installation no longer declares', async () => {
|
|
154
|
+
declareTemplate(['query'])
|
|
155
|
+
const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
|
|
156
|
+
;(process as any)['ROLE_TEMPLATES'] = {}
|
|
157
|
+
|
|
158
|
+
expect(await gapOf(await reread(role))).toBeNull()
|
|
159
|
+
})
|
|
160
|
+
|
|
117
161
|
it('does not take away what the administrator added beyond the template', async () => {
|
|
118
162
|
declareTemplate(['query'])
|
|
119
163
|
const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
|
|
@@ -131,19 +175,26 @@ describe('the gap between a role and its template', () => {
|
|
|
131
175
|
expect(gap.extraCount).toBe(1)
|
|
132
176
|
})
|
|
133
177
|
|
|
134
|
-
it('★ lays a role
|
|
178
|
+
it('★ lays a role named in the site\'s own words against the base the administrator gives it', async () => {
|
|
135
179
|
declareTemplate(['query', 'mutation'])
|
|
136
180
|
const role = await getRepository(Role).save({ domain, name: '품질검사', privileges: [await getRepository(Privilege).findOneBy({ category: 'board', name: 'query' })] })
|
|
137
181
|
|
|
138
182
|
expect(await gapOf(role)).toBeNull()
|
|
139
|
-
|
|
183
|
+
|
|
184
|
+
await baseOn(role, 'plant.qi')
|
|
185
|
+
expect(await gapOf(await reread(role))).toMatchObject({ templateId: 'plant.qi', missing: [{ category: 'board', axes: ['mutation'] }], extraCount: 0 })
|
|
186
|
+
|
|
187
|
+
await baseOn(role, null)
|
|
188
|
+
expect((await reread(role)).templateId).toBeNull()
|
|
189
|
+
expect(await gapOf(await reread(role))).toBeNull()
|
|
140
190
|
})
|
|
141
191
|
|
|
142
|
-
it('refuses a template id that does not exist rather than
|
|
192
|
+
it('refuses a base template id that does not exist rather than storing it', async () => {
|
|
143
193
|
declareTemplate(['query'])
|
|
144
194
|
const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
|
|
145
195
|
|
|
146
|
-
await expect(
|
|
196
|
+
await expect(baseOn(role, 'plant.nobody')).rejects.toThrow(/no role template "plant.nobody"/)
|
|
197
|
+
expect((await reread(role)).templateId).toBe('plant.qi')
|
|
147
198
|
})
|
|
148
199
|
})
|
|
149
200
|
|
|
@@ -119,7 +119,8 @@ describe('seeding', () => {
|
|
|
119
119
|
expect(await getRepository(Role).count()).toBe(1)
|
|
120
120
|
})
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
/* A role has one base template (ADR-0075 decision 1, amended 2026-10-26) - two apps declaring one name no longer union. */
|
|
123
|
+
it('refuses a second template onto a role of the same name based on the first', async () => {
|
|
123
124
|
registerRoleTemplate({ id: 'plant.qi', name: 'QUALITY-INSPECTOR', grants: [{ category: 'board', axes: ['query'] }] })
|
|
124
125
|
registerRoleTemplate({
|
|
125
126
|
id: 'twin.qi',
|
|
@@ -127,10 +128,10 @@ describe('seeding', () => {
|
|
|
127
128
|
grants: [{ category: 'board', axes: ['mutation'] }]
|
|
128
129
|
})
|
|
129
130
|
|
|
130
|
-
await mutation.createRoleFromTemplate('plant.qi', undefined, context())
|
|
131
|
-
|
|
131
|
+
const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
|
|
132
|
+
await expect(mutation.createRoleFromTemplate('twin.qi', undefined, context())).rejects.toThrow(/based on role template "plant.qi", not "twin.qi"/)
|
|
132
133
|
|
|
133
|
-
expect(await privilegesOf(role.id)).toEqual(['board:
|
|
134
|
+
expect(await privilegesOf(role.id)).toEqual(['board:query'])
|
|
134
135
|
expect(await getRepository(Role).count()).toBe(1)
|
|
135
136
|
})
|
|
136
137
|
|