938 lines
27 KiB
TypeScript
938 lines
27 KiB
TypeScript
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|
import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
|
|
import { RouteConfigManager } from '../ts/config/classes.route-config-manager.js';
|
|
import { SourcePolicyCompiler, sourcePolicyLimits } from '../ts/config/classes.source-policy-compiler.js';
|
|
import type { ISourceProfile, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
|
|
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
|
|
|
function injectProfile(resolver: ReferenceResolver, profile: ISourceProfile): void {
|
|
(resolver as any).profiles.set(profile.id, profile);
|
|
}
|
|
|
|
function makeRoute(): IRouteConfig {
|
|
return {
|
|
id: 'route-1',
|
|
name: 'gitea',
|
|
priority: 10,
|
|
match: { ports: 443, domains: 'code.example.com' },
|
|
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
|
|
};
|
|
}
|
|
|
|
function makeProfile(profile: Partial<ISourceProfile> & Pick<ISourceProfile, 'id' | 'name'>): ISourceProfile {
|
|
return {
|
|
description: '',
|
|
security: {},
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
createdBy: 'test',
|
|
...profile,
|
|
};
|
|
}
|
|
|
|
tap.test('source policy compiler expands one route into ordered source variants', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'trusted',
|
|
name: 'Trusted',
|
|
security: { ipAllowList: ['10.0.0.0/8'] },
|
|
}));
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'ai',
|
|
name: 'AI Crawlers',
|
|
security: {
|
|
ipAllowList: ['203.0.113.0/24'],
|
|
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
|
|
},
|
|
}));
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'public',
|
|
name: 'Public',
|
|
security: {
|
|
ipAllowList: ['*'],
|
|
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
|
},
|
|
}));
|
|
|
|
const metadata: IRouteMetadata = {
|
|
sourceBindings: [
|
|
{ sourceProfileRef: 'trusted' },
|
|
{ sourceProfileRef: 'ai' },
|
|
{ sourceProfileRef: 'public' },
|
|
],
|
|
};
|
|
|
|
const variants = SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1');
|
|
|
|
expect(variants.length).toEqual(3);
|
|
expect(variants[0].name).toEqual('gitea:source:Trusted');
|
|
expect(variants[0].match.clientIp).toEqual(['10.0.0.0/8']);
|
|
expect(variants[0].security?.ipAllowList).toBeUndefined();
|
|
expect(variants[1].security?.rateLimit?.maxRequests).toEqual(30);
|
|
expect(variants[2].match.clientIp).toBeUndefined();
|
|
expect(variants[2].security?.rateLimit?.maxRequests).toEqual(120);
|
|
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
|
|
expect(variants[1].priority! > variants[2].priority!).toBeTrue();
|
|
expect(variants.every((variant) => Number.isInteger(variant.priority))).toBeTrue();
|
|
expect(Math.min(...variants.map((variant) => variant.priority!))).toEqual(makeRoute().priority! + 1);
|
|
});
|
|
|
|
tap.test('source policy binding can override profile rate limit and 429 message', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'public',
|
|
name: 'Public',
|
|
security: {
|
|
ipAllowList: ['*'],
|
|
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
|
},
|
|
}));
|
|
|
|
const metadata: IRouteMetadata = {
|
|
sourceBindings: [
|
|
{
|
|
sourceProfileRef: 'public',
|
|
rateLimit: { enabled: true, maxRequests: 10, window: 60, keyBy: 'ip' },
|
|
onExceeded: { type: '429', errorMessage: 'Slow down' },
|
|
},
|
|
],
|
|
};
|
|
|
|
const [variant] = SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1');
|
|
|
|
expect(variant.security?.rateLimit?.maxRequests).toEqual(10);
|
|
expect(variant.security?.rateLimit?.errorMessage).toEqual('Slow down');
|
|
});
|
|
|
|
tap.test('source policy compiler forces source-policy rate limits to source IP keys', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'public',
|
|
name: 'Public',
|
|
security: {
|
|
ipAllowList: ['*'],
|
|
rateLimit: {
|
|
enabled: true,
|
|
maxRequests: 120,
|
|
window: 60,
|
|
keyBy: 'header',
|
|
headerName: 'x-forwarded-for',
|
|
},
|
|
},
|
|
}));
|
|
|
|
const variants = SourcePolicyCompiler.compileRoute(
|
|
makeRoute(),
|
|
{
|
|
sourceBindings: [
|
|
{
|
|
sourceProfileRef: 'public',
|
|
rateLimit: {
|
|
enabled: true,
|
|
maxRequests: 10,
|
|
window: 60,
|
|
keyBy: 'header',
|
|
headerName: 'x-client-id',
|
|
},
|
|
pathPolicies: [
|
|
{
|
|
pathClass: 'git-smart-http',
|
|
pathPatterns: ['/git'],
|
|
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'path' },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
resolver,
|
|
'route-1',
|
|
);
|
|
|
|
expect(variants).toHaveLength(2);
|
|
expect(variants[0].security?.rateLimit?.keyBy).toEqual('ip');
|
|
expect(variants[0].security?.rateLimit?.headerName).toBeUndefined();
|
|
expect(variants[1].security?.rateLimit?.keyBy).toEqual('ip');
|
|
expect(variants[1].security?.rateLimit?.headerName).toBeUndefined();
|
|
});
|
|
|
|
tap.test('source policy binding can split Gitea path classes before its fallback', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'ai',
|
|
name: 'AI Crawlers',
|
|
security: {
|
|
ipAllowList: ['203.0.113.0/24'],
|
|
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
|
|
},
|
|
}));
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'public',
|
|
name: 'Public',
|
|
security: {
|
|
ipAllowList: ['*'],
|
|
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
|
},
|
|
}));
|
|
|
|
const variants = SourcePolicyCompiler.compileRoute(
|
|
makeRoute(),
|
|
{
|
|
sourceBindings: [
|
|
{
|
|
sourceProfileRef: 'ai',
|
|
pathPolicies: [
|
|
{
|
|
pathClass: 'git-smart-http',
|
|
pathPatterns: ['/*/*.git/info/refs'],
|
|
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
|
|
},
|
|
{
|
|
pathClass: 'normal-html',
|
|
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'ip' },
|
|
},
|
|
],
|
|
},
|
|
{ sourceProfileRef: 'public' },
|
|
],
|
|
},
|
|
resolver,
|
|
'route-1',
|
|
);
|
|
|
|
expect(variants.length).toEqual(3);
|
|
expect(variants[0].name).toEqual('gitea:source:AI Crawlers:path:Git Smart HTTP');
|
|
expect(variants[0].match.clientIp).toEqual(['203.0.113.0/24']);
|
|
expect(variants[0].match.path).toEqual('/*/*.git/info/refs');
|
|
expect(variants[0].security?.rateLimit?.maxRequests).toEqual(600);
|
|
expect(variants[1].name).toEqual('gitea:source:AI Crawlers:path:Normal HTML');
|
|
expect(variants[1].match.path).toBeUndefined();
|
|
expect(variants[1].security?.rateLimit?.maxRequests).toEqual(20);
|
|
expect(variants[2].name).toEqual('gitea:source:Public');
|
|
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
|
|
expect(variants[1].priority! > variants[2].priority!).toBeTrue();
|
|
});
|
|
|
|
tap.test('source policy compiler uses built-in Gitea path class patterns', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'public',
|
|
name: 'Public',
|
|
security: {
|
|
ipAllowList: ['*'],
|
|
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
|
},
|
|
}));
|
|
|
|
const variants = SourcePolicyCompiler.compileRoute(
|
|
makeRoute(),
|
|
{
|
|
sourceBindings: [
|
|
{
|
|
sourceProfileRef: 'public',
|
|
pathPolicies: [{ pathClass: 'git-smart-http' }],
|
|
},
|
|
],
|
|
},
|
|
resolver,
|
|
'route-1',
|
|
);
|
|
|
|
expect(variants.map((variant) => variant.match.path)).toEqual([
|
|
'/*/*.git/info/refs',
|
|
'/*/*.git/git-upload-pack',
|
|
'/*/*.git/git-receive-pack',
|
|
'/*/*.git/info/lfs',
|
|
'/*/*.git/info/lfs/*',
|
|
undefined,
|
|
]);
|
|
expect(variants[0].id).toEqual('route-1:source:public:path:git-smart-http:1');
|
|
expect(variants[5].id).toEqual('route-1:source:public');
|
|
expect(variants[0].priority! > variants[5].priority!).toBeTrue();
|
|
});
|
|
|
|
tap.test('source policy compiler keeps path-specific variants above fallback variants', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'public',
|
|
name: 'Public',
|
|
security: {
|
|
ipAllowList: ['*'],
|
|
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
|
},
|
|
}));
|
|
|
|
const variants = SourcePolicyCompiler.compileRoute(
|
|
makeRoute(),
|
|
{
|
|
sourceBindings: [
|
|
{
|
|
sourceProfileRef: 'public',
|
|
pathPolicies: [
|
|
{
|
|
pathClass: 'normal-html',
|
|
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'ip' },
|
|
},
|
|
{
|
|
pathClass: 'git-smart-http',
|
|
pathPatterns: ['/*/*.git/info/refs'],
|
|
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
resolver,
|
|
'route-1',
|
|
);
|
|
|
|
const fallbackVariant = variants.find((variant) => variant.match.path === undefined)!;
|
|
const gitVariant = variants.find((variant) => variant.match.path === '/*/*.git/info/refs')!;
|
|
|
|
expect(gitVariant.priority! > fallbackVariant.priority!).toBeTrue();
|
|
expect(variants.every((variant) => Number.isInteger(variant.priority))).toBeTrue();
|
|
});
|
|
|
|
tap.test('source policy compiler fails closed when wildcard binding shadows later bindings', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'public',
|
|
name: 'Public',
|
|
security: { ipAllowList: ['*'] },
|
|
}));
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'trusted',
|
|
name: 'Trusted',
|
|
security: { ipAllowList: ['10.0.0.0/8'] },
|
|
}));
|
|
|
|
const variants = SourcePolicyCompiler.compileRoute(
|
|
makeRoute(),
|
|
{
|
|
sourceBindings: [
|
|
{ sourceProfileRef: 'public' },
|
|
{ sourceProfileRef: 'trusted' },
|
|
],
|
|
},
|
|
resolver,
|
|
'route-1',
|
|
);
|
|
|
|
expect(variants).toEqual([]);
|
|
});
|
|
|
|
tap.test('source policy compiler adds terminal deny fallback for private-only bindings', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'trusted',
|
|
name: 'Trusted',
|
|
security: { ipAllowList: ['10.0.0.0/8'] },
|
|
}));
|
|
|
|
const variants = SourcePolicyCompiler.compileRoute(
|
|
makeRoute(),
|
|
{
|
|
sourceBindings: [{ sourceProfileRef: 'trusted' }],
|
|
},
|
|
resolver,
|
|
'route-1',
|
|
);
|
|
|
|
expect(variants).toHaveLength(2);
|
|
expect(variants[0].match.clientIp).toEqual(['10.0.0.0/8']);
|
|
expect(variants[1].id).toEqual('route-1:source:deny-fallback');
|
|
expect(variants[1].match.clientIp).toBeUndefined();
|
|
expect(variants[1].action.type).toEqual('socket-handler');
|
|
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
|
|
expect(variants[1].priority! > makeRoute().priority!).toBeTrue();
|
|
});
|
|
|
|
tap.test('source policy compiler fails closed when expansion would exceed route variant caps', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'public',
|
|
name: 'Public',
|
|
security: { ipAllowList: ['*'] },
|
|
}));
|
|
const pathPolicies = Array.from({ length: sourcePolicyLimits.maxPathPoliciesPerBinding }, (_policy, policyIndex) => ({
|
|
pathClass: 'git-smart-http' as const,
|
|
pathPatterns: Array.from(
|
|
{ length: sourcePolicyLimits.maxPathPatternsPerPolicy },
|
|
(_pattern, patternIndex) => `/heavy-${policyIndex}-${patternIndex}`,
|
|
),
|
|
}));
|
|
const metadata: IRouteMetadata = {
|
|
sourceBindings: [{ sourceProfileRef: 'public', pathPolicies }],
|
|
};
|
|
|
|
expect(SourcePolicyCompiler.validateSourceBindingsShape(metadata.sourceBindings)).toContain('compiled route variants');
|
|
expect(SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1')).toEqual([]);
|
|
});
|
|
|
|
tap.test('source policy compiler fails closed when configured bindings cannot compile', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'empty-ai',
|
|
name: 'Empty AI',
|
|
security: {
|
|
ipAllowList: [],
|
|
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
|
|
},
|
|
}));
|
|
|
|
const emptyProfileVariants = SourcePolicyCompiler.compileRoute(
|
|
makeRoute(),
|
|
{
|
|
sourceBindings: [
|
|
{ sourceProfileRef: 'empty-ai' },
|
|
],
|
|
},
|
|
resolver,
|
|
'route-1',
|
|
);
|
|
|
|
const missingResolverVariants = SourcePolicyCompiler.compileRoute(
|
|
makeRoute(),
|
|
{
|
|
sourceBindings: [{ sourceProfileRef: 'empty-ai' }],
|
|
},
|
|
undefined,
|
|
'route-1',
|
|
);
|
|
|
|
expect(emptyProfileVariants.length).toEqual(0);
|
|
expect(missingResolverVariants.length).toEqual(0);
|
|
});
|
|
|
|
tap.test('source policy compiler keeps generated priorities inside SmartProxy bounds', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'trusted',
|
|
name: 'Trusted',
|
|
security: { ipAllowList: ['10.0.0.0/8'] },
|
|
}));
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'public',
|
|
name: 'Public',
|
|
security: {
|
|
ipAllowList: ['*'],
|
|
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
|
},
|
|
}));
|
|
|
|
const route = makeRoute();
|
|
route.priority = 9000;
|
|
const variants = SourcePolicyCompiler.compileRoute(
|
|
route,
|
|
{
|
|
sourceBindings: [
|
|
{ sourceProfileRef: 'trusted' },
|
|
{
|
|
sourceProfileRef: 'public',
|
|
pathPolicies: [{ pathClass: 'git-smart-http' }, { pathClass: 'normal-html' }],
|
|
},
|
|
],
|
|
},
|
|
resolver,
|
|
'route-1',
|
|
);
|
|
|
|
expect(variants.length > 0).toBeTrue();
|
|
expect(variants.every((variant) => variant.priority! <= 10000 && variant.priority! >= 0)).toBeTrue();
|
|
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
|
|
});
|
|
|
|
tap.test('source policy compiler fails closed when route priority lacks variant headroom', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'trusted',
|
|
name: 'Trusted',
|
|
security: { ipAllowList: ['10.0.0.0/8'] },
|
|
}));
|
|
|
|
const route = makeRoute();
|
|
route.priority = 10000;
|
|
const metadata: IRouteMetadata = {
|
|
sourceBindings: [{ sourceProfileRef: 'trusted' }],
|
|
};
|
|
|
|
expect(SourcePolicyCompiler.validateSourceBindingsShape(metadata.sourceBindings, route)).toContain('priority headroom');
|
|
expect(SourcePolicyCompiler.compileRoute(route, metadata, resolver, 'route-1')).toEqual([]);
|
|
});
|
|
|
|
tap.test('RouteConfigManager applies source policy as expanded runtime routes', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'trusted',
|
|
name: 'Trusted',
|
|
security: { ipAllowList: ['10.0.0.0/8'] },
|
|
}));
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'public',
|
|
name: 'Public',
|
|
security: {
|
|
ipAllowList: ['*'],
|
|
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
|
},
|
|
}));
|
|
|
|
const appliedRoutes: IRouteConfig[][] = [];
|
|
const manager = new RouteConfigManager(
|
|
() => ({
|
|
updateRoutes: async (routes: IRouteConfig[]) => {
|
|
appliedRoutes.push(routes);
|
|
},
|
|
} as any),
|
|
() => ({ enabled: false }),
|
|
undefined,
|
|
resolver,
|
|
);
|
|
(manager as any).routes.set('route-1', {
|
|
id: 'route-1',
|
|
route: makeRoute(),
|
|
enabled: true,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
createdBy: 'test',
|
|
origin: 'api',
|
|
metadata: {
|
|
sourceBindings: [
|
|
{ sourceProfileRef: 'trusted' },
|
|
{ sourceProfileRef: 'public' },
|
|
],
|
|
},
|
|
});
|
|
|
|
await manager.applyRoutes();
|
|
|
|
expect(appliedRoutes.length).toEqual(1);
|
|
expect(appliedRoutes[0].length).toEqual(2);
|
|
expect(appliedRoutes[0][0].match.clientIp).toEqual(['10.0.0.0/8']);
|
|
expect(appliedRoutes[0][1].match.clientIp).toBeUndefined();
|
|
expect(appliedRoutes[0][1].security?.rateLimit?.maxRequests).toEqual(120);
|
|
});
|
|
|
|
tap.test('RouteConfigManager does not apply an uncompiled source-policy route', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'empty-ai',
|
|
name: 'Empty AI',
|
|
security: {
|
|
ipAllowList: [],
|
|
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
|
|
},
|
|
}));
|
|
|
|
const appliedRoutes: IRouteConfig[][] = [];
|
|
const manager = new RouteConfigManager(
|
|
() => ({
|
|
updateRoutes: async (routes: IRouteConfig[]) => {
|
|
appliedRoutes.push(routes);
|
|
},
|
|
} as any),
|
|
() => ({ enabled: false }),
|
|
undefined,
|
|
resolver,
|
|
);
|
|
(manager as any).routes.set('route-1', {
|
|
id: 'route-1',
|
|
route: makeRoute(),
|
|
enabled: true,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
createdBy: 'test',
|
|
origin: 'api',
|
|
metadata: {
|
|
sourceBindings: [{ sourceProfileRef: 'empty-ai' }],
|
|
},
|
|
});
|
|
|
|
await manager.applyRoutes();
|
|
|
|
expect(appliedRoutes.length).toEqual(1);
|
|
expect(appliedRoutes[0].length).toEqual(0);
|
|
});
|
|
|
|
tap.test('RouteConfigManager fail-closes managed routes without source bindings', async () => {
|
|
const appliedRoutes: IRouteConfig[][] = [];
|
|
const manager = new RouteConfigManager(
|
|
() => ({
|
|
updateRoutes: async (routes: IRouteConfig[]) => {
|
|
appliedRoutes.push(routes);
|
|
},
|
|
} as any),
|
|
() => ({ enabled: false }),
|
|
);
|
|
(manager as any).routes.set('route-1', {
|
|
id: 'route-1',
|
|
route: makeRoute(),
|
|
enabled: true,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
createdBy: 'test',
|
|
origin: 'api',
|
|
metadata: {
|
|
ownerType: 'gatewayClient',
|
|
gatewayClientType: 'onebox',
|
|
gatewayClientId: 'box-1',
|
|
gatewayClientAppId: 'app-1',
|
|
externalKey: 'onebox:box-1:app-1:app.example.com',
|
|
},
|
|
});
|
|
|
|
await manager.applyRoutes();
|
|
|
|
expect(appliedRoutes).toHaveLength(1);
|
|
expect(appliedRoutes[0]).toHaveLength(0);
|
|
});
|
|
|
|
tap.test('RouteConfigManager rejects wildcard source policy bindings before later bindings', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'public',
|
|
name: 'Public',
|
|
security: { ipAllowList: ['*'] },
|
|
}));
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'trusted',
|
|
name: 'Trusted',
|
|
security: { ipAllowList: ['10.0.0.0/8'] },
|
|
}));
|
|
|
|
const manager = new RouteConfigManager(
|
|
() => undefined,
|
|
() => ({ enabled: false }),
|
|
undefined,
|
|
resolver,
|
|
);
|
|
(manager as any).routes.set('route-1', {
|
|
id: 'route-1',
|
|
route: makeRoute(),
|
|
enabled: true,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
createdBy: 'test',
|
|
origin: 'api',
|
|
metadata: {
|
|
sourceBindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
|
|
},
|
|
});
|
|
|
|
const result = await manager.updateRoute('route-1', {
|
|
metadata: {
|
|
sourceBindings: [{ sourceProfileRef: 'public' }, { sourceProfileRef: 'trusted' }],
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBeFalse();
|
|
expect(result.message).toContain('Wildcard source profile bindings must be last');
|
|
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].sourceProfileRef).toEqual('trusted');
|
|
});
|
|
|
|
tap.test('RouteConfigManager rejects missing source policy profiles', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'public',
|
|
name: 'Public',
|
|
security: { ipAllowList: ['*'] },
|
|
}));
|
|
|
|
const manager = new RouteConfigManager(
|
|
() => undefined,
|
|
() => ({ enabled: false }),
|
|
undefined,
|
|
resolver,
|
|
);
|
|
(manager as any).routes.set('route-1', {
|
|
id: 'route-1',
|
|
route: makeRoute(),
|
|
enabled: true,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
createdBy: 'test',
|
|
origin: 'api',
|
|
metadata: {
|
|
sourceBindings: [{ sourceProfileRef: 'public' }],
|
|
},
|
|
});
|
|
|
|
const result = await manager.updateRoute('route-1', {
|
|
metadata: {
|
|
sourceBindings: [{ sourceProfileRef: 'missing' }, { sourceProfileRef: 'public' }],
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBeFalse();
|
|
expect(result.message).toContain("Source profile 'missing' not found");
|
|
expect(manager.getRoute('route-1')?.metadata?.sourceBindings).toHaveLength(1);
|
|
});
|
|
|
|
tap.test('RouteConfigManager rejects source profiles without source matches', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'empty-ai',
|
|
name: 'Empty AI',
|
|
security: { ipAllowList: [] },
|
|
}));
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'public',
|
|
name: 'Public',
|
|
security: { ipAllowList: ['*'] },
|
|
}));
|
|
|
|
const manager = new RouteConfigManager(
|
|
() => undefined,
|
|
() => ({ enabled: false }),
|
|
undefined,
|
|
resolver,
|
|
);
|
|
(manager as any).routes.set('route-1', {
|
|
id: 'route-1',
|
|
route: makeRoute(),
|
|
enabled: true,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
createdBy: 'test',
|
|
origin: 'api',
|
|
metadata: {
|
|
sourceBindings: [{ sourceProfileRef: 'public' }],
|
|
},
|
|
});
|
|
|
|
const result = await manager.updateRoute('route-1', {
|
|
metadata: {
|
|
sourceBindings: [{ sourceProfileRef: 'empty-ai' }, { sourceProfileRef: 'public' }],
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBeFalse();
|
|
expect(result.message).toContain("Source profile 'Empty AI' has no source matches");
|
|
expect(manager.getRoute('route-1')?.metadata?.sourceBindings).toHaveLength(1);
|
|
});
|
|
|
|
tap.test('RouteConfigManager accepts private-only source bindings without public fallback', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'trusted',
|
|
name: 'Trusted',
|
|
security: { ipAllowList: ['10.0.0.0/8'] },
|
|
}));
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'public',
|
|
name: 'Public',
|
|
security: { ipAllowList: ['*'] },
|
|
}));
|
|
|
|
const manager = new RouteConfigManager(
|
|
() => undefined,
|
|
() => ({ enabled: false }),
|
|
undefined,
|
|
resolver,
|
|
);
|
|
(manager as any).persistRoute = async () => undefined;
|
|
(manager as any).applyRoutes = async () => undefined;
|
|
(manager as any).routes.set('route-1', {
|
|
id: 'route-1',
|
|
route: makeRoute(),
|
|
enabled: true,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
createdBy: 'test',
|
|
origin: 'api',
|
|
metadata: {
|
|
sourceBindings: [{ sourceProfileRef: 'public' }],
|
|
},
|
|
});
|
|
|
|
const result = await manager.updateRoute('route-1', {
|
|
metadata: {
|
|
sourceBindings: [{ sourceProfileRef: 'trusted' }],
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBeTrue();
|
|
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].sourceProfileRef).toEqual('trusted');
|
|
});
|
|
|
|
tap.test('RouteConfigManager rejects source policies with broad port range expansion', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'trusted',
|
|
name: 'Trusted',
|
|
security: { ipAllowList: ['10.0.0.0/8'] },
|
|
}));
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'public',
|
|
name: 'Public',
|
|
security: { ipAllowList: ['*'] },
|
|
}));
|
|
|
|
const manager = new RouteConfigManager(
|
|
() => undefined,
|
|
() => ({ enabled: false }),
|
|
undefined,
|
|
resolver,
|
|
);
|
|
(manager as any).routes.set('route-1', {
|
|
id: 'route-1',
|
|
route: makeRoute(),
|
|
enabled: true,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
createdBy: 'test',
|
|
origin: 'api',
|
|
metadata: {
|
|
sourceBindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
|
|
},
|
|
});
|
|
|
|
const result = await manager.updateRoute('route-1', {
|
|
route: {
|
|
match: { ports: [{ from: 1, to: 1_000_000_000 }], domains: 'code.example.com' },
|
|
} as any,
|
|
});
|
|
|
|
expect(result.success).toBeFalse();
|
|
expect(result.message).toContain('compiled route-port variants');
|
|
expect(manager.getRoute('route-1')?.route.match.ports).toEqual(443);
|
|
});
|
|
|
|
tap.test('RouteConfigManager rejects negative source-policy maxConnections overrides', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'public',
|
|
name: 'Public',
|
|
security: { ipAllowList: ['*'] },
|
|
}));
|
|
|
|
const manager = new RouteConfigManager(
|
|
() => undefined,
|
|
() => ({ enabled: false }),
|
|
undefined,
|
|
resolver,
|
|
);
|
|
(manager as any).routes.set('route-1', {
|
|
id: 'route-1',
|
|
route: makeRoute(),
|
|
enabled: true,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
createdBy: 'test',
|
|
origin: 'api',
|
|
metadata: {
|
|
sourceBindings: [{ sourceProfileRef: 'public' }],
|
|
},
|
|
});
|
|
|
|
const result = await manager.updateRoute('route-1', {
|
|
metadata: {
|
|
sourceBindings: [{ sourceProfileRef: 'public', maxConnections: -1 }],
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBeFalse();
|
|
expect(result.message).toContain('maxConnections');
|
|
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].maxConnections).toBeUndefined();
|
|
});
|
|
|
|
tap.test('RouteConfigManager rejects oversized nested source-policy rate limit messages', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'public',
|
|
name: 'Public',
|
|
security: { ipAllowList: ['*'] },
|
|
}));
|
|
|
|
const manager = new RouteConfigManager(
|
|
() => undefined,
|
|
() => ({ enabled: false }),
|
|
undefined,
|
|
resolver,
|
|
);
|
|
(manager as any).routes.set('route-1', {
|
|
id: 'route-1',
|
|
route: makeRoute(),
|
|
enabled: true,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
createdBy: 'test',
|
|
origin: 'api',
|
|
metadata: {
|
|
sourceBindings: [{ sourceProfileRef: 'public' }],
|
|
},
|
|
});
|
|
|
|
const result = await manager.updateRoute('route-1', {
|
|
metadata: {
|
|
sourceBindings: [
|
|
{
|
|
sourceProfileRef: 'public',
|
|
rateLimit: {
|
|
enabled: true,
|
|
maxRequests: 10,
|
|
window: 60,
|
|
keyBy: 'ip',
|
|
errorMessage: 'x'.repeat(sourcePolicyLimits.maxExceededMessageLength + 1),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBeFalse();
|
|
expect(result.message).toContain('rate limit error message');
|
|
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].rateLimit).toBeUndefined();
|
|
});
|
|
|
|
tap.test('RouteConfigManager rejects oversized source policy path patterns', async () => {
|
|
const resolver = new ReferenceResolver();
|
|
injectProfile(resolver, makeProfile({
|
|
id: 'public',
|
|
name: 'Public',
|
|
security: { ipAllowList: ['*'] },
|
|
}));
|
|
|
|
const manager = new RouteConfigManager(
|
|
() => undefined,
|
|
() => ({ enabled: false }),
|
|
undefined,
|
|
resolver,
|
|
);
|
|
(manager as any).routes.set('route-1', {
|
|
id: 'route-1',
|
|
route: makeRoute(),
|
|
enabled: true,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
createdBy: 'test',
|
|
origin: 'api',
|
|
metadata: {
|
|
sourceBindings: [{ sourceProfileRef: 'public' }],
|
|
},
|
|
});
|
|
|
|
const result = await manager.updateRoute('route-1', {
|
|
metadata: {
|
|
sourceBindings: [
|
|
{
|
|
sourceProfileRef: 'public',
|
|
pathPolicies: [
|
|
{
|
|
pathClass: 'git-smart-http',
|
|
pathPatterns: Array.from(
|
|
{ length: sourcePolicyLimits.maxPathPatternsPerPolicy + 1 },
|
|
(_item, index) => `/too-many-${index}`,
|
|
),
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBeFalse();
|
|
expect(result.message).toContain('path patterns');
|
|
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].pathPolicies).toBeUndefined();
|
|
});
|
|
|
|
export default tap.start();
|