@platform-mesh/portal-server-lib 0.0.0 → 0.5.3
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/.github/workflows/pipeline.yaml +20 -0
- package/.prettierrc.mjs +6 -0
- package/CODEOWNERS +4 -0
- package/CODE_OF_CONDUCT.md +86 -0
- package/CONTRIBUTING.md +40 -0
- package/LICENSE +201 -0
- package/LICENSES/Apache-2.0.txt +73 -0
- package/README.md +41 -0
- package/base.jest.config.js +15 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/portal-options/account-entity-context-provider.service.d.ts +4 -0
- package/dist/portal-options/account-entity-context-provider.service.js +35 -0
- package/dist/portal-options/account-entity-context-provider.service.js.map +1 -0
- package/dist/portal-options/auth-callback-provider.d.ts +10 -0
- package/dist/portal-options/auth-callback-provider.js +36 -0
- package/dist/portal-options/auth-callback-provider.js.map +1 -0
- package/dist/portal-options/auth-config-provider.d.ts +9 -0
- package/dist/portal-options/auth-config-provider.js +72 -0
- package/dist/portal-options/auth-config-provider.js.map +1 -0
- package/dist/portal-options/index.d.ts +10 -0
- package/dist/portal-options/index.js +11 -0
- package/dist/portal-options/index.js.map +1 -0
- package/dist/portal-options/logout-callback.service.d.ts +12 -0
- package/dist/portal-options/logout-callback.service.js +63 -0
- package/dist/portal-options/logout-callback.service.js.map +1 -0
- package/dist/portal-options/models/luigi-context.d.ts +4 -0
- package/dist/portal-options/models/luigi-context.js +2 -0
- package/dist/portal-options/models/luigi-context.js.map +1 -0
- package/dist/portal-options/pm-portal-context.service.d.ts +11 -0
- package/dist/portal-options/pm-portal-context.service.js +50 -0
- package/dist/portal-options/pm-portal-context.service.js.map +1 -0
- package/dist/portal-options/pm-request-context-provider.d.ts +13 -0
- package/dist/portal-options/pm-request-context-provider.js +34 -0
- package/dist/portal-options/pm-request-context-provider.js.map +1 -0
- package/dist/portal-options/service-providers/content-configuration-service-providers.service.d.ts +5 -0
- package/dist/portal-options/service-providers/content-configuration-service-providers.service.js +83 -0
- package/dist/portal-options/service-providers/content-configuration-service-providers.service.js.map +1 -0
- package/dist/portal-options/service-providers/contentconfigurations-query.d.ts +1 -0
- package/dist/portal-options/service-providers/contentconfigurations-query.js +22 -0
- package/dist/portal-options/service-providers/contentconfigurations-query.js.map +1 -0
- package/dist/portal-options/service-providers/kubernetes-service-providers.service.d.ts +8 -0
- package/dist/portal-options/service-providers/kubernetes-service-providers.service.js +98 -0
- package/dist/portal-options/service-providers/kubernetes-service-providers.service.js.map +1 -0
- package/dist/portal-options/service-providers/models/contentconfigurations.d.ts +20 -0
- package/dist/portal-options/service-providers/models/contentconfigurations.js +2 -0
- package/dist/portal-options/service-providers/models/contentconfigurations.js.map +1 -0
- package/dist/portal-options/service-providers/models/welcome-node-config.d.ts +2 -0
- package/dist/portal-options/service-providers/models/welcome-node-config.js +35 -0
- package/dist/portal-options/service-providers/models/welcome-node-config.js.map +1 -0
- package/dist/portal-options/services/iam-graphql.service.d.ts +7 -0
- package/dist/portal-options/services/iam-graphql.service.js +40 -0
- package/dist/portal-options/services/iam-graphql.service.js.map +1 -0
- package/dist/portal-options/services/kcp-k8s.service.d.ts +11 -0
- package/dist/portal-options/services/kcp-k8s.service.js +60 -0
- package/dist/portal-options/services/kcp-k8s.service.js.map +1 -0
- package/dist/portal-options/services/queries.d.ts +1 -0
- package/dist/portal-options/services/queries.js +7 -0
- package/dist/portal-options/services/queries.js.map +1 -0
- package/dist/portal-options/utils/domain.d.ts +3 -0
- package/dist/portal-options/utils/domain.js +11 -0
- package/dist/portal-options/utils/domain.js.map +1 -0
- package/eslint.config.mjs +27 -0
- package/jest.config.ts +42 -0
- package/nest-cli.json +6 -0
- package/package.json +85 -2
- package/renovate.json +6 -0
- package/src/index.ts +1 -0
- package/src/portal-options/account-entity-context-provider.service.ts +30 -0
- package/src/portal-options/auth-callback-provider.spec.ts +85 -0
- package/src/portal-options/auth-callback-provider.ts +27 -0
- package/src/portal-options/auth-config-provider.spec.ts +101 -0
- package/src/portal-options/auth-config-provider.ts +82 -0
- package/src/portal-options/index.ts +11 -0
- package/src/portal-options/logout-callback.service.spec.ts +113 -0
- package/src/portal-options/logout-callback.service.ts +60 -0
- package/src/portal-options/models/luigi-context.ts +4 -0
- package/src/portal-options/pm-portal-context.service.spec.ts +155 -0
- package/src/portal-options/pm-portal-context.service.ts +63 -0
- package/src/portal-options/pm-request-context-provider.spec.ts +69 -0
- package/src/portal-options/pm-request-context-provider.ts +33 -0
- package/src/portal-options/service-providers/content-configuration-service-providers.service.spec.ts +157 -0
- package/src/portal-options/service-providers/content-configuration-service-providers.service.ts +130 -0
- package/src/portal-options/service-providers/contentconfigurations-query.ts +22 -0
- package/src/portal-options/service-providers/kubernetes-service-providers.service.spec.ts +197 -0
- package/src/portal-options/service-providers/kubernetes-service-providers.service.ts +115 -0
- package/src/portal-options/service-providers/models/contentconfigurations.ts +13 -0
- package/src/portal-options/service-providers/models/welcome-node-config.ts +36 -0
- package/src/portal-options/services/iam-graphql.service.spec.ts +77 -0
- package/src/portal-options/services/iam-graphql.service.ts +33 -0
- package/src/portal-options/services/kcp-k8s.service.spec.ts +78 -0
- package/src/portal-options/services/kcp-k8s.service.ts +56 -0
- package/src/portal-options/services/queries.ts +7 -0
- package/src/portal-options/utils/domain.spec.ts +114 -0
- package/src/portal-options/utils/domain.ts +13 -0
- package/tsconfig.build.json +10 -0
- package/tsconfig.json +18 -0
- package/tsconfig.test.json +3 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { getDiscoveryEndpoint, getOrganization } from './utils/domain.js';
|
|
2
|
+
import { CoreV1Api, KubeConfig } from '@kubernetes/client-node';
|
|
3
|
+
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
|
4
|
+
import {
|
|
5
|
+
AuthConfigService,
|
|
6
|
+
DiscoveryService,
|
|
7
|
+
ServerAuthVariables,
|
|
8
|
+
} from '@openmfp/portal-server-lib';
|
|
9
|
+
import type { Request } from 'express';
|
|
10
|
+
|
|
11
|
+
@Injectable()
|
|
12
|
+
export class PMAuthConfigProvider implements AuthConfigService {
|
|
13
|
+
private k8sApi: CoreV1Api;
|
|
14
|
+
|
|
15
|
+
constructor(private discoveryService: DiscoveryService) {
|
|
16
|
+
const kc = new KubeConfig();
|
|
17
|
+
kc.loadFromDefault();
|
|
18
|
+
this.k8sApi = kc.makeApiClient(CoreV1Api);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async getAuthConfig(request: Request): Promise<ServerAuthVariables> {
|
|
22
|
+
const oidcUrl = getDiscoveryEndpoint(request);
|
|
23
|
+
const clientId = getOrganization(request);
|
|
24
|
+
|
|
25
|
+
const baseDomain = process.env['BASE_DOMAINS_DEFAULT'];
|
|
26
|
+
const clientSecret = await this.getClientSecret(clientId);
|
|
27
|
+
const oidc = await this.discoveryService.getOIDC(oidcUrl);
|
|
28
|
+
const oauthServerUrl =
|
|
29
|
+
oidc?.authorization_endpoint ?? process.env['AUTH_SERVER_URL_DEFAULT'];
|
|
30
|
+
const oauthTokenUrl =
|
|
31
|
+
oidc?.token_endpoint ?? process.env['TOKEN_URL_DEFAULT'];
|
|
32
|
+
|
|
33
|
+
if (!oauthServerUrl || !oauthTokenUrl || !clientId || !clientSecret) {
|
|
34
|
+
const hasClientSecret = !!clientSecret;
|
|
35
|
+
throw new HttpException(
|
|
36
|
+
{
|
|
37
|
+
message: 'Default auth configuration incomplete.',
|
|
38
|
+
error: `The default properly configured. oauthServerUrl: '${oauthServerUrl}' oauthTokenUrl: '${oauthTokenUrl}' clientId: '${clientId}', has client secret: ${String(
|
|
39
|
+
hasClientSecret,
|
|
40
|
+
)}`,
|
|
41
|
+
statusCode: HttpStatus.NOT_FOUND,
|
|
42
|
+
},
|
|
43
|
+
HttpStatus.NOT_FOUND,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
idpName: clientId,
|
|
49
|
+
baseDomain,
|
|
50
|
+
clientId,
|
|
51
|
+
clientSecret,
|
|
52
|
+
oauthServerUrl,
|
|
53
|
+
oauthTokenUrl,
|
|
54
|
+
oidcIssuerUrl: oidc?.issuer,
|
|
55
|
+
endSessionUrl: oidc?.end_session_endpoint,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private async getClientSecret(orgName: string) {
|
|
60
|
+
const secretName = `portal-client-secret-${orgName}`;
|
|
61
|
+
const namespace = 'platform-mesh-system';
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const res = await this.k8sApi.readNamespacedSecret({
|
|
65
|
+
namespace,
|
|
66
|
+
name: secretName,
|
|
67
|
+
});
|
|
68
|
+
const secretData = res.data;
|
|
69
|
+
|
|
70
|
+
return Buffer.from(
|
|
71
|
+
secretData['attribute.client_secret'],
|
|
72
|
+
'base64',
|
|
73
|
+
).toString('utf-8');
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error(
|
|
76
|
+
`Failed to fetch secret ${secretName}:`,
|
|
77
|
+
err.response?.body || err,
|
|
78
|
+
);
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from './account-entity-context-provider.service.js';
|
|
2
|
+
export * from './pm-portal-context.service.js';
|
|
3
|
+
export * from './pm-request-context-provider.js';
|
|
4
|
+
export * from './auth-config-provider.js';
|
|
5
|
+
export * from './service-providers/content-configuration-service-providers.service.js';
|
|
6
|
+
export * from './service-providers/kubernetes-service-providers.service.js';
|
|
7
|
+
export * from './auth-callback-provider.js';
|
|
8
|
+
export * from './logout-callback.service.js';
|
|
9
|
+
|
|
10
|
+
export * from './services/kcp-k8s.service.js';
|
|
11
|
+
export * from './services/iam-graphql.service.js';
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { PMLogoutService } from './logout-callback.service.js';
|
|
2
|
+
import { HttpService } from '@nestjs/axios';
|
|
3
|
+
import { Test, TestingModule } from '@nestjs/testing';
|
|
4
|
+
import {
|
|
5
|
+
AUTH_CONFIG_INJECTION_TOKEN,
|
|
6
|
+
AuthConfigService,
|
|
7
|
+
CookiesService,
|
|
8
|
+
} from '@openmfp/portal-server-lib';
|
|
9
|
+
import { Request, Response } from 'express';
|
|
10
|
+
import { of, throwError } from 'rxjs';
|
|
11
|
+
|
|
12
|
+
describe('PMLogoutService', () => {
|
|
13
|
+
let service: PMLogoutService;
|
|
14
|
+
let httpService: HttpService;
|
|
15
|
+
let authConfigService: AuthConfigService;
|
|
16
|
+
let cookiesService: CookiesService;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
20
|
+
providers: [
|
|
21
|
+
PMLogoutService,
|
|
22
|
+
{
|
|
23
|
+
provide: HttpService,
|
|
24
|
+
useValue: { post: jest.fn() },
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
provide: AUTH_CONFIG_INJECTION_TOKEN,
|
|
28
|
+
useValue: { getAuthConfig: jest.fn() },
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
provide: CookiesService,
|
|
32
|
+
useValue: { getAuthCookie: jest.fn() },
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
provide: 'AUTH_CONFIG_INJECTION_TOKEN',
|
|
36
|
+
useValue: {},
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
}).compile();
|
|
40
|
+
|
|
41
|
+
service = module.get(PMLogoutService);
|
|
42
|
+
httpService = module.get(HttpService);
|
|
43
|
+
authConfigService = module.get(AUTH_CONFIG_INJECTION_TOKEN);
|
|
44
|
+
cookiesService = module.get(CookiesService);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should call endSessionUrl with refresh token', async () => {
|
|
48
|
+
const mockRequest = {} as Request;
|
|
49
|
+
const mockResponse = {} as Response;
|
|
50
|
+
|
|
51
|
+
(authConfigService.getAuthConfig as jest.Mock).mockResolvedValue({
|
|
52
|
+
clientId: 'client-id',
|
|
53
|
+
clientSecret: 'secret',
|
|
54
|
+
endSessionUrl: 'https://keycloak/logout',
|
|
55
|
+
});
|
|
56
|
+
(cookiesService.getAuthCookie as jest.Mock).mockReturnValue(
|
|
57
|
+
'refresh-token',
|
|
58
|
+
);
|
|
59
|
+
(httpService.post as jest.Mock).mockReturnValue(of({ data: {} }));
|
|
60
|
+
|
|
61
|
+
await service.handleLogout(mockRequest, mockResponse);
|
|
62
|
+
|
|
63
|
+
expect(httpService.post).toHaveBeenCalledWith(
|
|
64
|
+
'https://keycloak/logout',
|
|
65
|
+
expect.any(URLSearchParams),
|
|
66
|
+
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should return logoutWithIdToken URL when logout fails', async () => {
|
|
71
|
+
const mockRequest = {
|
|
72
|
+
query: {
|
|
73
|
+
id_token_hint: 'idtoken',
|
|
74
|
+
post_logout_redirect_uri: 'https://redirect.com',
|
|
75
|
+
},
|
|
76
|
+
} as unknown as Request;
|
|
77
|
+
const mockResponse = {} as Response;
|
|
78
|
+
|
|
79
|
+
(authConfigService.getAuthConfig as jest.Mock).mockResolvedValue({
|
|
80
|
+
clientId: 'client-id',
|
|
81
|
+
clientSecret: 'secret',
|
|
82
|
+
endSessionUrl: 'https://keycloak/logout',
|
|
83
|
+
});
|
|
84
|
+
(cookiesService.getAuthCookie as jest.Mock).mockReturnValue(
|
|
85
|
+
'refresh-token',
|
|
86
|
+
);
|
|
87
|
+
(httpService.post as jest.Mock).mockReturnValue(
|
|
88
|
+
throwError(() => new Error('Network error')),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const result = await service.handleLogout(mockRequest, mockResponse);
|
|
92
|
+
|
|
93
|
+
expect(result).toBe(
|
|
94
|
+
'https://keycloak/logout?id_token_hint=idtoken&post_logout_redirect_uri=https%3A%2F%2Fredirect.com',
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('logoutWithIdToken should return valid URL', () => {
|
|
99
|
+
const request = {
|
|
100
|
+
query: {
|
|
101
|
+
id_token_hint: 'token123',
|
|
102
|
+
post_logout_redirect_uri: 'https://example.com',
|
|
103
|
+
},
|
|
104
|
+
} as unknown as Request;
|
|
105
|
+
const result = (service as any).logoutWithIdToken(
|
|
106
|
+
request,
|
|
107
|
+
'https://kc/logout',
|
|
108
|
+
);
|
|
109
|
+
expect(result).toBe(
|
|
110
|
+
'https://kc/logout?id_token_hint=token123&post_logout_redirect_uri=https%3A%2F%2Fexample.com',
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { HttpService } from '@nestjs/axios';
|
|
2
|
+
import { Inject, Injectable, Logger } from '@nestjs/common';
|
|
3
|
+
import {
|
|
4
|
+
AUTH_CONFIG_INJECTION_TOKEN,
|
|
5
|
+
AuthConfigService,
|
|
6
|
+
CookiesService,
|
|
7
|
+
LogoutCallback,
|
|
8
|
+
} from '@openmfp/portal-server-lib';
|
|
9
|
+
import { Request, Response } from 'express';
|
|
10
|
+
import { firstValueFrom } from 'rxjs';
|
|
11
|
+
|
|
12
|
+
@Injectable()
|
|
13
|
+
export class PMLogoutService implements LogoutCallback {
|
|
14
|
+
private logger: Logger = new Logger(PMLogoutService.name);
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
@Inject(AUTH_CONFIG_INJECTION_TOKEN)
|
|
18
|
+
private authConfigService: AuthConfigService,
|
|
19
|
+
private httpService: HttpService,
|
|
20
|
+
private cookiesService: CookiesService,
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
public async handleLogout(
|
|
24
|
+
request: Request,
|
|
25
|
+
response: Response,
|
|
26
|
+
): Promise<void | string> {
|
|
27
|
+
const authConfig = await this.authConfigService.getAuthConfig(request);
|
|
28
|
+
try {
|
|
29
|
+
const refreshToken = this.cookiesService.getAuthCookie(request);
|
|
30
|
+
|
|
31
|
+
const body = new URLSearchParams({
|
|
32
|
+
client_id: authConfig.clientId,
|
|
33
|
+
client_secret: authConfig.clientSecret,
|
|
34
|
+
refresh_token: refreshToken,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
await firstValueFrom(
|
|
38
|
+
this.httpService.post(authConfig.endSessionUrl, body, {
|
|
39
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
} catch (error: any) {
|
|
43
|
+
this.logger.error(
|
|
44
|
+
'Error during keycloak logout',
|
|
45
|
+
error?.response?.data || error.message,
|
|
46
|
+
);
|
|
47
|
+
this.logger.warn('Trying to log out with the id token');
|
|
48
|
+
return this.logoutWithIdToken(request, authConfig.endSessionUrl);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private logoutWithIdToken(request: Request, endSessionUrl: string) {
|
|
53
|
+
const { id_token_hint, post_logout_redirect_uri } = request.query;
|
|
54
|
+
const params = new URLSearchParams({
|
|
55
|
+
id_token_hint: String(id_token_hint || ''),
|
|
56
|
+
post_logout_redirect_uri: String(post_logout_redirect_uri || ''),
|
|
57
|
+
});
|
|
58
|
+
return `${endSessionUrl}?${params}`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { PMPortalContextService } from './pm-portal-context.service.js';
|
|
2
|
+
import { KcpKubernetesService } from './services/kcp-k8s.service.js';
|
|
3
|
+
import { getOrganization } from './utils/domain.js';
|
|
4
|
+
import { Test, TestingModule } from '@nestjs/testing';
|
|
5
|
+
import { Request } from 'express';
|
|
6
|
+
import { mock } from 'jest-mock-extended';
|
|
7
|
+
import process from 'node:process';
|
|
8
|
+
|
|
9
|
+
jest.mock('@kubernetes/client-node', () => {
|
|
10
|
+
class KubeConfig {
|
|
11
|
+
loadFromDefault = jest.fn();
|
|
12
|
+
loadFromFile = jest.fn();
|
|
13
|
+
getCurrentCluster = jest.fn().mockReturnValue({
|
|
14
|
+
server: 'https://k8s.example.com/base',
|
|
15
|
+
name: 'test-cluster',
|
|
16
|
+
});
|
|
17
|
+
makeApiClient = jest.fn();
|
|
18
|
+
addUser = jest.fn();
|
|
19
|
+
addContext = jest.fn();
|
|
20
|
+
setCurrentContext = jest.fn();
|
|
21
|
+
}
|
|
22
|
+
class CustomObjectsApi {}
|
|
23
|
+
return { KubeConfig, CustomObjectsApi };
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
jest.mock('./utils/domain.js', () => ({
|
|
27
|
+
getOrganization: jest.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
describe('PMPortalContextService', () => {
|
|
31
|
+
let service: PMPortalContextService;
|
|
32
|
+
let kcpKubernetesServiceMock: jest.Mocked<KcpKubernetesService>;
|
|
33
|
+
const mockedGetDomainAndOrganization = jest.mocked(getOrganization);
|
|
34
|
+
let mockRequest: any;
|
|
35
|
+
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
kcpKubernetesServiceMock = mock();
|
|
38
|
+
|
|
39
|
+
mockedGetDomainAndOrganization.mockReturnValue('test-org');
|
|
40
|
+
|
|
41
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
42
|
+
providers: [
|
|
43
|
+
PMPortalContextService,
|
|
44
|
+
{ provide: KcpKubernetesService, useValue: kcpKubernetesServiceMock },
|
|
45
|
+
],
|
|
46
|
+
}).compile();
|
|
47
|
+
|
|
48
|
+
service = module.get<PMPortalContextService>(PMPortalContextService);
|
|
49
|
+
mockRequest = {
|
|
50
|
+
hostname: 'test.example.com',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
jest.spyOn(console, 'log').mockImplementation();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
jest.restoreAllMocks();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should be defined', () => {
|
|
61
|
+
expect(service).toBeDefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should return context with kcp workspace url', async () => {
|
|
65
|
+
kcpKubernetesServiceMock.getKcpWorkspacePublicUrl.mockReturnValue(
|
|
66
|
+
'https://kcp.api.example.com/',
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const result = await service.getContextValues(
|
|
70
|
+
mockRequest as Request,
|
|
71
|
+
new Response(),
|
|
72
|
+
{},
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
expect(result).toEqual({
|
|
76
|
+
kcpWorkspaceUrl: 'https://kcp.api.example.com/',
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should return empty context when no environment variables match prefix', async () => {
|
|
81
|
+
const result = await service.getContextValues(
|
|
82
|
+
mockRequest as Request,
|
|
83
|
+
new Response(),
|
|
84
|
+
{},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(result).toEqual({});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should process GraphQL gateway API URL with subdomain when hostname differs from domain', async () => {
|
|
91
|
+
mockedGetDomainAndOrganization.mockReturnValue('test-org');
|
|
92
|
+
|
|
93
|
+
mockRequest.hostname = 'subdomain.example.com';
|
|
94
|
+
|
|
95
|
+
const result = await service.getContextValues(
|
|
96
|
+
mockRequest as Request,
|
|
97
|
+
new Response(),
|
|
98
|
+
{
|
|
99
|
+
crdGatewayApiUrl:
|
|
100
|
+
'https://${org-subdomain}api.example.com/${org-name}/graphql',
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(result.crdGatewayApiUrl).toBe(
|
|
105
|
+
'https://test-org.api.example.com/test-org/graphql',
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should process GraphQL IAM API URL with subdomain', async () => {
|
|
110
|
+
mockedGetDomainAndOrganization.mockReturnValue('test-org');
|
|
111
|
+
|
|
112
|
+
mockRequest.hostname = 'example.com';
|
|
113
|
+
|
|
114
|
+
const result = await service.getContextValues(
|
|
115
|
+
mockRequest as Request,
|
|
116
|
+
new Response(),
|
|
117
|
+
{ iamServiceApiUrl: 'https://${org-subdomain}example.com/iam/graphql' },
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(result.iamServiceApiUrl).toBe(
|
|
121
|
+
'https://test-org.example.com/iam/graphql',
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should process GraphQL gateway API URL without subdomain when hostname matches domain', async () => {
|
|
126
|
+
mockedGetDomainAndOrganization.mockReturnValue('test-org');
|
|
127
|
+
process.env['BASE_DOMAINS_DEFAULT'] = 'example.com';
|
|
128
|
+
mockRequest.hostname = 'example.com';
|
|
129
|
+
|
|
130
|
+
const result = await service.getContextValues(
|
|
131
|
+
mockRequest as Request,
|
|
132
|
+
new Response(),
|
|
133
|
+
{
|
|
134
|
+
crdGatewayApiUrl:
|
|
135
|
+
'https://${org-subdomain}api.example.com/${org-name}/graphql',
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(result.crdGatewayApiUrl).toBe(
|
|
140
|
+
'https://api.example.com/test-org/graphql',
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should handle undefined crdGatewayApiUrl gracefully', async () => {
|
|
145
|
+
const result = await service.getContextValues(
|
|
146
|
+
mockRequest as Request,
|
|
147
|
+
new Response(),
|
|
148
|
+
{ otherKey: 'value' },
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(result).toEqual({
|
|
152
|
+
otherKey: 'value',
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { PortalContext } from './models/luigi-context.js';
|
|
2
|
+
import { KcpKubernetesService } from './services/kcp-k8s.service.js';
|
|
3
|
+
import { getOrganization } from './utils/domain.js';
|
|
4
|
+
import { Injectable } from '@nestjs/common';
|
|
5
|
+
import { PortalContextProvider } from '@openmfp/portal-server-lib';
|
|
6
|
+
import type { Request, Response } from 'express';
|
|
7
|
+
import process from 'node:process';
|
|
8
|
+
|
|
9
|
+
@Injectable()
|
|
10
|
+
export class PMPortalContextService implements PortalContextProvider {
|
|
11
|
+
constructor(private kcpKubernetesService: KcpKubernetesService) {}
|
|
12
|
+
|
|
13
|
+
async getContextValues(
|
|
14
|
+
request: Request,
|
|
15
|
+
response: Response,
|
|
16
|
+
portalContext: PortalContext,
|
|
17
|
+
): Promise<PortalContext> {
|
|
18
|
+
this.processDynamicApiUrls(request, portalContext);
|
|
19
|
+
this.addKcpWorkspaceUrl(request, portalContext);
|
|
20
|
+
|
|
21
|
+
return portalContext;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private addKcpWorkspaceUrl(
|
|
25
|
+
request: Request,
|
|
26
|
+
portalContext: PortalContext,
|
|
27
|
+
) {
|
|
28
|
+
const organization = getOrganization(request);
|
|
29
|
+
const account = request.query?.['core_platform-mesh_io_account'];
|
|
30
|
+
|
|
31
|
+
portalContext.kcpWorkspaceUrl =
|
|
32
|
+
this.kcpKubernetesService.getKcpWorkspacePublicUrl(organization, account);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private processDynamicApiUrls(
|
|
36
|
+
request: Request,
|
|
37
|
+
portalContext: PortalContext,
|
|
38
|
+
): void {
|
|
39
|
+
const org = getOrganization(request);
|
|
40
|
+
const baseDomain = process.env['BASE_DOMAINS_DEFAULT'];
|
|
41
|
+
const subDomain = request.hostname !== baseDomain ? `${org}.` : '';
|
|
42
|
+
|
|
43
|
+
const replacements = {
|
|
44
|
+
'${org-subdomain}': subDomain,
|
|
45
|
+
'${org-name}': org,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const replacePlaceholders = (url?: string) =>
|
|
49
|
+
url
|
|
50
|
+
? Object.entries(replacements).reduce(
|
|
51
|
+
(acc, [key, value]) => acc.replace(key, value),
|
|
52
|
+
url,
|
|
53
|
+
)
|
|
54
|
+
: url;
|
|
55
|
+
|
|
56
|
+
portalContext.crdGatewayApiUrl = replacePlaceholders(
|
|
57
|
+
portalContext.crdGatewayApiUrl,
|
|
58
|
+
);
|
|
59
|
+
portalContext.iamServiceApiUrl = replacePlaceholders(
|
|
60
|
+
portalContext.iamServiceApiUrl,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { PMRequestContextProvider } from './pm-request-context-provider.js';
|
|
2
|
+
import { getOrganization } from './utils/domain.js';
|
|
3
|
+
import { PortalContextProviderImpl } from '@openmfp/portal-server-lib';
|
|
4
|
+
import type { Request } from 'express';
|
|
5
|
+
import { mock } from 'jest-mock-extended';
|
|
6
|
+
|
|
7
|
+
jest.mock('@kubernetes/client-node', () => {
|
|
8
|
+
class KubeConfig {
|
|
9
|
+
loadFromDefault = jest.fn();
|
|
10
|
+
loadFromFile = jest.fn();
|
|
11
|
+
getCurrentCluster = jest.fn().mockReturnValue({
|
|
12
|
+
server: 'https://k8s.example.com/base',
|
|
13
|
+
name: 'test-cluster',
|
|
14
|
+
});
|
|
15
|
+
makeApiClient = jest.fn();
|
|
16
|
+
addUser = jest.fn();
|
|
17
|
+
addContext = jest.fn();
|
|
18
|
+
setCurrentContext = jest.fn();
|
|
19
|
+
}
|
|
20
|
+
class CustomObjectsApi {}
|
|
21
|
+
return { KubeConfig, CustomObjectsApi };
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
jest.mock('./utils/domain.js', () => ({
|
|
25
|
+
getOrganization: jest.fn(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
describe('PMRequestContextProvider', () => {
|
|
29
|
+
let provider: PMRequestContextProvider;
|
|
30
|
+
const portalContextService = mock<PortalContextProviderImpl>();
|
|
31
|
+
const mockedGetOrganization = jest.mocked(getOrganization);
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
jest.resetAllMocks();
|
|
35
|
+
mockedGetOrganization.mockReturnValue('org1');
|
|
36
|
+
(
|
|
37
|
+
portalContextService.getContextValues as unknown as jest.Mock
|
|
38
|
+
).mockResolvedValue({
|
|
39
|
+
crdGatewayApiUrl: 'http://gateway/graphql',
|
|
40
|
+
other: 'x',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
provider = new PMRequestContextProvider(portalContextService);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should merge request query, portal context and organization from envService', async () => {
|
|
47
|
+
const req = {
|
|
48
|
+
query: { account: 'acc-123', extra: '1' },
|
|
49
|
+
hostname: 'org1.example.com',
|
|
50
|
+
} as unknown as Request;
|
|
51
|
+
const res = new Response();
|
|
52
|
+
|
|
53
|
+
const result = await provider.getContextValues(req, res);
|
|
54
|
+
|
|
55
|
+
expect(result).toMatchObject({
|
|
56
|
+
account: 'acc-123',
|
|
57
|
+
extra: '1',
|
|
58
|
+
crdGatewayApiUrl: 'http://gateway/graphql',
|
|
59
|
+
other: 'x',
|
|
60
|
+
organization: 'org1',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(mockedGetOrganization).toHaveBeenCalledWith(req);
|
|
64
|
+
expect(portalContextService.getContextValues).toHaveBeenCalledWith(
|
|
65
|
+
req,
|
|
66
|
+
res,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { getOrganization } from './utils/domain.js';
|
|
2
|
+
import { Injectable } from '@nestjs/common';
|
|
3
|
+
import {
|
|
4
|
+
PortalContextProviderImpl,
|
|
5
|
+
RequestContextProvider,
|
|
6
|
+
} from '@openmfp/portal-server-lib';
|
|
7
|
+
import type { Request, Response } from 'express';
|
|
8
|
+
|
|
9
|
+
export interface RequestContext extends Record<string, any> {
|
|
10
|
+
account?: string;
|
|
11
|
+
organization: string;
|
|
12
|
+
crdGatewayApiUrl?: string;
|
|
13
|
+
isSubDomain: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@Injectable()
|
|
17
|
+
export class PMRequestContextProvider implements RequestContextProvider {
|
|
18
|
+
constructor(private portalContextService: PortalContextProviderImpl) {}
|
|
19
|
+
|
|
20
|
+
async getContextValues(
|
|
21
|
+
request: Request,
|
|
22
|
+
response: Response,
|
|
23
|
+
): Promise<RequestContext> {
|
|
24
|
+
const organization = getOrganization(request);
|
|
25
|
+
const baseDomain = process.env['BASE_DOMAINS_DEFAULT'];
|
|
26
|
+
return {
|
|
27
|
+
...request.query,
|
|
28
|
+
...(await this.portalContextService.getContextValues(request, response)),
|
|
29
|
+
organization,
|
|
30
|
+
isSubDomain: request.hostname !== baseDomain,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|