@memberjunction/server 5.7.0 → 5.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/agents/skip-agent.d.ts +15 -1
- package/dist/agents/skip-agent.d.ts.map +1 -1
- package/dist/agents/skip-agent.js +78 -36
- package/dist/agents/skip-agent.js.map +1 -1
- package/dist/agents/skip-sdk.d.ts.map +1 -1
- package/dist/agents/skip-sdk.js +12 -0
- package/dist/agents/skip-sdk.js.map +1 -1
- package/dist/apolloServer/index.d.ts +10 -2
- package/dist/apolloServer/index.d.ts.map +1 -1
- package/dist/apolloServer/index.js +22 -8
- package/dist/apolloServer/index.js.map +1 -1
- package/dist/config.d.ts +125 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +23 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +17 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +144 -62
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +607 -116
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +3542 -775
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +32 -0
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -0
- package/dist/generic/CacheInvalidationResolver.js +80 -0
- package/dist/generic/CacheInvalidationResolver.js.map +1 -0
- package/dist/generic/PubSubManager.d.ts +27 -0
- package/dist/generic/PubSubManager.d.ts.map +1 -0
- package/dist/generic/PubSubManager.js +42 -0
- package/dist/generic/PubSubManager.js.map +1 -0
- package/dist/generic/ResolverBase.d.ts +14 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +50 -0
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/hooks.d.ts +65 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +14 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +176 -45
- package/dist/index.js.map +1 -1
- package/dist/multiTenancy/index.d.ts +47 -0
- package/dist/multiTenancy/index.d.ts.map +1 -0
- package/dist/multiTenancy/index.js +152 -0
- package/dist/multiTenancy/index.js.map +1 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +123 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.js +624 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -0
- package/dist/rest/RESTEndpointHandler.d.ts +3 -1
- package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
- package/dist/rest/RESTEndpointHandler.js +14 -33
- package/dist/rest/RESTEndpointHandler.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +61 -57
- package/src/__tests__/multiTenancy.security.test.ts +334 -0
- package/src/__tests__/multiTenancy.test.ts +225 -0
- package/src/__tests__/unifiedAuth.test.ts +416 -0
- package/src/agents/skip-agent.ts +87 -34
- package/src/agents/skip-sdk.ts +13 -0
- package/src/apolloServer/index.ts +32 -16
- package/src/config.ts +25 -0
- package/src/context.ts +205 -98
- package/src/generated/generated.ts +2334 -430
- package/src/generic/CacheInvalidationResolver.ts +66 -0
- package/src/generic/PubSubManager.ts +47 -0
- package/src/generic/ResolverBase.ts +53 -0
- package/src/hooks.ts +77 -0
- package/src/index.ts +203 -49
- package/src/multiTenancy/index.ts +183 -0
- package/src/resolvers/IntegrationDiscoveryResolver.ts +584 -0
- package/src/rest/RESTEndpointHandler.ts +23 -42
- package/src/types.ts +10 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the unified auth middleware (createUnifiedAuthMiddleware).
|
|
3
|
+
*
|
|
4
|
+
* Verifies: OPTIONS passthrough, token handling, userPayload attachment,
|
|
5
|
+
* 401 responses with proper error codes, and backward-compat properties.
|
|
6
|
+
*
|
|
7
|
+
* Because `createUnifiedAuthMiddleware` and `getUserPayload` live in the same
|
|
8
|
+
* module (context.ts), vi.mock cannot intercept the intra-module call.
|
|
9
|
+
* Instead we mock the LOWER-LEVEL dependencies that `getUserPayload` calls.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
12
|
+
|
|
13
|
+
// ─── Hoisted mocks (available inside vi.mock factories) ─────────────────────
|
|
14
|
+
const {
|
|
15
|
+
MockTokenExpiredError,
|
|
16
|
+
mockGetReadOnlyDS,
|
|
17
|
+
mockGetReadWriteDS,
|
|
18
|
+
mockJwtDecode,
|
|
19
|
+
mockJwtVerify,
|
|
20
|
+
mockVerifyUserRecord,
|
|
21
|
+
mockExtractUserInfo,
|
|
22
|
+
mockGetValidationOptions,
|
|
23
|
+
mockGetSigningKeys,
|
|
24
|
+
mockGetByIssuer,
|
|
25
|
+
} = vi.hoisted(() => {
|
|
26
|
+
class _MockTokenExpiredError extends Error {
|
|
27
|
+
constructor(public expiryDate: Date) {
|
|
28
|
+
super('Token expired');
|
|
29
|
+
this.name = 'TokenExpiredError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
MockTokenExpiredError: _MockTokenExpiredError,
|
|
35
|
+
mockGetReadOnlyDS: vi.fn(),
|
|
36
|
+
mockGetReadWriteDS: vi.fn(),
|
|
37
|
+
mockJwtDecode: vi.fn(),
|
|
38
|
+
mockJwtVerify: vi.fn(),
|
|
39
|
+
mockVerifyUserRecord: vi.fn(),
|
|
40
|
+
mockExtractUserInfo: vi.fn(),
|
|
41
|
+
mockGetValidationOptions: vi.fn(),
|
|
42
|
+
mockGetSigningKeys: vi.fn(),
|
|
43
|
+
mockGetByIssuer: vi.fn(),
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ─── Module mocks ───────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
vi.mock('../util.js', () => ({
|
|
50
|
+
GetReadOnlyDataSource: mockGetReadOnlyDS,
|
|
51
|
+
GetReadWriteDataSource: mockGetReadWriteDS,
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
vi.mock('jsonwebtoken', () => ({
|
|
55
|
+
default: {
|
|
56
|
+
decode: mockJwtDecode,
|
|
57
|
+
verify: mockJwtVerify,
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
vi.mock('../auth/index.js', () => ({
|
|
62
|
+
getSigningKeys: mockGetSigningKeys,
|
|
63
|
+
getSystemUser: vi.fn(),
|
|
64
|
+
getValidationOptions: mockGetValidationOptions,
|
|
65
|
+
verifyUserRecord: mockVerifyUserRecord,
|
|
66
|
+
extractUserInfoFromPayload: mockExtractUserInfo,
|
|
67
|
+
TokenExpiredError: MockTokenExpiredError,
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
vi.mock('../cache.js', () => {
|
|
71
|
+
const map = new Map<string, boolean>();
|
|
72
|
+
return { authCache: map };
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
vi.mock('../config.js', () => ({
|
|
76
|
+
configInfo: {
|
|
77
|
+
baseUrl: 'http://localhost',
|
|
78
|
+
graphqlPort: 4001,
|
|
79
|
+
graphqlRootPath: '/',
|
|
80
|
+
databaseSettings: { metadataCacheRefreshInterval: 0 },
|
|
81
|
+
},
|
|
82
|
+
userEmailMap: {},
|
|
83
|
+
apiKey: 'test-api-key',
|
|
84
|
+
mj_core_schema: '__mj',
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
vi.mock('../auth/AuthProviderFactory.js', () => ({
|
|
88
|
+
AuthProviderFactory: {
|
|
89
|
+
getInstance: () => ({ getByIssuer: mockGetByIssuer }),
|
|
90
|
+
},
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
vi.mock('@memberjunction/api-keys', () => ({
|
|
94
|
+
GetAPIKeyEngine: vi.fn(),
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
import { createUnifiedAuthMiddleware } from '../context.js';
|
|
98
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
99
|
+
import type { DataSourceInfo } from '../types.js';
|
|
100
|
+
|
|
101
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
function makeMockReq(overrides: Partial<Request> = {}): Request {
|
|
104
|
+
return {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: {},
|
|
107
|
+
path: '/',
|
|
108
|
+
url: '/',
|
|
109
|
+
ip: '127.0.0.1',
|
|
110
|
+
socket: { remoteAddress: '127.0.0.1' },
|
|
111
|
+
...overrides,
|
|
112
|
+
} as unknown as Request;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function makeMockRes(): Response & { _status: number; _json: Record<string, unknown> | null } {
|
|
116
|
+
const res = {
|
|
117
|
+
_status: 0,
|
|
118
|
+
_json: null as Record<string, unknown> | null,
|
|
119
|
+
status(code: number) {
|
|
120
|
+
res._status = code;
|
|
121
|
+
return res;
|
|
122
|
+
},
|
|
123
|
+
json(data: Record<string, unknown>) {
|
|
124
|
+
res._json = data;
|
|
125
|
+
return res;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
return res as unknown as Response & { _status: number; _json: Record<string, unknown> | null };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const mockDataSources: DataSourceInfo[] = [];
|
|
132
|
+
const MOCK_POOL = {} as unknown as import('mssql').ConnectionPool;
|
|
133
|
+
|
|
134
|
+
const mockUserRecord = {
|
|
135
|
+
ID: 'user-1',
|
|
136
|
+
Name: 'Test User',
|
|
137
|
+
Email: 'test@example.com',
|
|
138
|
+
IsActive: true,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** Configure lower-level mocks so getUserPayload succeeds for a bearer token */
|
|
142
|
+
function setupSuccessfulAuth() {
|
|
143
|
+
mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
|
|
144
|
+
mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
|
|
145
|
+
// Decode returns a valid, non-expired JWT payload
|
|
146
|
+
mockJwtDecode.mockReturnValue({
|
|
147
|
+
iss: 'https://test-issuer.com',
|
|
148
|
+
sub: 'user-1',
|
|
149
|
+
exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
|
|
150
|
+
});
|
|
151
|
+
// AuthProviderFactory finds the issuer
|
|
152
|
+
mockGetByIssuer.mockReturnValue({ name: 'test' });
|
|
153
|
+
// Validation options needed by verifyAsync
|
|
154
|
+
mockGetValidationOptions.mockReturnValue({ audience: ['test-audience'] });
|
|
155
|
+
// Signing keys callback
|
|
156
|
+
mockGetSigningKeys.mockReturnValue((_header: unknown, cb: (err: Error | null, key: string) => void) => {
|
|
157
|
+
cb(null, 'mock-key');
|
|
158
|
+
});
|
|
159
|
+
// jwt.verify calls the callback successfully
|
|
160
|
+
mockJwtVerify.mockImplementation(
|
|
161
|
+
(_token: string, _keyFn: unknown, _options: unknown, callback: (err: Error | null, decoded: unknown) => void) => {
|
|
162
|
+
callback(null, { iss: 'https://test-issuer.com', sub: 'user-1' });
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
// User info extraction
|
|
166
|
+
mockExtractUserInfo.mockReturnValue({
|
|
167
|
+
email: 'test@example.com',
|
|
168
|
+
firstName: 'Test',
|
|
169
|
+
lastName: 'User',
|
|
170
|
+
fullName: 'Test User',
|
|
171
|
+
preferredUsername: 'test@example.com',
|
|
172
|
+
});
|
|
173
|
+
// User record verification
|
|
174
|
+
mockVerifyUserRecord.mockResolvedValue(mockUserRecord);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
describe('createUnifiedAuthMiddleware', () => {
|
|
180
|
+
let middleware: ReturnType<typeof createUnifiedAuthMiddleware>;
|
|
181
|
+
|
|
182
|
+
beforeEach(() => {
|
|
183
|
+
vi.clearAllMocks();
|
|
184
|
+
middleware = createUnifiedAuthMiddleware(mockDataSources);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('OPTIONS passthrough', () => {
|
|
188
|
+
it('should call next() without auth for OPTIONS requests', async () => {
|
|
189
|
+
const req = makeMockReq({ method: 'OPTIONS' });
|
|
190
|
+
const res = makeMockRes();
|
|
191
|
+
const next = vi.fn();
|
|
192
|
+
|
|
193
|
+
await middleware(req, res, next);
|
|
194
|
+
|
|
195
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
196
|
+
expect(mockJwtDecode).not.toHaveBeenCalled();
|
|
197
|
+
expect(res._status).toBe(0); // no status set
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should not attach userPayload for OPTIONS requests', async () => {
|
|
201
|
+
const req = makeMockReq({ method: 'OPTIONS' });
|
|
202
|
+
const res = makeMockRes();
|
|
203
|
+
const next = vi.fn();
|
|
204
|
+
|
|
205
|
+
await middleware(req, res, next);
|
|
206
|
+
|
|
207
|
+
expect(req.userPayload).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('Successful authentication', () => {
|
|
212
|
+
beforeEach(() => {
|
|
213
|
+
setupSuccessfulAuth();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should attach userPayload to req on successful auth', async () => {
|
|
217
|
+
const req = makeMockReq({
|
|
218
|
+
headers: { authorization: 'Bearer valid-token' },
|
|
219
|
+
});
|
|
220
|
+
const res = makeMockRes();
|
|
221
|
+
const next = vi.fn();
|
|
222
|
+
|
|
223
|
+
await middleware(req, res, next);
|
|
224
|
+
|
|
225
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
226
|
+
expect(req.userPayload).toBeDefined();
|
|
227
|
+
expect(req.userPayload!.email).toBe('test@example.com');
|
|
228
|
+
expect(req.userPayload!.userRecord).toBe(mockUserRecord);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should set req.user for backward compatibility', async () => {
|
|
232
|
+
const req = makeMockReq({
|
|
233
|
+
headers: { authorization: 'Bearer valid-token' },
|
|
234
|
+
});
|
|
235
|
+
const res = makeMockRes();
|
|
236
|
+
const next = vi.fn();
|
|
237
|
+
|
|
238
|
+
await middleware(req, res, next);
|
|
239
|
+
|
|
240
|
+
const reqRecord = req as unknown as Record<string, unknown>;
|
|
241
|
+
expect(reqRecord['user']).toBeDefined();
|
|
242
|
+
expect((reqRecord['user'] as { email: string }).email).toBe('test@example.com');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should set req.mjUser to the userRecord', async () => {
|
|
246
|
+
const req = makeMockReq({
|
|
247
|
+
headers: { authorization: 'Bearer valid-token' },
|
|
248
|
+
});
|
|
249
|
+
const res = makeMockRes();
|
|
250
|
+
const next = vi.fn();
|
|
251
|
+
|
|
252
|
+
await middleware(req, res, next);
|
|
253
|
+
|
|
254
|
+
const reqRecord = req as unknown as Record<string, unknown>;
|
|
255
|
+
expect(reqRecord['mjUser']).toBe(mockUserRecord);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('Authentication failures', () => {
|
|
260
|
+
it('should return 401 when no authorization header is provided', async () => {
|
|
261
|
+
mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
|
|
262
|
+
mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
|
|
263
|
+
// No auth header → empty bearer token → empty token after strip → "Missing token"
|
|
264
|
+
const req = makeMockReq({ headers: {} });
|
|
265
|
+
const res = makeMockRes();
|
|
266
|
+
const next = vi.fn();
|
|
267
|
+
|
|
268
|
+
await middleware(req, res, next);
|
|
269
|
+
|
|
270
|
+
expect(next).not.toHaveBeenCalled();
|
|
271
|
+
expect(res._status).toBe(401);
|
|
272
|
+
expect(res._json).toEqual({ error: 'Authentication failed' });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('should return 401 with JWT_EXPIRED code for expired tokens', async () => {
|
|
276
|
+
mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
|
|
277
|
+
mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
|
|
278
|
+
// jwt.decode returns a payload with expired timestamp
|
|
279
|
+
mockJwtDecode.mockReturnValue({
|
|
280
|
+
iss: 'https://test-issuer.com',
|
|
281
|
+
sub: 'user-1',
|
|
282
|
+
exp: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const req = makeMockReq({
|
|
286
|
+
headers: { authorization: 'Bearer expired-token' },
|
|
287
|
+
});
|
|
288
|
+
const res = makeMockRes();
|
|
289
|
+
const next = vi.fn();
|
|
290
|
+
|
|
291
|
+
await middleware(req, res, next);
|
|
292
|
+
|
|
293
|
+
expect(next).not.toHaveBeenCalled();
|
|
294
|
+
expect(res._status).toBe(401);
|
|
295
|
+
expect(res._json).toEqual({
|
|
296
|
+
errors: [{
|
|
297
|
+
message: 'Token expired',
|
|
298
|
+
extensions: { code: 'JWT_EXPIRED' }
|
|
299
|
+
}]
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('should return 401 when jwt.decode returns null (corrupt token)', async () => {
|
|
304
|
+
mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
|
|
305
|
+
mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
|
|
306
|
+
mockJwtDecode.mockReturnValue(null);
|
|
307
|
+
|
|
308
|
+
const req = makeMockReq({
|
|
309
|
+
headers: { authorization: 'Bearer corrupt-token' },
|
|
310
|
+
});
|
|
311
|
+
const res = makeMockRes();
|
|
312
|
+
const next = vi.fn();
|
|
313
|
+
|
|
314
|
+
await middleware(req, res, next);
|
|
315
|
+
|
|
316
|
+
expect(next).not.toHaveBeenCalled();
|
|
317
|
+
expect(res._status).toBe(401);
|
|
318
|
+
expect(res._json).toEqual({ error: 'Authentication failed' });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should return 401 when verifyUserRecord returns null', async () => {
|
|
322
|
+
mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
|
|
323
|
+
mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
|
|
324
|
+
mockJwtDecode.mockReturnValue({
|
|
325
|
+
iss: 'https://test-issuer.com',
|
|
326
|
+
sub: 'user-1',
|
|
327
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
328
|
+
});
|
|
329
|
+
mockGetByIssuer.mockReturnValue({ name: 'test' });
|
|
330
|
+
mockGetValidationOptions.mockReturnValue({ audience: ['test-audience'] });
|
|
331
|
+
mockGetSigningKeys.mockReturnValue(
|
|
332
|
+
(_h: unknown, cb: (err: Error | null, key: string) => void) => cb(null, 'key')
|
|
333
|
+
);
|
|
334
|
+
mockJwtVerify.mockImplementation(
|
|
335
|
+
(_t: string, _k: unknown, _o: unknown, cb: (err: Error | null, d: unknown) => void) => {
|
|
336
|
+
cb(null, { iss: 'https://test-issuer.com' });
|
|
337
|
+
}
|
|
338
|
+
);
|
|
339
|
+
mockExtractUserInfo.mockReturnValue({ email: 'unknown@example.com' });
|
|
340
|
+
mockVerifyUserRecord.mockResolvedValue(null); // user not found
|
|
341
|
+
|
|
342
|
+
const req = makeMockReq({
|
|
343
|
+
headers: { authorization: 'Bearer valid-token' },
|
|
344
|
+
});
|
|
345
|
+
const res = makeMockRes();
|
|
346
|
+
const next = vi.fn();
|
|
347
|
+
|
|
348
|
+
await middleware(req, res, next);
|
|
349
|
+
|
|
350
|
+
expect(next).not.toHaveBeenCalled();
|
|
351
|
+
expect(res._status).toBe(401);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('should not call next() on auth failure', async () => {
|
|
355
|
+
mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
|
|
356
|
+
mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
|
|
357
|
+
mockJwtDecode.mockReturnValue(null); // invalid token
|
|
358
|
+
|
|
359
|
+
const req = makeMockReq({
|
|
360
|
+
headers: { authorization: 'Bearer bad-token' },
|
|
361
|
+
});
|
|
362
|
+
const res = makeMockRes();
|
|
363
|
+
const next = vi.fn();
|
|
364
|
+
|
|
365
|
+
await middleware(req, res, next);
|
|
366
|
+
|
|
367
|
+
expect(next).not.toHaveBeenCalled();
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe('HTTP method handling', () => {
|
|
372
|
+
beforeEach(() => {
|
|
373
|
+
setupSuccessfulAuth();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('should authenticate GET requests', async () => {
|
|
377
|
+
const req = makeMockReq({ method: 'GET', headers: { authorization: 'Bearer tok' } });
|
|
378
|
+
const res = makeMockRes();
|
|
379
|
+
const next = vi.fn();
|
|
380
|
+
|
|
381
|
+
await middleware(req, res, next);
|
|
382
|
+
|
|
383
|
+
expect(next).toHaveBeenCalled();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('should authenticate POST requests', async () => {
|
|
387
|
+
const req = makeMockReq({ method: 'POST', headers: { authorization: 'Bearer tok' } });
|
|
388
|
+
const res = makeMockRes();
|
|
389
|
+
const next = vi.fn();
|
|
390
|
+
|
|
391
|
+
await middleware(req, res, next);
|
|
392
|
+
|
|
393
|
+
expect(next).toHaveBeenCalled();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('should authenticate PUT requests', async () => {
|
|
397
|
+
const req = makeMockReq({ method: 'PUT', headers: { authorization: 'Bearer tok' } });
|
|
398
|
+
const res = makeMockRes();
|
|
399
|
+
const next = vi.fn();
|
|
400
|
+
|
|
401
|
+
await middleware(req, res, next);
|
|
402
|
+
|
|
403
|
+
expect(next).toHaveBeenCalled();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('should authenticate DELETE requests', async () => {
|
|
407
|
+
const req = makeMockReq({ method: 'DELETE', headers: { authorization: 'Bearer tok' } });
|
|
408
|
+
const res = makeMockRes();
|
|
409
|
+
const next = vi.fn();
|
|
410
|
+
|
|
411
|
+
await middleware(req, res, next);
|
|
412
|
+
|
|
413
|
+
expect(next).toHaveBeenCalled();
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
});
|
package/src/agents/skip-agent.ts
CHANGED
|
@@ -171,15 +171,16 @@ export class SkipProxyAgent extends BaseAgent {
|
|
|
171
171
|
// Call Skip API
|
|
172
172
|
const result = await this.skipSDK.chat(skipOptions);
|
|
173
173
|
|
|
174
|
-
// Handle Skip API errors
|
|
174
|
+
// Handle Skip API errors — surface the actual error message, not a generic wrapper
|
|
175
175
|
if (!result.success || !result.response) {
|
|
176
|
-
|
|
176
|
+
const errorMsg = result.error || 'No response received from Skip API';
|
|
177
|
+
LogError(`[SkipProxyAgent] Skip API call failed: ${errorMsg}`);
|
|
177
178
|
return {
|
|
178
179
|
finalStep: {
|
|
179
180
|
terminate: true,
|
|
180
181
|
step: 'Failed',
|
|
181
|
-
message:
|
|
182
|
-
errorMessage:
|
|
182
|
+
message: errorMsg,
|
|
183
|
+
errorMessage: errorMsg
|
|
183
184
|
} as BaseAgentNextStep<P>,
|
|
184
185
|
stepCount: 1
|
|
185
186
|
};
|
|
@@ -286,56 +287,108 @@ export class SkipProxyAgent extends BaseAgent {
|
|
|
286
287
|
}
|
|
287
288
|
|
|
288
289
|
/**
|
|
289
|
-
* Map Skip API response to MJ agent next step
|
|
290
|
+
* Map Skip API response to MJ agent next step.
|
|
291
|
+
*
|
|
292
|
+
* Checks for Skip-level errors first, then delegates to phase-specific handlers.
|
|
290
293
|
*/
|
|
291
294
|
private mapSkipResponseToNextStep(
|
|
292
295
|
apiResponse: SkipAPIResponse,
|
|
293
296
|
conversationId: string
|
|
294
297
|
): BaseAgentNextStep<ComponentSpec> {
|
|
295
|
-
//
|
|
298
|
+
// Check if Skip reported an error (success: false with any responsePhase)
|
|
299
|
+
if (!apiResponse.success) {
|
|
300
|
+
return this.handleSkipError(apiResponse);
|
|
301
|
+
}
|
|
296
302
|
|
|
297
303
|
switch (apiResponse.responsePhase) {
|
|
298
|
-
case 'analysis_complete':
|
|
299
|
-
|
|
300
|
-
const completeResponse = apiResponse as SkipAPIAnalysisCompleteResponse;
|
|
301
|
-
const componentSpec = completeResponse.componentOptions[0].option;
|
|
302
|
-
// Filter on system message and get the last one
|
|
303
|
-
const skipMessage = completeResponse.messages.filter(msg => msg.role === 'system').pop();
|
|
304
|
-
return {
|
|
305
|
-
terminate: true,
|
|
306
|
-
step: 'Success',
|
|
307
|
-
message: skipMessage?.content || completeResponse.title || 'Analysis complete',
|
|
308
|
-
newPayload: componentSpec
|
|
309
|
-
};
|
|
310
|
-
}
|
|
304
|
+
case 'analysis_complete':
|
|
305
|
+
return this.handleAnalysisComplete(apiResponse as SkipAPIAnalysisCompleteResponse);
|
|
311
306
|
|
|
312
|
-
case 'clarifying_question':
|
|
313
|
-
|
|
314
|
-
const clarifyResponse = apiResponse as SkipAPIClarifyingQuestionResponse;
|
|
307
|
+
case 'clarifying_question':
|
|
308
|
+
return this.handleClarifyingQuestion(apiResponse as SkipAPIClarifyingQuestionResponse);
|
|
315
309
|
|
|
310
|
+
default: {
|
|
311
|
+
const msg = `Unexpected Skip response phase: ${apiResponse.responsePhase}`;
|
|
312
|
+
LogError(`[SkipProxyAgent] ${msg}`);
|
|
316
313
|
return {
|
|
317
314
|
terminate: true,
|
|
318
|
-
step: '
|
|
319
|
-
message:
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
// The client will render this as an artifact and pass it back in the next request
|
|
323
|
-
newPayload: apiResponse.payload as any
|
|
315
|
+
step: 'Failed',
|
|
316
|
+
message: msg,
|
|
317
|
+
errorMessage: msg,
|
|
318
|
+
newPayload: undefined
|
|
324
319
|
};
|
|
325
320
|
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
326
323
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
324
|
+
/**
|
|
325
|
+
* Handle Skip error responses — extracts the most descriptive error available
|
|
326
|
+
*/
|
|
327
|
+
private handleSkipError(apiResponse: SkipAPIResponse): BaseAgentNextStep<ComponentSpec> {
|
|
328
|
+
// Try to get a meaningful error: explicit error field, last system message, or fallback
|
|
329
|
+
const lastSystemMessage = apiResponse.messages
|
|
330
|
+
?.filter(m => m.role === 'system')
|
|
331
|
+
.pop();
|
|
332
|
+
|
|
333
|
+
const errorDetail = apiResponse.error
|
|
334
|
+
|| lastSystemMessage?.content
|
|
335
|
+
|| 'Skip returned an error with no details';
|
|
336
|
+
|
|
337
|
+
LogError(`[SkipProxyAgent] Skip error (phase: ${apiResponse.responsePhase}): ${errorDetail}`);
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
terminate: true,
|
|
341
|
+
step: 'Failed',
|
|
342
|
+
message: errorDetail,
|
|
343
|
+
errorMessage: errorDetail,
|
|
344
|
+
newPayload: undefined
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Handle analysis_complete phase — validates componentOptions before accessing
|
|
350
|
+
*/
|
|
351
|
+
private handleAnalysisComplete(
|
|
352
|
+
response: SkipAPIAnalysisCompleteResponse
|
|
353
|
+
): BaseAgentNextStep<ComponentSpec> {
|
|
354
|
+
if (!response.componentOptions || response.componentOptions.length === 0) {
|
|
355
|
+
const msg = 'Skip completed analysis but returned no component options. '
|
|
356
|
+
+ `Title: "${response.title || 'none'}". `
|
|
357
|
+
+ `Result type: "${response.resultType || 'none'}"`;
|
|
358
|
+
LogError(`[SkipProxyAgent] ${msg}`);
|
|
330
359
|
return {
|
|
331
360
|
terminate: true,
|
|
332
361
|
step: 'Failed',
|
|
333
|
-
message:
|
|
334
|
-
errorMessage:
|
|
362
|
+
message: msg,
|
|
363
|
+
errorMessage: msg,
|
|
335
364
|
newPayload: undefined
|
|
336
365
|
};
|
|
337
366
|
}
|
|
338
|
-
|
|
367
|
+
|
|
368
|
+
const componentSpec = response.componentOptions[0].option;
|
|
369
|
+
const skipMessage = response.messages?.filter(msg => msg.role === 'system').pop();
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
terminate: true,
|
|
373
|
+
step: 'Success',
|
|
374
|
+
message: skipMessage?.content || response.title || 'Analysis complete',
|
|
375
|
+
newPayload: componentSpec
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Handle clarifying_question phase
|
|
381
|
+
*/
|
|
382
|
+
private handleClarifyingQuestion(
|
|
383
|
+
response: SkipAPIClarifyingQuestionResponse
|
|
384
|
+
): BaseAgentNextStep<ComponentSpec> {
|
|
385
|
+
return {
|
|
386
|
+
terminate: true,
|
|
387
|
+
step: 'Chat',
|
|
388
|
+
message: response.clarifyingQuestion,
|
|
389
|
+
responseForm: response.responseForm,
|
|
390
|
+
newPayload: response.payload as ComponentSpec
|
|
391
|
+
};
|
|
339
392
|
}
|
|
340
393
|
|
|
341
394
|
private tempHack(): BaseAgentNextStep<SkipAgentPayload> {
|
package/src/agents/skip-sdk.ts
CHANGED
|
@@ -255,6 +255,19 @@ export class SkipSDK {
|
|
|
255
255
|
if (responses && responses.length > 0) {
|
|
256
256
|
const finalResponse = responses[responses.length - 1].value as SkipAPIResponse;
|
|
257
257
|
|
|
258
|
+
// Check if Skip itself reported an error (success: false in the response body)
|
|
259
|
+
if (finalResponse.success === false) {
|
|
260
|
+
const skipError = finalResponse.error || 'Skip API returned an error response';
|
|
261
|
+
LogError(`[SkipSDK] Skip API error: ${skipError}`);
|
|
262
|
+
return {
|
|
263
|
+
success: false,
|
|
264
|
+
response: finalResponse,
|
|
265
|
+
responsePhase: finalResponse.responsePhase,
|
|
266
|
+
error: skipError,
|
|
267
|
+
allResponses: responses
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
258
271
|
return {
|
|
259
272
|
success: true,
|
|
260
273
|
response: finalResponse,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ApolloServer, ApolloServerOptions } from '@apollo/server';
|
|
1
|
+
import { ApolloServer, ApolloServerOptions, ApolloServerPlugin } from '@apollo/server';
|
|
2
2
|
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
|
|
3
3
|
import { Disposable } from 'graphql-ws';
|
|
4
4
|
import { Server } from 'http';
|
|
@@ -7,27 +7,43 @@ import { AppContext } from '../types.js';
|
|
|
7
7
|
import { Metadata } from '@memberjunction/core';
|
|
8
8
|
import { SQLServerDataProvider } from '@memberjunction/sqlserver-dataprovider';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Creates and configures an Apollo Server instance with built-in plugins
|
|
12
|
+
* for HTTP drain and WebSocket cleanup.
|
|
13
|
+
*
|
|
14
|
+
* @param configOverride - Apollo Server options (typically contains the schema)
|
|
15
|
+
* @param servers - HTTP server and WebSocket cleanup disposable
|
|
16
|
+
* @param additionalPlugins - Optional additional plugins to merge with built-in plugins
|
|
17
|
+
*/
|
|
10
18
|
const buildApolloServer = (
|
|
11
19
|
configOverride: ApolloServerOptions<AppContext>,
|
|
12
|
-
{ httpServer, serverCleanup }: { httpServer: Server; serverCleanup: Disposable }
|
|
13
|
-
|
|
14
|
-
|
|
20
|
+
{ httpServer, serverCleanup }: { httpServer: Server; serverCleanup: Disposable },
|
|
21
|
+
additionalPlugins?: ApolloServerPlugin[]
|
|
22
|
+
) => {
|
|
23
|
+
const builtInPlugins: ApolloServerPlugin[] = [
|
|
24
|
+
ApolloServerPluginDrainHttpServer({ httpServer }),
|
|
25
|
+
{
|
|
26
|
+
async serverWillStart() {
|
|
27
|
+
return {
|
|
28
|
+
async drainServer() {
|
|
29
|
+
await serverCleanup.dispose();
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const allPlugins = additionalPlugins
|
|
37
|
+
? [...builtInPlugins, ...additionalPlugins]
|
|
38
|
+
: builtInPlugins;
|
|
39
|
+
|
|
40
|
+
return new ApolloServer({
|
|
15
41
|
csrfPrevention: true,
|
|
16
42
|
cache: 'bounded',
|
|
17
|
-
plugins:
|
|
18
|
-
ApolloServerPluginDrainHttpServer({ httpServer }),
|
|
19
|
-
{
|
|
20
|
-
async serverWillStart() {
|
|
21
|
-
return {
|
|
22
|
-
async drainServer() {
|
|
23
|
-
await serverCleanup.dispose();
|
|
24
|
-
},
|
|
25
|
-
};
|
|
26
|
-
},
|
|
27
|
-
}
|
|
28
|
-
],
|
|
43
|
+
plugins: allPlugins,
|
|
29
44
|
introspection: enableIntrospection,
|
|
30
45
|
...configOverride,
|
|
31
46
|
});
|
|
47
|
+
};
|
|
32
48
|
|
|
33
49
|
export default buildApolloServer;
|