372 lines
12 KiB
TypeScript
372 lines
12 KiB
TypeScript
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|
import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
|
|
import type { ISecurityProfile, INetworkTarget, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
|
|
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
|
|
|
// ============================================================================
|
|
// Helpers: access private maps for direct unit testing without DB
|
|
// ============================================================================
|
|
|
|
function injectProfile(resolver: ReferenceResolver, profile: ISecurityProfile): void {
|
|
(resolver as any).profiles.set(profile.id, profile);
|
|
}
|
|
|
|
function injectTarget(resolver: ReferenceResolver, target: INetworkTarget): void {
|
|
(resolver as any).targets.set(target.id, target);
|
|
}
|
|
|
|
function makeProfile(overrides: Partial<ISecurityProfile> = {}): ISecurityProfile {
|
|
return {
|
|
id: 'profile-1',
|
|
name: 'STANDARD',
|
|
description: 'Test profile',
|
|
security: {
|
|
ipAllowList: ['192.168.0.0/16', '10.0.0.0/8'],
|
|
maxConnections: 1000,
|
|
},
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
createdBy: 'test',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeTarget(overrides: Partial<INetworkTarget> = {}): INetworkTarget {
|
|
return {
|
|
id: 'target-1',
|
|
name: 'INFRA',
|
|
description: 'Test target',
|
|
host: '192.168.5.247',
|
|
port: 443,
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
createdBy: 'test',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeRoute(overrides: Partial<IRouteConfig> = {}): IRouteConfig {
|
|
return {
|
|
name: 'test-route',
|
|
match: { ports: 443, domains: 'test.example.com' },
|
|
action: { type: 'forward', targets: [{ host: 'placeholder', port: 80 }] },
|
|
...overrides,
|
|
} as IRouteConfig;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Resolution tests
|
|
// ============================================================================
|
|
|
|
let resolver: ReferenceResolver;
|
|
|
|
tap.test('should create ReferenceResolver instance', async () => {
|
|
resolver = new ReferenceResolver();
|
|
expect(resolver).toBeTruthy();
|
|
});
|
|
|
|
tap.test('should list empty profiles and targets initially', async () => {
|
|
expect(resolver.listProfiles()).toBeArray();
|
|
expect(resolver.listProfiles().length).toEqual(0);
|
|
expect(resolver.listTargets()).toBeArray();
|
|
expect(resolver.listTargets().length).toEqual(0);
|
|
});
|
|
|
|
// ---- Security profile resolution ----
|
|
|
|
tap.test('should resolve security profile onto a route', async () => {
|
|
const profile = makeProfile();
|
|
injectProfile(resolver, profile);
|
|
|
|
const route = makeRoute();
|
|
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' };
|
|
|
|
const result = resolver.resolveRoute(route, metadata);
|
|
|
|
expect(result.route.security).toBeTruthy();
|
|
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
|
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
|
expect(result.route.security!.maxConnections).toEqual(1000);
|
|
expect(result.metadata.securityProfileName).toEqual('STANDARD');
|
|
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
|
});
|
|
|
|
tap.test('should merge inline route security with profile security', async () => {
|
|
const route = makeRoute({
|
|
security: {
|
|
ipAllowList: ['127.0.0.1'],
|
|
maxConnections: 5000,
|
|
},
|
|
});
|
|
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' };
|
|
|
|
const result = resolver.resolveRoute(route, metadata);
|
|
|
|
// IP lists are unioned
|
|
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
|
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
|
expect(result.route.security!.ipAllowList).toContain('127.0.0.1');
|
|
|
|
// Inline maxConnections overrides profile
|
|
expect(result.route.security!.maxConnections).toEqual(5000);
|
|
});
|
|
|
|
tap.test('should deduplicate IP lists during merge', async () => {
|
|
const route = makeRoute({
|
|
security: {
|
|
ipAllowList: ['192.168.0.0/16', '127.0.0.1'],
|
|
},
|
|
});
|
|
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' };
|
|
|
|
const result = resolver.resolveRoute(route, metadata);
|
|
|
|
// 192.168.0.0/16 appears in both profile and route, should be deduplicated
|
|
const count = result.route.security!.ipAllowList!.filter(ip => ip === '192.168.0.0/16').length;
|
|
expect(count).toEqual(1);
|
|
});
|
|
|
|
tap.test('should handle missing profile gracefully', async () => {
|
|
const route = makeRoute();
|
|
const metadata: IRouteMetadata = { securityProfileRef: 'nonexistent-profile' };
|
|
|
|
const result = resolver.resolveRoute(route, metadata);
|
|
|
|
// Route should be unchanged
|
|
expect(result.route.security).toBeUndefined();
|
|
expect(result.metadata.securityProfileName).toBeUndefined();
|
|
});
|
|
|
|
// ---- Profile inheritance ----
|
|
|
|
tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
|
|
const baseProfile = makeProfile({
|
|
id: 'base-profile',
|
|
name: 'BASE',
|
|
security: {
|
|
ipAllowList: ['10.0.0.0/8'],
|
|
maxConnections: 500,
|
|
},
|
|
});
|
|
injectProfile(resolver, baseProfile);
|
|
|
|
const extendedProfile = makeProfile({
|
|
id: 'extended-profile',
|
|
name: 'EXTENDED',
|
|
security: {
|
|
ipAllowList: ['160.79.104.0/21'],
|
|
},
|
|
extendsProfiles: ['base-profile'],
|
|
});
|
|
injectProfile(resolver, extendedProfile);
|
|
|
|
const route = makeRoute();
|
|
const metadata: IRouteMetadata = { securityProfileRef: 'extended-profile' };
|
|
|
|
const result = resolver.resolveRoute(route, metadata);
|
|
|
|
// Should have IPs from both base and extended profiles
|
|
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
|
expect(result.route.security!.ipAllowList).toContain('160.79.104.0/21');
|
|
// maxConnections from base (extended doesn't override)
|
|
expect(result.route.security!.maxConnections).toEqual(500);
|
|
expect(result.metadata.securityProfileName).toEqual('EXTENDED');
|
|
});
|
|
|
|
tap.test('should detect circular profile inheritance', async () => {
|
|
const profileA = makeProfile({
|
|
id: 'circular-a',
|
|
name: 'A',
|
|
security: { ipAllowList: ['1.1.1.1'] },
|
|
extendsProfiles: ['circular-b'],
|
|
});
|
|
const profileB = makeProfile({
|
|
id: 'circular-b',
|
|
name: 'B',
|
|
security: { ipAllowList: ['2.2.2.2'] },
|
|
extendsProfiles: ['circular-a'],
|
|
});
|
|
injectProfile(resolver, profileA);
|
|
injectProfile(resolver, profileB);
|
|
|
|
const route = makeRoute();
|
|
const metadata: IRouteMetadata = { securityProfileRef: 'circular-a' };
|
|
|
|
// Should not infinite loop — resolves what it can
|
|
const result = resolver.resolveRoute(route, metadata);
|
|
expect(result.route.security).toBeTruthy();
|
|
expect(result.route.security!.ipAllowList).toContain('1.1.1.1');
|
|
});
|
|
|
|
// ---- Network target resolution ----
|
|
|
|
tap.test('should resolve network target onto a route', async () => {
|
|
const target = makeTarget();
|
|
injectTarget(resolver, target);
|
|
|
|
const route = makeRoute();
|
|
const metadata: IRouteMetadata = { networkTargetRef: 'target-1' };
|
|
|
|
const result = resolver.resolveRoute(route, metadata);
|
|
|
|
expect(result.route.action.targets).toBeTruthy();
|
|
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
|
|
expect(result.route.action.targets![0].port).toEqual(443);
|
|
expect(result.metadata.networkTargetName).toEqual('INFRA');
|
|
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
|
});
|
|
|
|
tap.test('should handle missing target gracefully', async () => {
|
|
const route = makeRoute();
|
|
const metadata: IRouteMetadata = { networkTargetRef: 'nonexistent-target' };
|
|
|
|
const result = resolver.resolveRoute(route, metadata);
|
|
|
|
// Route targets should be unchanged (still the placeholder)
|
|
expect(result.route.action.targets![0].host).toEqual('placeholder');
|
|
expect(result.metadata.networkTargetName).toBeUndefined();
|
|
});
|
|
|
|
// ---- Combined resolution ----
|
|
|
|
tap.test('should resolve both profile and target simultaneously', async () => {
|
|
const route = makeRoute();
|
|
const metadata: IRouteMetadata = {
|
|
securityProfileRef: 'profile-1',
|
|
networkTargetRef: 'target-1',
|
|
};
|
|
|
|
const result = resolver.resolveRoute(route, metadata);
|
|
|
|
// Security from profile
|
|
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
|
expect(result.route.security!.maxConnections).toEqual(1000);
|
|
|
|
// Target from network target
|
|
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
|
|
expect(result.route.action.targets![0].port).toEqual(443);
|
|
|
|
// Both names recorded
|
|
expect(result.metadata.securityProfileName).toEqual('STANDARD');
|
|
expect(result.metadata.networkTargetName).toEqual('INFRA');
|
|
});
|
|
|
|
tap.test('should skip resolution when no metadata refs', async () => {
|
|
const route = makeRoute({
|
|
security: { ipAllowList: ['1.2.3.4'] },
|
|
});
|
|
const metadata: IRouteMetadata = {};
|
|
|
|
const result = resolver.resolveRoute(route, metadata);
|
|
|
|
// Route should be completely unchanged
|
|
expect(result.route.security!.ipAllowList).toContain('1.2.3.4');
|
|
expect(result.route.security!.ipAllowList!.length).toEqual(1);
|
|
expect(result.route.action.targets![0].host).toEqual('placeholder');
|
|
});
|
|
|
|
tap.test('should be idempotent — resolving twice gives same result', async () => {
|
|
const route = makeRoute();
|
|
const metadata: IRouteMetadata = {
|
|
securityProfileRef: 'profile-1',
|
|
networkTargetRef: 'target-1',
|
|
};
|
|
|
|
const first = resolver.resolveRoute(route, metadata);
|
|
const second = resolver.resolveRoute(first.route, first.metadata);
|
|
|
|
expect(second.route.security!.ipAllowList!.length).toEqual(first.route.security!.ipAllowList!.length);
|
|
expect(second.route.action.targets![0].host).toEqual(first.route.action.targets![0].host);
|
|
expect(second.route.action.targets![0].port).toEqual(first.route.action.targets![0].port);
|
|
});
|
|
|
|
// ---- Lookup helpers ----
|
|
|
|
tap.test('should find routes by profile ref (sync)', async () => {
|
|
const storedRoutes = new Map<string, any>();
|
|
storedRoutes.set('route-a', {
|
|
id: 'route-a',
|
|
route: makeRoute({ name: 'route-a' }),
|
|
enabled: true,
|
|
metadata: { securityProfileRef: 'profile-1' },
|
|
});
|
|
storedRoutes.set('route-b', {
|
|
id: 'route-b',
|
|
route: makeRoute({ name: 'route-b' }),
|
|
enabled: true,
|
|
metadata: { networkTargetRef: 'target-1' },
|
|
});
|
|
storedRoutes.set('route-c', {
|
|
id: 'route-c',
|
|
route: makeRoute({ name: 'route-c' }),
|
|
enabled: true,
|
|
metadata: { securityProfileRef: 'profile-1', networkTargetRef: 'target-1' },
|
|
});
|
|
|
|
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
|
|
expect(profileRefs.length).toEqual(2);
|
|
expect(profileRefs).toContain('route-a');
|
|
expect(profileRefs).toContain('route-c');
|
|
|
|
const targetRefs = resolver.findRoutesByTargetRefSync('target-1', storedRoutes);
|
|
expect(targetRefs.length).toEqual(2);
|
|
expect(targetRefs).toContain('route-b');
|
|
expect(targetRefs).toContain('route-c');
|
|
});
|
|
|
|
tap.test('should get profile usage for a specific profile ID', async () => {
|
|
const storedRoutes = new Map<string, any>();
|
|
storedRoutes.set('route-x', {
|
|
id: 'route-x',
|
|
route: makeRoute({ name: 'my-route' }),
|
|
enabled: true,
|
|
metadata: { securityProfileRef: 'profile-1' },
|
|
});
|
|
|
|
const usage = resolver.getProfileUsageForId('profile-1', storedRoutes);
|
|
expect(usage.length).toEqual(1);
|
|
expect(usage[0].id).toEqual('route-x');
|
|
expect(usage[0].routeName).toEqual('my-route');
|
|
});
|
|
|
|
tap.test('should get target usage for a specific target ID', async () => {
|
|
const storedRoutes = new Map<string, any>();
|
|
storedRoutes.set('route-y', {
|
|
id: 'route-y',
|
|
route: makeRoute({ name: 'other-route' }),
|
|
enabled: true,
|
|
metadata: { networkTargetRef: 'target-1' },
|
|
});
|
|
|
|
const usage = resolver.getTargetUsageForId('target-1', storedRoutes);
|
|
expect(usage.length).toEqual(1);
|
|
expect(usage[0].id).toEqual('route-y');
|
|
expect(usage[0].routeName).toEqual('other-route');
|
|
});
|
|
|
|
// ---- Profile/target getters ----
|
|
|
|
tap.test('should get profile by name', async () => {
|
|
const profile = resolver.getProfileByName('STANDARD');
|
|
expect(profile).toBeTruthy();
|
|
expect(profile!.id).toEqual('profile-1');
|
|
});
|
|
|
|
tap.test('should get target by name', async () => {
|
|
const target = resolver.getTargetByName('INFRA');
|
|
expect(target).toBeTruthy();
|
|
expect(target!.id).toEqual('target-1');
|
|
});
|
|
|
|
tap.test('should return undefined for nonexistent profile name', async () => {
|
|
const profile = resolver.getProfileByName('NONEXISTENT');
|
|
expect(profile).toBeUndefined();
|
|
});
|
|
|
|
tap.test('should return undefined for nonexistent target name', async () => {
|
|
const target = resolver.getTargetByName('NONEXISTENT');
|
|
expect(target).toBeUndefined();
|
|
});
|
|
|
|
export default tap.start();
|