ircu2/tools/iauthd-ts/tests/sasl.test.ts

274 lines
8.9 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { writeFileSync, unlinkSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
parseUsersFile,
verifyPassword,
generateHash,
decodeSASLPlain,
authenticatePlain,
} from '../src/sasl.js';
describe('SASL Module', () => {
describe('generateHash', () => {
it('should generate SHA-256 hash with $5$ prefix', () => {
const hash = generateHash('password', 'sha256');
expect(hash).toMatch(/^\$5\$[A-Za-z0-9]+\$[A-Za-z0-9+/]+$/);
});
it('should generate SHA-512 hash with $6$ prefix', () => {
const hash = generateHash('password', 'sha512');
expect(hash).toMatch(/^\$6\$[A-Za-z0-9]+\$[A-Za-z0-9+/]+$/);
});
it('should generate MD5 hash with $1$ prefix', () => {
const hash = generateHash('password', 'md5');
expect(hash).toMatch(/^\$1\$[A-Za-z0-9]+\$[A-Za-z0-9+/]+$/);
});
it('should generate different hashes for different passwords', () => {
const hash1 = generateHash('password1', 'sha256');
const hash2 = generateHash('password2', 'sha256');
expect(hash1).not.toBe(hash2);
});
it('should generate different hashes for same password (different salt)', () => {
const hash1 = generateHash('password', 'sha256');
const hash2 = generateHash('password', 'sha256');
// Salts are random, so hashes should be different
expect(hash1).not.toBe(hash2);
});
});
describe('verifyPassword', () => {
it('should verify SHA-256 hashed password', () => {
const hash = generateHash('testpass', 'sha256');
expect(verifyPassword('testpass', hash)).toBe(true);
expect(verifyPassword('wrongpass', hash)).toBe(false);
});
it('should verify SHA-512 hashed password', () => {
const hash = generateHash('testpass', 'sha512');
expect(verifyPassword('testpass', hash)).toBe(true);
expect(verifyPassword('wrongpass', hash)).toBe(false);
});
it('should verify MD5 hashed password', () => {
const hash = generateHash('testpass', 'md5');
expect(verifyPassword('testpass', hash)).toBe(true);
expect(verifyPassword('wrongpass', hash)).toBe(false);
});
it('should verify plain text password', () => {
expect(verifyPassword('plainpass', 'plainpass')).toBe(true);
expect(verifyPassword('wrongpass', 'plainpass')).toBe(false);
});
it('should reject invalid hash format', () => {
expect(verifyPassword('pass', '$invalid$format')).toBe(false);
expect(verifyPassword('pass', '$99$salt$hash')).toBe(false);
});
it('should handle empty password', () => {
const hash = generateHash('', 'sha256');
expect(verifyPassword('', hash)).toBe(true);
expect(verifyPassword('notempty', hash)).toBe(false);
});
});
describe('decodeSASLPlain', () => {
it('should decode valid SASL PLAIN data', () => {
// Format: base64(authzid \0 authcid \0 password)
const data = Buffer.from('\0testuser\0testpass').toString('base64');
const result = decodeSASLPlain(data);
expect(result).not.toBeNull();
expect(result?.authzid).toBe('');
expect(result?.authcid).toBe('testuser');
expect(result?.password).toBe('testpass');
});
it('should decode with authzid', () => {
const data = Buffer.from('authzid\0authcid\0password').toString('base64');
const result = decodeSASLPlain(data);
expect(result).not.toBeNull();
expect(result?.authzid).toBe('authzid');
expect(result?.authcid).toBe('authcid');
expect(result?.password).toBe('password');
});
it('should return null for invalid base64', () => {
const result = decodeSASLPlain('not-valid-base64!!!');
// Actually base64 decode may not fail, it will just produce garbage
// Let's test with wrong number of parts
});
it('should return null for wrong number of parts', () => {
// Only 2 parts instead of 3
const data = Buffer.from('user\0pass').toString('base64');
const result = decodeSASLPlain(data);
expect(result).toBeNull();
});
it('should handle empty fields', () => {
const data = Buffer.from('\0\0').toString('base64');
const result = decodeSASLPlain(data);
expect(result).not.toBeNull();
expect(result?.authzid).toBe('');
expect(result?.authcid).toBe('');
expect(result?.password).toBe('');
});
it('should handle special characters', () => {
const data = Buffer.from('\0user@example.com\0p@ss:word!').toString('base64');
const result = decodeSASLPlain(data);
expect(result).not.toBeNull();
expect(result?.authcid).toBe('user@example.com');
expect(result?.password).toBe('p@ss:word!');
});
});
describe('parseUsersFile', () => {
let tmpDir: string;
let usersFile: string;
beforeAll(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'iauthd-test-'));
usersFile = join(tmpDir, 'users');
});
afterAll(() => {
try {
unlinkSync(usersFile);
} catch {}
});
it('should parse simple users file', () => {
writeFileSync(usersFile, 'testuser:testpass\nadmin:adminpass\n');
const db = parseUsersFile(usersFile);
expect(db.users.size).toBe(2);
expect(db.users.get('testuser')?.passwordHash).toBe('testpass');
expect(db.users.get('admin')?.passwordHash).toBe('adminpass');
});
it('should skip comments and empty lines', () => {
writeFileSync(usersFile, '# Comment\n\nuser:pass\n \n# Another comment\n');
const db = parseUsersFile(usersFile);
expect(db.users.size).toBe(1);
expect(db.users.get('user')?.passwordHash).toBe('pass');
});
it('should lowercase usernames', () => {
writeFileSync(usersFile, 'TestUser:pass\nADMIN:pass2\n');
const db = parseUsersFile(usersFile);
expect(db.users.has('testuser')).toBe(true);
expect(db.users.has('admin')).toBe(true);
expect(db.users.has('TestUser')).toBe(false);
});
it('should handle passwords with colons', () => {
writeFileSync(usersFile, 'user:pass:with:colons\n');
const db = parseUsersFile(usersFile);
expect(db.users.get('user')?.passwordHash).toBe('pass:with:colons');
});
it('should handle crypt-style hashes', () => {
const hash = '$5$salt$hashvalue';
writeFileSync(usersFile, `user:${hash}\n`);
const db = parseUsersFile(usersFile);
expect(db.users.get('user')?.passwordHash).toBe(hash);
});
it('should return empty db for non-existent file', () => {
const db = parseUsersFile('/nonexistent/file');
expect(db.users.size).toBe(0);
});
it('should skip lines without colon', () => {
writeFileSync(usersFile, 'validuser:pass\ninvalidline\nanotheruser:anotherpass\n');
const db = parseUsersFile(usersFile);
expect(db.users.size).toBe(2);
expect(db.users.has('validuser')).toBe(true);
expect(db.users.has('anotheruser')).toBe(true);
});
});
describe('authenticatePlain', () => {
let tmpDir: string;
let usersFile: string;
beforeAll(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'iauthd-test-'));
usersFile = join(tmpDir, 'users');
// Create users file with various hash types
const sha256Hash = generateHash('sha256pass', 'sha256');
const sha512Hash = generateHash('sha512pass', 'sha512');
writeFileSync(usersFile, [
`plainuser:plainpass`,
`sha256user:${sha256Hash}`,
`sha512user:${sha512Hash}`,
].join('\n'));
});
afterAll(() => {
try {
unlinkSync(usersFile);
} catch {}
});
it('should authenticate with plain text password', () => {
const db = parseUsersFile(usersFile);
const result = authenticatePlain(db, 'plainuser', 'plainpass');
expect(result).toBe('plainuser');
});
it('should authenticate with SHA-256 hashed password', () => {
const db = parseUsersFile(usersFile);
const result = authenticatePlain(db, 'sha256user', 'sha256pass');
expect(result).toBe('sha256user');
});
it('should authenticate with SHA-512 hashed password', () => {
const db = parseUsersFile(usersFile);
const result = authenticatePlain(db, 'sha512user', 'sha512pass');
expect(result).toBe('sha512user');
});
it('should reject wrong password', () => {
const db = parseUsersFile(usersFile);
const result = authenticatePlain(db, 'plainuser', 'wrongpass');
expect(result).toBeNull();
});
it('should reject non-existent user', () => {
const db = parseUsersFile(usersFile);
const result = authenticatePlain(db, 'nouser', 'anypass');
expect(result).toBeNull();
});
it('should be case-insensitive for username', () => {
const db = parseUsersFile(usersFile);
const result = authenticatePlain(db, 'PlainUser', 'plainpass');
expect(result).toBe('plainuser');
});
});
});