@memberjunction/server 6.1.0-edge.5 → 6.1.0-edge.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/dist/.tsbuildinfo +1 -1
- package/dist/agentSessions/SessionJanitor.js +2 -2
- package/dist/agentSessions/SessionJanitor.js.map +1 -1
- package/dist/auth/exampleNewUserSubClass.d.ts.map +1 -1
- package/dist/auth/exampleNewUserSubClass.js +9 -4
- package/dist/auth/exampleNewUserSubClass.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +2 -8
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/auth/newUsers.d.ts.map +1 -1
- package/dist/auth/newUsers.js +4 -12
- package/dist/auth/newUsers.js.map +1 -1
- package/dist/auth/principals.d.ts +92 -0
- package/dist/auth/principals.d.ts.map +1 -0
- package/dist/auth/principals.js +193 -0
- package/dist/auth/principals.js.map +1 -0
- package/dist/config.d.ts +70 -12
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +32 -4
- package/dist/config.js.map +1 -1
- package/dist/generated/graphql-schemas/__mj.d.ts +5347 -5291
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +21098 -20793
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +35 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +128 -2
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -2
- package/dist/index.js.map +1 -1
- package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -1
- package/dist/realtimeWidget/WidgetSessionService.js +2 -12
- package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
- package/dist/realtimeWidget/visitorIdentity.d.ts.map +1 -1
- package/dist/realtimeWidget/visitorIdentity.js +20 -5
- package/dist/realtimeWidget/visitorIdentity.js.map +1 -1
- package/dist/resolvers/FileCategoryResolver.d.ts.map +1 -1
- package/dist/resolvers/FileCategoryResolver.js +1 -2
- package/dist/resolvers/FileCategoryResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +2 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts +13 -8
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +45 -8
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.js +11 -2
- package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
- package/dist/resolvers/UserViewResolver.d.ts.map +1 -1
- package/dist/resolvers/UserViewResolver.js +8 -4
- package/dist/resolvers/UserViewResolver.js.map +1 -1
- package/dist/rest/EntityCRUDHandler.d.ts +2 -1
- package/dist/rest/EntityCRUDHandler.d.ts.map +1 -1
- package/dist/rest/EntityCRUDHandler.js +3 -18
- package/dist/rest/EntityCRUDHandler.js.map +1 -1
- package/dist/rest/RESTEndpointHandler.d.ts +2 -1
- package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
- package/dist/rest/RESTEndpointHandler.js +3 -16
- package/dist/rest/RESTEndpointHandler.js.map +1 -1
- package/dist/telephony/TeamsMeetingsRouter.js +18 -2
- package/dist/telephony/TeamsMeetingsRouter.js.map +1 -1
- package/package.json +92 -91
- package/src/__tests__/EntityCRUDHandler.test.ts +18 -5
- package/src/__tests__/RemoteBrowserAudioStream.test.ts +81 -2
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +127 -3
- package/src/__tests__/UserViewResolver.updateWhereClause.test.ts +114 -0
- package/src/__tests__/WidgetSessionService.test.ts +4 -1
- package/src/__tests__/newUserRolesDefault.test.ts +81 -0
- package/src/__tests__/newUsers.test.ts +36 -14
- package/src/__tests__/principals.callSites.test.ts +112 -0
- package/src/__tests__/principals.logging.test.ts +160 -0
- package/src/__tests__/principals.test.ts +305 -0
- package/src/__tests__/resolverBase.refusalMessage.test.ts +88 -0
- package/src/__tests__/visitorIdentity.resolve.test.ts +61 -0
- package/src/agentSessions/SessionJanitor.ts +2 -2
- package/src/auth/exampleNewUserSubClass.ts +9 -6
- package/src/auth/magicLink/MagicLinkService.ts +2 -8
- package/src/auth/newUsers.ts +7 -15
- package/src/auth/principals.ts +257 -0
- package/src/config.ts +32 -4
- package/src/generated/graphql-schemas/__mj.ts +13937 -13726
- package/src/generic/ResolverBase.ts +157 -2
- package/src/index.ts +17 -2
- package/src/realtimeWidget/WidgetSessionService.ts +2 -14
- package/src/realtimeWidget/visitorIdentity.ts +21 -6
- package/src/resolvers/FileCategoryResolver.ts +2 -3
- package/src/resolvers/IntegrationDiscoveryResolver.ts +2 -0
- package/src/resolvers/QuerySystemUserResolver.ts +1 -1
- package/src/resolvers/RemoteBrowserActionResolver.ts +47 -8
- package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
- package/src/resolvers/UserViewResolver.ts +8 -4
- package/src/rest/EntityCRUDHandler.ts +3 -19
- package/src/rest/RESTEndpointHandler.ts +3 -17
- package/src/telephony/TeamsMeetingsRouter.ts +21 -2
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* There is ONE resolver for the three config-named context users — issue #4209, second half.
|
|
3
|
+
*
|
|
4
|
+
* The bug was not that a lookup used the wrong column; it was that the lookup was written out by
|
|
5
|
+
* hand at every consumer. Six copies existed across MJServer in three mutually inconsistent
|
|
6
|
+
* variants, and two of them could not resolve the value MJ itself ships. `auth/principals.ts`
|
|
7
|
+
* exists to make that impossible, and this test is what keeps it that way: any file that reads
|
|
8
|
+
* one of the three settings must go through the ladder rather than growing a fourth variant.
|
|
9
|
+
*
|
|
10
|
+
* Source-scanning rather than behavioural because the failure mode is *a new call site*, which no
|
|
11
|
+
* behavioural test of the existing call sites can see. Same technique as
|
|
12
|
+
* `AdhocQueryResolver.scopeGuard.test.ts` and `serverExtensionReservedRoots.test.ts`.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect } from 'vitest';
|
|
15
|
+
import { readdirSync, readFileSync, statSync } from 'fs';
|
|
16
|
+
import { dirname, join, relative, sep } from 'path';
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
|
|
19
|
+
const SRC_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
|
|
21
|
+
/** The three settings that name a principal in config. `config.ts` DEFINES them; it does not resolve them. */
|
|
22
|
+
const CONTEXT_USER_SETTINGS = [
|
|
23
|
+
'contextUserForNewUserCreation',
|
|
24
|
+
'contextUserForProvisioning',
|
|
25
|
+
'contextUserForLookup',
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Files that legitimately mention a setting without resolving it, as paths RELATIVE TO `src/`.
|
|
30
|
+
* Deliberately not basenames: exempting every file called `config.ts` anywhere under `src/` would
|
|
31
|
+
* let a future `realtimeWidget/config.ts` hand-roll a lookup and still pass this guard.
|
|
32
|
+
*/
|
|
33
|
+
const NOT_CONSUMERS = ['config.ts', 'auth/principals.ts'];
|
|
34
|
+
|
|
35
|
+
/** `src/`-relative, forward-slashed, so the assertions below read the same on every platform. */
|
|
36
|
+
function relativeToSrc(absolutePath: string): string {
|
|
37
|
+
return relative(SRC_ROOT, absolutePath).split(sep).join('/');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function everyTsFileUnder(dir: string): string[] {
|
|
41
|
+
return readdirSync(dir).flatMap((name) => {
|
|
42
|
+
const full = join(dir, name);
|
|
43
|
+
if (statSync(full).isDirectory()) {
|
|
44
|
+
return name === '__tests__' || name === 'generated' ? [] : everyTsFileUnder(full);
|
|
45
|
+
}
|
|
46
|
+
return name.endsWith('.ts') && !name.endsWith('.d.ts') ? [full] : [];
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Files that READ one of the settings — i.e. resolve it to a user, rather than declaring it. */
|
|
51
|
+
function consumersOfAContextUserSetting(): { path: string; source: string }[] {
|
|
52
|
+
return everyTsFileUnder(SRC_ROOT)
|
|
53
|
+
.filter((p) => !NOT_CONSUMERS.includes(relativeToSrc(p)))
|
|
54
|
+
.map((path) => ({ path: relativeToSrc(path), source: readFileSync(path, 'utf8') }))
|
|
55
|
+
.filter(({ source }) => CONTEXT_USER_SETTINGS.some((s) => source.includes(`.${s}`)));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('context-user resolution has exactly one implementation', () => {
|
|
59
|
+
it('finds the known consumers, so the scan itself is not silently matching nothing', () => {
|
|
60
|
+
const found = consumersOfAContextUserSetting().map((c) => c.path);
|
|
61
|
+
|
|
62
|
+
expect(found).toContain('auth/newUsers.ts');
|
|
63
|
+
expect(found).toContain('auth/magicLink/MagicLinkService.ts');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('routes every consumer through auth/principals.ts', () => {
|
|
67
|
+
const handRolled = consumersOfAContextUserSetting()
|
|
68
|
+
.filter(({ source }) => !source.includes('ResolveConfiguredPrincipal'))
|
|
69
|
+
.map(({ path }) => path);
|
|
70
|
+
|
|
71
|
+
expect(handRolled).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('leaves no consumer matching the setting against a single column by hand', () => {
|
|
75
|
+
// The two shapes that produced #4209: `UserByName(candidate)` and `u.Email === candidate`.
|
|
76
|
+
//
|
|
77
|
+
// Scoped to the statement that READS the setting, not the whole file: MagicLinkService
|
|
78
|
+
// also resolves the INVITEE by Email (`provisionUser`, ~270 lines away), which is tier-1
|
|
79
|
+
// identity and correct. A file-wide grep would condemn it, and a guard that cries wolf
|
|
80
|
+
// gets deleted.
|
|
81
|
+
const COLUMN_MATCH = /UserByName\s*\(|\.(Email|Name)\b[^\n]*===/;
|
|
82
|
+
const handRolled = consumersOfAContextUserSetting()
|
|
83
|
+
.filter(({ source }) =>
|
|
84
|
+
source.split('\n').some(
|
|
85
|
+
(line) => CONTEXT_USER_SETTINGS.some((s) => line.includes(`.${s}`)) && COLUMN_MATCH.test(line),
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
.map(({ path }) => path);
|
|
89
|
+
|
|
90
|
+
expect(handRolled).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('the ladder is reachable by the integrators the example is written for', () => {
|
|
95
|
+
// `exampleNewUserSubClass.ts` exists to be COPIED into a deployment's own package — its header
|
|
96
|
+
// says so, and it tells you to uncomment `@RegisterClass` in your code. Now that it resolves
|
|
97
|
+
// through `ResolveConfiguredPrincipal`, an integrator who copies it needs to be able to import
|
|
98
|
+
// that. `package.json` publishes only `"."`, so the package index is the whole public surface,
|
|
99
|
+
// and this guard cannot see downstream code — it can only make sure the door exists.
|
|
100
|
+
it('exports ResolveConfiguredPrincipal from the package index', () => {
|
|
101
|
+
const index = readFileSync(join(SRC_ROOT, 'index.ts'), 'utf8');
|
|
102
|
+
|
|
103
|
+
expect(index).toMatch(/ResolveConfiguredPrincipal/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('exports the types a caller needs to hold the result', () => {
|
|
107
|
+
const index = readFileSync(join(SRC_ROOT, 'index.ts'), 'utf8');
|
|
108
|
+
|
|
109
|
+
expect(index).toMatch(/ResolvablePrincipal/);
|
|
110
|
+
expect(index).toMatch(/PrincipalResolution/);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log policy for context-user resolution — the second half of issue #4209.
|
|
3
|
+
*
|
|
4
|
+
* The defect had two costs. One was attribution (covered by principals.test.ts). The other was
|
|
5
|
+
* volume: because the shipped default could never resolve, EVERY magic-link redeem and EVERY
|
|
6
|
+
* auto-provisioned user emitted an error-level line, on every host that took the default. Real
|
|
7
|
+
* errors were buried under it.
|
|
8
|
+
*
|
|
9
|
+
* Resolution is a pure function; this file covers the impure shell around it — reading the user
|
|
10
|
+
* cache and deciding how often a misconfiguration is worth saying out loud.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
13
|
+
|
|
14
|
+
const { mockLogError, mockUsers, SYSTEM_USER_ID } = vi.hoisted(() => ({
|
|
15
|
+
mockLogError: vi.fn(),
|
|
16
|
+
mockUsers: [] as Array<Record<string, unknown>>,
|
|
17
|
+
SYSTEM_USER_ID: 'ECAFCCEC-6A37-EF11-86D4-000D3A4E707E',
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('@memberjunction/core', () => ({
|
|
21
|
+
LogError: mockLogError,
|
|
22
|
+
UserInfo: class {},
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock('@memberjunction/generic-database-provider', () => ({
|
|
26
|
+
UserCache: {
|
|
27
|
+
get Users() {
|
|
28
|
+
return mockUsers;
|
|
29
|
+
},
|
|
30
|
+
Instance: {
|
|
31
|
+
get Users() {
|
|
32
|
+
return mockUsers;
|
|
33
|
+
},
|
|
34
|
+
get SYSTEM_USER_ID() {
|
|
35
|
+
return SYSTEM_USER_ID;
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
/** Stock seed: the system user MJ's own default is aiming at, plus a human Owner. */
|
|
42
|
+
const SYSTEM = { ID: SYSTEM_USER_ID, Name: 'System', Email: 'not.set@nowhere.com', Type: 'Owner ', IsActive: true };
|
|
43
|
+
const HOST_ADMIN = { ID: 'AAAA1111-0000-0000-0000-000000000001', Name: 'jane.admin@acme.com', Email: 'jane.admin@acme.com', Type: 'Owner', IsActive: true };
|
|
44
|
+
|
|
45
|
+
/** Fresh module per test: the de-duplication state is module-scoped by design. */
|
|
46
|
+
async function loadSubject() {
|
|
47
|
+
vi.resetModules();
|
|
48
|
+
return import('../auth/principals.js');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.clearAllMocks();
|
|
53
|
+
mockUsers.length = 0;
|
|
54
|
+
mockUsers.push(SYSTEM, HOST_ADMIN);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('ResolveConfiguredPrincipal', () => {
|
|
58
|
+
it('resolves MJ\'s own shipped default without logging anything', async () => {
|
|
59
|
+
const { ResolveConfiguredPrincipal } = await loadSubject();
|
|
60
|
+
|
|
61
|
+
const user = ResolveConfiguredPrincipal('not.set@nowhere.com', 'MagicLink');
|
|
62
|
+
|
|
63
|
+
expect(user).toBe(SYSTEM);
|
|
64
|
+
expect(mockLogError).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('logs an unresolvable candidate ONCE however many times it is asked for', async () => {
|
|
68
|
+
const { ResolveConfiguredPrincipal } = await loadSubject();
|
|
69
|
+
|
|
70
|
+
for (let i = 0; i < 25; i++) {
|
|
71
|
+
ResolveConfiguredPrincipal('ghost@nowhere.example', 'MagicLink');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
expect(mockLogError).toHaveBeenCalledTimes(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('names both the purpose and the candidate so the operator can find the setting', async () => {
|
|
78
|
+
const { ResolveConfiguredPrincipal } = await loadSubject();
|
|
79
|
+
|
|
80
|
+
ResolveConfiguredPrincipal('ghost@nowhere.example', 'MagicLink');
|
|
81
|
+
|
|
82
|
+
const logged = String(mockLogError.mock.calls[0][0]);
|
|
83
|
+
expect(logged).toContain('MagicLink');
|
|
84
|
+
expect(logged).toContain('ghost@nowhere.example');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('still reports a DIFFERENT misconfiguration rather than swallowing it', async () => {
|
|
88
|
+
const { ResolveConfiguredPrincipal } = await loadSubject();
|
|
89
|
+
|
|
90
|
+
ResolveConfiguredPrincipal('ghost@nowhere.example', 'MagicLink');
|
|
91
|
+
ResolveConfiguredPrincipal('other@nowhere.example', 'MagicLink');
|
|
92
|
+
|
|
93
|
+
expect(mockLogError).toHaveBeenCalledTimes(2);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('reports the same candidate separately for a different purpose', async () => {
|
|
97
|
+
const { ResolveConfiguredPrincipal } = await loadSubject();
|
|
98
|
+
|
|
99
|
+
ResolveConfiguredPrincipal('ghost@nowhere.example', 'MagicLink');
|
|
100
|
+
ResolveConfiguredPrincipal('ghost@nowhere.example', 'Widget');
|
|
101
|
+
|
|
102
|
+
expect(mockLogError).toHaveBeenCalledTimes(2);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('does not treat an unset candidate as a misconfiguration', async () => {
|
|
106
|
+
const { ResolveConfiguredPrincipal } = await loadSubject();
|
|
107
|
+
|
|
108
|
+
const user = ResolveConfiguredPrincipal(undefined, 'NewUser');
|
|
109
|
+
|
|
110
|
+
expect(user).toBe(SYSTEM);
|
|
111
|
+
expect(mockLogError).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('returns null and says so when the deployment offers no principal at all', async () => {
|
|
115
|
+
mockUsers.length = 0;
|
|
116
|
+
const { ResolveConfiguredPrincipal } = await loadSubject();
|
|
117
|
+
|
|
118
|
+
const user = ResolveConfiguredPrincipal('ghost@nowhere.example', 'MagicLink');
|
|
119
|
+
|
|
120
|
+
expect(user).toBeNull();
|
|
121
|
+
expect(mockLogError).toHaveBeenCalledTimes(1);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('the misconfiguration report stays bounded WITHOUT regressing to per-request logging', () => {
|
|
126
|
+
it('publishes its bound, so this test cannot silently pass against a different one', async () => {
|
|
127
|
+
const { MAX_REPORTED_MISCONFIGURATIONS } = await loadSubject();
|
|
128
|
+
|
|
129
|
+
expect(typeof MAX_REPORTED_MISCONFIGURATIONS).toBe('number');
|
|
130
|
+
expect(MAX_REPORTED_MISCONFIGURATIONS).toBeGreaterThan(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('still says a NEW misconfiguration exactly once after the bound is reached', async () => {
|
|
134
|
+
// The bound exists so a caller passing a per-request candidate cannot grow the tracker
|
|
135
|
+
// forever. But whatever it does at the limit must not be "log every time" — that is
|
|
136
|
+
// precisely the per-request error line issue #4209 was filed about, reintroduced for the
|
|
137
|
+
// one caller the bound was added for.
|
|
138
|
+
const { ResolveConfiguredPrincipal, MAX_REPORTED_MISCONFIGURATIONS } = await loadSubject();
|
|
139
|
+
expect(typeof MAX_REPORTED_MISCONFIGURATIONS).toBe('number'); // else the loop below is a no-op and this passes for free
|
|
140
|
+
for (let i = 0; i < MAX_REPORTED_MISCONFIGURATIONS + 5; i++) {
|
|
141
|
+
ResolveConfiguredPrincipal(`filler-${i}@nowhere.example`, 'MagicLink');
|
|
142
|
+
}
|
|
143
|
+
mockLogError.mockClear();
|
|
144
|
+
|
|
145
|
+
ResolveConfiguredPrincipal('late@nowhere.example', 'MagicLink');
|
|
146
|
+
ResolveConfiguredPrincipal('late@nowhere.example', 'MagicLink');
|
|
147
|
+
ResolveConfiguredPrincipal('late@nowhere.example', 'MagicLink');
|
|
148
|
+
|
|
149
|
+
expect(mockLogError).toHaveBeenCalledTimes(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('does not grow without bound', async () => {
|
|
153
|
+
const { ResolveConfiguredPrincipal, ReportedMisconfigurationCount, MAX_REPORTED_MISCONFIGURATIONS } = await loadSubject();
|
|
154
|
+
for (let i = 0; i < MAX_REPORTED_MISCONFIGURATIONS * 3; i++) {
|
|
155
|
+
ResolveConfiguredPrincipal(`filler-${i}@nowhere.example`, 'MagicLink');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
expect(ReportedMisconfigurationCount()).toBeLessThanOrEqual(MAX_REPORTED_MISCONFIGURATIONS);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolution of an operator-named context user — regression suite for issue #4209.
|
|
3
|
+
*
|
|
4
|
+
* MJServer has three settings that name a user in config (`contextUserForNewUserCreation`,
|
|
5
|
+
* `contextUserForProvisioning`, `contextUserForLookup`). All three were resolved through
|
|
6
|
+
* `UserCache.UserByName`, which matches `User.Name` — but the shipped default for the first is
|
|
7
|
+
* `'not.set@nowhere.com'`, an EMAIL. On a stock database the seeded system user is
|
|
8
|
+
* `Name='System'` / `Email='not.set@nowhere.com'`, so the default names the very user it was
|
|
9
|
+
* aiming at and can never reach it. Every magic-link redeem logged an error and provisioned
|
|
10
|
+
* under whichever user happened to sort first as an Owner.
|
|
11
|
+
*
|
|
12
|
+
* These tests drive the pure core of the resolver, so they need neither the `UserCache`
|
|
13
|
+
* singleton nor a database — the seam is `(candidate, users) -> resolution`.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect } from 'vitest';
|
|
16
|
+
import { resolvePrincipalFrom, type ResolvablePrincipal } from '../auth/principals.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The system user's ID, pinned by `UserCache` (`SYSTEM_USER_ID`) and seeded by every baseline.
|
|
20
|
+
* Spelled in the mixed case the migration writes to prove the match is case-insensitive.
|
|
21
|
+
*/
|
|
22
|
+
const SYSTEM_USER_ID = 'ECAFCCEC-6A37-EF11-86D4-000D3A4E707E';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The shipped default for `userHandling.contextUserForNewUserCreation` — see `src/config.ts`.
|
|
26
|
+
* It is email-shaped, which is the whole defect: it is matched against `Name`.
|
|
27
|
+
*/
|
|
28
|
+
const SHIPPED_DEFAULT = 'not.set@nowhere.com';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Rows exactly as seeded by `migrations/v5/B202607091514__v5.46.x__Baseline.sql`. `Type` is
|
|
32
|
+
* `NCHAR(15)`, so the seeded values carry trailing padding — the resolver must trim before
|
|
33
|
+
* comparing, and these fixtures keep the padding so that stays tested.
|
|
34
|
+
*/
|
|
35
|
+
const SYSTEM: ResolvablePrincipal = {
|
|
36
|
+
ID: SYSTEM_USER_ID,
|
|
37
|
+
Name: 'System',
|
|
38
|
+
Email: 'not.set@nowhere.com',
|
|
39
|
+
Type: 'Owner ',
|
|
40
|
+
IsActive: true,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const ANONYMOUS: ResolvablePrincipal = {
|
|
44
|
+
ID: '273910DF-28F1-45C1-A8F8-6E9AD8E5F008',
|
|
45
|
+
Name: 'Anonymous',
|
|
46
|
+
Email: 'anonymous@magic-link.local',
|
|
47
|
+
Type: 'User ',
|
|
48
|
+
IsActive: true,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** A human admin, as every real host has: created by MJ, so `Name` IS the email address. */
|
|
52
|
+
const HOST_ADMIN: ResolvablePrincipal = {
|
|
53
|
+
ID: 'AAAA1111-0000-0000-0000-000000000001',
|
|
54
|
+
Name: 'jane.admin@acme.com',
|
|
55
|
+
Email: 'jane.admin@acme.com',
|
|
56
|
+
Type: 'Owner',
|
|
57
|
+
IsActive: true,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** The stock seed plus one human admin — the shape of an ordinary deployment. */
|
|
61
|
+
const STOCK_HOST: ResolvablePrincipal[] = [SYSTEM, ANONYMOUS, HOST_ADMIN];
|
|
62
|
+
|
|
63
|
+
describe('resolvePrincipalFrom', () => {
|
|
64
|
+
describe('the shipped default (#4209)', () => {
|
|
65
|
+
it('resolves the email-shaped default to the seeded System user', () => {
|
|
66
|
+
const result = resolvePrincipalFrom(SHIPPED_DEFAULT, STOCK_HOST, SYSTEM_USER_ID);
|
|
67
|
+
|
|
68
|
+
expect(result.user).toBe(SYSTEM);
|
|
69
|
+
expect(result.reason).toBe('email');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('reports no warning when the default resolves, so a stock host logs nothing', () => {
|
|
73
|
+
const result = resolvePrincipalFrom(SHIPPED_DEFAULT, STOCK_HOST, SYSTEM_USER_ID);
|
|
74
|
+
|
|
75
|
+
expect(result.warning).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('attribution determinism', () => {
|
|
80
|
+
it('resolves identically however the user cache happens to be ordered', () => {
|
|
81
|
+
// `SELECT * FROM vwUsers` carries no ORDER BY and the cache is mutated in place at
|
|
82
|
+
// runtime, so row order differs between boots AND within a process. Attribution must
|
|
83
|
+
// not depend on it: this is what made CreatedByUserID incidental.
|
|
84
|
+
const ordered = resolvePrincipalFrom(SHIPPED_DEFAULT, STOCK_HOST, SYSTEM_USER_ID);
|
|
85
|
+
const reversed = resolvePrincipalFrom(SHIPPED_DEFAULT, [...STOCK_HOST].reverse(), SYSTEM_USER_ID);
|
|
86
|
+
|
|
87
|
+
expect(reversed.user).toBe(ordered.user);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('explicitly configured principals', () => {
|
|
92
|
+
it('keeps a Name match ahead of another user whose Email is that same string', () => {
|
|
93
|
+
// The compatibility guarantee: a host that resolves today must resolve to the SAME
|
|
94
|
+
// user after the Email fallback is added. Name is tried first for exactly this reason.
|
|
95
|
+
const namedSupport: ResolvablePrincipal = {
|
|
96
|
+
ID: 'BBBB2222-0000-0000-0000-000000000002',
|
|
97
|
+
Name: 'support@acme.com',
|
|
98
|
+
Email: 'someone.else@acme.com',
|
|
99
|
+
Type: 'User',
|
|
100
|
+
IsActive: true,
|
|
101
|
+
};
|
|
102
|
+
const emailSupport: ResolvablePrincipal = {
|
|
103
|
+
ID: 'CCCC3333-0000-0000-0000-000000000003',
|
|
104
|
+
Name: 'Bob Support',
|
|
105
|
+
Email: 'support@acme.com',
|
|
106
|
+
Type: 'User',
|
|
107
|
+
IsActive: true,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const result = resolvePrincipalFrom('support@acme.com', [emailSupport, namedSupport], SYSTEM_USER_ID);
|
|
111
|
+
|
|
112
|
+
expect(result.user).toBe(namedSupport);
|
|
113
|
+
expect(result.reason).toBe('name');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('duplicate matches resolve deterministically', () => {
|
|
118
|
+
// `User.Name` carries NO unique constraint. MJ's own rows do not collide — `NewUserBase`
|
|
119
|
+
// sets `Name = email` and `UQ_User_Email` makes Email unique, so MJ-created Names are
|
|
120
|
+
// unique BY CONSEQUENCE, not by rule — but a hand-created or externally-synced row can
|
|
121
|
+
// collide, and then `find()` returns whichever the cache happens to hold first. That is
|
|
122
|
+
// the same cache-order dependence the Owner rung is sorted to remove, so every rung
|
|
123
|
+
// resolves ties the same way: lowest ID.
|
|
124
|
+
const LOW: ResolvablePrincipal = { ID: '11111111-0000-0000-0000-000000000001', Name: 'shared.name', Email: 'low@acme.com', Type: 'User', IsActive: true };
|
|
125
|
+
const HIGH: ResolvablePrincipal = { ID: 'FFFFFFFF-0000-0000-0000-00000000000F', Name: 'shared.name', Email: 'high@acme.com', Type: 'User', IsActive: true };
|
|
126
|
+
|
|
127
|
+
it('picks the lowest-ID user when two users share the configured Name', () => {
|
|
128
|
+
const result = resolvePrincipalFrom('shared.name', [HIGH, LOW, SYSTEM], SYSTEM_USER_ID);
|
|
129
|
+
|
|
130
|
+
expect(result.user).toBe(LOW);
|
|
131
|
+
expect(result.reason).toBe('name');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('resolves a duplicated Name identically however the cache is ordered', () => {
|
|
135
|
+
const forward = resolvePrincipalFrom('shared.name', [HIGH, LOW, SYSTEM], SYSTEM_USER_ID);
|
|
136
|
+
const reversed = resolvePrincipalFrom('shared.name', [SYSTEM, LOW, HIGH], SYSTEM_USER_ID);
|
|
137
|
+
|
|
138
|
+
expect(reversed.user).toBe(forward.user);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('applies the same tiebreak to the Email rung', () => {
|
|
142
|
+
// `UQ_User_Email` should make this unreachable from the database, but `UserCache` is
|
|
143
|
+
// also written through `SetUsers` and mutated in place by `Users.push`, so the rung
|
|
144
|
+
// must not depend on the constraint holding in memory.
|
|
145
|
+
const lowEmail: ResolvablePrincipal = { ...LOW, Name: 'a.person', Email: 'shared@acme.com' };
|
|
146
|
+
const highEmail: ResolvablePrincipal = { ...HIGH, Name: 'b.person', Email: 'shared@acme.com' };
|
|
147
|
+
|
|
148
|
+
const result = resolvePrincipalFrom('shared@acme.com', [highEmail, lowEmail, SYSTEM], SYSTEM_USER_ID);
|
|
149
|
+
|
|
150
|
+
expect(result.user).toBe(lowEmail);
|
|
151
|
+
expect(result.reason).toBe('email');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('a DEACTIVATED configured candidate', () => {
|
|
156
|
+
/**
|
|
157
|
+
* The case an operator actually hits: the admin whose address sits in
|
|
158
|
+
* `contextUserForProvisioning` leaves, and their account is deactivated. Every other rung
|
|
159
|
+
* requires `IsActive`, so without this one the ladder would read "we never act as a
|
|
160
|
+
* disabled user, except the one the operator named" — and provisioning would keep running,
|
|
161
|
+
* silently, as someone who no longer works here.
|
|
162
|
+
*/
|
|
163
|
+
const RETIRED: ResolvablePrincipal = {
|
|
164
|
+
ID: 'BBBB2222-0000-0000-0000-000000000002',
|
|
165
|
+
Name: 'departed@acme.com',
|
|
166
|
+
Email: 'departed@acme.com',
|
|
167
|
+
Type: 'Owner ',
|
|
168
|
+
IsActive: false,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
it('does not resolve a deactivated candidate by Email', () => {
|
|
172
|
+
const result = resolvePrincipalFrom('departed@acme.com', [...STOCK_HOST, RETIRED], SYSTEM_USER_ID);
|
|
173
|
+
|
|
174
|
+
expect(result.user).toBe(SYSTEM);
|
|
175
|
+
expect(result.reason).toBe('system');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('does not resolve a deactivated candidate by Name', () => {
|
|
179
|
+
// The Name rung is the compatibility rung and is tried first, so it needs the guard
|
|
180
|
+
// most: without it the guarantee holds only for the rungs nobody configures.
|
|
181
|
+
const renamed: ResolvablePrincipal = { ...RETIRED, Name: 'ops-service-account', Email: 'ops@acme.com' };
|
|
182
|
+
|
|
183
|
+
const result = resolvePrincipalFrom('ops-service-account', [...STOCK_HOST, renamed], SYSTEM_USER_ID);
|
|
184
|
+
|
|
185
|
+
expect(result.user).toBe(SYSTEM);
|
|
186
|
+
expect(result.reason).toBe('system');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('reports that the candidate matched but is inactive, not that it matched nothing', () => {
|
|
190
|
+
const result = resolvePrincipalFrom('departed@acme.com', [...STOCK_HOST, RETIRED], SYSTEM_USER_ID);
|
|
191
|
+
|
|
192
|
+
// "matched no user's Name or Email" would send the operator hunting for a typo in a
|
|
193
|
+
// value that is spelled correctly. The fix here is to reactivate the account or name a
|
|
194
|
+
// different user, and the message has to point at that one.
|
|
195
|
+
expect(result.warning).toMatch(/inactive/i);
|
|
196
|
+
expect(result.warning).not.toMatch(/matched no user/i);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('advances to the next rung rather than stopping at the inactive match', () => {
|
|
200
|
+
// An inactive Name match must not shadow an ACTIVE Email match. The ladder's job is to
|
|
201
|
+
// find the user the string names, and at that point it has not run out of places to
|
|
202
|
+
// look — this is #4209's own shape, with the Name holder deactivated.
|
|
203
|
+
const inactiveByName: ResolvablePrincipal = {
|
|
204
|
+
ID: 'CCCC3333-0000-0000-0000-000000000003',
|
|
205
|
+
Name: 'ops@acme.com',
|
|
206
|
+
Email: 'old-ops@acme.com',
|
|
207
|
+
Type: 'User ',
|
|
208
|
+
IsActive: false,
|
|
209
|
+
};
|
|
210
|
+
const activeByEmail: ResolvablePrincipal = {
|
|
211
|
+
ID: 'DDDD4444-0000-0000-0000-000000000004',
|
|
212
|
+
Name: 'Ops Service Account',
|
|
213
|
+
Email: 'ops@acme.com',
|
|
214
|
+
Type: 'User ',
|
|
215
|
+
IsActive: true,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const result = resolvePrincipalFrom('ops@acme.com', [inactiveByName, activeByEmail], SYSTEM_USER_ID);
|
|
219
|
+
|
|
220
|
+
expect(result.user).toBe(activeByEmail);
|
|
221
|
+
expect(result.reason).toBe('email');
|
|
222
|
+
expect(result.warning).toBeUndefined();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
describe('fallbacks', () => {
|
|
226
|
+
it('falls back to the System user rather than an arbitrary Owner when the candidate is unresolvable', () => {
|
|
227
|
+
const result = resolvePrincipalFrom('nobody@nowhere.example', STOCK_HOST, SYSTEM_USER_ID);
|
|
228
|
+
|
|
229
|
+
expect(result.user).toBe(SYSTEM);
|
|
230
|
+
expect(result.reason).toBe('system');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('warns when a configured candidate did not resolve, naming the candidate', () => {
|
|
234
|
+
const result = resolvePrincipalFrom('nobody@nowhere.example', STOCK_HOST, SYSTEM_USER_ID);
|
|
235
|
+
|
|
236
|
+
expect(result.warning).toContain('nobody@nowhere.example');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('falls back to the System user WITHOUT warning when no candidate is configured', () => {
|
|
240
|
+
// Zod defaults this setting to '', so an unset/blank value must not be reported as a
|
|
241
|
+
// misconfiguration — but it must still land somewhere deterministic.
|
|
242
|
+
const result = resolvePrincipalFrom('', STOCK_HOST, SYSTEM_USER_ID);
|
|
243
|
+
|
|
244
|
+
expect(result.user).toBe(SYSTEM);
|
|
245
|
+
expect(result.warning).toBeUndefined();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('picks the lowest-ID active Owner when the deployment has no System user', () => {
|
|
249
|
+
// Last resort, and still deterministic — never "whichever Owner sorts first in the cache".
|
|
250
|
+
const laterOwner: ResolvablePrincipal = {
|
|
251
|
+
ID: 'FFFF9999-0000-0000-0000-000000000009',
|
|
252
|
+
Name: 'zoe@acme.com',
|
|
253
|
+
Email: 'zoe@acme.com',
|
|
254
|
+
Type: 'Owner ',
|
|
255
|
+
IsActive: true,
|
|
256
|
+
};
|
|
257
|
+
const users = [laterOwner, HOST_ADMIN, ANONYMOUS];
|
|
258
|
+
|
|
259
|
+
const result = resolvePrincipalFrom('nobody@nowhere.example', users, SYSTEM_USER_ID);
|
|
260
|
+
|
|
261
|
+
expect(result.user).toBe(HOST_ADMIN);
|
|
262
|
+
expect(result.reason).toBe('owner');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('never returns an inactive user as the Owner fallback', () => {
|
|
266
|
+
const inactiveOwner: ResolvablePrincipal = {
|
|
267
|
+
ID: '11111111-0000-0000-0000-000000000000', // sorts first, so only IsActive can exclude it
|
|
268
|
+
Name: 'retired@acme.com',
|
|
269
|
+
Email: 'retired@acme.com',
|
|
270
|
+
Type: 'Owner',
|
|
271
|
+
IsActive: false,
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const result = resolvePrincipalFrom('nobody@nowhere.example', [inactiveOwner, HOST_ADMIN], SYSTEM_USER_ID);
|
|
275
|
+
|
|
276
|
+
expect(result.user).toBe(HOST_ADMIN);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('does not fall back to a DEACTIVATED system user', () => {
|
|
280
|
+
// The Owner rung requires IsActive; the system rung must too, or the guarantee is
|
|
281
|
+
// "we never act as a disabled user, except as the one user we reach first".
|
|
282
|
+
const inactiveSystem: ResolvablePrincipal = { ...SYSTEM, IsActive: false };
|
|
283
|
+
|
|
284
|
+
const result = resolvePrincipalFrom('nobody@nowhere.example', [inactiveSystem, HOST_ADMIN], SYSTEM_USER_ID);
|
|
285
|
+
|
|
286
|
+
expect(result.user).toBe(HOST_ADMIN);
|
|
287
|
+
expect(result.reason).toBe('owner');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('returns no principal when the system user is deactivated and no active Owner remains', () => {
|
|
291
|
+
const inactiveSystem: ResolvablePrincipal = { ...SYSTEM, IsActive: false };
|
|
292
|
+
|
|
293
|
+
const result = resolvePrincipalFrom('nobody@nowhere.example', [inactiveSystem, ANONYMOUS], SYSTEM_USER_ID);
|
|
294
|
+
|
|
295
|
+
expect(result.user).toBeNull();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('returns no principal at all when the cache is empty', () => {
|
|
299
|
+
const result = resolvePrincipalFrom(SHIPPED_DEFAULT, [], SYSTEM_USER_ID);
|
|
300
|
+
|
|
301
|
+
expect(result.user).toBeNull();
|
|
302
|
+
expect(result.reason).toBe('none');
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the API says when a save or a delete is REFUSED.
|
|
3
|
+
*
|
|
4
|
+
* The three write resolvers all end the same way — `throw new GraphQLError(<a string off
|
|
5
|
+
* LatestResult> ?? 'Unknown error')` — and they did not agree on which string. `CreateRecord` read
|
|
6
|
+
* `CompleteMessage`; `UpdateRecord` and `DeleteRecord` read `Message`. A validation refusal puts its
|
|
7
|
+
* reasons in `Errors` and leaves `Message` null, so the same rule on the same entity explained itself
|
|
8
|
+
* on a create and produced the literal text "Unknown error" on an update.
|
|
9
|
+
*
|
|
10
|
+
* Nothing could catch it: both properties exist, both are strings, and the `??` fallback renders the
|
|
11
|
+
* failure as a plausible-looking error rather than a crash. It is visible only by refusing an update
|
|
12
|
+
* and reading what comes back.
|
|
13
|
+
*
|
|
14
|
+
* The first two cases below guard the direction of RISK in the change — that swapping the property
|
|
15
|
+
* cannot lose a plain `Message`, and cannot blank out the `'Unknown error'` fallback. The last case
|
|
16
|
+
* is the only one that fails if a resolver is switched back, and it has to read the source to do it:
|
|
17
|
+
* any test that restates what a resolver reads stays green through the regression, which is exactly
|
|
18
|
+
* how this survived (a create-path test proved nothing about update).
|
|
19
|
+
*/
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { describe, expect, it } from 'vitest';
|
|
23
|
+
import { BaseEntityResult } from '@memberjunction/core';
|
|
24
|
+
|
|
25
|
+
/** The shape a validation refusal leaves: `Message` untouched, reasons in `Errors`. */
|
|
26
|
+
function refusedByValidation(...messages: string[]): BaseEntityResult {
|
|
27
|
+
const result = new BaseEntityResult();
|
|
28
|
+
result.Success = false;
|
|
29
|
+
result.Errors = messages.map((Message) => ({
|
|
30
|
+
Source: 'SomeField',
|
|
31
|
+
Message,
|
|
32
|
+
Value: null,
|
|
33
|
+
Type: 'Failure',
|
|
34
|
+
})) as unknown as BaseEntityResult['Errors'];
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Exactly what the update/delete resolvers hand to `GraphQLError`, fallback included. */
|
|
39
|
+
const asReported = (r: BaseEntityResult) => r.CompleteMessage ?? 'Unknown error';
|
|
40
|
+
|
|
41
|
+
describe('switching the update/delete throws to CompleteMessage', () => {
|
|
42
|
+
it('does NOT lose a plain Message — it is a superset, not a replacement', () => {
|
|
43
|
+
// The risk in the change: a non-validation failure (a provider/SQL error) sets `Message` and
|
|
44
|
+
// no `Errors`. The new property must still report it.
|
|
45
|
+
const result = new BaseEntityResult();
|
|
46
|
+
result.Success = false;
|
|
47
|
+
result.Message = 'Timeout expired while saving.';
|
|
48
|
+
|
|
49
|
+
expect(asReported(result)).toBe('Timeout expired while saving.');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('still falls back to "Unknown error" when there is genuinely nothing to say', () => {
|
|
53
|
+
// The other direction: if CompleteMessage returned '' rather than undefined for an empty
|
|
54
|
+
// result, `??` would not fire and the user would get a blank error.
|
|
55
|
+
expect(asReported(new BaseEntityResult())).toBe('Unknown error');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('carries the validation prose that "Unknown error" used to replace', () => {
|
|
59
|
+
const prose = 'Record what this contract says instead of the standard clause.';
|
|
60
|
+
const result = refusedByValidation(prose);
|
|
61
|
+
|
|
62
|
+
expect(result.Message, 'a refusal leaves Message unset — this is why reading it failed').toBeFalsy();
|
|
63
|
+
expect(asReported(result)).toBe(prose);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('ResolverBase selects CompleteMessage on every write-refusal throw', () => {
|
|
68
|
+
const source = readFileSync(fileURLToPath(new URL('../generic/ResolverBase.ts', import.meta.url)), 'utf8');
|
|
69
|
+
|
|
70
|
+
it('no refusal throw reads the bare LatestResult.Message', () => {
|
|
71
|
+
// `\b` so the longer `CompleteMessage` does not match.
|
|
72
|
+
const offenders = source
|
|
73
|
+
.split('\n')
|
|
74
|
+
.map((line, i) => ({ line: line.trim(), n: i + 1 }))
|
|
75
|
+
.filter(({ line }) => /LatestResult\??\.Message\b/.test(line));
|
|
76
|
+
|
|
77
|
+
expect(
|
|
78
|
+
offenders,
|
|
79
|
+
`these lines still read the bare Message:\n${offenders.map((o) => ` ${o.n}: ${o.line}`).join('\n')}`,
|
|
80
|
+
).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('keeps the three error codes distinct — the fix must not have collapsed the paths', () => {
|
|
84
|
+
for (const code of ['CREATE_ENTITY_ERROR', 'SAVE_ENTITY_ERROR', 'DELETE_ENTITY_ERROR']) {
|
|
85
|
+
expect(source, code).toContain(code);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|