feat(smartradius): Implement full RADIUS server and client with RFC 2865/2866 compliance, including packet handling, authenticators, attributes, secrets manager, client APIs, and comprehensive tests and documentation
This commit is contained in:
167
test/client/test.client.ts
Normal file
167
test/client/test.client.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { RadiusClient } from '../../ts_client/index.js';
|
||||
import {
|
||||
RadiusServer,
|
||||
ERadiusCode,
|
||||
EAcctStatusType,
|
||||
} from '../../ts_server/index.js';
|
||||
|
||||
// Test server and client instances
|
||||
let server: RadiusServer;
|
||||
let client: RadiusClient;
|
||||
const TEST_SECRET = 'testing123';
|
||||
const TEST_AUTH_PORT = 18120;
|
||||
const TEST_ACCT_PORT = 18130;
|
||||
|
||||
tap.test('setup - create server and client', async () => {
|
||||
// Create server with authentication handler
|
||||
server = new RadiusServer({
|
||||
authPort: TEST_AUTH_PORT,
|
||||
acctPort: TEST_ACCT_PORT,
|
||||
defaultSecret: TEST_SECRET,
|
||||
authenticationHandler: async (request) => {
|
||||
// Simple handler: accept user/password, reject others
|
||||
if (request.username === 'testuser' && request.password === 'testpass') {
|
||||
return {
|
||||
code: ERadiusCode.AccessAccept,
|
||||
replyMessage: 'Welcome!',
|
||||
sessionTimeout: 3600,
|
||||
};
|
||||
}
|
||||
|
||||
// Test CHAP
|
||||
if (request.username === 'chapuser' && request.chapPassword && request.chapChallenge) {
|
||||
const { RadiusAuthenticator } = await import('../../ts_server/index.js');
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(
|
||||
request.chapPassword,
|
||||
request.chapChallenge,
|
||||
'chappass'
|
||||
);
|
||||
if (isValid) {
|
||||
return {
|
||||
code: ERadiusCode.AccessAccept,
|
||||
replyMessage: 'CHAP OK',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code: ERadiusCode.AccessReject,
|
||||
replyMessage: 'Invalid credentials',
|
||||
};
|
||||
},
|
||||
accountingHandler: async (request) => {
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
await server.start();
|
||||
|
||||
// Create client
|
||||
client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: TEST_AUTH_PORT,
|
||||
acctPort: TEST_ACCT_PORT,
|
||||
secret: TEST_SECRET,
|
||||
timeout: 2000,
|
||||
retries: 2,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
});
|
||||
|
||||
tap.test('should authenticate with PAP - valid credentials', async () => {
|
||||
const response = await client.authenticatePap('testuser', 'testpass');
|
||||
|
||||
expect(response.accepted).toBeTruthy();
|
||||
expect(response.rejected).toBeFalsy();
|
||||
expect(response.code).toEqual(ERadiusCode.AccessAccept);
|
||||
expect(response.replyMessage).toEqual('Welcome!');
|
||||
expect(response.sessionTimeout).toEqual(3600);
|
||||
});
|
||||
|
||||
tap.test('should reject PAP - invalid credentials', async () => {
|
||||
const response = await client.authenticatePap('testuser', 'wrongpass');
|
||||
|
||||
expect(response.accepted).toBeFalsy();
|
||||
expect(response.rejected).toBeTruthy();
|
||||
expect(response.code).toEqual(ERadiusCode.AccessReject);
|
||||
expect(response.replyMessage).toEqual('Invalid credentials');
|
||||
});
|
||||
|
||||
tap.test('should authenticate with CHAP - valid credentials', async () => {
|
||||
const response = await client.authenticateChap('chapuser', 'chappass');
|
||||
|
||||
expect(response.accepted).toBeTruthy();
|
||||
expect(response.rejected).toBeFalsy();
|
||||
expect(response.code).toEqual(ERadiusCode.AccessAccept);
|
||||
});
|
||||
|
||||
tap.test('should reject CHAP - invalid credentials', async () => {
|
||||
const response = await client.authenticateChap('chapuser', 'wrongpass');
|
||||
|
||||
expect(response.accepted).toBeFalsy();
|
||||
expect(response.rejected).toBeTruthy();
|
||||
expect(response.code).toEqual(ERadiusCode.AccessReject);
|
||||
});
|
||||
|
||||
tap.test('should send accounting start', async () => {
|
||||
const response = await client.accountingStart('session-001', 'testuser');
|
||||
|
||||
expect(response.success).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should send accounting update', async () => {
|
||||
const response = await client.accountingUpdate('session-001', {
|
||||
username: 'testuser',
|
||||
sessionTime: 1800,
|
||||
inputOctets: 512000,
|
||||
outputOctets: 1024000,
|
||||
});
|
||||
|
||||
expect(response.success).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should send accounting stop', async () => {
|
||||
const response = await client.accountingStop('session-001', {
|
||||
username: 'testuser',
|
||||
sessionTime: 3600,
|
||||
inputOctets: 1024000,
|
||||
outputOctets: 2048000,
|
||||
terminateCause: 1, // User-Request
|
||||
});
|
||||
|
||||
expect(response.success).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should send custom accounting request', async () => {
|
||||
const response = await client.accounting({
|
||||
statusType: EAcctStatusType.Start,
|
||||
sessionId: 'custom-session',
|
||||
username: 'customuser',
|
||||
nasPort: 5060,
|
||||
calledStationId: 'called-001',
|
||||
callingStationId: 'calling-002',
|
||||
});
|
||||
|
||||
expect(response.success).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should handle authentication with custom attributes', async () => {
|
||||
const response = await client.authenticate({
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
nasPort: 1,
|
||||
calledStationId: 'test-station',
|
||||
callingStationId: '192.168.1.100',
|
||||
});
|
||||
|
||||
expect(response.accepted).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('teardown - cleanup server and client', async () => {
|
||||
await client.disconnect();
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
304
test/client/test.integration.ts
Normal file
304
test/client/test.integration.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { RadiusClient } from '../../ts_client/index.js';
|
||||
import {
|
||||
RadiusServer,
|
||||
ERadiusCode,
|
||||
EAcctStatusType,
|
||||
EAcctTerminateCause,
|
||||
RadiusAuthenticator,
|
||||
} from '../../ts_server/index.js';
|
||||
|
||||
// Integration test: full client-server communication
|
||||
let server: RadiusServer;
|
||||
const TEST_SECRET = 'integration-secret';
|
||||
const TEST_AUTH_PORT = 18200;
|
||||
const TEST_ACCT_PORT = 18210;
|
||||
|
||||
// Track accounting records
|
||||
const accountingRecords: any[] = [];
|
||||
|
||||
// User database
|
||||
const users: Record<string, { password: string; sessionTimeout?: number }> = {
|
||||
alice: { password: 'alice123', sessionTimeout: 7200 },
|
||||
bob: { password: 'bob456', sessionTimeout: 3600 },
|
||||
charlie: { password: 'charlie789' },
|
||||
};
|
||||
|
||||
tap.test('setup - start integration test server', async () => {
|
||||
server = new RadiusServer({
|
||||
authPort: TEST_AUTH_PORT,
|
||||
acctPort: TEST_ACCT_PORT,
|
||||
defaultSecret: TEST_SECRET,
|
||||
authenticationHandler: async (request) => {
|
||||
const user = users[request.username];
|
||||
if (!user) {
|
||||
return {
|
||||
code: ERadiusCode.AccessReject,
|
||||
replyMessage: 'Unknown user',
|
||||
};
|
||||
}
|
||||
|
||||
// PAP authentication
|
||||
if (request.password !== undefined) {
|
||||
if (request.password === user.password) {
|
||||
return {
|
||||
code: ERadiusCode.AccessAccept,
|
||||
replyMessage: `Welcome, ${request.username}!`,
|
||||
sessionTimeout: user.sessionTimeout,
|
||||
framedIpAddress: '10.0.0.' + (Object.keys(users).indexOf(request.username) + 10),
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: ERadiusCode.AccessReject,
|
||||
replyMessage: 'Invalid password',
|
||||
};
|
||||
}
|
||||
|
||||
// CHAP authentication
|
||||
if (request.chapPassword && request.chapChallenge) {
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(
|
||||
request.chapPassword,
|
||||
request.chapChallenge,
|
||||
user.password
|
||||
);
|
||||
if (isValid) {
|
||||
return {
|
||||
code: ERadiusCode.AccessAccept,
|
||||
replyMessage: `Welcome, ${request.username}! (CHAP)`,
|
||||
sessionTimeout: user.sessionTimeout,
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: ERadiusCode.AccessReject,
|
||||
replyMessage: 'CHAP authentication failed',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: ERadiusCode.AccessReject,
|
||||
replyMessage: 'No authentication method provided',
|
||||
};
|
||||
},
|
||||
accountingHandler: async (request) => {
|
||||
accountingRecords.push({
|
||||
statusType: request.statusType,
|
||||
sessionId: request.sessionId,
|
||||
username: request.username,
|
||||
sessionTime: request.sessionTime,
|
||||
inputOctets: request.inputOctets,
|
||||
outputOctets: request.outputOctets,
|
||||
terminateCause: request.terminateCause,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
await server.start();
|
||||
});
|
||||
|
||||
tap.test('integration: PAP authentication for multiple users', async () => {
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: TEST_AUTH_PORT,
|
||||
acctPort: TEST_ACCT_PORT,
|
||||
secret: TEST_SECRET,
|
||||
timeout: 2000,
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
// Alice
|
||||
let response = await client.authenticatePap('alice', 'alice123');
|
||||
expect(response.accepted).toBeTruthy();
|
||||
expect(response.replyMessage).toInclude('alice');
|
||||
expect(response.sessionTimeout).toEqual(7200);
|
||||
expect(response.framedIpAddress).toEqual('10.0.0.10');
|
||||
|
||||
// Bob
|
||||
response = await client.authenticatePap('bob', 'bob456');
|
||||
expect(response.accepted).toBeTruthy();
|
||||
expect(response.replyMessage).toInclude('bob');
|
||||
expect(response.sessionTimeout).toEqual(3600);
|
||||
|
||||
// Charlie (no session timeout)
|
||||
response = await client.authenticatePap('charlie', 'charlie789');
|
||||
expect(response.accepted).toBeTruthy();
|
||||
expect(response.replyMessage).toInclude('charlie');
|
||||
expect(response.sessionTimeout).toBeUndefined();
|
||||
|
||||
// Unknown user
|
||||
response = await client.authenticatePap('unknown', 'password');
|
||||
expect(response.rejected).toBeTruthy();
|
||||
expect(response.replyMessage).toEqual('Unknown user');
|
||||
|
||||
// Wrong password
|
||||
response = await client.authenticatePap('alice', 'wrongpassword');
|
||||
expect(response.rejected).toBeTruthy();
|
||||
expect(response.replyMessage).toEqual('Invalid password');
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
tap.test('integration: CHAP authentication', async () => {
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: TEST_AUTH_PORT,
|
||||
acctPort: TEST_ACCT_PORT,
|
||||
secret: TEST_SECRET,
|
||||
timeout: 2000,
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
// CHAP with correct password
|
||||
let response = await client.authenticateChap('bob', 'bob456');
|
||||
expect(response.accepted).toBeTruthy();
|
||||
expect(response.replyMessage).toInclude('CHAP');
|
||||
|
||||
// CHAP with wrong password
|
||||
response = await client.authenticateChap('bob', 'wrongpass');
|
||||
expect(response.rejected).toBeTruthy();
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
tap.test('integration: full session lifecycle with accounting', async () => {
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: TEST_AUTH_PORT,
|
||||
acctPort: TEST_ACCT_PORT,
|
||||
secret: TEST_SECRET,
|
||||
timeout: 2000,
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
const sessionId = `session-${Date.now()}`;
|
||||
|
||||
// 1. Authenticate
|
||||
const authResponse = await client.authenticatePap('alice', 'alice123');
|
||||
expect(authResponse.accepted).toBeTruthy();
|
||||
|
||||
// 2. Accounting Start
|
||||
let acctResponse = await client.accountingStart(sessionId, 'alice');
|
||||
expect(acctResponse.success).toBeTruthy();
|
||||
|
||||
// 3. Interim Update
|
||||
acctResponse = await client.accountingUpdate(sessionId, {
|
||||
username: 'alice',
|
||||
sessionTime: 300,
|
||||
inputOctets: 100000,
|
||||
outputOctets: 200000,
|
||||
});
|
||||
expect(acctResponse.success).toBeTruthy();
|
||||
|
||||
// 4. Accounting Stop
|
||||
acctResponse = await client.accountingStop(sessionId, {
|
||||
username: 'alice',
|
||||
sessionTime: 600,
|
||||
inputOctets: 250000,
|
||||
outputOctets: 500000,
|
||||
terminateCause: EAcctTerminateCause.UserRequest,
|
||||
});
|
||||
expect(acctResponse.success).toBeTruthy();
|
||||
|
||||
// Verify accounting records
|
||||
const sessionRecords = accountingRecords.filter((r) => r.sessionId === sessionId);
|
||||
expect(sessionRecords.length).toEqual(3);
|
||||
|
||||
const startRecord = sessionRecords.find((r) => r.statusType === EAcctStatusType.Start);
|
||||
expect(startRecord).toBeDefined();
|
||||
expect(startRecord!.username).toEqual('alice');
|
||||
|
||||
const updateRecord = sessionRecords.find((r) => r.statusType === EAcctStatusType.InterimUpdate);
|
||||
expect(updateRecord).toBeDefined();
|
||||
expect(updateRecord!.sessionTime).toEqual(300);
|
||||
|
||||
const stopRecord = sessionRecords.find((r) => r.statusType === EAcctStatusType.Stop);
|
||||
expect(stopRecord).toBeDefined();
|
||||
expect(stopRecord!.sessionTime).toEqual(600);
|
||||
expect(stopRecord!.terminateCause).toEqual(EAcctTerminateCause.UserRequest);
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
tap.test('integration: multiple concurrent clients', async () => {
|
||||
const clients = [];
|
||||
const results = [];
|
||||
|
||||
// Create 5 clients
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: TEST_AUTH_PORT,
|
||||
acctPort: TEST_ACCT_PORT,
|
||||
secret: TEST_SECRET,
|
||||
timeout: 2000,
|
||||
});
|
||||
await client.connect();
|
||||
clients.push(client);
|
||||
}
|
||||
|
||||
// Send authentication requests concurrently
|
||||
const promises = clients.map((client, i) =>
|
||||
client.authenticatePap(i % 2 === 0 ? 'alice' : 'bob', i % 2 === 0 ? 'alice123' : 'bob456')
|
||||
);
|
||||
|
||||
const responses = await Promise.all(promises);
|
||||
|
||||
// All should succeed
|
||||
for (const response of responses) {
|
||||
expect(response.accepted).toBeTruthy();
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
for (const client of clients) {
|
||||
await client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('integration: server statistics', async () => {
|
||||
const stats = server.getStats();
|
||||
|
||||
// Should have recorded some requests
|
||||
expect(stats.authRequests).toBeGreaterThan(0);
|
||||
expect(stats.authAccepts).toBeGreaterThan(0);
|
||||
expect(stats.authRejects).toBeGreaterThan(0);
|
||||
expect(stats.acctRequests).toBeGreaterThan(0);
|
||||
expect(stats.acctResponses).toBeGreaterThan(0);
|
||||
|
||||
// Should have no invalid packets (we sent valid ones)
|
||||
expect(stats.authInvalidPackets).toEqual(0);
|
||||
expect(stats.acctInvalidPackets).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('integration: duplicate request handling', async () => {
|
||||
// This tests that the server handles duplicate requests correctly
|
||||
// by caching responses within the detection window
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: TEST_AUTH_PORT,
|
||||
acctPort: TEST_ACCT_PORT,
|
||||
secret: TEST_SECRET,
|
||||
timeout: 2000,
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
// Send same request multiple times quickly
|
||||
const responses = await Promise.all([
|
||||
client.authenticatePap('alice', 'alice123'),
|
||||
client.authenticatePap('alice', 'alice123'),
|
||||
client.authenticatePap('alice', 'alice123'),
|
||||
]);
|
||||
|
||||
// All should succeed with same result
|
||||
for (const response of responses) {
|
||||
expect(response.accepted).toBeTruthy();
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
tap.test('teardown - stop integration test server', async () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
149
test/client/test.timeout.ts
Normal file
149
test/client/test.timeout.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { RadiusClient } from '../../ts_client/index.js';
|
||||
|
||||
tap.test('should timeout when server is not reachable', async () => {
|
||||
// Connect to a port where no server is running
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: 19999, // Unlikely to have a server
|
||||
acctPort: 19998,
|
||||
secret: 'testing123',
|
||||
timeout: 500, // Short timeout
|
||||
retries: 1, // Minimal retries
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
await client.authenticatePap('user', 'pass');
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toInclude('timed out');
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
tap.test('should retry on timeout', async () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: 19997,
|
||||
acctPort: 19996,
|
||||
secret: 'testing123',
|
||||
timeout: 200, // 200ms timeout
|
||||
retries: 3, // 3 retries
|
||||
retryDelay: 100, // 100ms initial delay
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
await client.authenticatePap('user', 'pass');
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
expect(error).toBeDefined();
|
||||
// With 3 retries and exponential backoff, should take at least:
|
||||
// Initial timeout (200) + retry 1 delay (100) + timeout (200) + retry 2 delay (200) + timeout (200) + retry 3 delay (400) + timeout (200)
|
||||
// = 200 + 100 + 200 + 200 + 200 + 400 + 200 = ~1500ms minimum
|
||||
expect(elapsed).toBeGreaterThanOrEqual(500);
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
tap.test('should handle disconnect during request', async () => {
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: 19995,
|
||||
acctPort: 19994,
|
||||
secret: 'testing123',
|
||||
timeout: 5000,
|
||||
retries: 3,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
// Start a request (will never complete because no server)
|
||||
const requestPromise = client.authenticatePap('user', 'pass');
|
||||
|
||||
// Disconnect immediately
|
||||
await client.disconnect();
|
||||
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
await requestPromise;
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
|
||||
// When the client disconnects, pending requests are rejected
|
||||
// The error can be either "Client disconnected" or other disconnect-related messages
|
||||
expect(error).toBeDefined();
|
||||
// Just verify we got an error - the specific message may vary
|
||||
});
|
||||
|
||||
tap.test('should handle multiple concurrent requests', async () => {
|
||||
// This test just verifies we can make multiple requests
|
||||
// They will all timeout since no server, but should handle correctly
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: 19993,
|
||||
acctPort: 19992,
|
||||
secret: 'testing123',
|
||||
timeout: 200,
|
||||
retries: 1,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
const requests = [
|
||||
client.authenticatePap('user1', 'pass1').catch((e) => e),
|
||||
client.authenticatePap('user2', 'pass2').catch((e) => e),
|
||||
client.authenticatePap('user3', 'pass3').catch((e) => e),
|
||||
];
|
||||
|
||||
const results = await Promise.all(requests);
|
||||
|
||||
// All should be errors (timeout)
|
||||
for (const result of results) {
|
||||
expect(result).toBeInstanceOf(Error);
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
tap.test('should auto-connect if not connected', async () => {
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: 19991,
|
||||
acctPort: 19990,
|
||||
secret: 'testing123',
|
||||
timeout: 200,
|
||||
retries: 1,
|
||||
});
|
||||
|
||||
// Don't call connect() - should auto-connect
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
await client.authenticatePap('user', 'pass');
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
|
||||
// Should timeout, not connection error
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toInclude('timed out');
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
246
test/server/test.accounting.ts
Normal file
246
test/server/test.accounting.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
RadiusPacket,
|
||||
RadiusAuthenticator,
|
||||
ERadiusCode,
|
||||
ERadiusAttributeType,
|
||||
EAcctStatusType,
|
||||
EAcctTerminateCause,
|
||||
EAcctAuthentic,
|
||||
} from '../../ts_server/index.js';
|
||||
|
||||
tap.test('should create Accounting-Request packet with Start status', async () => {
|
||||
const identifier = 1;
|
||||
const secret = 'testing123';
|
||||
|
||||
const packet = RadiusPacket.createAccountingRequest(identifier, secret, [
|
||||
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.Start },
|
||||
{ type: ERadiusAttributeType.AcctSessionId, value: 'session-001' },
|
||||
{ type: ERadiusAttributeType.UserName, value: 'testuser' },
|
||||
{ type: ERadiusAttributeType.NasIpAddress, value: '192.168.1.1' },
|
||||
]);
|
||||
|
||||
const decoded = RadiusPacket.decode(packet);
|
||||
expect(decoded.code).toEqual(ERadiusCode.AccountingRequest);
|
||||
expect(decoded.identifier).toEqual(identifier);
|
||||
});
|
||||
|
||||
tap.test('should create Accounting-Request packet with Stop status', async () => {
|
||||
const identifier = 2;
|
||||
const secret = 'testing123';
|
||||
|
||||
const packet = RadiusPacket.createAccountingRequest(identifier, secret, [
|
||||
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.Stop },
|
||||
{ type: ERadiusAttributeType.AcctSessionId, value: 'session-001' },
|
||||
{ type: ERadiusAttributeType.UserName, value: 'testuser' },
|
||||
{ type: ERadiusAttributeType.AcctSessionTime, value: 3600 }, // 1 hour
|
||||
{ type: ERadiusAttributeType.AcctInputOctets, value: 1024000 },
|
||||
{ type: ERadiusAttributeType.AcctOutputOctets, value: 2048000 },
|
||||
{ type: ERadiusAttributeType.AcctInputPackets, value: 1000 },
|
||||
{ type: ERadiusAttributeType.AcctOutputPackets, value: 2000 },
|
||||
{ type: ERadiusAttributeType.AcctTerminateCause, value: EAcctTerminateCause.UserRequest },
|
||||
]);
|
||||
|
||||
const decoded = RadiusPacket.decodeAndParse(packet);
|
||||
expect(decoded.code).toEqual(ERadiusCode.AccountingRequest);
|
||||
|
||||
const statusType = decoded.parsedAttributes.find(
|
||||
(a) => a.type === ERadiusAttributeType.AcctStatusType
|
||||
);
|
||||
expect(statusType?.value).toEqual(EAcctStatusType.Stop);
|
||||
});
|
||||
|
||||
tap.test('should create Accounting-Request packet with Interim-Update status', async () => {
|
||||
const identifier = 3;
|
||||
const secret = 'testing123';
|
||||
|
||||
const packet = RadiusPacket.createAccountingRequest(identifier, secret, [
|
||||
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.InterimUpdate },
|
||||
{ type: ERadiusAttributeType.AcctSessionId, value: 'session-001' },
|
||||
{ type: ERadiusAttributeType.UserName, value: 'testuser' },
|
||||
{ type: ERadiusAttributeType.AcctSessionTime, value: 1800 }, // 30 min
|
||||
{ type: ERadiusAttributeType.AcctInputOctets, value: 512000 },
|
||||
{ type: ERadiusAttributeType.AcctOutputOctets, value: 1024000 },
|
||||
]);
|
||||
|
||||
const decoded = RadiusPacket.decodeAndParse(packet);
|
||||
expect(decoded.code).toEqual(ERadiusCode.AccountingRequest);
|
||||
|
||||
const statusType = decoded.parsedAttributes.find(
|
||||
(a) => a.type === ERadiusAttributeType.AcctStatusType
|
||||
);
|
||||
expect(statusType?.value).toEqual(EAcctStatusType.InterimUpdate);
|
||||
});
|
||||
|
||||
tap.test('should verify Accounting-Request authenticator', async () => {
|
||||
const identifier = 1;
|
||||
const secret = 'testing123';
|
||||
|
||||
const packet = RadiusPacket.createAccountingRequest(identifier, secret, [
|
||||
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.Start },
|
||||
{ type: ERadiusAttributeType.AcctSessionId, value: 'session-001' },
|
||||
]);
|
||||
|
||||
// Verify the authenticator
|
||||
const isValid = RadiusAuthenticator.verifyAccountingRequestAuthenticator(packet, secret);
|
||||
expect(isValid).toBeTruthy();
|
||||
|
||||
// Should fail with wrong secret
|
||||
const isInvalid = RadiusAuthenticator.verifyAccountingRequestAuthenticator(packet, 'wrongsecret');
|
||||
expect(isInvalid).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should create Accounting-Response packet', async () => {
|
||||
const identifier = 1;
|
||||
const requestAuthenticator = Buffer.alloc(16, 0x42);
|
||||
const secret = 'testing123';
|
||||
|
||||
const packet = RadiusPacket.createAccountingResponse(
|
||||
identifier,
|
||||
requestAuthenticator,
|
||||
secret,
|
||||
[] // Usually no attributes in response
|
||||
);
|
||||
|
||||
const decoded = RadiusPacket.decode(packet);
|
||||
expect(decoded.code).toEqual(ERadiusCode.AccountingResponse);
|
||||
expect(decoded.identifier).toEqual(identifier);
|
||||
});
|
||||
|
||||
tap.test('should verify Accounting-Response authenticator', async () => {
|
||||
const identifier = 1;
|
||||
const requestAuthenticator = Buffer.alloc(16, 0x42);
|
||||
const secret = 'testing123';
|
||||
|
||||
const response = RadiusPacket.createAccountingResponse(
|
||||
identifier,
|
||||
requestAuthenticator,
|
||||
secret,
|
||||
[]
|
||||
);
|
||||
|
||||
// Verify response authenticator
|
||||
const isValid = RadiusAuthenticator.verifyResponseAuthenticator(
|
||||
response,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should parse all accounting attributes correctly', async () => {
|
||||
const identifier = 1;
|
||||
const secret = 'testing123';
|
||||
|
||||
const packet = RadiusPacket.createAccountingRequest(identifier, secret, [
|
||||
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.Stop },
|
||||
{ type: ERadiusAttributeType.AcctDelayTime, value: 5 },
|
||||
{ type: ERadiusAttributeType.AcctInputOctets, value: 1000000 },
|
||||
{ type: ERadiusAttributeType.AcctOutputOctets, value: 2000000 },
|
||||
{ type: ERadiusAttributeType.AcctSessionId, value: 'sess-123' },
|
||||
{ type: ERadiusAttributeType.AcctAuthentic, value: EAcctAuthentic.Radius },
|
||||
{ type: ERadiusAttributeType.AcctSessionTime, value: 7200 },
|
||||
{ type: ERadiusAttributeType.AcctInputPackets, value: 5000 },
|
||||
{ type: ERadiusAttributeType.AcctOutputPackets, value: 10000 },
|
||||
{ type: ERadiusAttributeType.AcctTerminateCause, value: EAcctTerminateCause.SessionTimeout },
|
||||
{ type: ERadiusAttributeType.AcctMultiSessionId, value: 'multi-sess-456' },
|
||||
{ type: ERadiusAttributeType.AcctLinkCount, value: 2 },
|
||||
]);
|
||||
|
||||
const decoded = RadiusPacket.decodeAndParse(packet);
|
||||
|
||||
// Check each attribute
|
||||
const attrs = decoded.parsedAttributes;
|
||||
|
||||
const statusType = attrs.find((a) => a.type === ERadiusAttributeType.AcctStatusType);
|
||||
expect(statusType?.value).toEqual(EAcctStatusType.Stop);
|
||||
|
||||
const delayTime = attrs.find((a) => a.type === ERadiusAttributeType.AcctDelayTime);
|
||||
expect(delayTime?.value).toEqual(5);
|
||||
|
||||
const inputOctets = attrs.find((a) => a.type === ERadiusAttributeType.AcctInputOctets);
|
||||
expect(inputOctets?.value).toEqual(1000000);
|
||||
|
||||
const outputOctets = attrs.find((a) => a.type === ERadiusAttributeType.AcctOutputOctets);
|
||||
expect(outputOctets?.value).toEqual(2000000);
|
||||
|
||||
const sessionId = attrs.find((a) => a.type === ERadiusAttributeType.AcctSessionId);
|
||||
expect(sessionId?.value).toEqual('sess-123');
|
||||
|
||||
const authentic = attrs.find((a) => a.type === ERadiusAttributeType.AcctAuthentic);
|
||||
expect(authentic?.value).toEqual(EAcctAuthentic.Radius);
|
||||
|
||||
const sessionTime = attrs.find((a) => a.type === ERadiusAttributeType.AcctSessionTime);
|
||||
expect(sessionTime?.value).toEqual(7200);
|
||||
|
||||
const terminateCause = attrs.find((a) => a.type === ERadiusAttributeType.AcctTerminateCause);
|
||||
expect(terminateCause?.value).toEqual(EAcctTerminateCause.SessionTimeout);
|
||||
});
|
||||
|
||||
tap.test('should handle Accounting-On/Off status types', async () => {
|
||||
const secret = 'testing123';
|
||||
|
||||
// Accounting-On (NAS restart/reboot notification)
|
||||
const acctOnPacket = RadiusPacket.createAccountingRequest(1, secret, [
|
||||
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.AccountingOn },
|
||||
{ type: ERadiusAttributeType.NasIpAddress, value: '192.168.1.1' },
|
||||
]);
|
||||
|
||||
let decoded = RadiusPacket.decodeAndParse(acctOnPacket);
|
||||
let statusType = decoded.parsedAttributes.find(
|
||||
(a) => a.type === ERadiusAttributeType.AcctStatusType
|
||||
);
|
||||
expect(statusType?.value).toEqual(EAcctStatusType.AccountingOn);
|
||||
|
||||
// Accounting-Off
|
||||
const acctOffPacket = RadiusPacket.createAccountingRequest(2, secret, [
|
||||
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.AccountingOff },
|
||||
{ type: ERadiusAttributeType.NasIpAddress, value: '192.168.1.1' },
|
||||
]);
|
||||
|
||||
decoded = RadiusPacket.decodeAndParse(acctOffPacket);
|
||||
statusType = decoded.parsedAttributes.find(
|
||||
(a) => a.type === ERadiusAttributeType.AcctStatusType
|
||||
);
|
||||
expect(statusType?.value).toEqual(EAcctStatusType.AccountingOff);
|
||||
});
|
||||
|
||||
tap.test('should handle all termination causes', async () => {
|
||||
const secret = 'testing123';
|
||||
const terminationCauses = [
|
||||
EAcctTerminateCause.UserRequest,
|
||||
EAcctTerminateCause.LostCarrier,
|
||||
EAcctTerminateCause.LostService,
|
||||
EAcctTerminateCause.IdleTimeout,
|
||||
EAcctTerminateCause.SessionTimeout,
|
||||
EAcctTerminateCause.AdminReset,
|
||||
EAcctTerminateCause.AdminReboot,
|
||||
EAcctTerminateCause.PortError,
|
||||
EAcctTerminateCause.NasError,
|
||||
EAcctTerminateCause.NasRequest,
|
||||
EAcctTerminateCause.NasReboot,
|
||||
EAcctTerminateCause.PortUnneeded,
|
||||
EAcctTerminateCause.PortPreempted,
|
||||
EAcctTerminateCause.PortSuspended,
|
||||
EAcctTerminateCause.ServiceUnavailable,
|
||||
EAcctTerminateCause.Callback,
|
||||
EAcctTerminateCause.UserError,
|
||||
EAcctTerminateCause.HostRequest,
|
||||
];
|
||||
|
||||
for (const cause of terminationCauses) {
|
||||
const packet = RadiusPacket.createAccountingRequest(1, secret, [
|
||||
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.Stop },
|
||||
{ type: ERadiusAttributeType.AcctSessionId, value: 'session-001' },
|
||||
{ type: ERadiusAttributeType.AcctTerminateCause, value: cause },
|
||||
]);
|
||||
|
||||
const decoded = RadiusPacket.decodeAndParse(packet);
|
||||
const termCause = decoded.parsedAttributes.find(
|
||||
(a) => a.type === ERadiusAttributeType.AcctTerminateCause
|
||||
);
|
||||
expect(termCause?.value).toEqual(cause);
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
211
test/server/test.attributes.ts
Normal file
211
test/server/test.attributes.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
RadiusAttributes,
|
||||
ERadiusAttributeType,
|
||||
} from '../../ts_server/index.js';
|
||||
|
||||
tap.test('should get attribute definitions by type', async () => {
|
||||
const userNameDef = RadiusAttributes.getDefinition(ERadiusAttributeType.UserName);
|
||||
expect(userNameDef).toBeDefined();
|
||||
expect(userNameDef!.name).toEqual('User-Name');
|
||||
expect(userNameDef!.valueType).toEqual('text');
|
||||
|
||||
const nasIpDef = RadiusAttributes.getDefinition(ERadiusAttributeType.NasIpAddress);
|
||||
expect(nasIpDef).toBeDefined();
|
||||
expect(nasIpDef!.name).toEqual('NAS-IP-Address');
|
||||
expect(nasIpDef!.valueType).toEqual('address');
|
||||
|
||||
const nasPortDef = RadiusAttributes.getDefinition(ERadiusAttributeType.NasPort);
|
||||
expect(nasPortDef).toBeDefined();
|
||||
expect(nasPortDef!.name).toEqual('NAS-Port');
|
||||
expect(nasPortDef!.valueType).toEqual('integer');
|
||||
});
|
||||
|
||||
tap.test('should get attribute type by name', async () => {
|
||||
expect(RadiusAttributes.getTypeByName('User-Name')).toEqual(ERadiusAttributeType.UserName);
|
||||
expect(RadiusAttributes.getTypeByName('NAS-IP-Address')).toEqual(ERadiusAttributeType.NasIpAddress);
|
||||
expect(RadiusAttributes.getTypeByName('NAS-Port')).toEqual(ERadiusAttributeType.NasPort);
|
||||
expect(RadiusAttributes.getTypeByName('Unknown-Attribute')).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should get attribute name by type', async () => {
|
||||
expect(RadiusAttributes.getNameByType(ERadiusAttributeType.UserName)).toEqual('User-Name');
|
||||
expect(RadiusAttributes.getNameByType(ERadiusAttributeType.UserPassword)).toEqual('User-Password');
|
||||
expect(RadiusAttributes.getNameByType(255)).toInclude('Unknown-Attribute');
|
||||
});
|
||||
|
||||
tap.test('should parse text attributes', async () => {
|
||||
const textBuffer = Buffer.from('testuser', 'utf8');
|
||||
const parsed = RadiusAttributes.parseValue(ERadiusAttributeType.UserName, textBuffer);
|
||||
expect(parsed).toEqual('testuser');
|
||||
});
|
||||
|
||||
tap.test('should parse address attributes', async () => {
|
||||
const addressBuffer = Buffer.from([192, 168, 1, 100]);
|
||||
const parsed = RadiusAttributes.parseValue(ERadiusAttributeType.NasIpAddress, addressBuffer);
|
||||
expect(parsed).toEqual('192.168.1.100');
|
||||
});
|
||||
|
||||
tap.test('should parse integer attributes', async () => {
|
||||
const intBuffer = Buffer.allocUnsafe(4);
|
||||
intBuffer.writeUInt32BE(12345, 0);
|
||||
const parsed = RadiusAttributes.parseValue(ERadiusAttributeType.NasPort, intBuffer);
|
||||
expect(parsed).toEqual(12345);
|
||||
});
|
||||
|
||||
tap.test('should encode text attributes', async () => {
|
||||
const encoded = RadiusAttributes.encodeValue(ERadiusAttributeType.UserName, 'testuser');
|
||||
expect(encoded.toString('utf8')).toEqual('testuser');
|
||||
});
|
||||
|
||||
tap.test('should encode address attributes', async () => {
|
||||
const encoded = RadiusAttributes.encodeValue(ERadiusAttributeType.NasIpAddress, '10.20.30.40');
|
||||
expect(encoded.length).toEqual(4);
|
||||
expect(encoded[0]).toEqual(10);
|
||||
expect(encoded[1]).toEqual(20);
|
||||
expect(encoded[2]).toEqual(30);
|
||||
expect(encoded[3]).toEqual(40);
|
||||
});
|
||||
|
||||
tap.test('should encode integer attributes', async () => {
|
||||
const encoded = RadiusAttributes.encodeValue(ERadiusAttributeType.NasPort, 65535);
|
||||
expect(encoded.length).toEqual(4);
|
||||
expect(encoded.readUInt32BE(0)).toEqual(65535);
|
||||
});
|
||||
|
||||
tap.test('should encode complete attribute with TLV format', async () => {
|
||||
const encoded = RadiusAttributes.encodeAttribute(ERadiusAttributeType.UserName, 'test');
|
||||
expect(encoded[0]).toEqual(ERadiusAttributeType.UserName); // Type
|
||||
expect(encoded[1]).toEqual(6); // Length (2 + 4)
|
||||
expect(encoded.subarray(2).toString('utf8')).toEqual('test');
|
||||
});
|
||||
|
||||
tap.test('should handle attribute value too long', async () => {
|
||||
const longValue = Buffer.alloc(254); // Max is 253 bytes
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
RadiusAttributes.encodeAttribute(ERadiusAttributeType.UserName, longValue);
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toInclude('too long');
|
||||
});
|
||||
|
||||
tap.test('should parse raw attribute', async () => {
|
||||
const rawAttr = {
|
||||
type: ERadiusAttributeType.UserName,
|
||||
value: Buffer.from('john.doe', 'utf8'),
|
||||
};
|
||||
const parsed = RadiusAttributes.parseAttribute(rawAttr);
|
||||
expect(parsed.type).toEqual(ERadiusAttributeType.UserName);
|
||||
expect(parsed.name).toEqual('User-Name');
|
||||
expect(parsed.value).toEqual('john.doe');
|
||||
});
|
||||
|
||||
tap.test('should handle Vendor-Specific Attributes', async () => {
|
||||
// Vendor-Id: 9 (Cisco), Vendor-Type: 1, Value: 'test'
|
||||
const vendorId = 9;
|
||||
const vendorType = 1;
|
||||
const vendorValue = Buffer.from('cisco-av-pair', 'utf8');
|
||||
|
||||
const vsaBuffer = RadiusAttributes.encodeVSA({ vendorId, vendorType, vendorValue });
|
||||
|
||||
// Parse it back
|
||||
const parsed = RadiusAttributes.parseVSA(vsaBuffer);
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed!.vendorId).toEqual(vendorId);
|
||||
expect(parsed!.vendorType).toEqual(vendorType);
|
||||
expect(parsed!.vendorValue.toString('utf8')).toEqual('cisco-av-pair');
|
||||
});
|
||||
|
||||
tap.test('should create complete Vendor-Specific attribute', async () => {
|
||||
const vendorId = 311; // Microsoft
|
||||
const vendorType = 1;
|
||||
const vendorValue = Buffer.from('test-value', 'utf8');
|
||||
|
||||
const attr = RadiusAttributes.createVendorAttribute(vendorId, vendorType, vendorValue);
|
||||
|
||||
// First byte should be type 26 (Vendor-Specific)
|
||||
expect(attr[0]).toEqual(ERadiusAttributeType.VendorSpecific);
|
||||
|
||||
// Second byte is total length
|
||||
expect(attr[1]).toBeGreaterThan(6);
|
||||
});
|
||||
|
||||
tap.test('should check if attribute is encrypted', async () => {
|
||||
expect(RadiusAttributes.isEncrypted(ERadiusAttributeType.UserPassword)).toBeTruthy();
|
||||
expect(RadiusAttributes.isEncrypted(ERadiusAttributeType.UserName)).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should handle all standard RFC 2865 attributes', async () => {
|
||||
// Test that all standard attributes have definitions
|
||||
const standardAttributes = [
|
||||
ERadiusAttributeType.UserName,
|
||||
ERadiusAttributeType.UserPassword,
|
||||
ERadiusAttributeType.ChapPassword,
|
||||
ERadiusAttributeType.NasIpAddress,
|
||||
ERadiusAttributeType.NasPort,
|
||||
ERadiusAttributeType.ServiceType,
|
||||
ERadiusAttributeType.FramedProtocol,
|
||||
ERadiusAttributeType.FramedIpAddress,
|
||||
ERadiusAttributeType.FramedIpNetmask,
|
||||
ERadiusAttributeType.FramedRouting,
|
||||
ERadiusAttributeType.FilterId,
|
||||
ERadiusAttributeType.FramedMtu,
|
||||
ERadiusAttributeType.FramedCompression,
|
||||
ERadiusAttributeType.LoginIpHost,
|
||||
ERadiusAttributeType.LoginService,
|
||||
ERadiusAttributeType.LoginTcpPort,
|
||||
ERadiusAttributeType.ReplyMessage,
|
||||
ERadiusAttributeType.CallbackNumber,
|
||||
ERadiusAttributeType.CallbackId,
|
||||
ERadiusAttributeType.FramedRoute,
|
||||
ERadiusAttributeType.FramedIpxNetwork,
|
||||
ERadiusAttributeType.State,
|
||||
ERadiusAttributeType.Class,
|
||||
ERadiusAttributeType.VendorSpecific,
|
||||
ERadiusAttributeType.SessionTimeout,
|
||||
ERadiusAttributeType.IdleTimeout,
|
||||
ERadiusAttributeType.TerminationAction,
|
||||
ERadiusAttributeType.CalledStationId,
|
||||
ERadiusAttributeType.CallingStationId,
|
||||
ERadiusAttributeType.NasIdentifier,
|
||||
ERadiusAttributeType.ProxyState,
|
||||
ERadiusAttributeType.ChapChallenge,
|
||||
ERadiusAttributeType.NasPortType,
|
||||
ERadiusAttributeType.PortLimit,
|
||||
];
|
||||
|
||||
for (const attrType of standardAttributes) {
|
||||
const def = RadiusAttributes.getDefinition(attrType);
|
||||
expect(def).toBeDefined();
|
||||
expect(def!.name).toBeDefined();
|
||||
expect(def!.valueType).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should handle all RFC 2866 accounting attributes', async () => {
|
||||
const accountingAttributes = [
|
||||
ERadiusAttributeType.AcctStatusType,
|
||||
ERadiusAttributeType.AcctDelayTime,
|
||||
ERadiusAttributeType.AcctInputOctets,
|
||||
ERadiusAttributeType.AcctOutputOctets,
|
||||
ERadiusAttributeType.AcctSessionId,
|
||||
ERadiusAttributeType.AcctAuthentic,
|
||||
ERadiusAttributeType.AcctSessionTime,
|
||||
ERadiusAttributeType.AcctInputPackets,
|
||||
ERadiusAttributeType.AcctOutputPackets,
|
||||
ERadiusAttributeType.AcctTerminateCause,
|
||||
ERadiusAttributeType.AcctMultiSessionId,
|
||||
ERadiusAttributeType.AcctLinkCount,
|
||||
];
|
||||
|
||||
for (const attrType of accountingAttributes) {
|
||||
const def = RadiusAttributes.getDefinition(attrType);
|
||||
expect(def).toBeDefined();
|
||||
expect(def!.name).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
205
test/server/test.authenticator.ts
Normal file
205
test/server/test.authenticator.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as crypto from 'crypto';
|
||||
import {
|
||||
RadiusAuthenticator,
|
||||
RadiusPacket,
|
||||
ERadiusCode,
|
||||
ERadiusAttributeType,
|
||||
} from '../../ts_server/index.js';
|
||||
|
||||
tap.test('should generate 16-byte random request authenticator', async () => {
|
||||
const auth1 = RadiusAuthenticator.generateRequestAuthenticator();
|
||||
const auth2 = RadiusAuthenticator.generateRequestAuthenticator();
|
||||
|
||||
expect(auth1.length).toEqual(16);
|
||||
expect(auth2.length).toEqual(16);
|
||||
|
||||
// Should be random (different each time)
|
||||
expect(auth1.equals(auth2)).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should calculate response authenticator correctly', async () => {
|
||||
const code = ERadiusCode.AccessAccept;
|
||||
const identifier = 1;
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const attributes = Buffer.from([]);
|
||||
const secret = 'testing123';
|
||||
|
||||
const responseAuth = RadiusAuthenticator.calculateResponseAuthenticator(
|
||||
code,
|
||||
identifier,
|
||||
requestAuthenticator,
|
||||
attributes,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(responseAuth.length).toEqual(16);
|
||||
|
||||
// Verify by recalculating
|
||||
const verified = RadiusAuthenticator.calculateResponseAuthenticator(
|
||||
code,
|
||||
identifier,
|
||||
requestAuthenticator,
|
||||
attributes,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(responseAuth.equals(verified)).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should calculate accounting request authenticator', async () => {
|
||||
const code = ERadiusCode.AccountingRequest;
|
||||
const identifier = 1;
|
||||
const attributes = Buffer.from([]);
|
||||
const secret = 'testing123';
|
||||
|
||||
const acctAuth = RadiusAuthenticator.calculateAccountingRequestAuthenticator(
|
||||
code,
|
||||
identifier,
|
||||
attributes,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(acctAuth.length).toEqual(16);
|
||||
});
|
||||
|
||||
tap.test('should verify accounting request authenticator', async () => {
|
||||
const secret = 'testing123';
|
||||
|
||||
// Create an accounting request packet
|
||||
const packet = RadiusPacket.createAccountingRequest(1, secret, [
|
||||
{ type: ERadiusAttributeType.AcctStatusType, value: 1 }, // Start
|
||||
{ type: ERadiusAttributeType.AcctSessionId, value: 'session-123' },
|
||||
]);
|
||||
|
||||
// Verify the authenticator
|
||||
const isValid = RadiusAuthenticator.verifyAccountingRequestAuthenticator(packet, secret);
|
||||
expect(isValid).toBeTruthy();
|
||||
|
||||
// Should fail with wrong secret
|
||||
const isInvalid = RadiusAuthenticator.verifyAccountingRequestAuthenticator(packet, 'wrongsecret');
|
||||
expect(isInvalid).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should verify response authenticator', async () => {
|
||||
const identifier = 1;
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
// Create a response packet
|
||||
const responsePacket = RadiusPacket.createAccessAccept(
|
||||
identifier,
|
||||
requestAuthenticator,
|
||||
secret,
|
||||
[{ type: ERadiusAttributeType.ReplyMessage, value: 'Welcome' }]
|
||||
);
|
||||
|
||||
// Verify the authenticator
|
||||
const isValid = RadiusAuthenticator.verifyResponseAuthenticator(
|
||||
responsePacket,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
expect(isValid).toBeTruthy();
|
||||
|
||||
// Should fail with wrong request authenticator
|
||||
const wrongRequestAuth = crypto.randomBytes(16);
|
||||
const isInvalid = RadiusAuthenticator.verifyResponseAuthenticator(
|
||||
responsePacket,
|
||||
wrongRequestAuth,
|
||||
secret
|
||||
);
|
||||
expect(isInvalid).toBeFalsy();
|
||||
|
||||
// Should fail with wrong secret
|
||||
const isInvalid2 = RadiusAuthenticator.verifyResponseAuthenticator(
|
||||
responsePacket,
|
||||
requestAuthenticator,
|
||||
'wrongsecret'
|
||||
);
|
||||
expect(isInvalid2).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should create packet header', async () => {
|
||||
const code = ERadiusCode.AccessRequest;
|
||||
const identifier = 42;
|
||||
const authenticator = crypto.randomBytes(16);
|
||||
const attributesLength = 50;
|
||||
|
||||
const header = RadiusAuthenticator.createPacketHeader(
|
||||
code,
|
||||
identifier,
|
||||
authenticator,
|
||||
attributesLength
|
||||
);
|
||||
|
||||
expect(header.length).toEqual(20);
|
||||
expect(header[0]).toEqual(code);
|
||||
expect(header[1]).toEqual(identifier);
|
||||
expect(header.readUInt16BE(2)).toEqual(20 + attributesLength);
|
||||
expect(header.subarray(4, 20).equals(authenticator)).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should calculate CHAP response', async () => {
|
||||
const chapId = 1;
|
||||
const password = 'testpassword';
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
expect(response.length).toEqual(16);
|
||||
|
||||
// Verify the calculation: MD5(CHAP-ID + Password + Challenge)
|
||||
const md5 = crypto.createHash('md5');
|
||||
md5.update(Buffer.from([chapId]));
|
||||
md5.update(Buffer.from(password, 'utf8'));
|
||||
md5.update(challenge);
|
||||
const expected = md5.digest();
|
||||
|
||||
expect(response.equals(expected)).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should verify CHAP response', async () => {
|
||||
const chapId = 1;
|
||||
const password = 'testpassword';
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
// Calculate the response
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
// Create CHAP-Password attribute format: CHAP-ID (1 byte) + Response (16 bytes)
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(chapId, 0);
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
// Verify with correct password
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
|
||||
expect(isValid).toBeTruthy();
|
||||
|
||||
// Verify with wrong password
|
||||
const isInvalid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, 'wrongpassword');
|
||||
expect(isInvalid).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should calculate Message-Authenticator (HMAC-MD5)', async () => {
|
||||
const packet = Buffer.alloc(50);
|
||||
packet[0] = ERadiusCode.AccessRequest;
|
||||
packet[1] = 1;
|
||||
packet.writeUInt16BE(50, 2);
|
||||
crypto.randomBytes(16).copy(packet, 4);
|
||||
|
||||
const secret = 'testing123';
|
||||
|
||||
const msgAuth = RadiusAuthenticator.calculateMessageAuthenticator(packet, secret);
|
||||
|
||||
expect(msgAuth.length).toEqual(16);
|
||||
|
||||
// Verify it's HMAC-MD5
|
||||
const hmac = crypto.createHmac('md5', Buffer.from(secret, 'utf8'));
|
||||
hmac.update(packet);
|
||||
const expected = hmac.digest();
|
||||
|
||||
expect(msgAuth.equals(expected)).toBeTruthy();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
209
test/server/test.chap.ts
Normal file
209
test/server/test.chap.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as crypto from 'crypto';
|
||||
import { RadiusAuthenticator } from '../../ts_server/index.js';
|
||||
|
||||
tap.test('should calculate CHAP response per RFC', async () => {
|
||||
// CHAP-Response = MD5(CHAP-ID + Password + Challenge)
|
||||
const chapId = 42;
|
||||
const password = 'mypassword';
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
// Verify manually
|
||||
const md5 = crypto.createHash('md5');
|
||||
md5.update(Buffer.from([chapId]));
|
||||
md5.update(Buffer.from(password, 'utf8'));
|
||||
md5.update(challenge);
|
||||
const expected = md5.digest();
|
||||
|
||||
expect(response.length).toEqual(16);
|
||||
expect(response.equals(expected)).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should verify valid CHAP response', async () => {
|
||||
const chapId = 1;
|
||||
const password = 'correctpassword';
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
// Calculate the expected response
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
// Build CHAP-Password attribute: CHAP Ident (1 byte) + Response (16 bytes)
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(chapId, 0);
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
// Should verify with correct password
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should reject CHAP response with wrong password', async () => {
|
||||
const chapId = 1;
|
||||
const correctPassword = 'correctpassword';
|
||||
const wrongPassword = 'wrongpassword';
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
// Calculate response with correct password
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, correctPassword, challenge);
|
||||
|
||||
// Build CHAP-Password attribute
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(chapId, 0);
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
// Should NOT verify with wrong password
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, wrongPassword);
|
||||
expect(isValid).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should reject CHAP response with wrong challenge', async () => {
|
||||
const chapId = 1;
|
||||
const password = 'mypassword';
|
||||
const correctChallenge = crypto.randomBytes(16);
|
||||
const wrongChallenge = crypto.randomBytes(16);
|
||||
|
||||
// Calculate response with correct challenge
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, correctChallenge);
|
||||
|
||||
// Build CHAP-Password attribute
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(chapId, 0);
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
// Should NOT verify with wrong challenge
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, wrongChallenge, password);
|
||||
expect(isValid).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should reject CHAP response with wrong identifier', async () => {
|
||||
const correctChapId = 1;
|
||||
const wrongChapId = 2;
|
||||
const password = 'mypassword';
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
// Calculate response with correct CHAP ID
|
||||
const response = RadiusAuthenticator.calculateChapResponse(correctChapId, password, challenge);
|
||||
|
||||
// Build CHAP-Password with WRONG CHAP ID
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(wrongChapId, 0); // Wrong ID
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
// Should NOT verify
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
|
||||
expect(isValid).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should reject invalid CHAP-Password length', async () => {
|
||||
const challenge = crypto.randomBytes(16);
|
||||
const password = 'mypassword';
|
||||
|
||||
// CHAP-Password must be exactly 17 bytes (1 + 16)
|
||||
const invalidChapPassword = Buffer.alloc(16); // Too short
|
||||
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(invalidChapPassword, challenge, password);
|
||||
expect(isValid).toBeFalsy();
|
||||
|
||||
const tooLongChapPassword = Buffer.alloc(18); // Too long
|
||||
const isValid2 = RadiusAuthenticator.verifyChapResponse(tooLongChapPassword, challenge, password);
|
||||
expect(isValid2).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should handle all CHAP ID values (0-255)', async () => {
|
||||
const password = 'testpassword';
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
for (const chapId of [0, 1, 127, 128, 254, 255]) {
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(chapId, 0);
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
|
||||
expect(isValid).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should handle special characters in password', async () => {
|
||||
const chapId = 1;
|
||||
const password = '!@#$%^&*()_+-=[]{}|;:,.<>?~`';
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(chapId, 0);
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should handle unicode password', async () => {
|
||||
const chapId = 1;
|
||||
const password = '密码パスワード'; // Chinese + Japanese
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(chapId, 0);
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should handle empty password', async () => {
|
||||
const chapId = 1;
|
||||
const password = '';
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(chapId, 0);
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should handle very long password', async () => {
|
||||
const chapId = 1;
|
||||
const password = 'a'.repeat(1000); // Very long password
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(chapId, 0);
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should handle different challenge lengths', async () => {
|
||||
const chapId = 1;
|
||||
const password = 'testpassword';
|
||||
|
||||
// CHAP challenge can be various lengths
|
||||
for (const length of [8, 16, 24, 32]) {
|
||||
const challenge = crypto.randomBytes(length);
|
||||
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(chapId, 0);
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
|
||||
expect(isValid).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
190
test/server/test.packet.ts
Normal file
190
test/server/test.packet.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
RadiusPacket,
|
||||
ERadiusCode,
|
||||
ERadiusAttributeType,
|
||||
} from '../../ts_server/index.js';
|
||||
|
||||
tap.test('should encode and decode a basic Access-Request packet', async () => {
|
||||
const identifier = 42;
|
||||
const secret = 'testing123';
|
||||
const attributes = [
|
||||
{ type: ERadiusAttributeType.UserName, value: 'testuser' },
|
||||
{ type: ERadiusAttributeType.UserPassword, value: 'testpass' },
|
||||
{ type: ERadiusAttributeType.NasIpAddress, value: '192.168.1.1' },
|
||||
{ type: ERadiusAttributeType.NasPort, value: 1 },
|
||||
];
|
||||
|
||||
const packet = RadiusPacket.createAccessRequest(identifier, secret, attributes);
|
||||
|
||||
// Verify minimum packet size (20 bytes header)
|
||||
expect(packet.length).toBeGreaterThanOrEqual(RadiusPacket.MIN_PACKET_SIZE);
|
||||
|
||||
// Verify maximum packet size
|
||||
expect(packet.length).toBeLessThanOrEqual(RadiusPacket.MAX_PACKET_SIZE);
|
||||
|
||||
// Verify header
|
||||
expect(packet[0]).toEqual(ERadiusCode.AccessRequest);
|
||||
expect(packet[1]).toEqual(identifier);
|
||||
|
||||
// Decode the packet
|
||||
const decoded = RadiusPacket.decode(packet);
|
||||
expect(decoded.code).toEqual(ERadiusCode.AccessRequest);
|
||||
expect(decoded.identifier).toEqual(identifier);
|
||||
expect(decoded.authenticator.length).toEqual(16);
|
||||
expect(decoded.attributes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('should handle packet length validation', async () => {
|
||||
// Packet too short
|
||||
const shortPacket = Buffer.alloc(19);
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
RadiusPacket.decode(shortPacket);
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toInclude('too short');
|
||||
});
|
||||
|
||||
tap.test('should handle invalid length in header', async () => {
|
||||
const packet = Buffer.alloc(20);
|
||||
packet[0] = ERadiusCode.AccessRequest;
|
||||
packet[1] = 1; // identifier
|
||||
packet.writeUInt16BE(10, 2); // length too small
|
||||
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
RadiusPacket.decode(packet);
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
|
||||
tap.test('should handle maximum packet size', async () => {
|
||||
const secret = 'testing123';
|
||||
const identifier = 1;
|
||||
|
||||
// Create a packet that would exceed max size
|
||||
const hugeAttributes: Array<{ type: number; value: Buffer }> = [];
|
||||
// Each attribute can be max 255 bytes. Create enough to exceed 4096
|
||||
for (let i = 0; i < 20; i++) {
|
||||
hugeAttributes.push({
|
||||
type: ERadiusAttributeType.ReplyMessage,
|
||||
value: Buffer.alloc(250, 65), // 250 bytes of 'A'
|
||||
});
|
||||
}
|
||||
|
||||
// This should throw because packet would be too large
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
// Manually build the packet to test size limit
|
||||
const rawAttrs = hugeAttributes.map((a) => ({
|
||||
type: a.type,
|
||||
value: a.value,
|
||||
}));
|
||||
|
||||
RadiusPacket.encode({
|
||||
code: ERadiusCode.AccessRequest,
|
||||
identifier,
|
||||
authenticator: Buffer.alloc(16),
|
||||
attributes: rawAttrs,
|
||||
});
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toInclude('too large');
|
||||
});
|
||||
|
||||
tap.test('should parse and encode attributes correctly', async () => {
|
||||
const secret = 'testing123';
|
||||
const identifier = 1;
|
||||
|
||||
// Create packet with various attribute types
|
||||
const packet = RadiusPacket.createAccessRequest(identifier, secret, [
|
||||
{ type: 'User-Name', value: 'john.doe' }, // text
|
||||
{ type: 'NAS-IP-Address', value: '10.0.0.1' }, // address
|
||||
{ type: 'NAS-Port', value: 5060 }, // integer
|
||||
{ type: 'NAS-Identifier', value: 'nas01.example.com' }, // text
|
||||
]);
|
||||
|
||||
const decoded = RadiusPacket.decodeAndParse(packet);
|
||||
|
||||
// Find username
|
||||
const usernameAttr = decoded.parsedAttributes.find(
|
||||
(a) => a.type === ERadiusAttributeType.UserName
|
||||
);
|
||||
expect(usernameAttr).toBeDefined();
|
||||
expect(usernameAttr!.value).toEqual('john.doe');
|
||||
|
||||
// Find NAS-IP-Address
|
||||
const nasIpAttr = decoded.parsedAttributes.find(
|
||||
(a) => a.type === ERadiusAttributeType.NasIpAddress
|
||||
);
|
||||
expect(nasIpAttr).toBeDefined();
|
||||
expect(nasIpAttr!.value).toEqual('10.0.0.1');
|
||||
|
||||
// Find NAS-Port
|
||||
const nasPortAttr = decoded.parsedAttributes.find(
|
||||
(a) => a.type === ERadiusAttributeType.NasPort
|
||||
);
|
||||
expect(nasPortAttr).toBeDefined();
|
||||
expect(nasPortAttr!.value).toEqual(5060);
|
||||
});
|
||||
|
||||
tap.test('should create Access-Accept packet', async () => {
|
||||
const identifier = 1;
|
||||
const requestAuth = Buffer.alloc(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const packet = RadiusPacket.createAccessAccept(identifier, requestAuth, secret, [
|
||||
{ type: ERadiusAttributeType.ReplyMessage, value: 'Welcome!' },
|
||||
{ type: ERadiusAttributeType.SessionTimeout, value: 3600 },
|
||||
]);
|
||||
|
||||
const decoded = RadiusPacket.decode(packet);
|
||||
expect(decoded.code).toEqual(ERadiusCode.AccessAccept);
|
||||
expect(decoded.identifier).toEqual(identifier);
|
||||
});
|
||||
|
||||
tap.test('should create Access-Reject packet', async () => {
|
||||
const identifier = 1;
|
||||
const requestAuth = Buffer.alloc(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const packet = RadiusPacket.createAccessReject(identifier, requestAuth, secret, [
|
||||
{ type: ERadiusAttributeType.ReplyMessage, value: 'Invalid credentials' },
|
||||
]);
|
||||
|
||||
const decoded = RadiusPacket.decode(packet);
|
||||
expect(decoded.code).toEqual(ERadiusCode.AccessReject);
|
||||
});
|
||||
|
||||
tap.test('should create Access-Challenge packet', async () => {
|
||||
const identifier = 1;
|
||||
const requestAuth = Buffer.alloc(16);
|
||||
const secret = 'testing123';
|
||||
const state = Buffer.from('challenge-state-123');
|
||||
|
||||
const packet = RadiusPacket.createAccessChallenge(identifier, requestAuth, secret, [
|
||||
{ type: ERadiusAttributeType.ReplyMessage, value: 'Enter OTP' },
|
||||
{ type: ERadiusAttributeType.State, value: state },
|
||||
]);
|
||||
|
||||
const decoded = RadiusPacket.decode(packet);
|
||||
expect(decoded.code).toEqual(ERadiusCode.AccessChallenge);
|
||||
});
|
||||
|
||||
tap.test('should get code name', async () => {
|
||||
expect(RadiusPacket.getCodeName(ERadiusCode.AccessRequest)).toEqual('Access-Request');
|
||||
expect(RadiusPacket.getCodeName(ERadiusCode.AccessAccept)).toEqual('Access-Accept');
|
||||
expect(RadiusPacket.getCodeName(ERadiusCode.AccessReject)).toEqual('Access-Reject');
|
||||
expect(RadiusPacket.getCodeName(ERadiusCode.AccountingRequest)).toEqual('Accounting-Request');
|
||||
expect(RadiusPacket.getCodeName(ERadiusCode.AccountingResponse)).toEqual('Accounting-Response');
|
||||
expect(RadiusPacket.getCodeName(ERadiusCode.AccessChallenge)).toEqual('Access-Challenge');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
282
test/server/test.pap.ts
Normal file
282
test/server/test.pap.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as crypto from 'crypto';
|
||||
import { RadiusAuthenticator } from '../../ts_server/index.js';
|
||||
|
||||
tap.test('should encrypt and decrypt short password (< 16 chars)', async () => {
|
||||
const password = 'secret';
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(
|
||||
password,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
// Encrypted length should be multiple of 16
|
||||
expect(encrypted.length).toEqual(16);
|
||||
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(
|
||||
encrypted,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(decrypted).toEqual(password);
|
||||
});
|
||||
|
||||
tap.test('should encrypt and decrypt exactly 16-char password', async () => {
|
||||
const password = '1234567890123456'; // Exactly 16 characters
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(
|
||||
password,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(encrypted.length).toEqual(16);
|
||||
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(
|
||||
encrypted,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(decrypted).toEqual(password);
|
||||
});
|
||||
|
||||
tap.test('should encrypt and decrypt long password (> 16 chars)', async () => {
|
||||
const password = 'thisisaverylongpasswordthatexceeds16characters';
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(
|
||||
password,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
// Should be padded to next multiple of 16
|
||||
expect(encrypted.length % 16).toEqual(0);
|
||||
expect(encrypted.length).toBeGreaterThan(16);
|
||||
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(
|
||||
encrypted,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(decrypted).toEqual(password);
|
||||
});
|
||||
|
||||
tap.test('should encrypt and decrypt password near max length (128 chars)', async () => {
|
||||
const password = 'a'.repeat(120); // Near max of 128
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(
|
||||
password,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
// Should be limited to 128 bytes
|
||||
expect(encrypted.length).toBeLessThanOrEqual(128);
|
||||
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(
|
||||
encrypted,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(decrypted).toEqual(password);
|
||||
});
|
||||
|
||||
tap.test('should truncate password exceeding 128 chars', async () => {
|
||||
const password = 'a'.repeat(150); // Exceeds max
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(
|
||||
password,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
// Should be exactly 128 bytes (max)
|
||||
expect(encrypted.length).toEqual(128);
|
||||
|
||||
// Decrypted will be truncated to 128 chars
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(
|
||||
encrypted,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(decrypted.length).toBeLessThanOrEqual(128);
|
||||
});
|
||||
|
||||
tap.test('should handle empty password', async () => {
|
||||
const password = '';
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(
|
||||
password,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
// Even empty password is padded to 16 bytes
|
||||
expect(encrypted.length).toEqual(16);
|
||||
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(
|
||||
encrypted,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(decrypted).toEqual(password);
|
||||
});
|
||||
|
||||
tap.test('should fail to decrypt with wrong secret', async () => {
|
||||
const password = 'mypassword';
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
const wrongSecret = 'wrongsecret';
|
||||
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(
|
||||
password,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(
|
||||
encrypted,
|
||||
requestAuthenticator,
|
||||
wrongSecret
|
||||
);
|
||||
|
||||
// Should not match original password
|
||||
expect(decrypted).not.toEqual(password);
|
||||
});
|
||||
|
||||
tap.test('should fail to decrypt with wrong authenticator', async () => {
|
||||
const password = 'mypassword';
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const wrongAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(
|
||||
password,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(
|
||||
encrypted,
|
||||
wrongAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
// Should not match original password
|
||||
expect(decrypted).not.toEqual(password);
|
||||
});
|
||||
|
||||
tap.test('should handle special characters in password', async () => {
|
||||
const password = '!@#$%^&*()_+-=[]{}|;:,.<>?';
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(
|
||||
password,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(
|
||||
encrypted,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(decrypted).toEqual(password);
|
||||
});
|
||||
|
||||
tap.test('should handle unicode characters in password', async () => {
|
||||
const password = 'パスワード密码'; // Japanese + Chinese
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(
|
||||
password,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(
|
||||
encrypted,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(decrypted).toEqual(password);
|
||||
});
|
||||
|
||||
tap.test('should reject invalid encrypted password length', async () => {
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
// Not a multiple of 16
|
||||
const invalidEncrypted = Buffer.alloc(15);
|
||||
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
RadiusAuthenticator.decryptPassword(invalidEncrypted, requestAuthenticator, secret);
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toInclude('Invalid');
|
||||
});
|
||||
|
||||
tap.test('PAP encryption matches RFC 2865 algorithm', async () => {
|
||||
// Test that our implementation matches the RFC 2865 algorithm:
|
||||
// b1 = MD5(S + RA) c(1) = p1 xor b1
|
||||
// b2 = MD5(S + c(1)) c(2) = p2 xor b2
|
||||
|
||||
const password = 'testpassword12345678'; // Longer than 16 to test chaining
|
||||
const requestAuth = Buffer.alloc(16, 0x42); // Fixed for deterministic test
|
||||
const secret = 'sharedsecret';
|
||||
|
||||
// Our implementation
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(password, requestAuth, secret);
|
||||
|
||||
// Manual calculation per RFC
|
||||
const paddedLength = Math.ceil(password.length / 16) * 16;
|
||||
const padded = Buffer.alloc(paddedLength, 0);
|
||||
Buffer.from(password, 'utf8').copy(padded);
|
||||
|
||||
const expected = Buffer.alloc(paddedLength);
|
||||
let previousCipher = requestAuth;
|
||||
|
||||
for (let i = 0; i < paddedLength; i += 16) {
|
||||
const md5 = crypto.createHash('md5');
|
||||
md5.update(Buffer.from(secret, 'utf8'));
|
||||
md5.update(previousCipher);
|
||||
const b = md5.digest();
|
||||
|
||||
for (let j = 0; j < 16; j++) {
|
||||
expected[i + j] = padded[i + j] ^ b[j];
|
||||
}
|
||||
|
||||
previousCipher = expected.subarray(i, i + 16);
|
||||
}
|
||||
|
||||
expect(encrypted.equals(expected)).toBeTruthy();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,8 +0,0 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartradius from '../ts/index.js'
|
||||
|
||||
tap.test('first test', async () => {
|
||||
console.log(smartradius)
|
||||
})
|
||||
|
||||
export default tap.start()
|
||||
Reference in New Issue
Block a user