@siduri-x/api 1.0.1 → 1.0.4
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/dist/index.js +18 -9
- package/package.json +16 -12
- package/src/app.ts +294 -95
- package/src/auth.test.ts +56 -23
- package/src/auth.ts +74 -22
- package/src/context-mapper.test.ts +42 -147
- package/src/context-mapper.ts +44 -179
- package/src/index.test.ts +61 -32
- package/src/index.ts +30 -21
- package/src/runtime.test.ts +57 -5
- package/src/t5-experience.test.ts +0 -1
- package/src/t6-security.test.ts +0 -1
- package/dist/app.d.ts +0 -9
- package/dist/app.js +0 -480
- package/dist/auth.d.ts +0 -8
- package/dist/auth.js +0 -40
- package/dist/auth.test.d.ts +0 -1
- package/dist/auth.test.js +0 -48
- package/dist/b0-b6.test.d.ts +0 -1
- package/dist/b0-b6.test.js +0 -121
- package/dist/context-mapper.d.ts +0 -15
- package/dist/context-mapper.js +0 -287
- package/dist/context-mapper.test.d.ts +0 -1
- package/dist/context-mapper.test.js +0 -233
- package/dist/cors.d.ts +0 -3
- package/dist/cors.js +0 -42
- package/dist/index.d.ts +0 -6
- package/dist/index.test.d.ts +0 -1
- package/dist/index.test.js +0 -115
- package/dist/runtime.d.ts +0 -1
- package/dist/runtime.js +0 -17
- package/dist/runtime.test.d.ts +0 -1
- package/dist/runtime.test.js +0 -240
- package/dist/smoke.test.d.ts +0 -0
- package/dist/smoke.test.js +0 -6
- package/dist/t4-gating.test.d.ts +0 -1
- package/dist/t4-gating.test.js +0 -193
- package/dist/t5-experience.test.d.ts +0 -1
- package/dist/t5-experience.test.js +0 -156
- package/dist/t6-security.test.d.ts +0 -1
- package/dist/t6-security.test.js +0 -424
- package/dist/t7-release.test.d.ts +0 -1
- package/dist/t7-release.test.js +0 -119
package/src/auth.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { resolveIdentity } from './auth';
|
|
1
|
+
import { resolveIdentity, requireAuth } from './auth';
|
|
2
2
|
|
|
3
|
-
describe('Auth Identity Resolution', () => {
|
|
3
|
+
describe('Auth Identity Resolution (Single-Owner External Boundary)', () => {
|
|
4
4
|
const originalEnv = process.env;
|
|
5
5
|
|
|
6
6
|
beforeEach(() => {
|
|
@@ -12,46 +12,79 @@ describe('Auth Identity Resolution', () => {
|
|
|
12
12
|
process.env = originalEnv;
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
-
const mockReq = (token?: string) => ({
|
|
15
|
+
const mockReq = (token?: string, ip: string = '192.168.1.50') => ({
|
|
16
16
|
headers: {
|
|
17
|
-
authorization: token ? `Bearer ${token}` : undefined
|
|
18
|
-
}
|
|
17
|
+
authorization: token ? `Bearer ${token}` : undefined,
|
|
18
|
+
},
|
|
19
|
+
ip,
|
|
20
|
+
socket: { remoteAddress: ip },
|
|
19
21
|
} as any);
|
|
20
22
|
|
|
21
|
-
test('
|
|
22
|
-
process.env.
|
|
23
|
-
process.env.OPERATOR_TOKEN = 'operator-secret';
|
|
23
|
+
test('authenticates external request when token matches configured AUTH_TOKEN', () => {
|
|
24
|
+
process.env.AUTH_TOKEN = 'secret-auth-key';
|
|
24
25
|
process.env.NODE_ENV = 'production';
|
|
25
26
|
|
|
26
|
-
const
|
|
27
|
-
expect(
|
|
27
|
+
const identity = resolveIdentity(mockReq('secret-auth-key'));
|
|
28
|
+
expect(identity.authenticated).toBe(true);
|
|
29
|
+
expect(identity.source).toBe('external');
|
|
28
30
|
});
|
|
29
31
|
|
|
30
|
-
test('
|
|
32
|
+
test('authenticates external request when token matches legacy OWNER_TOKEN', () => {
|
|
31
33
|
process.env.OWNER_TOKEN = 'owner-secret';
|
|
32
|
-
process.env.OPERATOR_TOKEN = 'operator-secret';
|
|
33
34
|
process.env.NODE_ENV = 'production';
|
|
34
35
|
|
|
35
|
-
const
|
|
36
|
-
expect(
|
|
36
|
+
const identity = resolveIdentity(mockReq('owner-secret'));
|
|
37
|
+
expect(identity.authenticated).toBe(true);
|
|
38
|
+
expect(identity.source).toBe('external');
|
|
37
39
|
});
|
|
38
40
|
|
|
39
|
-
test('
|
|
41
|
+
test('rejects external request when token is missing in production with configured token', () => {
|
|
42
|
+
process.env.AUTH_TOKEN = 'secret-auth-key';
|
|
40
43
|
process.env.NODE_ENV = 'production';
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
|
|
45
|
+
const identity = resolveIdentity(mockReq());
|
|
46
|
+
expect(identity.authenticated).toBe(false);
|
|
43
47
|
});
|
|
44
48
|
|
|
45
|
-
test('
|
|
49
|
+
test('rejects external request for invalid token in production', () => {
|
|
50
|
+
process.env.AUTH_TOKEN = 'secret-auth-key';
|
|
46
51
|
process.env.NODE_ENV = 'production';
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
|
|
53
|
+
const identity = resolveIdentity(mockReq('invalid-token'));
|
|
54
|
+
expect(identity.authenticated).toBe(false);
|
|
49
55
|
});
|
|
50
56
|
|
|
51
|
-
test('
|
|
57
|
+
test('authenticates local loopback request in development mode', () => {
|
|
52
58
|
process.env.NODE_ENV = 'development';
|
|
53
59
|
process.env.DEV_LOCAL_AUTH_ROLE = 'OWNER';
|
|
54
|
-
|
|
55
|
-
|
|
60
|
+
process.env.AUTH_TOKEN = 'secret-key';
|
|
61
|
+
|
|
62
|
+
const devIdentity = resolveIdentity(mockReq(undefined, '127.0.0.1'));
|
|
63
|
+
expect(devIdentity.authenticated).toBe(true);
|
|
64
|
+
expect(devIdentity.source).toBe('local');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('requireAuth middleware accepts authenticated request and rejects unauthenticated with 401', () => {
|
|
68
|
+
process.env.AUTH_TOKEN = 'secret-key';
|
|
69
|
+
process.env.NODE_ENV = 'production';
|
|
70
|
+
|
|
71
|
+
const nextFn = jest.fn();
|
|
72
|
+
const resUnauthorized = {
|
|
73
|
+
status: jest.fn().mockReturnThis(),
|
|
74
|
+
json: jest.fn().mockReturnThis(),
|
|
75
|
+
} as any;
|
|
76
|
+
|
|
77
|
+
// Unauthorized call
|
|
78
|
+
requireAuth(mockReq('wrong-token'), resUnauthorized, nextFn);
|
|
79
|
+
expect(resUnauthorized.status).toHaveBeenCalledWith(401);
|
|
80
|
+
expect(nextFn).not.toHaveBeenCalled();
|
|
81
|
+
|
|
82
|
+
// Authorized call
|
|
83
|
+
const resAuthorized = {
|
|
84
|
+
status: jest.fn().mockReturnThis(),
|
|
85
|
+
json: jest.fn().mockReturnThis(),
|
|
86
|
+
} as any;
|
|
87
|
+
requireAuth(mockReq('secret-key'), resAuthorized, nextFn);
|
|
88
|
+
expect(nextFn).toHaveBeenCalled();
|
|
56
89
|
});
|
|
57
90
|
});
|
package/src/auth.ts
CHANGED
|
@@ -1,49 +1,101 @@
|
|
|
1
1
|
import { Request, Response, NextFunction } from 'express';
|
|
2
2
|
|
|
3
|
-
export type Role = 'OWNER' | 'OPERATOR' | 'VIEWER';
|
|
3
|
+
export type Role = 'OWNER' | 'OPERATOR' | 'VIEWER' | 'user' | string;
|
|
4
4
|
|
|
5
5
|
export interface Identity {
|
|
6
|
-
|
|
6
|
+
authenticated: boolean;
|
|
7
|
+
source: 'local' | 'external';
|
|
8
|
+
actorId?: string;
|
|
9
|
+
role?: Role;
|
|
10
|
+
[key: string]: unknown;
|
|
7
11
|
}
|
|
8
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Checks whether an incoming request originates from the local machine loopback interface.
|
|
15
|
+
*/
|
|
16
|
+
export function isLocalRequest(req: Request): boolean {
|
|
17
|
+
const ip = req.ip || req.socket?.remoteAddress || '';
|
|
18
|
+
return (
|
|
19
|
+
ip === '127.0.0.1' ||
|
|
20
|
+
ip === '::1' ||
|
|
21
|
+
ip === '::ffff:127.0.0.1' ||
|
|
22
|
+
ip.endsWith('127.0.0.1') ||
|
|
23
|
+
ip === 'localhost'
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolves request identity according to the single-owner external machine boundary security model.
|
|
29
|
+
*
|
|
30
|
+
* - Security is enforced at the external machine boundary, NOT internally.
|
|
31
|
+
* - External network requests require a matching token (AUTH_TOKEN / OWNER_TOKEN).
|
|
32
|
+
* - There are no internal viewer/operator/owner role privileges inside the machine.
|
|
33
|
+
*/
|
|
9
34
|
export function resolveIdentity(req: Request): Identity {
|
|
10
|
-
const authHeader = req.headers
|
|
35
|
+
const authHeader = req.headers?.authorization;
|
|
11
36
|
const token = authHeader?.startsWith('Bearer ') ? authHeader.split(' ')[1] : undefined;
|
|
37
|
+
const ownerToken = process.env.AUTH_TOKEN || process.env.API_TOKEN || process.env.OWNER_TOKEN;
|
|
38
|
+
const operatorToken = process.env.OPERATOR_TOKEN;
|
|
39
|
+
const configuredToken = ownerToken || operatorToken;
|
|
40
|
+
|
|
41
|
+
const local = isLocalRequest(req);
|
|
12
42
|
|
|
13
43
|
// 1. Explicit token matches
|
|
14
|
-
if (
|
|
15
|
-
return { role: 'OWNER' };
|
|
44
|
+
if (ownerToken && token === ownerToken) {
|
|
45
|
+
return { authenticated: true, source: local ? 'local' : 'external', role: 'OWNER' };
|
|
16
46
|
}
|
|
17
|
-
if (
|
|
18
|
-
return { role: 'OPERATOR' };
|
|
47
|
+
if (operatorToken && token === operatorToken) {
|
|
48
|
+
return { authenticated: true, source: local ? 'local' : 'external', role: 'OPERATOR' };
|
|
19
49
|
}
|
|
20
50
|
|
|
21
|
-
// 2.
|
|
51
|
+
// 2. Dev local auth role fallback
|
|
22
52
|
const isDev = process.env.NODE_ENV !== 'production';
|
|
23
53
|
if (isDev && process.env.DEV_LOCAL_AUTH_ROLE) {
|
|
24
54
|
const fallbackRole = process.env.DEV_LOCAL_AUTH_ROLE.toUpperCase() as Role;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
55
|
+
return {
|
|
56
|
+
authenticated: fallbackRole === 'OWNER' || fallbackRole === 'OPERATOR',
|
|
57
|
+
source: 'local',
|
|
58
|
+
role: fallbackRole,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 3. Default unauthenticated / visitor
|
|
63
|
+
return { authenticated: false, source: local ? 'local' : 'external', role: 'VIEWER' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* External boundary authentication middleware.
|
|
68
|
+
* Verifies that requests crossing the external boundary are authorized.
|
|
69
|
+
*/
|
|
70
|
+
export function requireAuth(req: Request, res: Response, next: NextFunction) {
|
|
71
|
+
const identity = resolveIdentity(req);
|
|
72
|
+
(req as any).identity = identity;
|
|
73
|
+
|
|
74
|
+
if (!identity.authenticated) {
|
|
75
|
+
return res.status(401).json({ error: 'Unauthorized: missing or invalid authentication token' });
|
|
28
76
|
}
|
|
77
|
+
next();
|
|
78
|
+
}
|
|
29
79
|
|
|
30
|
-
|
|
31
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Attaches the resolved machine identity to the request.
|
|
82
|
+
*/
|
|
83
|
+
export function attachIdentity(req: Request, res: Response, next: NextFunction) {
|
|
84
|
+
(req as any).identity = resolveIdentity(req);
|
|
85
|
+
next();
|
|
32
86
|
}
|
|
33
87
|
|
|
34
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Compatibility alias: in single-owner model, all authenticated callers have full access.
|
|
90
|
+
*/
|
|
91
|
+
export function requireRole(allowedRoles?: Role[]) {
|
|
35
92
|
return (req: Request, res: Response, next: NextFunction) => {
|
|
36
93
|
const identity = resolveIdentity(req);
|
|
37
94
|
(req as any).identity = identity;
|
|
38
|
-
|
|
39
|
-
if (!
|
|
40
|
-
return res.status(403).json({ error: `Forbidden: requires one of ${allowedRoles.join(', ')}` });
|
|
95
|
+
|
|
96
|
+
if (!identity.authenticated) {
|
|
97
|
+
return res.status(403).json({ error: `Forbidden: requires one of ${(allowedRoles || []).join(', ')}` });
|
|
41
98
|
}
|
|
42
99
|
next();
|
|
43
100
|
};
|
|
44
101
|
}
|
|
45
|
-
|
|
46
|
-
export function attachIdentity(req: Request, res: Response, next: NextFunction) {
|
|
47
|
-
(req as any).identity = resolveIdentity(req);
|
|
48
|
-
next();
|
|
49
|
-
}
|
|
@@ -1,116 +1,56 @@
|
|
|
1
1
|
import { mapRequestContext } from './context-mapper';
|
|
2
2
|
|
|
3
|
-
describe('API Request Context Mapper (
|
|
4
|
-
|
|
5
|
-
test('T1-01: Anonymous public chat with no audience resolves to configured public audience and no subject', () => {
|
|
3
|
+
describe('API Request Context Mapper (Single-Owner, Single-Machine)', () => {
|
|
4
|
+
test('Local chat request maps to valid RequestContext with local user defaults', () => {
|
|
6
5
|
const input = {
|
|
7
6
|
companionId: 'companion-a',
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
actorId: 'anonymous-session-a',
|
|
11
|
-
sessionId: 'session-a',
|
|
12
|
-
authorizationRole: 'viewer',
|
|
13
|
-
capabilities: ['chat:public'],
|
|
14
|
-
authenticated: false,
|
|
15
|
-
},
|
|
16
|
-
conversation: {
|
|
17
|
-
channel: 'public',
|
|
18
|
-
correlationId: 'corr-public-a',
|
|
19
|
-
},
|
|
20
|
-
},
|
|
21
|
-
message: 'Hello.',
|
|
22
|
-
history: [],
|
|
7
|
+
message: 'Hello world',
|
|
8
|
+
correlationId: 'corr-local-1',
|
|
23
9
|
};
|
|
24
10
|
|
|
25
|
-
const result = mapRequestContext(input
|
|
26
|
-
endpointPolicy: 'public',
|
|
27
|
-
defaultPublicAudience: 'audience-public',
|
|
28
|
-
});
|
|
11
|
+
const result = mapRequestContext(input);
|
|
29
12
|
|
|
30
13
|
expect(result.accepted).toBe(true);
|
|
31
14
|
expect(result.context?.companionId).toBe('companion-a');
|
|
32
|
-
expect(result.context?.
|
|
33
|
-
expect(result.context?.
|
|
34
|
-
expect(result.context?.
|
|
35
|
-
expect(result.
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
// T1-02: OWNER public chat
|
|
39
|
-
test('T1-02: OWNER public chat maps authorization metadata only with no relationship or private routing', () => {
|
|
40
|
-
const input = {
|
|
41
|
-
id: 'companion-a',
|
|
42
|
-
role: 'OWNER',
|
|
43
|
-
correlationId: 'corr-owner-public',
|
|
44
|
-
message: 'Public broadcast message',
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const result = mapRequestContext(input, { endpointPolicy: 'public' });
|
|
48
|
-
expect(result.accepted).toBe(true);
|
|
49
|
-
expect(result.context?.actor.authorizationRole).toBe('administrator');
|
|
50
|
-
expect(result.context?.conversation.channel).toBe('public');
|
|
51
|
-
expect(result.context?.conversation.audienceId).toBe('audience-public');
|
|
15
|
+
expect(result.context?.actor.actorId).toBe('local-user');
|
|
16
|
+
expect(result.context?.actor.authenticated).toBe(true);
|
|
17
|
+
expect(result.context?.conversation.correlationId).toBe('corr-local-1');
|
|
18
|
+
expect(result.context?.source).toBe('local');
|
|
52
19
|
expect(result.context?.subject).toBeUndefined();
|
|
53
|
-
expect(result.diagnostics).toContain('legacy_role_mapped_to_authorization');
|
|
54
20
|
});
|
|
55
21
|
|
|
56
|
-
|
|
57
|
-
test('T1-03: Private chat without capability is rejected before organ invocation', () => {
|
|
22
|
+
test('Accepts structured context envelope', () => {
|
|
58
23
|
const input = {
|
|
59
24
|
companionId: 'companion-a',
|
|
60
25
|
context: {
|
|
61
26
|
actor: {
|
|
62
|
-
actorId: '
|
|
63
|
-
sessionId: 'session-
|
|
64
|
-
authorizationRole: 'viewer',
|
|
65
|
-
capabilities: ['chat:public'], // Missing 'chat:private'
|
|
27
|
+
actorId: 'user-main',
|
|
28
|
+
sessionId: 'session-main',
|
|
66
29
|
authenticated: true,
|
|
30
|
+
capabilities: ['chat', 'system'],
|
|
67
31
|
},
|
|
68
32
|
conversation: {
|
|
69
|
-
|
|
70
|
-
audienceId: 'audience-private-a',
|
|
71
|
-
correlationId: 'corr-private-a',
|
|
33
|
+
correlationId: 'corr-structured-1',
|
|
72
34
|
},
|
|
73
35
|
},
|
|
74
|
-
message: '
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
const result = mapRequestContext(input, { endpointPolicy: 'private' });
|
|
78
|
-
expect(result.accepted).toBe(false);
|
|
79
|
-
expect(result.error?.code).toBe('UNAUTHORIZED_CHANNEL_OR_CAPABILITY');
|
|
80
|
-
expect(result.error?.fields).toContain('actor.capabilities');
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
// T1-04: Operator request without operator context
|
|
84
|
-
test('T1-04: Operator request without operator context is rejected with diagnostic', () => {
|
|
85
|
-
const input = {
|
|
86
|
-
id: 'companion-a',
|
|
87
|
-
role: 'OPERATOR',
|
|
88
|
-
correlationId: 'corr-op-missing',
|
|
36
|
+
message: 'Hello structured context',
|
|
89
37
|
};
|
|
90
38
|
|
|
91
|
-
const result = mapRequestContext(input
|
|
92
|
-
expect(result.accepted).toBe(
|
|
93
|
-
expect(result.
|
|
94
|
-
expect(result.
|
|
95
|
-
expect.arrayContaining(['conversation.channel', 'conversation.audienceId', 'actor.capabilities'])
|
|
96
|
-
);
|
|
39
|
+
const result = mapRequestContext(input);
|
|
40
|
+
expect(result.accepted).toBe(true);
|
|
41
|
+
expect(result.context?.actor.actorId).toBe('user-main');
|
|
42
|
+
expect(result.context?.conversation.correlationId).toBe('corr-structured-1');
|
|
97
43
|
});
|
|
98
44
|
|
|
99
|
-
|
|
100
|
-
test('T1-05: Global primary_user input is rejected/quarantined at mapper', () => {
|
|
45
|
+
test('Rejects global primary_user subject', () => {
|
|
101
46
|
const input = {
|
|
102
47
|
companionId: 'companion-a',
|
|
103
48
|
context: {
|
|
104
49
|
actor: {
|
|
105
|
-
actorId: '
|
|
50
|
+
actorId: 'user-a',
|
|
106
51
|
sessionId: 'session-a',
|
|
107
|
-
authorizationRole: 'viewer',
|
|
108
|
-
capabilities: ['chat:public'],
|
|
109
|
-
authenticated: true,
|
|
110
52
|
},
|
|
111
53
|
conversation: {
|
|
112
|
-
channel: 'public',
|
|
113
|
-
audienceId: 'audience-public',
|
|
114
54
|
correlationId: 'corr-primary-user',
|
|
115
55
|
},
|
|
116
56
|
subject: {
|
|
@@ -126,47 +66,15 @@ describe('API Request Context Mapper (P2 & T1-01 to T1-10)', () => {
|
|
|
126
66
|
expect(result.error?.field).toBe('subject.subjectId');
|
|
127
67
|
});
|
|
128
68
|
|
|
129
|
-
|
|
130
|
-
test('T1-06: MASTER_PRIVATE in public mode is rejected with no fallback', () => {
|
|
131
|
-
const input = {
|
|
132
|
-
companionId: 'companion-a',
|
|
133
|
-
context: {
|
|
134
|
-
actor: {
|
|
135
|
-
actorId: 'actor-a',
|
|
136
|
-
sessionId: 'session-a',
|
|
137
|
-
authorizationRole: 'viewer',
|
|
138
|
-
capabilities: ['chat:public'],
|
|
139
|
-
authenticated: false,
|
|
140
|
-
},
|
|
141
|
-
conversation: {
|
|
142
|
-
channel: 'public',
|
|
143
|
-
audienceId: 'MASTER_PRIVATE',
|
|
144
|
-
correlationId: 'corr-master-private',
|
|
145
|
-
},
|
|
146
|
-
},
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
const result = mapRequestContext(input, { endpointPolicy: 'public' });
|
|
150
|
-
expect(result.accepted).toBe(false);
|
|
151
|
-
expect(result.error?.code).toBe('LEGACY_PERSONAL_AUDIENCE');
|
|
152
|
-
expect(result.error?.field).toBe('audienceId');
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
// T1-07: Missing actor on explicit teaching
|
|
156
|
-
test('T1-07: Missing actor on explicit teaching produces no durable profile and pending/anonymous context only', () => {
|
|
69
|
+
test('Rejects missing actor on structured context', () => {
|
|
157
70
|
const input = {
|
|
158
71
|
companionId: 'companion-a',
|
|
159
72
|
context: {
|
|
160
73
|
actor: {
|
|
161
74
|
actorId: '',
|
|
162
75
|
sessionId: 'session-a',
|
|
163
|
-
authorizationRole: 'viewer',
|
|
164
|
-
capabilities: ['chat:public'],
|
|
165
|
-
authenticated: false,
|
|
166
76
|
},
|
|
167
77
|
conversation: {
|
|
168
|
-
channel: 'public',
|
|
169
|
-
audienceId: 'audience-public',
|
|
170
78
|
correlationId: 'corr-teach-no-actor',
|
|
171
79
|
},
|
|
172
80
|
},
|
|
@@ -178,18 +86,13 @@ describe('API Request Context Mapper (P2 & T1-01 to T1-10)', () => {
|
|
|
178
86
|
expect(result.error?.fields).toContain('actor.actorId');
|
|
179
87
|
});
|
|
180
88
|
|
|
181
|
-
|
|
182
|
-
test('T1-08: Two companion IDs with same actor remain isolated under context mapper', () => {
|
|
89
|
+
test('Two companion IDs with same actor remain isolated under context mapper', () => {
|
|
183
90
|
const actor = {
|
|
184
|
-
actorId: '
|
|
91
|
+
actorId: 'user-a',
|
|
185
92
|
sessionId: 'session-a',
|
|
186
|
-
authorizationRole: 'viewer' as const,
|
|
187
|
-
capabilities: ['chat:public'],
|
|
188
93
|
authenticated: true,
|
|
189
94
|
};
|
|
190
95
|
const conv = {
|
|
191
|
-
channel: 'public' as const,
|
|
192
|
-
audienceId: 'audience-public',
|
|
193
96
|
correlationId: 'corr-iso-1',
|
|
194
97
|
};
|
|
195
98
|
|
|
@@ -205,21 +108,27 @@ describe('API Request Context Mapper (P2 & T1-01 to T1-10)', () => {
|
|
|
205
108
|
expect(resB.context?.companionId).toBe('companion-b');
|
|
206
109
|
});
|
|
207
110
|
|
|
208
|
-
|
|
209
|
-
|
|
111
|
+
test('Rejects missing companionId', () => {
|
|
112
|
+
const input = {
|
|
113
|
+
message: 'No companion id',
|
|
114
|
+
correlationId: 'corr-no-comp',
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const result = mapRequestContext(input);
|
|
118
|
+
expect(result.accepted).toBe(false);
|
|
119
|
+
expect(result.error?.code).toBe('MISSING_CONTEXT');
|
|
120
|
+
expect(result.error?.fields).toContain('companionId');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('Rejects missing correlation ID when generateCorrelationId is not set', () => {
|
|
210
124
|
const input = {
|
|
211
125
|
companionId: 'companion-a',
|
|
212
126
|
context: {
|
|
213
127
|
actor: {
|
|
214
|
-
actorId: '
|
|
128
|
+
actorId: 'user-a',
|
|
215
129
|
sessionId: 'session-a',
|
|
216
|
-
authorizationRole: 'viewer',
|
|
217
|
-
capabilities: ['chat:public'],
|
|
218
|
-
authenticated: true,
|
|
219
130
|
},
|
|
220
131
|
conversation: {
|
|
221
|
-
channel: 'public',
|
|
222
|
-
audienceId: 'audience-public',
|
|
223
132
|
correlationId: '',
|
|
224
133
|
},
|
|
225
134
|
},
|
|
@@ -231,28 +140,14 @@ describe('API Request Context Mapper (P2 & T1-01 to T1-10)', () => {
|
|
|
231
140
|
expect(result.error?.fields).toContain('conversation.correlationId');
|
|
232
141
|
});
|
|
233
142
|
|
|
234
|
-
|
|
235
|
-
test('T1-10: Ambiguous legacy request emits structured diagnostic and rejects ambiguous mapping', () => {
|
|
143
|
+
test('Generates correlationId when generateCorrelationId option is true', () => {
|
|
236
144
|
const input = {
|
|
237
145
|
companionId: 'companion-a',
|
|
238
|
-
|
|
239
|
-
context: {
|
|
240
|
-
actor: {
|
|
241
|
-
actorId: 'actor-a',
|
|
242
|
-
sessionId: 'session-a',
|
|
243
|
-
authorizationRole: 'viewer',
|
|
244
|
-
capabilities: ['chat:public'],
|
|
245
|
-
authenticated: true,
|
|
246
|
-
},
|
|
247
|
-
// Role supplied at top level without channel or audience
|
|
248
|
-
},
|
|
146
|
+
generateCorrelationId: true,
|
|
249
147
|
};
|
|
250
148
|
|
|
251
149
|
const result = mapRequestContext(input);
|
|
252
|
-
expect(result.accepted).toBe(
|
|
253
|
-
expect(result.
|
|
254
|
-
expect(result.error?.conflicts).toEqual(
|
|
255
|
-
expect.arrayContaining(['role_does_not_select_audience', 'role_does_not_select_subject'])
|
|
256
|
-
);
|
|
150
|
+
expect(result.accepted).toBe(true);
|
|
151
|
+
expect(result.context?.conversation.correlationId).toMatch(/^corr-/);
|
|
257
152
|
});
|
|
258
153
|
});
|