@proteinjs/user-server 1.19.0 → 1.20.0
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/CHANGELOG.md +11 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/src/routes/DevBootstrapRoles.d.ts +21 -0
- package/dist/src/routes/DevBootstrapRoles.d.ts.map +1 -0
- package/dist/src/routes/DevBootstrapRoles.js +64 -0
- package/dist/src/routes/DevBootstrapRoles.js.map +1 -0
- package/dist/src/routes/devLogin.d.ts +11 -0
- package/dist/src/routes/devLogin.d.ts.map +1 -1
- package/dist/src/routes/devLogin.js +23 -3
- package/dist/src/routes/devLogin.js.map +1 -1
- package/dist/src/services/Roles.d.ts +29 -1
- package/dist/src/services/Roles.d.ts.map +1 -1
- package/dist/src/services/Roles.js +88 -14
- package/dist/src/services/Roles.js.map +1 -1
- package/dist/test/DevLogin.test.js +5 -2
- package/dist/test/DevLogin.test.js.map +1 -1
- package/dist/test/DevLoginBootstrapAdmin.test.js +2 -0
- package/dist/test/DevLoginBootstrapAdmin.test.js.map +1 -1
- package/dist/test/DevLoginBootstrapRoles.test.d.ts +2 -0
- package/dist/test/DevLoginBootstrapRoles.test.d.ts.map +1 -0
- package/dist/test/DevLoginBootstrapRoles.test.js +491 -0
- package/dist/test/DevLoginBootstrapRoles.test.js.map +1 -0
- package/generated/index.ts +1 -1
- package/package.json +2 -2
- package/src/routes/DevBootstrapRoles.ts +50 -0
- package/src/routes/devLogin.ts +18 -0
- package/src/services/Roles.ts +61 -1
- package/test/DevLogin.test.ts +5 -2
- package/test/DevLoginBootstrapAdmin.test.ts +2 -0
- package/test/DevLoginBootstrapRoles.test.ts +248 -0
package/test/DevLogin.test.ts
CHANGED
|
@@ -14,8 +14,9 @@ const testEnv = new UserServerTestEnvironment();
|
|
|
14
14
|
* - Double gate: `DEVELOPMENT` AND `DEV_AUTO_LOGIN_EMAIL` both present, else 404 (unchanged).
|
|
15
15
|
* - Domain rail: a `?email=` param must share `DEV_AUTO_LOGIN_EMAIL`'s domain — even a dev server
|
|
16
16
|
* must not mint sessions (much less accounts) for arbitrary domains. Others 400.
|
|
17
|
-
* The first-admin door (`DEV_BOOTSTRAP_ADMIN_EMAIL`)
|
|
18
|
-
*
|
|
17
|
+
* The first-admin door (`DEV_BOOTSTRAP_ADMIN_EMAIL`) and the role-bootstrap door (`DEV_BOOTSTRAP_ROLES`)
|
|
18
|
+
* have their own suites (DevLoginBootstrapAdmin.test.ts, DevLoginBootstrapRoles.test.ts); here both
|
|
19
|
+
* variables are unset, so every created account is role-less.
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
const ENV_EMAIL = 'dev@test.local';
|
|
@@ -27,6 +28,7 @@ describe('devLogin route', () => {
|
|
|
27
28
|
DEVELOPMENT: process.env.DEVELOPMENT,
|
|
28
29
|
DEV_AUTO_LOGIN_EMAIL: process.env.DEV_AUTO_LOGIN_EMAIL,
|
|
29
30
|
DEV_BOOTSTRAP_ADMIN_EMAIL: process.env.DEV_BOOTSTRAP_ADMIN_EMAIL,
|
|
31
|
+
DEV_BOOTSTRAP_ROLES: process.env.DEV_BOOTSTRAP_ROLES,
|
|
30
32
|
};
|
|
31
33
|
|
|
32
34
|
beforeAll(async () => {
|
|
@@ -41,6 +43,7 @@ describe('devLogin route', () => {
|
|
|
41
43
|
process.env.DEVELOPMENT = 'true';
|
|
42
44
|
process.env.DEV_AUTO_LOGIN_EMAIL = ENV_EMAIL;
|
|
43
45
|
delete process.env.DEV_BOOTSTRAP_ADMIN_EMAIL;
|
|
46
|
+
delete process.env.DEV_BOOTSTRAP_ROLES;
|
|
44
47
|
});
|
|
45
48
|
|
|
46
49
|
afterEach(() => {
|
|
@@ -33,6 +33,7 @@ describe('devLogin — the DEV_BOOTSTRAP_ADMIN_EMAIL first-admin door', () => {
|
|
|
33
33
|
DEVELOPMENT: process.env.DEVELOPMENT,
|
|
34
34
|
DEV_AUTO_LOGIN_EMAIL: process.env.DEV_AUTO_LOGIN_EMAIL,
|
|
35
35
|
DEV_BOOTSTRAP_ADMIN_EMAIL: process.env.DEV_BOOTSTRAP_ADMIN_EMAIL,
|
|
36
|
+
DEV_BOOTSTRAP_ROLES: process.env.DEV_BOOTSTRAP_ROLES,
|
|
36
37
|
};
|
|
37
38
|
|
|
38
39
|
beforeAll(async () => {
|
|
@@ -47,6 +48,7 @@ describe('devLogin — the DEV_BOOTSTRAP_ADMIN_EMAIL first-admin door', () => {
|
|
|
47
48
|
process.env.DEVELOPMENT = 'true';
|
|
48
49
|
process.env.DEV_AUTO_LOGIN_EMAIL = ENV_EMAIL;
|
|
49
50
|
process.env.DEV_BOOTSTRAP_ADMIN_EMAIL = BOOTSTRAP_EMAIL;
|
|
51
|
+
delete process.env.DEV_BOOTSTRAP_ROLES; // the role-bootstrap door has its own suite; here it is closed
|
|
50
52
|
// Every case starts from a fresh database: no accounts, no audit trail.
|
|
51
53
|
const db = getDbAsSystem();
|
|
52
54
|
await db.delete(tables.RoleGrantEvent, {});
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { getDbAsSystem } from '@proteinjs/db';
|
|
2
|
+
import { Logger } from '@proteinjs/logger';
|
|
3
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
4
|
+
import { RoleCatalogEntry, tables } from '@proteinjs/user';
|
|
5
|
+
import { invokeDevLogin } from './devLoginHarness';
|
|
6
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
7
|
+
|
|
8
|
+
const testEnv = new UserServerTestEnvironment();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `DEV_BOOTSTRAP_ROLES` — the dev role-bootstrap door INSIDE `/dev/login`. A fresh development
|
|
12
|
+
* database has one admin at most (the first-admin door) and every other account is role-less —
|
|
13
|
+
* so a consumer's `adminGrantOnly` roles needed a manual admin act on every fresh database. The
|
|
14
|
+
* same door, the same two gates, one more variable: `email:role[,role];email:role…` — on a hit
|
|
15
|
+
* whose resolved address is listed, the listed roles the account does not hold are granted, once
|
|
16
|
+
* each:
|
|
17
|
+
* - behind the door's existing two gates (DEVELOPMENT AND DEV_AUTO_LOGIN_EMAIL): closed = 404 as
|
|
18
|
+
* before, and the variable changes nothing;
|
|
19
|
+
* - only for the request whose resolved address equals a listed address exactly (case-normalized
|
|
20
|
+
* the way every account email is) — every other address is untouched;
|
|
21
|
+
* - idempotent: a role the account holds is reported `held`, never re-granted, never re-audited;
|
|
22
|
+
* nothing is EVER revoked (a role the list does not name stays);
|
|
23
|
+
* - admin-grant-only roles are granted (that is the point — a fresh development database has no
|
|
24
|
+
* admin to grant them); break-glass and roles the catalog does not know are REFUSED and named;
|
|
25
|
+
* - each grant is audited like any grant (a role_grant_event row; actor = the account itself);
|
|
26
|
+
* - the outcome is ONE marker line, `[dev-bootstrap] <email>: granted …; held …; refused …`, the
|
|
27
|
+
* line provisioning tooling reads back from the server log as its proof.
|
|
28
|
+
* Outcomes are asserted on the rows (roles, audit), the marker line on the logger (it IS the
|
|
29
|
+
* contract its readers parse).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const ENV_EMAIL = 'dev@test.local';
|
|
33
|
+
const TEAM_EMAIL = 'team@test.local';
|
|
34
|
+
const OPS_EMAIL = 'ops@test.local';
|
|
35
|
+
const ROLES_ENV = `${TEAM_EMAIL}:staff,dev;${OPS_EMAIL}:ops`;
|
|
36
|
+
|
|
37
|
+
type SourceRepositoryInternals = { objectCache: Record<string, unknown[]> };
|
|
38
|
+
|
|
39
|
+
const userRow = async (email: string) => await getDbAsSystem().get(tables.User, { email });
|
|
40
|
+
const auditRows = async () => await getDbAsSystem().query(tables.RoleGrantEvent, {});
|
|
41
|
+
const adminRows = async () =>
|
|
42
|
+
(await getDbAsSystem().query(tables.User, {})).filter((user) => (user.roles ?? []).includes('admin'));
|
|
43
|
+
|
|
44
|
+
describe('devLogin — the DEV_BOOTSTRAP_ROLES role-bootstrap door', () => {
|
|
45
|
+
const originalEnv = {
|
|
46
|
+
DEVELOPMENT: process.env.DEVELOPMENT,
|
|
47
|
+
DEV_AUTO_LOGIN_EMAIL: process.env.DEV_AUTO_LOGIN_EMAIL,
|
|
48
|
+
DEV_BOOTSTRAP_ADMIN_EMAIL: process.env.DEV_BOOTSTRAP_ADMIN_EMAIL,
|
|
49
|
+
DEV_BOOTSTRAP_ROLES: process.env.DEV_BOOTSTRAP_ROLES,
|
|
50
|
+
};
|
|
51
|
+
let infoSpy: jest.SpyInstance;
|
|
52
|
+
const markerLines = () =>
|
|
53
|
+
infoSpy.mock.calls
|
|
54
|
+
.map((call) => String((call[0] as { message?: string })?.message ?? ''))
|
|
55
|
+
.filter((message) => message.startsWith('[dev-bootstrap]'));
|
|
56
|
+
|
|
57
|
+
beforeAll(async () => {
|
|
58
|
+
await testEnv.beforeAll();
|
|
59
|
+
// A consumer's catalog shape: 'staff' is admin-grant-only, 'dev'/'ops' plain.
|
|
60
|
+
(SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache['@proteinjs/user/RoleCatalogEntry'] = [
|
|
61
|
+
{ role: 'staff', description: 'Staff member', adminGrantOnly: true } as RoleCatalogEntry,
|
|
62
|
+
{ role: 'dev', description: 'Developer tooling' } as RoleCatalogEntry,
|
|
63
|
+
{ role: 'ops', description: 'Operations' } as RoleCatalogEntry,
|
|
64
|
+
];
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterAll(async () => {
|
|
68
|
+
await testEnv.afterAll();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
beforeEach(async () => {
|
|
72
|
+
process.env.DEVELOPMENT = 'true';
|
|
73
|
+
process.env.DEV_AUTO_LOGIN_EMAIL = ENV_EMAIL;
|
|
74
|
+
delete process.env.DEV_BOOTSTRAP_ADMIN_EMAIL;
|
|
75
|
+
process.env.DEV_BOOTSTRAP_ROLES = ROLES_ENV;
|
|
76
|
+
infoSpy = jest.spyOn(Logger.prototype, 'info');
|
|
77
|
+
// Every case starts from a fresh database: no accounts, no audit trail.
|
|
78
|
+
const db = getDbAsSystem();
|
|
79
|
+
await db.delete(tables.RoleGrantEvent, {});
|
|
80
|
+
await db.delete(tables.User, {});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
infoSpy.mockRestore();
|
|
85
|
+
for (const [key, value] of Object.entries(originalEnv)) {
|
|
86
|
+
if (value === undefined) {
|
|
87
|
+
delete process.env[key];
|
|
88
|
+
} else {
|
|
89
|
+
process.env[key] = value;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('a fresh database + a listed address: the account is created carrying the listed roles (the admin-grant-only one included), one audit row per role, logged in, and the marker line names the grants', async () => {
|
|
95
|
+
expect(await userRow(TEAM_EMAIL)).toBeUndefined();
|
|
96
|
+
|
|
97
|
+
const outcome = await invokeDevLogin({ email: TEAM_EMAIL });
|
|
98
|
+
|
|
99
|
+
expect(outcome.loggedInAs).toBe(TEAM_EMAIL);
|
|
100
|
+
expect(outcome.sessionSaved).toBe(true);
|
|
101
|
+
expect(outcome.redirect).toBe('/');
|
|
102
|
+
const team = await userRow(TEAM_EMAIL);
|
|
103
|
+
expect(team!.roles).toEqual(['staff', 'dev']);
|
|
104
|
+
const events = await auditRows();
|
|
105
|
+
expect(events.map((e) => ({ actor: e.actor, target: e.target, role: e.role, action: e.action }))).toEqual(
|
|
106
|
+
expect.arrayContaining([
|
|
107
|
+
{ actor: team!.id, target: team!.id, role: 'staff', action: 'grant' },
|
|
108
|
+
{ actor: team!.id, target: team!.id, role: 'dev', action: 'grant' },
|
|
109
|
+
])
|
|
110
|
+
);
|
|
111
|
+
expect(events).toHaveLength(2);
|
|
112
|
+
expect(markerLines()).toEqual([`[dev-bootstrap] ${TEAM_EMAIL}: granted staff, dev; held (none); refused (none)`]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('a second hit for the same address writes nothing: the roles stand, the audit trail is unchanged, the marker line reports them held', async () => {
|
|
116
|
+
await invokeDevLogin({ email: TEAM_EMAIL });
|
|
117
|
+
infoSpy.mockClear();
|
|
118
|
+
|
|
119
|
+
const outcome = await invokeDevLogin({ email: TEAM_EMAIL });
|
|
120
|
+
|
|
121
|
+
expect(outcome.loggedInAs).toBe(TEAM_EMAIL);
|
|
122
|
+
expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['staff', 'dev']);
|
|
123
|
+
expect(await auditRows()).toHaveLength(2);
|
|
124
|
+
expect(markerLines()).toEqual([`[dev-bootstrap] ${TEAM_EMAIL}: granted (none); held staff, dev; refused (none)`]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('an existing account holding one listed role and one unlisted role: only the missing role is granted, the unlisted one stays — the door never revokes', async () => {
|
|
128
|
+
await testEnv.createUser({ name: 'Team', email: TEAM_EMAIL, roles: ['dev', 'sessions'] });
|
|
129
|
+
|
|
130
|
+
await invokeDevLogin({ email: TEAM_EMAIL });
|
|
131
|
+
|
|
132
|
+
expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['dev', 'sessions', 'staff']);
|
|
133
|
+
const events = await auditRows();
|
|
134
|
+
expect(events).toHaveLength(1);
|
|
135
|
+
expect(events[0]).toMatchObject({ role: 'staff', action: 'grant' });
|
|
136
|
+
expect(markerLines()).toEqual([`[dev-bootstrap] ${TEAM_EMAIL}: granted staff; held dev; refused (none)`]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('an address the list does not name: an ordinary account, no grant, no audit row, no marker line — and the listed addresses stay uncreated', async () => {
|
|
140
|
+
const outcome = await invokeDevLogin({ email: 'agent@test.local' });
|
|
141
|
+
|
|
142
|
+
expect(outcome.loggedInAs).toBe('agent@test.local');
|
|
143
|
+
expect((await userRow('agent@test.local'))!.roles).toEqual([]);
|
|
144
|
+
expect(await auditRows()).toHaveLength(0);
|
|
145
|
+
expect(markerLines()).toEqual([]);
|
|
146
|
+
expect(await userRow(TEAM_EMAIL)).toBeUndefined();
|
|
147
|
+
expect(await userRow(OPS_EMAIL)).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('each listed address gets ITS roles only — the second entry never inherits the first', async () => {
|
|
151
|
+
await invokeDevLogin({ email: OPS_EMAIL });
|
|
152
|
+
|
|
153
|
+
expect((await userRow(OPS_EMAIL))!.roles).toEqual(['ops']);
|
|
154
|
+
expect(await auditRows()).toHaveLength(1);
|
|
155
|
+
expect(markerLines()).toEqual([`[dev-bootstrap] ${OPS_EMAIL}: granted ops; held (none); refused (none)`]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('the match is case-normalized like every account email, and the grammar tolerates whitespace around every token', async () => {
|
|
159
|
+
process.env.DEV_BOOTSTRAP_ROLES = ` Team@Test.local : staff , dev ; ${OPS_EMAIL}: ops `;
|
|
160
|
+
|
|
161
|
+
await invokeDevLogin({ email: 'team@test.local' });
|
|
162
|
+
|
|
163
|
+
expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['staff', 'dev']);
|
|
164
|
+
expect(await auditRows()).toHaveLength(2);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('a role the catalog does not know is refused and named; the known roles in the same entry are still granted', async () => {
|
|
168
|
+
process.env.DEV_BOOTSTRAP_ROLES = `${TEAM_EMAIL}:staff,no-such-role`;
|
|
169
|
+
|
|
170
|
+
await invokeDevLogin({ email: TEAM_EMAIL });
|
|
171
|
+
|
|
172
|
+
expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['staff']);
|
|
173
|
+
expect(await auditRows()).toHaveLength(1);
|
|
174
|
+
expect(markerLines()).toEqual([
|
|
175
|
+
`[dev-bootstrap] ${TEAM_EMAIL}: granted staff; held (none); refused no-such-role (unknown role)`,
|
|
176
|
+
]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("break-glass is never this door's to grant: 'admin' in the list is refused and named (the first-admin door is the one path)", async () => {
|
|
180
|
+
process.env.DEV_BOOTSTRAP_ROLES = `${TEAM_EMAIL}:admin,dev`;
|
|
181
|
+
|
|
182
|
+
await invokeDevLogin({ email: TEAM_EMAIL });
|
|
183
|
+
|
|
184
|
+
expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['dev']);
|
|
185
|
+
expect(await adminRows()).toHaveLength(0);
|
|
186
|
+
expect((await auditRows()).map((e) => e.role)).toEqual(['dev']);
|
|
187
|
+
expect(markerLines()).toEqual([
|
|
188
|
+
`[dev-bootstrap] ${TEAM_EMAIL}: granted dev; held (none); refused admin (break-glass)`,
|
|
189
|
+
]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('the default path is a request for DEV_AUTO_LOGIN_EMAIL: when that address is listed, the default account carries the roles', async () => {
|
|
193
|
+
process.env.DEV_BOOTSTRAP_ROLES = `${ENV_EMAIL}:dev`;
|
|
194
|
+
|
|
195
|
+
const outcome = await invokeDevLogin();
|
|
196
|
+
|
|
197
|
+
expect(outcome.loggedInAs).toBe(ENV_EMAIL);
|
|
198
|
+
expect((await userRow(ENV_EMAIL))!.roles).toEqual(['dev']);
|
|
199
|
+
expect(await auditRows()).toHaveLength(1);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('composes with the first-admin door: the same address listed in both gets admin first, then its roles — three audited grants, one account', async () => {
|
|
203
|
+
process.env.DEV_BOOTSTRAP_ADMIN_EMAIL = TEAM_EMAIL;
|
|
204
|
+
|
|
205
|
+
await invokeDevLogin({ email: TEAM_EMAIL });
|
|
206
|
+
|
|
207
|
+
expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['admin', 'staff', 'dev']);
|
|
208
|
+
expect(await auditRows()).toHaveLength(3);
|
|
209
|
+
expect(await adminRows()).toHaveLength(1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('with the variable unset a listed address is an ordinary account — the omission is the safety (a deployment outside development never sets it)', async () => {
|
|
213
|
+
delete process.env.DEV_BOOTSTRAP_ROLES;
|
|
214
|
+
|
|
215
|
+
await invokeDevLogin({ email: TEAM_EMAIL });
|
|
216
|
+
|
|
217
|
+
expect((await userRow(TEAM_EMAIL))!.roles).toEqual([]);
|
|
218
|
+
expect(await auditRows()).toHaveLength(0);
|
|
219
|
+
expect(markerLines()).toEqual([]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('malformed entries (no colon, no roles) are ignored; the well-formed entries beside them are honored', async () => {
|
|
223
|
+
process.env.DEV_BOOTSTRAP_ROLES = `garbage;${OPS_EMAIL}:;${TEAM_EMAIL}:dev;:ops`;
|
|
224
|
+
|
|
225
|
+
await invokeDevLogin({ email: TEAM_EMAIL });
|
|
226
|
+
await invokeDevLogin({ email: OPS_EMAIL });
|
|
227
|
+
|
|
228
|
+
expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['dev']);
|
|
229
|
+
expect((await userRow(OPS_EMAIL))!.roles).toEqual([]);
|
|
230
|
+
expect(await auditRows()).toHaveLength(1);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it.each([['DEVELOPMENT'], ['DEV_AUTO_LOGIN_EMAIL']])(
|
|
234
|
+
'gate closed (%s unset): 404 exactly as before — no session, no account, no grant; the variable changes nothing',
|
|
235
|
+
async (gate) => {
|
|
236
|
+
delete process.env[gate];
|
|
237
|
+
|
|
238
|
+
const outcome = await invokeDevLogin({ email: TEAM_EMAIL });
|
|
239
|
+
|
|
240
|
+
expect(outcome.status).toBe(404);
|
|
241
|
+
expect(outcome.loggedInAs).toBeUndefined();
|
|
242
|
+
expect(outcome.sessionSaved).toBe(false);
|
|
243
|
+
expect(await userRow(TEAM_EMAIL)).toBeUndefined();
|
|
244
|
+
expect(await auditRows()).toHaveLength(0);
|
|
245
|
+
expect(markerLines()).toEqual([]);
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
});
|