566 lines
19 KiB
TypeScript
566 lines
19 KiB
TypeScript
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
import { WorkHosterHandler } from '../ts/opsserver/handlers/workhoster.handler.js';
|
|
import * as plugins from '../ts/plugins.js';
|
|
import * as interfaces from '../ts_interfaces/index.js';
|
|
|
|
type TScope = interfaces.data.TApiTokenScope;
|
|
|
|
const fireTypedRequest = async (
|
|
router: plugins.typedrequest.TypedRouter,
|
|
method: string,
|
|
request: Record<string, any>,
|
|
) => {
|
|
return await router.routeAndAddResponse({
|
|
method,
|
|
request,
|
|
response: {},
|
|
correlation: {
|
|
id: `${method}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
phase: 'request',
|
|
},
|
|
} as any, { localRequest: true, skipHooks: true }) as any;
|
|
};
|
|
|
|
const makeApiTokenManager = (
|
|
scopes: TScope[],
|
|
policy?: interfaces.data.IApiTokenPolicy,
|
|
) => {
|
|
const token = {
|
|
id: 'token-1',
|
|
name: 'workhoster-test-token',
|
|
scopes,
|
|
createdBy: 'token-user',
|
|
createdAt: Date.now(),
|
|
expiresAt: null,
|
|
lastUsedAt: null,
|
|
enabled: true,
|
|
policy,
|
|
} as interfaces.data.IStoredApiToken;
|
|
|
|
return {
|
|
validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null,
|
|
hasScope: (storedToken: interfaces.data.IStoredApiToken, scope: TScope) => {
|
|
if (storedToken.policy?.role === 'admin') return true;
|
|
const isGatewayClientToken = storedToken.policy?.role === 'gatewayClient';
|
|
const gatewayClientAllowedScopes = new Set<TScope>([
|
|
'gateway-clients:read',
|
|
'gateway-clients:write',
|
|
'workhosters:read',
|
|
'workhosters:write',
|
|
]);
|
|
if (isGatewayClientToken && !gatewayClientAllowedScopes.has(scope)) return false;
|
|
if (!isGatewayClientToken && storedToken.scopes.includes('*')) return true;
|
|
const scopes = new Set(storedToken.scopes);
|
|
for (const policyScope of storedToken.policy?.scopes || []) {
|
|
scopes.add(policyScope);
|
|
}
|
|
const compatibilityAliases: Partial<Record<TScope, TScope[]>> = {
|
|
'gateway-clients:read': ['workhosters:read'],
|
|
'gateway-clients:write': ['workhosters:write'],
|
|
'workhosters:read': ['gateway-clients:read'],
|
|
'workhosters:write': ['gateway-clients:write'],
|
|
};
|
|
return scopes.has(scope) || Boolean(compatibilityAliases[scope]?.some((alias) => scopes.has(alias)));
|
|
},
|
|
};
|
|
};
|
|
|
|
const makeRouteConfigManager = () => {
|
|
const routes = new Map<string, interfaces.data.IRoute>();
|
|
let nextRouteNumber = 1;
|
|
|
|
return {
|
|
routes,
|
|
manager: {
|
|
findApiRouteByExternalKey: (externalKey: string) => {
|
|
return Array.from(routes.values()).find((route) =>
|
|
route.origin === 'api' && route.metadata?.externalKey === externalKey,
|
|
);
|
|
},
|
|
createRoute: async (
|
|
route: interfaces.data.IDcRouterRouteConfig,
|
|
createdBy: string,
|
|
enabled = true,
|
|
metadata?: interfaces.data.IRouteMetadata,
|
|
) => {
|
|
const id = `route-${nextRouteNumber++}`;
|
|
routes.set(id, {
|
|
id,
|
|
route,
|
|
enabled,
|
|
createdBy,
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
origin: 'api',
|
|
metadata,
|
|
});
|
|
return id;
|
|
},
|
|
updateRoute: async (
|
|
id: string,
|
|
patch: {
|
|
route?: Partial<interfaces.data.IDcRouterRouteConfig>;
|
|
enabled?: boolean;
|
|
metadata?: Partial<interfaces.data.IRouteMetadata>;
|
|
},
|
|
) => {
|
|
const storedRoute = routes.get(id);
|
|
if (!storedRoute) return { success: false, message: 'Route not found' };
|
|
if (patch.route) {
|
|
storedRoute.route = { ...storedRoute.route, ...patch.route } as interfaces.data.IDcRouterRouteConfig;
|
|
}
|
|
if (patch.enabled !== undefined) {
|
|
storedRoute.enabled = patch.enabled;
|
|
}
|
|
if (patch.metadata) {
|
|
storedRoute.metadata = { ...storedRoute.metadata, ...patch.metadata };
|
|
}
|
|
storedRoute.updatedAt = Date.now();
|
|
return { success: true };
|
|
},
|
|
deleteRoute: async (id: string) => {
|
|
const deleted = routes.delete(id);
|
|
return deleted ? { success: true } : { success: false, message: 'Route not found' };
|
|
},
|
|
},
|
|
};
|
|
};
|
|
|
|
const setupHandler = (options: {
|
|
scopes: TScope[];
|
|
policy?: interfaces.data.IApiTokenPolicy;
|
|
isAdmin?: boolean;
|
|
dcRouterRef?: Record<string, any>;
|
|
}) => {
|
|
const typedrouter = new plugins.typedrequest.TypedRouter();
|
|
const opsServerRef: any = {
|
|
typedrouter,
|
|
adminHandler: {
|
|
validateIdentity: async (identity: interfaces.data.IIdentity) => options.isAdmin
|
|
? { ...identity, role: 'admin' }
|
|
: identity,
|
|
adminIdentityGuard: {
|
|
exec: async () => Boolean(options.isAdmin),
|
|
},
|
|
},
|
|
dcRouterRef: {
|
|
options: {},
|
|
apiTokenManager: makeApiTokenManager(options.scopes, options.policy),
|
|
...options.dcRouterRef,
|
|
},
|
|
};
|
|
|
|
new WorkHosterHandler(opsServerRef);
|
|
return { typedrouter, opsServerRef };
|
|
};
|
|
|
|
tap.test('WorkHosterHandler exposes capabilities and managed domains with workhosters:read', async () => {
|
|
const { typedrouter } = setupHandler({
|
|
scopes: ['workhosters:read'],
|
|
dcRouterRef: {
|
|
options: {
|
|
remoteIngressConfig: { enabled: true },
|
|
dnsScopes: ['example.com'],
|
|
http3: { enabled: false },
|
|
},
|
|
routeConfigManager: {
|
|
getMergedRoutes: () => ({ routes: [] }),
|
|
},
|
|
smartProxy: {},
|
|
emailDomainManager: {},
|
|
emailServer: {},
|
|
dnsManager: {
|
|
listDomains: async () => [
|
|
{ id: 'domain-1', name: 'example.com', source: 'dcrouter', authoritative: true },
|
|
{ id: 'domain-2', name: 'provider.example', source: 'provider', providerId: 'cloudflare-1', authoritative: false },
|
|
],
|
|
toPublicDomain: (domainDoc: any) => ({
|
|
...domainDoc,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
createdBy: 'test',
|
|
}),
|
|
},
|
|
},
|
|
});
|
|
|
|
const capabilitiesResult = await fireTypedRequest(typedrouter, 'getGatewayCapabilities', {
|
|
apiToken: 'valid-token',
|
|
});
|
|
expect(capabilitiesResult.error).toBeUndefined();
|
|
expect(capabilitiesResult.response.capabilities.routes.idempotentSync).toEqual(true);
|
|
expect(capabilitiesResult.response.capabilities.domains.read).toEqual(true);
|
|
expect(capabilitiesResult.response.capabilities.certificates.export).toEqual(true);
|
|
expect(capabilitiesResult.response.capabilities.email.inbound).toEqual(true);
|
|
expect(capabilitiesResult.response.capabilities.remoteIngress.enabled).toEqual(true);
|
|
expect(capabilitiesResult.response.capabilities.dns.authoritative).toEqual(true);
|
|
expect(capabilitiesResult.response.capabilities.http3.enabled).toEqual(false);
|
|
|
|
const domainsResult = await fireTypedRequest(typedrouter, 'getWorkHosterDomains', {
|
|
apiToken: 'valid-token',
|
|
});
|
|
expect(domainsResult.error).toBeUndefined();
|
|
expect(domainsResult.response.domains.length).toEqual(2);
|
|
expect(domainsResult.response.domains[0].capabilities.canCreateSubdomains).toEqual(true);
|
|
expect(domainsResult.response.domains[1].capabilities.canManageDnsRecords).toEqual(true);
|
|
expect(domainsResult.response.domains[1].capabilities.canIssueCertificates).toEqual(true);
|
|
expect(domainsResult.response.domains[1].capabilities.canHostEmail).toEqual(true);
|
|
});
|
|
|
|
tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:write', async () => {
|
|
const routeConfig = makeRouteConfigManager();
|
|
const { typedrouter } = setupHandler({
|
|
scopes: ['workhosters:write'],
|
|
dcRouterRef: {
|
|
options: {},
|
|
routeConfigManager: routeConfig.manager,
|
|
},
|
|
});
|
|
const ownership: interfaces.data.IWorkAppRouteOwnership = {
|
|
workHosterType: 'onebox',
|
|
workHosterId: 'box-1',
|
|
workAppId: 'app-1',
|
|
hostname: 'app.example.com',
|
|
};
|
|
|
|
const createResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
|
apiToken: 'valid-token',
|
|
ownership,
|
|
route: {
|
|
match: { ports: [443], domains: ['app.example.com'] },
|
|
action: {
|
|
type: 'forward',
|
|
targets: [{ host: '10.0.0.2', port: 8080 }],
|
|
tls: { mode: 'terminate', certificate: 'auto' },
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(createResult.error).toBeUndefined();
|
|
expect(createResult.response).toEqual({ success: true, action: 'created', routeId: 'route-1' });
|
|
expect(routeConfig.routes.size).toEqual(1);
|
|
|
|
const createdRoute = routeConfig.routes.get('route-1')!;
|
|
expect(createdRoute.createdBy).toEqual('token-user');
|
|
expect(createdRoute.route.name?.startsWith('gateway-client-onebox-box-1-app-1-app-example-com')).toEqual(true);
|
|
expect(createdRoute.metadata).toEqual({
|
|
ownerType: 'gatewayClient',
|
|
gatewayClientType: 'onebox',
|
|
gatewayClientId: 'box-1',
|
|
gatewayClientAppId: 'app-1',
|
|
workHosterType: 'onebox',
|
|
workHosterId: 'box-1',
|
|
workAppId: 'app-1',
|
|
externalKey: 'onebox:box-1:app-1:app.example.com',
|
|
});
|
|
|
|
const updateResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
|
apiToken: 'valid-token',
|
|
ownership,
|
|
enabled: false,
|
|
route: {
|
|
name: 'updated-workapp-route',
|
|
match: { ports: [443], domains: ['app.example.com'] },
|
|
action: {
|
|
type: 'forward',
|
|
targets: [{ host: '10.0.0.3', port: 3000 }],
|
|
tls: { mode: 'terminate', certificate: 'auto' },
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(updateResult.error).toBeUndefined();
|
|
expect(updateResult.response).toEqual({ success: true, action: 'updated', routeId: 'route-1' });
|
|
expect(routeConfig.routes.size).toEqual(1);
|
|
expect(routeConfig.routes.get('route-1')?.enabled).toEqual(false);
|
|
expect(routeConfig.routes.get('route-1')?.route.name).toEqual('updated-workapp-route');
|
|
expect(routeConfig.routes.get('route-1')?.route.action.targets?.[0].host).toEqual('10.0.0.3');
|
|
|
|
const deleteResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
|
apiToken: 'valid-token',
|
|
ownership,
|
|
delete: true,
|
|
});
|
|
|
|
expect(deleteResult.error).toBeUndefined();
|
|
expect(deleteResult.response).toEqual({ success: true, action: 'deleted', routeId: 'route-1' });
|
|
expect(routeConfig.routes.size).toEqual(0);
|
|
|
|
const unchangedResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
|
apiToken: 'valid-token',
|
|
ownership,
|
|
delete: true,
|
|
});
|
|
|
|
expect(unchangedResult.error).toBeUndefined();
|
|
expect(unchangedResult.response).toEqual({ success: true, action: 'unchanged' });
|
|
});
|
|
|
|
tap.test('WorkHosterHandler exposes gateway client context for token-bound clients', async () => {
|
|
const { typedrouter } = setupHandler({
|
|
scopes: ['gateway-clients:read'],
|
|
policy: {
|
|
role: 'gatewayClient',
|
|
gatewayClient: { type: 'onebox', id: 'box-policy' },
|
|
hostnamePatterns: ['*.example.com'],
|
|
allowedRouteTargets: [{ host: '10.0.0.2', ports: [8080] }],
|
|
capabilities: {
|
|
readDomains: true,
|
|
readDnsRecords: true,
|
|
syncRoutes: true,
|
|
},
|
|
},
|
|
dcRouterRef: { options: {} },
|
|
});
|
|
|
|
const result = await fireTypedRequest(typedrouter, 'getGatewayClientContext', {
|
|
apiToken: 'valid-token',
|
|
});
|
|
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.response.context.gatewayClient).toEqual({ type: 'onebox', id: 'box-policy' });
|
|
expect(result.response.context.hostnamePatterns).toEqual(['*.example.com']);
|
|
expect(result.response.context.capabilities.syncRoutes).toEqual(true);
|
|
});
|
|
|
|
tap.test('WorkHosterHandler derives route ownership from gateway client token policy', async () => {
|
|
const routeConfig = makeRouteConfigManager();
|
|
const { typedrouter } = setupHandler({
|
|
scopes: ['gateway-clients:write'],
|
|
policy: {
|
|
role: 'gatewayClient',
|
|
gatewayClient: { type: 'onebox', id: 'box-policy' },
|
|
hostnamePatterns: ['*.example.com'],
|
|
allowedRouteTargets: [{ host: '10.0.0.2', ports: [8080] }],
|
|
capabilities: { syncRoutes: true },
|
|
},
|
|
dcRouterRef: {
|
|
options: {},
|
|
routeConfigManager: routeConfig.manager,
|
|
},
|
|
});
|
|
|
|
const createResult = await fireTypedRequest(typedrouter, 'syncGatewayClientRoute', {
|
|
apiToken: 'valid-token',
|
|
ownership: {
|
|
appId: 'app-1',
|
|
hostname: 'app.example.com',
|
|
},
|
|
route: {
|
|
match: { ports: [443], domains: ['app.example.com'] },
|
|
action: {
|
|
type: 'forward',
|
|
targets: [{ host: '10.0.0.2', port: 8080 }],
|
|
tls: { mode: 'terminate', certificate: 'auto' },
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(createResult.error).toBeUndefined();
|
|
expect(createResult.response).toEqual({ success: true, action: 'created', routeId: 'route-1' });
|
|
expect(routeConfig.routes.get('route-1')?.metadata?.gatewayClientId).toEqual('box-policy');
|
|
expect(routeConfig.routes.get('route-1')?.metadata?.externalKey).toEqual('onebox:box-policy:app-1:app.example.com');
|
|
|
|
const spoofResult = await fireTypedRequest(typedrouter, 'syncGatewayClientRoute', {
|
|
apiToken: 'valid-token',
|
|
ownership: {
|
|
gatewayClientType: 'onebox',
|
|
gatewayClientId: 'other-box',
|
|
appId: 'app-1',
|
|
hostname: 'app.example.com',
|
|
},
|
|
delete: true,
|
|
});
|
|
|
|
expect(spoofResult.error?.text).toEqual('gateway client token cannot act for this ownership');
|
|
});
|
|
|
|
tap.test('WorkHosterHandler manages durable gateway clients and creates scoped tokens', async () => {
|
|
const identity: interfaces.data.IIdentity = {
|
|
jwt: 'admin-jwt',
|
|
userId: 'admin-user',
|
|
name: 'admin',
|
|
expiresAt: Date.now() + 3600000,
|
|
};
|
|
const gatewayClient: interfaces.data.IGatewayClient = {
|
|
id: 'onebox-main',
|
|
type: 'onebox',
|
|
name: 'Main Onebox',
|
|
hostnamePatterns: ['*.apps.example.com'],
|
|
allowedRouteTargets: [{ host: 'onebox-smartproxy', ports: [80] }],
|
|
capabilities: { readDomains: true, readDnsRecords: true, syncRoutes: true },
|
|
enabled: true,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
createdBy: 'admin-user',
|
|
};
|
|
let createdTokenPolicy: interfaces.data.IApiTokenPolicy | undefined;
|
|
const { typedrouter } = setupHandler({
|
|
scopes: [],
|
|
isAdmin: true,
|
|
dcRouterRef: {
|
|
options: {},
|
|
gatewayClientManager: {
|
|
listClients: async () => [gatewayClient],
|
|
getClient: async (id: string) => id === gatewayClient.id ? gatewayClient : null,
|
|
},
|
|
apiTokenManager: {
|
|
listTokens: () => [{
|
|
id: 'token-1',
|
|
name: 'token',
|
|
scopes: ['gateway-clients:read'],
|
|
policy: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-main' } },
|
|
createdAt: 1,
|
|
expiresAt: null,
|
|
lastUsedAt: null,
|
|
enabled: true,
|
|
}],
|
|
createToken: async (
|
|
_name: string,
|
|
_scopes: TScope[],
|
|
_expiresInDays: number | null,
|
|
_createdBy: string,
|
|
policy?: interfaces.data.IApiTokenPolicy,
|
|
) => {
|
|
createdTokenPolicy = policy;
|
|
return { id: 'new-token', rawToken: 'dcr_created' };
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const listResult = await fireTypedRequest(typedrouter, 'listGatewayClients', { identity });
|
|
expect(listResult.error).toBeUndefined();
|
|
expect(listResult.response.gatewayClients[0].tokenCount).toEqual(1);
|
|
|
|
const tokenResult = await fireTypedRequest(typedrouter, 'createGatewayClientToken', {
|
|
identity,
|
|
gatewayClientId: 'onebox-main',
|
|
});
|
|
expect(tokenResult.error).toBeUndefined();
|
|
expect(tokenResult.response.tokenValue).toEqual('dcr_created');
|
|
expect(createdTokenPolicy?.gatewayClient).toEqual({ type: 'onebox', id: 'onebox-main' });
|
|
expect(createdTokenPolicy?.allowedRouteTargets).toEqual([{ host: 'onebox-smartproxy', ports: [80] }]);
|
|
});
|
|
|
|
tap.test('WorkHosterHandler rejects WorkApp route sync without workhosters:write', async () => {
|
|
const routeConfig = makeRouteConfigManager();
|
|
const { typedrouter } = setupHandler({
|
|
scopes: ['workhosters:read'],
|
|
dcRouterRef: {
|
|
options: {},
|
|
routeConfigManager: routeConfig.manager,
|
|
},
|
|
});
|
|
|
|
const result = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
|
apiToken: 'valid-token',
|
|
ownership: {
|
|
workHosterType: 'onebox',
|
|
workHosterId: 'box-1',
|
|
workAppId: 'app-1',
|
|
hostname: 'app.example.com',
|
|
},
|
|
delete: true,
|
|
});
|
|
|
|
expect(result.error?.text).toEqual('insufficient scope');
|
|
expect(routeConfig.routes.size).toEqual(0);
|
|
});
|
|
|
|
tap.test('WorkHosterHandler exposes and syncs WorkApp mail identities', async () => {
|
|
const syncedRequests: Array<{ data: any; userId: string }> = [];
|
|
const identity: interfaces.data.IWorkAppMailIdentity = {
|
|
id: 'mail-1',
|
|
externalKey: 'onebox:box-1:app-1:hello@example.com',
|
|
ownership: {
|
|
workHosterType: 'onebox',
|
|
workHosterId: 'box-1',
|
|
workAppId: 'app-1',
|
|
},
|
|
address: 'hello@example.com',
|
|
localPart: 'hello',
|
|
domain: 'example.com',
|
|
enabled: true,
|
|
inbound: {
|
|
enabled: true,
|
|
targetHost: '10.0.0.2',
|
|
targetPort: 2525,
|
|
},
|
|
smtp: {
|
|
enabled: true,
|
|
username: 'workapp-user',
|
|
},
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
createdBy: 'token-user',
|
|
};
|
|
const { typedrouter } = setupHandler({
|
|
scopes: ['workhosters:read', 'workhosters:write'],
|
|
dcRouterRef: {
|
|
options: {},
|
|
workAppMailManager: {
|
|
listMailIdentities: async (filter: any) => filter.workAppId === 'app-1' ? [identity] : [],
|
|
syncMailIdentity: async (data: any, userId: string) => {
|
|
syncedRequests.push({ data, userId });
|
|
return {
|
|
success: true,
|
|
action: 'created',
|
|
identity,
|
|
smtpCredentials: {
|
|
username: 'workapp-user',
|
|
password: 'generated-password',
|
|
},
|
|
};
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const listResult = await fireTypedRequest(typedrouter, 'getWorkAppMailIdentities', {
|
|
apiToken: 'valid-token',
|
|
ownership: { workAppId: 'app-1' },
|
|
});
|
|
expect(listResult.error).toBeUndefined();
|
|
expect(listResult.response.identities).toEqual([identity]);
|
|
|
|
const syncResult = await fireTypedRequest(typedrouter, 'syncWorkAppMailIdentity', {
|
|
apiToken: 'valid-token',
|
|
ownership: identity.ownership,
|
|
localPart: 'hello',
|
|
domain: 'example.com',
|
|
inbound: identity.inbound,
|
|
});
|
|
expect(syncResult.error).toBeUndefined();
|
|
expect(syncResult.response.success).toEqual(true);
|
|
expect(syncResult.response.smtpCredentials.password).toEqual('generated-password');
|
|
expect(syncedRequests[0].userId).toEqual('token-user');
|
|
});
|
|
|
|
tap.test('WorkHosterHandler rejects WorkApp mail sync without workhosters:write', async () => {
|
|
const { typedrouter } = setupHandler({
|
|
scopes: ['workhosters:read'],
|
|
dcRouterRef: {
|
|
options: {},
|
|
workAppMailManager: {
|
|
syncMailIdentity: async () => ({ success: true }),
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await fireTypedRequest(typedrouter, 'syncWorkAppMailIdentity', {
|
|
apiToken: 'valid-token',
|
|
ownership: {
|
|
workHosterType: 'onebox',
|
|
workHosterId: 'box-1',
|
|
workAppId: 'app-1',
|
|
},
|
|
localPart: 'hello',
|
|
domain: 'example.com',
|
|
});
|
|
|
|
expect(result.error?.text).toEqual('insufficient scope');
|
|
});
|
|
|
|
export default tap.start();
|