cabloy 5.1.240 → 5.1.241
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/.cabloy-version +1 -1
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/repo-docs/backend/openapi-guide.md +18 -0
- package/repo-e2e/specs/home-user-account.spec.ts +53 -8
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/pnpm-lock.yaml +16 -141
- package/vona/src/suite/a-commerce/modules/commerce-siteadmin/src/dto/operatorContext.ts +4 -1
- package/vona/src/suite/a-commerce/modules/commerce-siteadmin/src/service/operator.ts +1 -0
- package/vona/src/suite/a-commerce/modules/commerce-siteadmin/test/operatorAccess.test.ts +1 -0
- package/vona/src/suite/a-home/modules/home-user/src/bean/meta.version.ts +1 -0
- package/vona/src/suite/a-home/modules/home-user/src/controller/passport.ts +1 -0
- package/vona/src/suite/a-home/modules/home-user/src/dto/accountCurrent.ts +3 -0
- package/vona/src/suite/a-home/modules/home-user/src/dto/accountProfileUpdate.ts +9 -2
- package/vona/src/suite/a-home/modules/home-user/src/dto/passportUser.ts +3 -0
- package/vona/src/suite/a-home/modules/home-user/src/entity/user.ts +3 -0
- package/vona/src/suite/a-home/modules/home-user/src/service/account.ts +7 -3
- package/vona/src/suite/a-home/modules/home-user/src/service/userAdapter.ts +1 -0
- package/vona/src/suite/a-home/modules/home-user/test/account.test.ts +133 -8
- package/vona/src/suite/a-home/modules/home-user/test/accountOpenapi.test.ts +32 -5
- package/vona/src/suite/a-home/modules/home-user/test/passport.test.ts +159 -1
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-permission/src/bean/bean.permission.ts +54 -8
- package/vona/src/suite-vendor/a-vona/modules/a-permission/test/permission.test.ts +228 -16
- package/vona/src/suite-vendor/a-vona/modules/a-user/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/types/user.ts +1 -0
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/src/suite/a-commerce/modules/commerce-siteadmin/src/api/openapi/schemas.ts +68 -52
- package/zova/src/suite/a-commerce/modules/commerce-siteadmin/src/api/openapi/types.ts +530 -344
- package/zova/src/suite/a-commerce/modules/commerce-siteadmin/src/page/dashboard/controller.tsx +7 -1
- package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +6 -1
- package/zova/src/suite/a-home/modules/home-base/src/config/locale/en-us.ts +1 -0
- package/zova/src/suite/a-home/modules/home-base/src/config/locale/zh-cn.ts +1 -0
- package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.user.tsx +1 -1
- package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/render.user.tsx +3 -1
- package/zova/src/suite/a-home/modules/home-passport/src/model/passport.ts +4 -0
- package/zova/src/suite/a-home/modules/home-user/src/config/locale/en-us.ts +1 -0
- package/zova/src/suite/a-home/modules/home-user/src/config/locale/zh-cn.ts +1 -0
- package/zova/src/suite/a-home/modules/home-user/src/model/account.ts +2 -1
- package/zova/src/suite/a-home/modules/home-user/src/page/account/controller.tsx +17 -3
|
@@ -34,19 +34,22 @@ describe('account.test.ts', { concurrency: false }, () => {
|
|
|
34
34
|
innerAccess: false,
|
|
35
35
|
});
|
|
36
36
|
assert.equal(current.name, fixture!.name);
|
|
37
|
+
assert.equal(current.displayName, undefined);
|
|
37
38
|
assert.equal(current.hasSimpleAuth, true);
|
|
38
39
|
assert.equal(current.canSendSetPasswordLink, false);
|
|
39
40
|
|
|
40
41
|
const updated = await app.bean.executor.performAction('patch', profilePath, {
|
|
41
42
|
innerAccess: false,
|
|
42
43
|
body: {
|
|
43
|
-
|
|
44
|
+
displayName: ' Updated display name ',
|
|
45
|
+
name: `${fixture!.name}-must-not-rename`,
|
|
44
46
|
locale: 'en_us',
|
|
45
47
|
tz: 'America/New_York',
|
|
46
48
|
ignored: 'must-not-persist',
|
|
47
49
|
},
|
|
48
50
|
});
|
|
49
|
-
assert.equal(updated.name,
|
|
51
|
+
assert.equal(updated.name, fixture!.name);
|
|
52
|
+
assert.equal(updated.displayName, 'Updated display name');
|
|
50
53
|
assert.equal(updated.locale, 'en-us');
|
|
51
54
|
assert.equal(updated.tz, 'America/New_York');
|
|
52
55
|
} finally {
|
|
@@ -56,13 +59,23 @@ describe('account.test.ts', { concurrency: false }, () => {
|
|
|
56
59
|
|
|
57
60
|
await app.bean.executor.mockCtx(async () => {
|
|
58
61
|
const user = await app.bean.user.findOneById(fixture!.userId);
|
|
59
|
-
assert.equal(user?.name,
|
|
62
|
+
assert.equal(user?.name, fixture!.name);
|
|
63
|
+
assert.equal(user?.displayName, 'Updated display name');
|
|
60
64
|
assert.ok(user);
|
|
61
65
|
assert.equal('ignored' in user, false);
|
|
66
|
+
assert.equal(
|
|
67
|
+
await app.bean.user.findOneByName(`${fixture!.name}-must-not-rename`),
|
|
68
|
+
undefined,
|
|
69
|
+
);
|
|
62
70
|
});
|
|
63
71
|
|
|
64
72
|
await app.bean.executor.mockCtx(async () => {
|
|
65
|
-
await app.bean.
|
|
73
|
+
const jwt = await app.bean.auth.authenticate('auth-simple:simple', {
|
|
74
|
+
clientName: 'default',
|
|
75
|
+
state: { intention: 'login' },
|
|
76
|
+
clientOptions: { username: fixture!.name, password: 'initial-password' },
|
|
77
|
+
});
|
|
78
|
+
assert.ok(jwt?.accessToken);
|
|
66
79
|
try {
|
|
67
80
|
const result = await app.bean.executor.performAction('post', passwordChangePath, {
|
|
68
81
|
innerAccess: false,
|
|
@@ -91,6 +104,105 @@ describe('account.test.ts', { concurrency: false }, () => {
|
|
|
91
104
|
}
|
|
92
105
|
});
|
|
93
106
|
|
|
107
|
+
it('retains omitted displayName, clears null or blank, and validates the trimmed limit', async () => {
|
|
108
|
+
let fixture: IAccountFixture | undefined;
|
|
109
|
+
try {
|
|
110
|
+
fixture = await createFixture();
|
|
111
|
+
const displayName = 'd'.repeat(100);
|
|
112
|
+
const updated = await updateProfile(fixture, { displayName: ` ${displayName} ` });
|
|
113
|
+
assert.equal(updated.displayName, displayName);
|
|
114
|
+
assert.equal(updated.name, fixture.name);
|
|
115
|
+
|
|
116
|
+
const retained = await updateProfile(fixture, { locale: 'en-us' });
|
|
117
|
+
assert.equal(retained.displayName, displayName);
|
|
118
|
+
|
|
119
|
+
await app.bean.executor.mockCtx(async () => {
|
|
120
|
+
await app.bean.passport.signinSystem('mock', -10005 as any, fixture!.name);
|
|
121
|
+
try {
|
|
122
|
+
const account = app.scope('home-user').service.account;
|
|
123
|
+
const result = await account.updateProfile({
|
|
124
|
+
name: `${fixture!.name}-service-must-not-rename`,
|
|
125
|
+
} as unknown as Parameters<typeof account.updateProfile>[0]);
|
|
126
|
+
assert.equal(result.name, fixture!.name);
|
|
127
|
+
assert.equal(result.displayName, displayName);
|
|
128
|
+
} finally {
|
|
129
|
+
await app.bean.passport.signout();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
for (const invalid of ['d'.repeat(101), ` ${'d'.repeat(101)} `]) {
|
|
134
|
+
await assert.rejects(
|
|
135
|
+
() => updateProfile(fixture!, { displayName: invalid }),
|
|
136
|
+
(error: { code?: number }) => error.code === 422,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
await app.bean.executor.mockCtx(async () => {
|
|
140
|
+
const user = await app.bean.user.findOneById(fixture!.userId);
|
|
141
|
+
assert.equal(user?.name, fixture!.name);
|
|
142
|
+
assert.equal(user?.displayName, displayName);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
for (const empty of [null, ' \t\n ', '']) {
|
|
146
|
+
await updateProfile(fixture, { displayName: 'Before clearing' });
|
|
147
|
+
const cleared = await updateProfile(fixture, { displayName: empty });
|
|
148
|
+
assert.equal(cleared.name, fixture.name);
|
|
149
|
+
assert.equal(cleared.displayName, undefined);
|
|
150
|
+
await app.bean.executor.mockCtx(async () => {
|
|
151
|
+
const user = await app.bean.user.findOneById(fixture!.userId);
|
|
152
|
+
assert.ok(user);
|
|
153
|
+
assert.ok(user.displayName == null);
|
|
154
|
+
await app.bean.passport.signinSystem('mock', -10006 as any, fixture!.name);
|
|
155
|
+
try {
|
|
156
|
+
const current = await app.bean.executor.performAction('get', currentPath, {
|
|
157
|
+
innerAccess: false,
|
|
158
|
+
});
|
|
159
|
+
assert.equal(current.displayName, undefined);
|
|
160
|
+
} finally {
|
|
161
|
+
await app.bean.passport.signout();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
} finally {
|
|
166
|
+
if (fixture) await removeFixture(fixture);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('allows two users to share displayName while retaining their original username logins', async () => {
|
|
171
|
+
const fixtures: IAccountFixture[] = [];
|
|
172
|
+
const displayName = `Shared ${crypto.randomUUID()}`;
|
|
173
|
+
try {
|
|
174
|
+
fixtures.push(await createFixture());
|
|
175
|
+
fixtures.push(await createFixture());
|
|
176
|
+
for (const fixture of fixtures) {
|
|
177
|
+
const updated = await updateProfile(fixture, { displayName });
|
|
178
|
+
assert.equal(updated.name, fixture.name);
|
|
179
|
+
assert.equal(updated.displayName, displayName);
|
|
180
|
+
}
|
|
181
|
+
for (const fixture of fixtures) {
|
|
182
|
+
await app.bean.executor.mockCtx(async () => {
|
|
183
|
+
try {
|
|
184
|
+
const jwt = await app.bean.auth.authenticate('auth-simple:simple', {
|
|
185
|
+
clientName: 'default',
|
|
186
|
+
state: { intention: 'login' },
|
|
187
|
+
clientOptions: { username: fixture.name, password: 'initial-password' },
|
|
188
|
+
});
|
|
189
|
+
assert.ok(jwt?.accessToken);
|
|
190
|
+
assert.equal(app.bean.passport.currentUser?.id.toString(), fixture.userId);
|
|
191
|
+
const current = await app.bean.executor.performAction('get', currentPath, {
|
|
192
|
+
innerAccess: false,
|
|
193
|
+
});
|
|
194
|
+
assert.equal(current.name, fixture.name);
|
|
195
|
+
assert.equal(current.displayName, displayName);
|
|
196
|
+
} finally {
|
|
197
|
+
await app.bean.passport.signout();
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
} finally {
|
|
202
|
+
for (const fixture of fixtures.reverse()) await removeFixture(fixture);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
94
206
|
it('accepts a freshly uploaded avatar during profile update', async () => {
|
|
95
207
|
let fixture: IAccountFixture | undefined;
|
|
96
208
|
let imageId: string | number | undefined;
|
|
@@ -133,7 +245,6 @@ describe('account.test.ts', { concurrency: false }, () => {
|
|
|
133
245
|
const updated = await app.bean.executor.performAction('patch', profilePath, {
|
|
134
246
|
innerAccess: false,
|
|
135
247
|
body: {
|
|
136
|
-
name: fixture!.name,
|
|
137
248
|
avatar: uploadedUrl,
|
|
138
249
|
},
|
|
139
250
|
});
|
|
@@ -145,7 +256,7 @@ describe('account.test.ts', { concurrency: false }, () => {
|
|
|
145
256
|
|
|
146
257
|
const preserved = await app.bean.executor.performAction('patch', profilePath, {
|
|
147
258
|
innerAccess: false,
|
|
148
|
-
body: {
|
|
259
|
+
body: {},
|
|
149
260
|
});
|
|
150
261
|
assert.equal(preserved.avatar, uploadedUrl);
|
|
151
262
|
|
|
@@ -153,7 +264,6 @@ describe('account.test.ts', { concurrency: false }, () => {
|
|
|
153
264
|
const thirdPartyUpdated = await app.bean.executor.performAction('patch', profilePath, {
|
|
154
265
|
innerAccess: false,
|
|
155
266
|
body: {
|
|
156
|
-
name: fixture!.name,
|
|
157
267
|
avatar: thirdPartyAvatar,
|
|
158
268
|
},
|
|
159
269
|
});
|
|
@@ -161,7 +271,7 @@ describe('account.test.ts', { concurrency: false }, () => {
|
|
|
161
271
|
|
|
162
272
|
const cleared = await app.bean.executor.performAction('patch', profilePath, {
|
|
163
273
|
innerAccess: false,
|
|
164
|
-
body: {
|
|
274
|
+
body: { avatar: null },
|
|
165
275
|
});
|
|
166
276
|
assert.equal(cleared.avatar, undefined);
|
|
167
277
|
} finally {
|
|
@@ -214,6 +324,20 @@ describe('account.test.ts', { concurrency: false }, () => {
|
|
|
214
324
|
});
|
|
215
325
|
});
|
|
216
326
|
|
|
327
|
+
async function updateProfile(fixture: IAccountFixture, body: Record<string, unknown>) {
|
|
328
|
+
return await app.bean.executor.mockCtx(async () => {
|
|
329
|
+
await app.bean.passport.signinSystem('mock', -10007 as any, fixture.name);
|
|
330
|
+
try {
|
|
331
|
+
return await app.bean.executor.performAction('patch', profilePath, {
|
|
332
|
+
innerAccess: false,
|
|
333
|
+
body,
|
|
334
|
+
});
|
|
335
|
+
} finally {
|
|
336
|
+
await app.bean.passport.signout();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
217
341
|
async function createFixture(): Promise<IAccountFixture> {
|
|
218
342
|
return await app.bean.executor.mockCtx(async () => {
|
|
219
343
|
const name = `account-test-${crypto.randomUUID()}`;
|
|
@@ -244,6 +368,7 @@ async function removeFixture(fixture: IAccountFixture): Promise<void> {
|
|
|
244
368
|
await app.bean.executor.mockCtx(async () => {
|
|
245
369
|
await app.scope('a-auth').model.auth.deleteById(fixture.authId);
|
|
246
370
|
await app.scope('auth-simple').model.authSimple.deleteById(fixture.authSimpleId);
|
|
371
|
+
await app.scope('home-user').model.roleUser.delete({ userId: fixture.userId });
|
|
247
372
|
await app.bean.user.removeById(fixture.userId);
|
|
248
373
|
});
|
|
249
374
|
}
|
|
@@ -110,25 +110,38 @@ describe('accountOpenapi.test.ts', () => {
|
|
|
110
110
|
assert.equal(property?.rest?.form?.required, true);
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
|
+
if (expectation.action === 'current' || expectation.action === 'updateProfile') {
|
|
114
|
+
const response = operation.responses?.['200'];
|
|
115
|
+
assert.ok(response && 'content' in response);
|
|
116
|
+
const body = resolveSchema(doc, response.content?.['application/json']?.schema);
|
|
117
|
+
const current = resolveSchema(doc, body.properties?.data);
|
|
118
|
+
assert.deepEqual(inlineSchema(current.properties?.displayName).type, ['string', 'null']);
|
|
119
|
+
assert.equal(current.required?.includes('displayName') ?? false, false);
|
|
120
|
+
assert.equal(inlineSchema(current.properties?.name).type, 'string');
|
|
121
|
+
assert.ok(current.required?.includes('name'));
|
|
122
|
+
}
|
|
113
123
|
if (expectation.action === 'updateProfile') {
|
|
114
124
|
const schemaResolved = requestSchema(doc, operation);
|
|
115
125
|
assert.deepEqual(Object.keys(schemaResolved?.properties ?? {}), [
|
|
116
|
-
'
|
|
126
|
+
'displayName',
|
|
117
127
|
'avatar',
|
|
118
128
|
'locale',
|
|
119
129
|
'tz',
|
|
120
130
|
]);
|
|
121
|
-
assert.deepEqual(schemaResolved
|
|
131
|
+
assert.deepEqual(schemaResolved.required ?? [], []);
|
|
132
|
+
assert.equal(schemaResolved.properties?.name, undefined);
|
|
122
133
|
for (const [field, title] of [
|
|
123
|
-
['
|
|
134
|
+
['displayName', 'home-user::AccountDisplayName'],
|
|
124
135
|
['avatar', 'home-user::AccountAvatar'],
|
|
125
136
|
['locale', 'home-user::AccountLocale'],
|
|
126
137
|
['tz', 'home-user::AccountTimezone'],
|
|
127
138
|
]) {
|
|
128
139
|
assert.equal(inlineSchema(schemaResolved.properties?.[field]).title?.toString(), title);
|
|
129
140
|
}
|
|
130
|
-
|
|
131
|
-
assert.
|
|
141
|
+
const displayName = inlineSchema(schemaResolved.properties?.displayName);
|
|
142
|
+
assert.deepEqual(displayName.type, ['string', 'null']);
|
|
143
|
+
assert.equal(displayName.maxLength, 100);
|
|
144
|
+
assert.ok(!displayName.minLength);
|
|
132
145
|
assert.deepEqual(inlineSchema(schemaResolved.properties?.avatar).type, [
|
|
133
146
|
'string',
|
|
134
147
|
'null',
|
|
@@ -154,6 +167,20 @@ function inlineSchema(
|
|
|
154
167
|
return schema;
|
|
155
168
|
}
|
|
156
169
|
|
|
170
|
+
function resolveSchema(
|
|
171
|
+
doc: OpenAPIObject,
|
|
172
|
+
schema: ReferenceObject | SchemaObject | undefined,
|
|
173
|
+
): ISchemaObjectExtensionField {
|
|
174
|
+
assert.ok(schema);
|
|
175
|
+
if ('$ref' in schema && typeof schema.$ref === 'string') {
|
|
176
|
+
return resolveSchema(
|
|
177
|
+
doc,
|
|
178
|
+
doc.components?.schemas?.[schema.$ref.replace('#/components/schemas/', '')],
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return inlineSchema(schema);
|
|
182
|
+
}
|
|
183
|
+
|
|
157
184
|
function requestSchema(
|
|
158
185
|
doc: OpenAPIObject,
|
|
159
186
|
operation: OperationObject,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SchemaObject } from 'openapi3-ts/oas31';
|
|
2
|
-
import type { IPassport } from 'vona-module-a-user';
|
|
2
|
+
import type { IAuthUserProfile, IPassport } from 'vona-module-a-user';
|
|
3
3
|
|
|
4
4
|
import assert from 'node:assert';
|
|
5
5
|
import { describe, it, mock } from 'node:test';
|
|
@@ -12,6 +12,7 @@ function createPassport(): IPassport {
|
|
|
12
12
|
user: {
|
|
13
13
|
id: 'passport-response-user',
|
|
14
14
|
name: 'Passport response fixture',
|
|
15
|
+
displayName: 'Passport display name',
|
|
15
16
|
activated: true,
|
|
16
17
|
accountStatus: 'active',
|
|
17
18
|
instanceId: 'untrusted-user-instance',
|
|
@@ -38,12 +39,26 @@ describe('passport.test.ts', { concurrency: false }, () => {
|
|
|
38
39
|
assert.ok(result);
|
|
39
40
|
assert.equal(result.instanceId, instanceId);
|
|
40
41
|
assert.equal(result.user.id, passport.user!.id);
|
|
42
|
+
assert.equal(result.user.name, passport.user!.name);
|
|
43
|
+
assert.equal(result.user.displayName, passport.user!.displayName);
|
|
41
44
|
assert.equal(result.auth, passport.auth);
|
|
42
45
|
assert.equal(result.roles, passport.roles);
|
|
43
46
|
assert.equal('instanceId' in result.user, false);
|
|
44
47
|
});
|
|
45
48
|
});
|
|
46
49
|
|
|
50
|
+
it('current leaves an absent displayName unset instead of falling back to the username', async () => {
|
|
51
|
+
await app.bean.executor.mockCtx(async () => {
|
|
52
|
+
const passport = createPassport();
|
|
53
|
+
delete passport.user!.displayName;
|
|
54
|
+
app.ctx.state.passport = passport;
|
|
55
|
+
const result = await app.bean._getBean(ControllerPassport).current();
|
|
56
|
+
assert.ok(result);
|
|
57
|
+
assert.equal(result.user.name, passport.user!.name);
|
|
58
|
+
assert.equal(result.user.displayName, undefined);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
47
62
|
it('current returns explicit null when the request has no instance', async () => {
|
|
48
63
|
await app.bean.executor.mockCtx(
|
|
49
64
|
async () => {
|
|
@@ -98,6 +113,8 @@ describe('passport.test.ts', { concurrency: false }, () => {
|
|
|
98
113
|
assert.equal(response.passport.instanceId, app.ctx.instance?.id ?? null);
|
|
99
114
|
assert.ok(Object.hasOwn(response.passport, 'instanceId'));
|
|
100
115
|
assert.equal(response.passport.user.id, 'passport-response-user');
|
|
116
|
+
assert.equal(response.passport.user.displayName, 'Passport display name');
|
|
117
|
+
assert.equal(response.passport.user.name, 'Passport response fixture');
|
|
101
118
|
assert.equal(response.jwt, jwt);
|
|
102
119
|
}
|
|
103
120
|
assert.equal(authenticate.mock.callCount(), 4);
|
|
@@ -112,6 +129,144 @@ describe('passport.test.ts', { concurrency: false }, () => {
|
|
|
112
129
|
}
|
|
113
130
|
});
|
|
114
131
|
|
|
132
|
+
it('userOfProfile maps only an actual trimmed provider displayName without name fallbacks', async () => {
|
|
133
|
+
const profile: IAuthUserProfile = {
|
|
134
|
+
id: 'display-name-profile',
|
|
135
|
+
username: 'provider-username',
|
|
136
|
+
name: { givenName: 'Given', familyName: 'Family' },
|
|
137
|
+
emails: [{ value: 'profile@example.test' }],
|
|
138
|
+
photos: [{ value: 'https://example.test/avatar.png' }],
|
|
139
|
+
};
|
|
140
|
+
for (const displayName of [undefined, '', ' \t ', ' Provider display name ']) {
|
|
141
|
+
await app.bean.executor.mockCtx(async () => {
|
|
142
|
+
const user = await app.scope('home-user').service.userAdapter.userOfProfile({
|
|
143
|
+
...profile,
|
|
144
|
+
...(displayName === undefined ? {} : { displayName }),
|
|
145
|
+
});
|
|
146
|
+
assert.equal(user.name, profile.username);
|
|
147
|
+
assert.equal(user.displayName, displayName?.trim() || undefined);
|
|
148
|
+
assert.equal(user.email, profile.emails![0].value);
|
|
149
|
+
assert.equal(user.avatar, profile.photos![0].value);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('OAuth creation maps displayName but existing login and linking preserve account profile fields', async () => {
|
|
155
|
+
const suffix = crypto.randomUUID();
|
|
156
|
+
const oauthName = `display-oauth-${suffix}`;
|
|
157
|
+
const linkedName = `display-linked-${suffix}`;
|
|
158
|
+
const profileId = `display-profile-${suffix}`;
|
|
159
|
+
const linkedProfileId = `display-link-${suffix}`;
|
|
160
|
+
const profile: IAuthUserProfile = {
|
|
161
|
+
id: profileId,
|
|
162
|
+
username: oauthName,
|
|
163
|
+
displayName: ' Initial provider display ',
|
|
164
|
+
confirmed: true,
|
|
165
|
+
};
|
|
166
|
+
let oauthUserId: string | undefined;
|
|
167
|
+
let linkedUserId: string | undefined;
|
|
168
|
+
try {
|
|
169
|
+
await app.bean.executor.mockCtx(async () => {
|
|
170
|
+
const provider = await app.bean.authProvider.get({
|
|
171
|
+
providerName: 'auth-oauth:oauth',
|
|
172
|
+
clientName: 'github',
|
|
173
|
+
});
|
|
174
|
+
const passport = await app
|
|
175
|
+
.scope('a-auth')
|
|
176
|
+
.service.auth.issuePassport(profile, provider, {});
|
|
177
|
+
assert.ok(passport.user);
|
|
178
|
+
oauthUserId = passport.user.id.toString();
|
|
179
|
+
assert.equal(passport.user.name, oauthName);
|
|
180
|
+
assert.equal(passport.user.displayName, 'Initial provider display');
|
|
181
|
+
await app.bean.user.updateById(oauthUserId, { displayName: 'Account-owned display' });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await app.bean.executor.mockCtx(async () => {
|
|
185
|
+
const provider = await app.bean.authProvider.get({
|
|
186
|
+
providerName: 'auth-oauth:oauth',
|
|
187
|
+
clientName: 'github',
|
|
188
|
+
});
|
|
189
|
+
const passport = await app.scope('a-auth').service.auth.issuePassport(
|
|
190
|
+
{ ...profile, username: `${oauthName}-changed`, displayName: 'Changed provider display' },
|
|
191
|
+
provider,
|
|
192
|
+
{},
|
|
193
|
+
{
|
|
194
|
+
intention: 'login',
|
|
195
|
+
authProviderId: provider.id,
|
|
196
|
+
instanceName: app.ctx.instanceName!,
|
|
197
|
+
locale: app.ctx.locale,
|
|
198
|
+
tz: app.ctx.tz,
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
assert.equal(passport.user?.id.toString(), oauthUserId);
|
|
202
|
+
assert.equal(passport.user?.name, oauthName);
|
|
203
|
+
assert.equal(passport.user?.displayName, 'Account-owned display');
|
|
204
|
+
const persisted = await app.bean.user.findOneById(oauthUserId!);
|
|
205
|
+
assert.equal(persisted?.displayName, 'Account-owned display');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await app.bean.executor.mockCtx(async () => {
|
|
209
|
+
const user = await app.bean.user.register({ name: linkedName }, true);
|
|
210
|
+
linkedUserId = user.id.toString();
|
|
211
|
+
});
|
|
212
|
+
await app.bean.executor.mockCtx(async () => {
|
|
213
|
+
const user = await app.bean.user.findOneById(linkedUserId!);
|
|
214
|
+
assert.ok(user);
|
|
215
|
+
await app.bean.passport.setCurrent({ user });
|
|
216
|
+
try {
|
|
217
|
+
const provider = await app.bean.authProvider.get({
|
|
218
|
+
providerName: 'auth-oauth:oauth',
|
|
219
|
+
clientName: 'github',
|
|
220
|
+
});
|
|
221
|
+
const passport = await app.scope('a-auth').service.auth.issuePassport(
|
|
222
|
+
{ ...profile, id: linkedProfileId },
|
|
223
|
+
provider,
|
|
224
|
+
{},
|
|
225
|
+
{
|
|
226
|
+
intention: 'associate',
|
|
227
|
+
authProviderId: provider.id,
|
|
228
|
+
instanceName: app.ctx.instanceName!,
|
|
229
|
+
locale: app.ctx.locale,
|
|
230
|
+
tz: app.ctx.tz,
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
assert.equal(passport.user?.id.toString(), linkedUserId);
|
|
234
|
+
assert.equal(passport.user?.name, linkedName);
|
|
235
|
+
assert.equal(passport.user?.displayName, undefined);
|
|
236
|
+
const persisted = await app.bean.user.findOneById(linkedUserId!);
|
|
237
|
+
assert.equal(persisted?.name, linkedName);
|
|
238
|
+
assert.equal(persisted?.displayName, undefined);
|
|
239
|
+
const auth = await app.scope('a-auth').model.auth.get({
|
|
240
|
+
authProviderId: provider.id,
|
|
241
|
+
profileId: linkedProfileId,
|
|
242
|
+
});
|
|
243
|
+
assert.equal(auth?.userId.toString(), linkedUserId);
|
|
244
|
+
} finally {
|
|
245
|
+
await app.bean.passport.setCurrent(undefined);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
} finally {
|
|
249
|
+
await app.bean.executor.mockCtx(async () => {
|
|
250
|
+
const provider = await app.bean.authProvider.get({
|
|
251
|
+
providerName: 'auth-oauth:oauth',
|
|
252
|
+
clientName: 'github',
|
|
253
|
+
});
|
|
254
|
+
for (const id of [linkedProfileId, profileId]) {
|
|
255
|
+
await app
|
|
256
|
+
.scope('a-auth')
|
|
257
|
+
.model.auth.delete({ authProviderId: provider.id, profileId: id });
|
|
258
|
+
}
|
|
259
|
+
for (const name of [linkedName, oauthName]) {
|
|
260
|
+
const user = await app.bean.user.findOneByName(name);
|
|
261
|
+
if (user) {
|
|
262
|
+
await app.scope('home-user').model.roleUser.delete({ userId: user.id });
|
|
263
|
+
await app.bean.user.removeById(user.id);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
115
270
|
it('OpenAPI exposes a required nullable table identity on current and every Passport/JWT response', async () => {
|
|
116
271
|
await app.bean.executor.mockCtx(async () => {
|
|
117
272
|
const actions = [
|
|
@@ -148,6 +303,9 @@ describe('passport.test.ts', { concurrency: false }, () => {
|
|
|
148
303
|
const instanceId = resolve(passport.properties?.instanceId as SchemaObject);
|
|
149
304
|
const user = resolve(passport.properties?.user as SchemaObject);
|
|
150
305
|
const userId = resolve(user.properties?.id as SchemaObject);
|
|
306
|
+
const displayName = resolve(user.properties?.displayName as SchemaObject);
|
|
307
|
+
assert.deepEqual(displayName.type, ['string', 'null']);
|
|
308
|
+
assert.equal(user.required?.includes('displayName') ?? false, false);
|
|
151
309
|
const types = (schema: SchemaObject): string[] => {
|
|
152
310
|
if (schema.anyOf)
|
|
153
311
|
return schema.anyOf.flatMap(item => types(resolve(item as SchemaObject)));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vona-suite-a-cabloy",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.70",
|
|
4
4
|
"gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
|
|
5
5
|
"description": "",
|
|
6
6
|
"author": "",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"vona-module-a-cabloy": "workspace:^",
|
|
11
11
|
"vona-module-a-layoutprofile": "^5.0.2",
|
|
12
12
|
"vona-module-a-markdown": "^5.0.3",
|
|
13
|
-
"vona-module-a-rbac": "^5.0.
|
|
13
|
+
"vona-module-a-rbac": "^5.0.18",
|
|
14
14
|
"vona-module-a-socket": "workspace:^",
|
|
15
15
|
"vona-module-a-ssr": "workspace:^",
|
|
16
16
|
"vona-module-a-ssrhmr": "workspace:^"
|
|
@@ -11,7 +11,7 @@ import type { ContextRoute, IRecordResourceNameToRoutePathItem } from 'vona-modu
|
|
|
11
11
|
import { appResource, BeanBase, beanFullNameFromOnionName } from 'vona';
|
|
12
12
|
import { Bean } from 'vona-module-a-bean';
|
|
13
13
|
import { Caching } from 'vona-module-a-caching';
|
|
14
|
-
import { clearRbacDecision,
|
|
14
|
+
import { clearRbacDecision, getRbacDecision, SymbolRbacDecision } from 'vona-module-a-rbac';
|
|
15
15
|
import {
|
|
16
16
|
composeGuards,
|
|
17
17
|
getCacheControllerRoutes,
|
|
@@ -107,6 +107,23 @@ export class BeanPermission extends BeanBase {
|
|
|
107
107
|
return typeof permission === 'boolean' ? permission : permission.allowed;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Checks current guards without permission events, result caches or matcher projection.
|
|
112
|
+
* A true result does not authorize individual records or prepare data scope/owner values.
|
|
113
|
+
* The target action's RBAC decision is not retained; scoped operations must authorize separately.
|
|
114
|
+
*/
|
|
115
|
+
public async checkPermissionActionGuards(
|
|
116
|
+
resource: keyof IResourceRecord,
|
|
117
|
+
actionKey: string,
|
|
118
|
+
): Promise<boolean> {
|
|
119
|
+
if (!Object.hasOwn(recordResourceNameToRoutePath, resource)) return false;
|
|
120
|
+
const routePathInfo = recordResourceNameToRoutePath[resource];
|
|
121
|
+
if (!routePathInfo || !appResource.getBean(routePathInfo.controller)) return false;
|
|
122
|
+
const route = this._getControllerActionRoute(resource, actionKey);
|
|
123
|
+
if (!route?.route?.meta || !this._matchPassportMeta(route.route.meta)) return false;
|
|
124
|
+
return await this._evaluatePermissionAction(route, true);
|
|
125
|
+
}
|
|
126
|
+
|
|
110
127
|
public async retrievePermissionAction(
|
|
111
128
|
resource: keyof IResourceRecord,
|
|
112
129
|
actionKey: string,
|
|
@@ -201,12 +218,17 @@ export class BeanPermission extends BeanBase {
|
|
|
201
218
|
return true;
|
|
202
219
|
}
|
|
203
220
|
|
|
204
|
-
private async _evaluatePermissionAction(route: ContextRoute): Promise<
|
|
221
|
+
private async _evaluatePermissionAction(route: ContextRoute, guardsOnly: true): Promise<boolean>;
|
|
222
|
+
private async _evaluatePermissionAction(route: ContextRoute): Promise<IOpenapiPermissionAction>;
|
|
223
|
+
private async _evaluatePermissionAction(
|
|
224
|
+
route: ContextRoute,
|
|
225
|
+
guardsOnly = false,
|
|
226
|
+
): Promise<IOpenapiPermissionAction> {
|
|
205
227
|
const ctx = this.ctx as any;
|
|
206
|
-
const
|
|
228
|
+
const routeDescriptorPrevious = Object.getOwnPropertyDescriptor(ctx, 'route');
|
|
229
|
+
const innerAccessDescriptorPrevious = Object.getOwnPropertyDescriptor(ctx, 'innerAccess');
|
|
207
230
|
const innerAccessPrevious = ctx.innerAccess;
|
|
208
|
-
const
|
|
209
|
-
const rbacDecisionPrevious = ctx[SymbolRbacDecision];
|
|
231
|
+
const rbacDecisionDescriptorPrevious = Object.getOwnPropertyDescriptor(ctx, SymbolRbacDecision);
|
|
210
232
|
try {
|
|
211
233
|
ctx.route = route;
|
|
212
234
|
ctx.innerAccess = false;
|
|
@@ -214,19 +236,43 @@ export class BeanPermission extends BeanBase {
|
|
|
214
236
|
const result = await composeGuards(this.app, route)(this.ctx);
|
|
215
237
|
if (result === false) return false;
|
|
216
238
|
if (!route.route.meta?.[BeanFullNameGuardRbac]) return true;
|
|
239
|
+
if (guardsOnly) {
|
|
240
|
+
const action = this.bean.rbacCatalog.getAction(route);
|
|
241
|
+
const decision = getRbacDecision(this.ctx);
|
|
242
|
+
return Boolean(
|
|
243
|
+
action &&
|
|
244
|
+
Object.hasOwn(ctx, SymbolRbacDecision) &&
|
|
245
|
+
decision?.allowed === true &&
|
|
246
|
+
decision.actionKey === action.actionKey &&
|
|
247
|
+
decision.action?.actionKey === action.actionKey &&
|
|
248
|
+
decision.action.actionInheritKey === action.actionInheritKey &&
|
|
249
|
+
decision.action.controllerBeanFullName === action.controllerBeanFullName &&
|
|
250
|
+
decision.action.action === action.action,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
217
253
|
const rbacScopeCurrent = await this.bean.rbacScope.current();
|
|
218
254
|
return rbacScopeCurrent.permissionProjection();
|
|
219
255
|
} catch (err: any) {
|
|
220
256
|
if ([401, 403].includes(err?.code)) return false;
|
|
221
257
|
throw err;
|
|
222
258
|
} finally {
|
|
223
|
-
if (
|
|
224
|
-
|
|
259
|
+
if (rbacDecisionDescriptorPrevious) {
|
|
260
|
+
Object.defineProperty(ctx, SymbolRbacDecision, rbacDecisionDescriptorPrevious);
|
|
225
261
|
} else {
|
|
226
262
|
clearRbacDecision(ctx);
|
|
227
263
|
}
|
|
228
|
-
|
|
264
|
+
if (routeDescriptorPrevious) {
|
|
265
|
+
Object.defineProperty(ctx, 'route', routeDescriptorPrevious);
|
|
266
|
+
} else {
|
|
267
|
+
delete ctx.route;
|
|
268
|
+
}
|
|
269
|
+
// Vona's inherited innerAccess accessor owns its value in a context symbol.
|
|
229
270
|
ctx.innerAccess = innerAccessPrevious;
|
|
271
|
+
if (innerAccessDescriptorPrevious) {
|
|
272
|
+
Object.defineProperty(ctx, 'innerAccess', innerAccessDescriptorPrevious);
|
|
273
|
+
} else {
|
|
274
|
+
delete ctx.innerAccess;
|
|
275
|
+
}
|
|
230
276
|
}
|
|
231
277
|
}
|
|
232
278
|
}
|