feat(config): add reusable security profiles and network targets with route reference resolution
This commit is contained in:
371
test/test.reference-resolver.ts
Normal file
371
test/test.reference-resolver.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
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();
|
||||
208
test/test.security-profiles-api.ts
Normal file
208
test/test.security-profiles-api.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
const TEST_PORT = 3200;
|
||||
const TEST_URL = `http://localhost:${TEST_PORT}/typedrequest`;
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
|
||||
// ============================================================================
|
||||
// Setup — db disabled, handlers return graceful fallbacks
|
||||
// ============================================================================
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
testDcRouter = new DcRouter({
|
||||
opsServerPort: TEST_PORT,
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
await testDcRouter.start();
|
||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
tap.test('should login as admin', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
TEST_URL,
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
adminIdentity = response.identity;
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Security Profile endpoints (graceful fallbacks when resolver unavailable)
|
||||
// ============================================================================
|
||||
|
||||
tap.test('should return empty profiles list when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfiles>(
|
||||
TEST_URL,
|
||||
'getSecurityProfiles'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
identity: adminIdentity,
|
||||
});
|
||||
|
||||
expect(response.profiles).toBeArray();
|
||||
expect(response.profiles.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('should return null for single profile when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfile>(
|
||||
TEST_URL,
|
||||
'getSecurityProfile'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
identity: adminIdentity,
|
||||
id: 'nonexistent',
|
||||
});
|
||||
|
||||
expect(response.profile).toEqual(null);
|
||||
});
|
||||
|
||||
tap.test('should return failure for create profile when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_CreateSecurityProfile>(
|
||||
TEST_URL,
|
||||
'createSecurityProfile'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
identity: adminIdentity,
|
||||
name: 'TEST',
|
||||
security: { ipAllowList: ['*'] },
|
||||
});
|
||||
|
||||
expect(response.success).toBeFalse();
|
||||
expect(response.message).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should return empty profile usage when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfileUsage>(
|
||||
TEST_URL,
|
||||
'getSecurityProfileUsage'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
identity: adminIdentity,
|
||||
id: 'nonexistent',
|
||||
});
|
||||
|
||||
expect(response.routes).toBeArray();
|
||||
expect(response.routes.length).toEqual(0);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Network Target endpoints (graceful fallbacks when resolver unavailable)
|
||||
// ============================================================================
|
||||
|
||||
tap.test('should return empty targets list when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargets>(
|
||||
TEST_URL,
|
||||
'getNetworkTargets'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
identity: adminIdentity,
|
||||
});
|
||||
|
||||
expect(response.targets).toBeArray();
|
||||
expect(response.targets.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('should return null for single target when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTarget>(
|
||||
TEST_URL,
|
||||
'getNetworkTarget'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
identity: adminIdentity,
|
||||
id: 'nonexistent',
|
||||
});
|
||||
|
||||
expect(response.target).toEqual(null);
|
||||
});
|
||||
|
||||
tap.test('should return failure for create target when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_CreateNetworkTarget>(
|
||||
TEST_URL,
|
||||
'createNetworkTarget'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
identity: adminIdentity,
|
||||
name: 'TEST',
|
||||
host: '127.0.0.1',
|
||||
port: 443,
|
||||
});
|
||||
|
||||
expect(response.success).toBeFalse();
|
||||
expect(response.message).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should return empty target usage when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargetUsage>(
|
||||
TEST_URL,
|
||||
'getNetworkTargetUsage'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
identity: adminIdentity,
|
||||
id: 'nonexistent',
|
||||
});
|
||||
|
||||
expect(response.routes).toBeArray();
|
||||
expect(response.routes.length).toEqual(0);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Auth rejection
|
||||
// ============================================================================
|
||||
|
||||
tap.test('should reject unauthenticated profile requests', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfiles>(
|
||||
TEST_URL,
|
||||
'getSecurityProfiles'
|
||||
);
|
||||
|
||||
try {
|
||||
await req.fire({} as any);
|
||||
expect(true).toBeFalse();
|
||||
} catch (error) {
|
||||
expect(error).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should reject unauthenticated target requests', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargets>(
|
||||
TEST_URL,
|
||||
'getNetworkTargets'
|
||||
);
|
||||
|
||||
try {
|
||||
await req.fire({} as any);
|
||||
expect(true).toBeFalse();
|
||||
} catch (error) {
|
||||
expect(error).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Cleanup
|
||||
// ============================================================================
|
||||
|
||||
tap.test('should stop DCRouter', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
Reference in New Issue
Block a user