@platform-mesh/portal-server-lib 0.5.44 → 0.5.45

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.
@@ -1,115 +1,170 @@
1
+ import { K8sRequestContext, K8sResourceDescriptor } from '../models/k8s.js';
1
2
  import { KcpKubernetesService } from './kcp-k8s.service.js';
2
3
  import type { Request } from 'express';
3
4
 
4
- jest.mock('@kubernetes/client-node', () => {
5
- const makeApiClient = jest.fn(() => ({}));
6
- const getCurrentCluster = jest.fn().mockReturnValue({
7
- server: 'https://kcp.example.com/base',
8
- name: 'test-cluster',
9
- });
10
- return {
11
- CustomObjectsApi: jest.fn(),
12
- KubeConfig: jest.fn().mockImplementation(() => ({
13
- loadFromFile: jest.fn(),
14
- addUser: jest.fn(),
15
- addContext: jest.fn(),
16
- setCurrentContext: jest.fn(),
17
- getCurrentCluster,
18
- makeApiClient,
19
- })),
20
- };
21
- });
5
+ const mockListClusterCustomObject = jest.fn();
6
+ const mockReadNamespacedSecret = jest.fn();
7
+ const mockMakeApiClient = jest.fn();
8
+ const mockGetCurrentCluster = jest.fn();
9
+ const mockLoadFromFile = jest.fn();
10
+ const mockAddUser = jest.fn();
11
+ const mockAddContext = jest.fn();
12
+ const mockSetCurrentContext = jest.fn();
13
+
14
+ jest.mock('@kubernetes/client-node', () => ({
15
+ CustomObjectsApi: jest.fn().mockImplementation(() => ({
16
+ listClusterCustomObject: mockListClusterCustomObject,
17
+ })),
18
+ CoreV1Api: jest.fn().mockImplementation(() => ({
19
+ readNamespacedSecret: mockReadNamespacedSecret,
20
+ })),
21
+ KubeConfig: jest.fn().mockImplementation(() => ({
22
+ loadFromFile: mockLoadFromFile,
23
+ addUser: mockAddUser,
24
+ addContext: mockAddContext,
25
+ setCurrentContext: mockSetCurrentContext,
26
+ getCurrentCluster: mockGetCurrentCluster,
27
+ makeApiClient: mockMakeApiClient,
28
+ })),
29
+ }));
22
30
 
23
31
  jest.mock('../utils/domain.js', () => ({
24
32
  getOrganization: jest.fn(() => 'org-1'),
25
33
  }));
26
34
 
35
+ jest.mock('@kubernetes/client-node/dist/gen/middleware.js', () => ({
36
+ PromiseMiddlewareWrapper: class {
37
+ constructor(public options: any) {}
38
+ },
39
+ }));
40
+
27
41
  describe('KcpKubernetesService', () => {
28
42
  const OLD_ENV = process.env;
29
43
 
30
44
  beforeEach(() => {
31
- jest.resetModules();
32
- process.env = { ...OLD_ENV, KUBECONFIG_KCP: '/tmp/kcp.kubeconfig' };
45
+ jest.clearAllMocks();
46
+ process.env = {
47
+ ...OLD_ENV,
48
+ KUBECONFIG_KCP: '/tmp/kcp.kubeconfig',
49
+ BASE_DOMAINS_DEFAULT: 'example.com',
50
+ };
51
+
52
+ mockGetCurrentCluster.mockReturnValue({
53
+ server: 'https://kcp.example.com/base',
54
+ name: 'test-cluster',
55
+ });
56
+
57
+ mockMakeApiClient.mockReturnValue({
58
+ listClusterCustomObject: mockListClusterCustomObject,
59
+ readNamespacedSecret: mockReadNamespacedSecret,
60
+ });
33
61
  });
34
62
 
35
63
  afterAll(() => {
36
64
  process.env = OLD_ENV;
37
65
  });
38
66
 
39
- it('initializes k8s client and baseUrl from kubeconfig', () => {
40
- const svc = new KcpKubernetesService();
41
- expect(svc.getKcpK8sApiClient()).toBeDefined();
42
- expect(svc.getKcpWorkspaceUrl('org1', 'acc1').toString()).toBe(
43
- 'https://kcp.example.com/clusters/root:orgs:org1:acc1',
44
- );
45
- });
67
+ describe('initialization', () => {
68
+ it('initializes k8s client and baseUrl from kubeconfig', () => {
69
+ const svc = new KcpKubernetesService();
70
+ expect(svc.getKcpK8sCustomObjectsApiOIDCUser()).toBeDefined();
71
+ expect(svc.getKcpWorkspaceUrl('org1', 'acc1').toString()).toBe(
72
+ 'https://kcp.example.com/clusters/root:orgs:org1:acc1',
73
+ );
74
+ });
46
75
 
47
- it('builds workspace url without account', () => {
48
- const svc = new KcpKubernetesService();
49
- expect(svc.getKcpWorkspaceUrl('org1', '').toString()).toBe(
50
- 'https://kcp.example.com/clusters/root:orgs:org1',
51
- );
52
- });
76
+ it('calls kubeconfig methods for OIDC user setup', () => {
77
+ new KcpKubernetesService();
78
+ expect(mockLoadFromFile).toHaveBeenCalledWith('/tmp/kcp.kubeconfig');
79
+ expect(mockAddUser).toHaveBeenCalledWith({ name: 'oidc' });
80
+ expect(mockAddContext).toHaveBeenCalledWith({
81
+ name: 'oidc',
82
+ user: 'oidc',
83
+ cluster: 'test-cluster',
84
+ });
85
+ expect(mockSetCurrentContext).toHaveBeenCalledWith('oidc');
86
+ });
53
87
 
54
- it('builds virtual workspace url with account', () => {
55
- const svc = new KcpKubernetesService();
56
- expect(svc.getKcpVirtualWorkspaceUrl('orgX', 'accY').toString()).toBe(
57
- 'https://kcp.example.com/services/contentconfigurations/clusters/root:orgs:orgX:accY',
58
- );
88
+ it('creates multiple API clients', () => {
89
+ new KcpKubernetesService();
90
+ expect(mockMakeApiClient).toHaveBeenCalledTimes(3);
91
+ });
59
92
  });
60
93
 
61
- it('builds virtual workspace url without account', () => {
62
- const svc = new KcpKubernetesService();
63
- expect(svc.getKcpVirtualWorkspaceUrl('orgX', '').toString()).toBe(
64
- 'https://kcp.example.com/services/contentconfigurations/clusters/root:orgs:orgX',
65
- );
94
+ describe('getters', () => {
95
+ it('returns custom objects API for OIDC user', () => {
96
+ const svc = new KcpKubernetesService();
97
+ const api = svc.getKcpK8sCustomObjectsApiOIDCUser();
98
+ expect(api).toBeDefined();
99
+ });
100
+
101
+ it('returns custom objects API', () => {
102
+ const svc = new KcpKubernetesService();
103
+ const api = svc.getKcpK8sCustomObjectsApi();
104
+ expect(api).toBeDefined();
105
+ });
106
+
107
+ it('returns core v1 API', () => {
108
+ const svc = new KcpKubernetesService();
109
+ const api = svc.getKcpK8sCoreV1Api();
110
+ expect(api).toBeDefined();
111
+ });
66
112
  });
67
113
 
68
- describe('KcpKubernetesService - getKcpWorkspacePublicUrl', () => {
69
- const ORIGINAL_ENV = process.env;
114
+ describe('workspace URL building', () => {
115
+ it('builds workspace url with organization and account', () => {
116
+ const svc = new KcpKubernetesService();
117
+ expect(svc.getKcpWorkspaceUrl('org1', 'acc1').toString()).toBe(
118
+ 'https://kcp.example.com/clusters/root:orgs:org1:acc1',
119
+ );
120
+ });
70
121
 
71
- beforeEach(() => {
72
- jest.resetModules();
73
- process.env = { ...ORIGINAL_ENV };
74
- process.env.BASE_DOMAINS_DEFAULT = 'example.com';
75
- process.env.KUBECONFIG_KCP = __filename; // loadFromFile is called; path must exist
122
+ it('builds workspace url without account', () => {
123
+ const svc = new KcpKubernetesService();
124
+ expect(svc.getKcpWorkspaceUrl('org1', '').toString()).toBe(
125
+ 'https://kcp.example.com/clusters/root:orgs:org1',
126
+ );
76
127
  });
77
128
 
78
- afterEach(() => {
79
- process.env = ORIGINAL_ENV;
80
- jest.restoreAllMocks();
129
+ it('builds workspace url without organization', () => {
130
+ const svc = new KcpKubernetesService();
131
+ expect(svc.getKcpWorkspaceUrl('', '').toString()).toBe(
132
+ 'https://kcp.example.com/clusters/root:orgs',
133
+ );
81
134
  });
82
135
 
83
- const createService = () => {
84
- // Mock KubeConfig internals to avoid reading real kubeconfig/server
85
- const loadFromFile = jest.fn();
86
- const addUser = jest.fn();
87
- const addContext = jest.fn();
88
- const setCurrentContext = jest.fn();
89
- const getCurrentCluster = jest.fn(
90
- () => ({ name: 'c', server: 'https://kcp.internal' }) as any,
136
+ it('builds workspace url with only organization', () => {
137
+ const svc = new KcpKubernetesService();
138
+ expect(svc.getKcpWorkspaceUrl('orgX').toString()).toBe(
139
+ 'https://kcp.example.com/clusters/root:orgs:orgX',
91
140
  );
92
- const makeApiClient = jest.fn(() => ({}));
141
+ });
93
142
 
94
- jest.doMock('@kubernetes/client-node', () => {
95
- return {
96
- KubeConfig: jest.fn().mockImplementation(() => ({
97
- loadFromFile,
98
- addUser,
99
- addContext,
100
- setCurrentContext,
101
- getCurrentCluster,
102
- makeApiClient,
103
- })),
104
- CustomObjectsApi: jest.fn(),
105
- };
106
- });
143
+ it('builds workspace url without parameters', () => {
144
+ const svc = new KcpKubernetesService();
145
+ expect(svc.getKcpWorkspaceUrl().toString()).toBe(
146
+ 'https://kcp.example.com/clusters/root:orgs',
147
+ );
148
+ });
149
+ });
107
150
 
108
- // Re-require module to use mocked client-node
109
- const { KcpKubernetesService: Svc } = require('./kcp-k8s.service');
110
- return new Svc() as KcpKubernetesService;
111
- };
151
+ describe('virtual workspace URL building', () => {
152
+ it('builds virtual workspace url with account', () => {
153
+ const svc = new KcpKubernetesService();
154
+ expect(svc.getKcpVirtualWorkspaceUrl('orgX', 'accY').toString()).toBe(
155
+ 'https://kcp.example.com/services/contentconfigurations/clusters/root:orgs:orgX:accY',
156
+ );
157
+ });
158
+
159
+ it('builds virtual workspace url without account', () => {
160
+ const svc = new KcpKubernetesService();
161
+ expect(svc.getKcpVirtualWorkspaceUrl('orgX', '').toString()).toBe(
162
+ 'https://kcp.example.com/services/contentconfigurations/clusters/root:orgs:orgX',
163
+ );
164
+ });
165
+ });
112
166
 
167
+ describe('getKcpWorkspacePublicUrl', () => {
113
168
  const makeReq = (overrides: Partial<Request> = {}): Request =>
114
169
  ({
115
170
  headers: {},
@@ -118,7 +173,7 @@ describe('KcpKubernetesService', () => {
118
173
  }) as unknown as Request;
119
174
 
120
175
  it('builds URL with organization and account from query', () => {
121
- const svc = createService();
176
+ const svc = new KcpKubernetesService();
122
177
  const req = makeReq({
123
178
  query: { 'core_platform-mesh_io_account': 'acc-1' } as any,
124
179
  headers: { host: 'kcp.api.example.com' } as any,
@@ -130,18 +185,20 @@ describe('KcpKubernetesService', () => {
130
185
  );
131
186
  });
132
187
 
133
- it('omits port for standard ports 80/443 and empty', () => {
134
- const svc = createService();
135
-
136
- let req = makeReq({
188
+ it('omits port for standard port 80', () => {
189
+ const svc = new KcpKubernetesService();
190
+ const req = makeReq({
137
191
  query: { 'core_platform-mesh_io_account': 'acc' } as any,
138
192
  headers: { host: 'kcp.api.example.com:80' } as any,
139
193
  });
140
194
  expect(svc.getKcpWorkspacePublicUrl(req)).toBe(
141
195
  'https://kcp.api.example.com/clusters/root:orgs:org-1:acc',
142
196
  );
197
+ });
143
198
 
144
- req = makeReq({
199
+ it('omits port for standard port 443', () => {
200
+ const svc = new KcpKubernetesService();
201
+ const req = makeReq({
145
202
  query: { 'core_platform-mesh_io_account': 'acc' } as any,
146
203
  headers: {
147
204
  'x-forwarded-port': '443',
@@ -151,8 +208,11 @@ describe('KcpKubernetesService', () => {
151
208
  expect(svc.getKcpWorkspacePublicUrl(req)).toBe(
152
209
  'https://kcp.api.example.com/clusters/root:orgs:org-1:acc',
153
210
  );
211
+ });
154
212
 
155
- req = makeReq({
213
+ it('omits port when no port provided', () => {
214
+ const svc = new KcpKubernetesService();
215
+ const req = makeReq({
156
216
  query: { 'core_platform-mesh_io_account': 'acc' } as any,
157
217
  headers: { host: 'kcp.api.example.com' } as any,
158
218
  });
@@ -162,7 +222,7 @@ describe('KcpKubernetesService', () => {
162
222
  });
163
223
 
164
224
  it('appends non-standard port from x-forwarded-port', () => {
165
- const svc = createService();
225
+ const svc = new KcpKubernetesService();
166
226
  const req = makeReq({
167
227
  query: { 'core_platform-mesh_io_account': 'acc' } as any,
168
228
  headers: {
@@ -177,8 +237,24 @@ describe('KcpKubernetesService', () => {
177
237
  );
178
238
  });
179
239
 
240
+ it('handles x-forwarded-port as array', () => {
241
+ const svc = new KcpKubernetesService();
242
+ const req = makeReq({
243
+ query: { 'core_platform-mesh_io_account': 'acc' } as any,
244
+ headers: {
245
+ 'x-forwarded-port': ['9000', '8000'],
246
+ host: 'kcp.api.example.com',
247
+ } as any,
248
+ });
249
+
250
+ const url = svc.getKcpWorkspacePublicUrl(req);
251
+ expect(url).toBe(
252
+ 'https://kcp.api.example.com:9000/clusters/root:orgs:org-1:acc',
253
+ );
254
+ });
255
+
180
256
  it('falls back to port from host header when x-forwarded-port not present', () => {
181
- const svc = createService();
257
+ const svc = new KcpKubernetesService();
182
258
  const req = makeReq({
183
259
  query: { 'core_platform-mesh_io_account': 'acc' } as any,
184
260
  headers: { host: 'kcp.api.example.com:3000' } as any,
@@ -192,7 +268,7 @@ describe('KcpKubernetesService', () => {
192
268
 
193
269
  it('uses FRONTEND_PORT env when provided', () => {
194
270
  process.env.FRONTEND_PORT = '4200';
195
- const svc = createService();
271
+ const svc = new KcpKubernetesService();
196
272
  const req = makeReq({
197
273
  query: { 'core_platform-mesh_io_account': 'acc' } as any,
198
274
  headers: { host: 'kcp.api.example.com' } as any,
@@ -203,5 +279,342 @@ describe('KcpKubernetesService', () => {
203
279
  'https://kcp.api.example.com:4200/clusters/root:orgs:org-1:acc',
204
280
  );
205
281
  });
282
+
283
+ it('prioritizes FRONTEND_PORT over x-forwarded-port', () => {
284
+ process.env.FRONTEND_PORT = '5000';
285
+ const svc = new KcpKubernetesService();
286
+ const req = makeReq({
287
+ query: { 'core_platform-mesh_io_account': 'acc' } as any,
288
+ headers: {
289
+ 'x-forwarded-port': '8080',
290
+ host: 'kcp.api.example.com',
291
+ } as any,
292
+ });
293
+
294
+ const url = svc.getKcpWorkspacePublicUrl(req);
295
+ expect(url).toBe(
296
+ 'https://kcp.api.example.com:5000/clusters/root:orgs:org-1:acc',
297
+ );
298
+ });
299
+
300
+ it('builds URL without account when not in query', () => {
301
+ const svc = new KcpKubernetesService();
302
+ const req = makeReq({
303
+ query: {} as any,
304
+ headers: { host: 'kcp.api.example.com' } as any,
305
+ });
306
+
307
+ const url = svc.getKcpWorkspacePublicUrl(req);
308
+ expect(url).toBe('https://kcp.api.example.com/clusters/root:orgs:org-1');
309
+ });
310
+ });
311
+
312
+ describe('listClusterCustomObject', () => {
313
+ it('calls API with correct workspace URL and parameters', async () => {
314
+ const svc = new KcpKubernetesService();
315
+ const gvr: K8sResourceDescriptor = {
316
+ group: 'apps',
317
+ version: 'v1',
318
+ plural: 'deployments',
319
+ name: 'my-deployment',
320
+ };
321
+ const context: K8sRequestContext = {
322
+ organization: 'test-org',
323
+ 'core_platform-mesh_io_account': 'test-account',
324
+ };
325
+
326
+ mockListClusterCustomObject.mockResolvedValue({ data: {} });
327
+
328
+ await svc.listClusterCustomObject(gvr, context);
329
+
330
+ expect(mockListClusterCustomObject).toHaveBeenCalledWith(
331
+ gvr,
332
+ expect.objectContaining({
333
+ middleware: expect.any(Array),
334
+ }),
335
+ );
336
+ });
337
+
338
+ it('builds correct URL path in middleware', async () => {
339
+ const svc = new KcpKubernetesService();
340
+ const gvr: K8sResourceDescriptor = {
341
+ group: 'batch',
342
+ version: 'v1',
343
+ plural: 'jobs',
344
+ name: 'test-job',
345
+ };
346
+ const context: K8sRequestContext = {
347
+ organization: 'org1',
348
+ 'core_platform-mesh_io_account': 'acc1',
349
+ };
350
+
351
+ let capturedContext: any;
352
+ mockListClusterCustomObject.mockImplementation(
353
+ async (gvrParam, options) => {
354
+ const middleware = options.middleware[0];
355
+ const mockContext = {
356
+ setUrl: jest.fn(),
357
+ };
358
+ capturedContext = mockContext;
359
+ await middleware.options.pre(mockContext);
360
+ return { data: {} };
361
+ },
362
+ );
363
+
364
+ await svc.listClusterCustomObject(gvr, context);
365
+
366
+ expect(capturedContext.setUrl).toHaveBeenCalledWith(
367
+ 'https://kcp.example.com/clusters/root:orgs:org1:acc1/apis/batch/v1/jobs/test-job',
368
+ );
369
+ });
370
+
371
+ it('handles context without account', async () => {
372
+ const svc = new KcpKubernetesService();
373
+ const gvr: K8sResourceDescriptor = {
374
+ group: 'core',
375
+ version: 'v1',
376
+ plural: 'pods',
377
+ name: 'my-pod',
378
+ };
379
+ const context: K8sRequestContext = {
380
+ organization: 'org2',
381
+ };
382
+
383
+ let capturedContext: any;
384
+ mockListClusterCustomObject.mockImplementation(
385
+ async (gvrParam, options) => {
386
+ const middleware = options.middleware[0];
387
+ const mockContext = {
388
+ setUrl: jest.fn(),
389
+ };
390
+ capturedContext = mockContext;
391
+ await middleware.options.pre(mockContext);
392
+ return { data: {} };
393
+ },
394
+ );
395
+
396
+ await svc.listClusterCustomObject(gvr, context);
397
+
398
+ expect(capturedContext.setUrl).toHaveBeenCalledWith(
399
+ 'https://kcp.example.com/clusters/root:orgs:org2/apis/core/v1/pods/my-pod',
400
+ );
401
+ });
402
+ });
403
+
404
+ describe('listClusterCustomObjectInKcpVirtualWorkspace', () => {
405
+ it('calls OIDC API with correct virtual workspace URL and token', async () => {
406
+ const svc = new KcpKubernetesService();
407
+ const gvr: K8sResourceDescriptor = {
408
+ group: 'networking.k8s.io',
409
+ version: 'v1',
410
+ plural: 'ingresses',
411
+ name: undefined,
412
+ };
413
+ const context: K8sRequestContext = {
414
+ organization: 'virtual-org',
415
+ 'core_platform-mesh_io_account': 'virtual-acc',
416
+ };
417
+ const token = 'test-bearer-token';
418
+
419
+ mockListClusterCustomObject.mockResolvedValue({ data: [] });
420
+
421
+ await svc.listClusterCustomObjectInKcpVirtualWorkspace(
422
+ gvr,
423
+ context,
424
+ token,
425
+ );
426
+
427
+ expect(mockListClusterCustomObject).toHaveBeenCalled();
428
+ });
429
+
430
+ it('sets Authorization header with Bearer token', async () => {
431
+ const svc = new KcpKubernetesService();
432
+ const gvr: K8sResourceDescriptor = {
433
+ group: 'apps',
434
+ version: 'v1',
435
+ plural: 'statefulsets',
436
+ name: undefined,
437
+ };
438
+ const context: K8sRequestContext = {
439
+ organization: 'auth-org',
440
+ 'core_platform-mesh_io_account': 'auth-acc',
441
+ };
442
+ const token = 'my-secret-token';
443
+
444
+ let capturedContext: any;
445
+ mockListClusterCustomObject.mockImplementation(
446
+ async (gvrParam, options) => {
447
+ const middleware = options.middleware[0];
448
+ const mockContext = {
449
+ setUrl: jest.fn(),
450
+ setHeaderParam: jest.fn(),
451
+ };
452
+ capturedContext = mockContext;
453
+ await middleware.options.pre(mockContext);
454
+ return { data: [] };
455
+ },
456
+ );
457
+
458
+ await svc.listClusterCustomObjectInKcpVirtualWorkspace(
459
+ gvr,
460
+ context,
461
+ token,
462
+ );
463
+
464
+ expect(capturedContext.setHeaderParam).toHaveBeenCalledWith(
465
+ 'Authorization',
466
+ 'Bearer my-secret-token',
467
+ );
468
+ });
469
+
470
+ it('builds correct virtual workspace URL path', async () => {
471
+ const svc = new KcpKubernetesService();
472
+ const gvr: K8sResourceDescriptor = {
473
+ group: 'rbac.authorization.k8s.io',
474
+ version: 'v1',
475
+ plural: 'roles',
476
+ name: undefined,
477
+ };
478
+ const context: K8sRequestContext = {
479
+ organization: 'rbac-org',
480
+ 'core_platform-mesh_io_account': 'rbac-acc',
481
+ };
482
+
483
+ let capturedContext: any;
484
+ mockListClusterCustomObject.mockImplementation(
485
+ async (gvrParam, options) => {
486
+ const middleware = options.middleware[0];
487
+ const mockContext = {
488
+ setUrl: jest.fn(),
489
+ setHeaderParam: jest.fn(),
490
+ };
491
+ capturedContext = mockContext;
492
+ await middleware.options.pre(mockContext);
493
+ return { data: [] };
494
+ },
495
+ );
496
+
497
+ await svc.listClusterCustomObjectInKcpVirtualWorkspace(
498
+ gvr,
499
+ context,
500
+ 'token',
501
+ );
502
+
503
+ expect(capturedContext.setUrl).toHaveBeenCalledWith(
504
+ 'https://kcp.example.com/services/contentconfigurations/clusters/root:orgs:rbac-org:rbac-acc/apis/rbac.authorization.k8s.io/v1/roles',
505
+ );
506
+ });
507
+ });
508
+
509
+ describe('getClientSecret', () => {
510
+ it('retrieves and decodes client secret successfully', async () => {
511
+ const svc = new KcpKubernetesService();
512
+ const orgName = 'test-org';
513
+ const encodedSecret = Buffer.from('my-secret-value').toString('base64');
514
+
515
+ mockReadNamespacedSecret.mockResolvedValue({
516
+ data: {
517
+ client_secret: encodedSecret,
518
+ },
519
+ });
520
+
521
+ const result = await svc.getClientSecret(orgName);
522
+
523
+ expect(result).toBe('my-secret-value');
524
+ expect(mockReadNamespacedSecret).toHaveBeenCalledWith(
525
+ {
526
+ namespace: 'default',
527
+ name: 'portal-client-secret-test-org-test-org',
528
+ },
529
+ expect.objectContaining({
530
+ middleware: expect.any(Array),
531
+ }),
532
+ );
533
+ });
534
+
535
+ it('builds correct secret name and namespace', async () => {
536
+ const svc = new KcpKubernetesService();
537
+ const orgName = 'my-company';
538
+
539
+ mockReadNamespacedSecret.mockResolvedValue({
540
+ data: {
541
+ client_secret: Buffer.from('secret').toString('base64'),
542
+ },
543
+ });
544
+
545
+ await svc.getClientSecret(orgName);
546
+
547
+ expect(mockReadNamespacedSecret).toHaveBeenCalledWith(
548
+ {
549
+ namespace: 'default',
550
+ name: 'portal-client-secret-my-company-my-company',
551
+ },
552
+ expect.any(Object),
553
+ );
554
+ });
555
+
556
+ it('uses correct workspace URL in middleware', async () => {
557
+ const svc = new KcpKubernetesService();
558
+ const orgName = 'url-org';
559
+
560
+ let capturedContext: any;
561
+ mockReadNamespacedSecret.mockImplementation(async (params, options) => {
562
+ const middleware = options.middleware[0];
563
+ const mockContext = {
564
+ setUrl: jest.fn(),
565
+ };
566
+ capturedContext = mockContext;
567
+ await middleware.options.pre(mockContext);
568
+ return {
569
+ data: {
570
+ client_secret: Buffer.from('test').toString('base64'),
571
+ },
572
+ };
573
+ });
574
+
575
+ await svc.getClientSecret(orgName);
576
+
577
+ expect(capturedContext.setUrl).toHaveBeenCalledWith(
578
+ 'https://kcp.example.com/clusters/root:orgs/api/v1/namespaces/default/secrets/portal-client-secret-url-org-url-org',
579
+ );
580
+ });
581
+
582
+ it('throws error when secret retrieval fails', async () => {
583
+ const svc = new KcpKubernetesService();
584
+ const orgName = 'fail-org';
585
+ const error = new Error('Secret not found');
586
+
587
+ mockReadNamespacedSecret.mockRejectedValue(error);
588
+
589
+ await expect(svc.getClientSecret(orgName)).rejects.toThrow(
590
+ 'Secret not found',
591
+ );
592
+ });
593
+
594
+ it('logs error with secret name when retrieval fails', async () => {
595
+ const svc = new KcpKubernetesService();
596
+ const orgName = 'error-org';
597
+ const error = {
598
+ response: {
599
+ body: { message: 'Not found' },
600
+ },
601
+ };
602
+
603
+ mockReadNamespacedSecret.mockRejectedValue(error);
604
+
605
+ await expect(svc.getClientSecret(orgName)).rejects.toEqual(error);
606
+ });
607
+
608
+ it('handles error without response body', async () => {
609
+ const svc = new KcpKubernetesService();
610
+ const orgName = 'no-response-org';
611
+ const error = new Error('Network error');
612
+
613
+ mockReadNamespacedSecret.mockRejectedValue(error);
614
+
615
+ await expect(svc.getClientSecret(orgName)).rejects.toThrow(
616
+ 'Network error',
617
+ );
618
+ });
206
619
  });
207
620
  });