test: add comprehensive tests for security.txt and canary.txt

Added 38 new tests (16 + 22) covering all features of the new generators:

## security.txt Tests (16 tests)
- RFC 9116 field validation (Canonical, Contact, Expires)
- Automatic mailto: prefix handling for email contacts
- Auto-expiration calculation (1 year from generation)
- Multiple contact methods support
- Multiple encryption keys
- All optional fields: acknowledgments, preferredLanguages, policy, hiring
- Proper field ordering compliance

## canary.txt Tests (22 tests)
- Compact field: value format validation
- Frequency-based expiration (daily: 2d, weekly: 10d, monthly: 35d, quarterly: 100d, yearly: 380d)
- Statement filtering (only non-received statements appear)
- Default statements vs custom statements
- Function-based dynamic statements
- Blockchain proof formatting (Network:Address:TxHash)
- Personnel duress statement
- Verification field
- Previous canary references
- Contact with mailto: prefix
- Organization and frequency fields

Test suite now at 72 total tests (up from 34), all passing.
This commit is contained in:
Ryan Malloy 2025-11-03 08:19:38 -07:00
parent 6de34f55a9
commit 2063d81e60
2 changed files with 586 additions and 0 deletions

345
tests/canary.test.ts Normal file
View File

@ -0,0 +1,345 @@
import { describe, it, expect } from 'vitest';
import { generateCanaryTxt } from '../src/generators/canary.js';
describe('generateCanaryTxt', () => {
const testURL = new URL('https://example.com');
it('generates basic canary with defaults', () => {
const result = generateCanaryTxt({}, testURL);
expect(result).toContain('Canonical-URL:');
expect(result).toContain('Issued:');
expect(result).toContain('Expires:');
expect(result).toContain('Statement:');
});
it('includes organization name', () => {
const result = generateCanaryTxt(
{
organization: 'Test Corp',
},
testURL
);
expect(result).toContain('Organization: Test Corp');
});
it('includes contact information with mailto prefix', () => {
const result = generateCanaryTxt(
{
contact: 'canary@example.com',
},
testURL
);
expect(result).toContain('Contact: mailto:canary@example.com');
});
it('automatically calculates expiration for daily frequency', () => {
const result = generateCanaryTxt(
{
frequency: 'daily',
expires: 'auto',
},
testURL
);
expect(result).toContain('Frequency: daily');
expect(result).toContain('Expires: ');
// Extract the date
const match = result.match(/Expires: (.+)/);
expect(match).toBeTruthy();
if (match) {
const expiresDate = new Date(match[1]);
const now = new Date();
const twoDaysFromNow = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
// Should be approximately 2 days from now (within 1 minute)
const diff = Math.abs(expiresDate.getTime() - twoDaysFromNow.getTime());
expect(diff).toBeLessThan(60 * 1000);
}
});
it('automatically calculates expiration for weekly frequency', () => {
const result = generateCanaryTxt(
{
frequency: 'weekly',
expires: 'auto',
},
testURL
);
const match = result.match(/Expires: (.+)/);
if (match) {
const expiresDate = new Date(match[1]);
const now = new Date();
const tenDaysFromNow = new Date(now.getTime() + 10 * 24 * 60 * 60 * 1000);
const diff = Math.abs(expiresDate.getTime() - tenDaysFromNow.getTime());
expect(diff).toBeLessThan(60 * 1000);
}
});
it('automatically calculates expiration for monthly frequency', () => {
const result = generateCanaryTxt(
{
frequency: 'monthly',
expires: 'auto',
},
testURL
);
const match = result.match(/Expires: (.+)/);
if (match) {
const expiresDate = new Date(match[1]);
const now = new Date();
const thirtyFiveDaysFromNow = new Date(now.getTime() + 35 * 24 * 60 * 60 * 1000);
const diff = Math.abs(expiresDate.getTime() - thirtyFiveDaysFromNow.getTime());
expect(diff).toBeLessThan(60 * 1000);
}
});
it('automatically calculates expiration for quarterly frequency', () => {
const result = generateCanaryTxt(
{
frequency: 'quarterly',
expires: 'auto',
},
testURL
);
const match = result.match(/Expires: (.+)/);
if (match) {
const expiresDate = new Date(match[1]);
const now = new Date();
const oneHundredDaysFromNow = new Date(now.getTime() + 100 * 24 * 60 * 60 * 1000);
const diff = Math.abs(expiresDate.getTime() - oneHundredDaysFromNow.getTime());
expect(diff).toBeLessThan(60 * 1000);
}
});
it('automatically calculates expiration for yearly frequency', () => {
const result = generateCanaryTxt(
{
frequency: 'yearly',
expires: 'auto',
},
testURL
);
const match = result.match(/Expires: (.+)/);
if (match) {
const expiresDate = new Date(match[1]);
const now = new Date();
const threeHundredEightyDaysFromNow = new Date(now.getTime() + 380 * 24 * 60 * 60 * 1000);
const diff = Math.abs(expiresDate.getTime() - threeHundredEightyDaysFromNow.getTime());
expect(diff).toBeLessThan(60 * 1000);
}
});
it('accepts custom expiration date', () => {
const customExpires = '2026-01-15T00:00:00Z';
const result = generateCanaryTxt(
{
expires: customExpires,
},
testURL
);
expect(result).toContain(`Expires: ${customExpires}`);
});
it('includes default statements when none provided', () => {
const result = generateCanaryTxt({}, testURL);
expect(result).toContain(' - National Security Letters (NSLs)');
expect(result).toContain(' - FISA court orders');
expect(result).toContain(' - Gag orders preventing disclosure');
expect(result).toContain(' - Secret government requests for user data');
expect(result).toContain(' - Requests to install surveillance capabilities');
});
it('supports custom statements as array', () => {
const result = generateCanaryTxt(
{
statements: [
{ type: 'custom1', description: 'Custom order type 1', received: false },
{ type: 'custom2', description: 'Custom order type 2', received: true },
],
},
testURL
);
// Only non-received statements are listed
expect(result).toContain(' - Custom order type 1');
// Received statements are NOT included in the output
expect(result).not.toContain('Custom order type 2');
});
it('supports statements as function', () => {
const result = generateCanaryTxt(
{
statements: () => [
{ type: 'dynamic', description: 'Dynamically generated statement', received: false },
],
},
testURL
);
expect(result).toContain(' - Dynamically generated statement');
});
it('filters out received statements from output', () => {
const result = generateCanaryTxt(
{
statements: [
{ type: 'test1', description: 'Not received', received: false },
{ type: 'test2', description: 'Received', received: true },
],
},
testURL
);
// Only non-received statements appear
expect(result).toContain(' - Not received');
// Received statements are filtered out
expect(result).not.toContain('Received');
});
it('includes additional statement', () => {
const result = generateCanaryTxt(
{
additionalStatement: 'We are committed to transparency and user privacy.',
},
testURL
);
expect(result).toContain('We are committed to transparency and user privacy.');
});
it('includes verification information', () => {
const result = generateCanaryTxt(
{
verification: 'PGP Signature: https://example.com/canary.txt.asc',
},
testURL
);
expect(result).toContain('Verification: PGP Signature: https://example.com/canary.txt.asc');
});
it('includes previous canary reference', () => {
const result = generateCanaryTxt(
{
previousCanary: 'https://example.com/canary-2024-11.txt',
},
testURL
);
expect(result).toContain('Previous-Canary: https://example.com/canary-2024-11.txt');
});
it('includes blockchain proof', () => {
const result = generateCanaryTxt(
{
blockchainProof: {
network: 'Ethereum',
address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
txHash: '0xabc123...',
timestamp: '2024-11-03T12:00:00Z',
},
},
testURL
);
expect(result).toContain('Blockchain-Proof: Ethereum:0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb:0xabc123...');
expect(result).toContain('Blockchain-Timestamp: 2024-11-03T12:00:00Z');
});
it('includes personnel duress statement when enabled', () => {
const result = generateCanaryTxt(
{
personnelStatement: true,
},
testURL
);
expect(result).toContain('Key Personnel Statement: All key personnel with access to');
expect(result).toContain('infrastructure remain free and under no duress');
});
it('excludes personnel statement when disabled', () => {
const result = generateCanaryTxt(
{
personnelStatement: false,
},
testURL
);
expect(result).not.toContain('Key Personnel Statement');
});
it('generates complete canary with all fields', () => {
const result = generateCanaryTxt(
{
organization: 'Example Corp',
contact: 'canary@example.com',
frequency: 'monthly',
expires: '2026-01-15T00:00:00Z',
statements: [
{ type: 'nsl', description: 'National Security Letters', received: false },
{ type: 'gag', description: 'Gag orders', received: false },
],
additionalStatement: 'We value transparency.',
verification: 'PGP Signature: https://example.com/canary.txt.asc',
previousCanary: 'https://example.com/canary-2024-10.txt',
blockchainProof: {
network: 'Bitcoin',
address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh',
},
personnelStatement: true,
},
testURL
);
expect(result).toContain('Organization: Example Corp');
expect(result).toContain('Contact: mailto:canary@example.com');
expect(result).toContain('Frequency: monthly');
expect(result).toContain('Expires: 2026-01-15T00:00:00Z');
expect(result).toContain('Previous-Canary: https://example.com/canary-2024-10.txt');
expect(result).toContain(' - National Security Letters');
expect(result).toContain(' - Gag orders');
expect(result).toContain('We value transparency.');
expect(result).toContain('Verification: PGP Signature');
expect(result).toContain('Blockchain-Proof: Bitcoin:bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh');
expect(result).toContain('Key Personnel Statement');
});
it('formats Issued date as ISO date', () => {
const result = generateCanaryTxt({}, testURL);
const match = result.match(/Issued: (.+)/);
expect(match).toBeTruthy();
if (match) {
// Should be valid ISO date
const date = new Date(match[1]);
expect(date.toString()).not.toBe('Invalid Date');
// Should be recent (within 1 minute)
const now = new Date();
const diff = Math.abs(date.getTime() - now.getTime());
expect(diff).toBeLessThan(60 * 1000);
}
});
it('ends with newline', () => {
const result = generateCanaryTxt({}, testURL);
expect(result.endsWith('\n')).toBe(true);
});
});

241
tests/security.test.ts Normal file
View File

@ -0,0 +1,241 @@
import { describe, it, expect } from 'vitest';
import { generateSecurityTxt } from '../src/generators/security.js';
describe('generateSecurityTxt', () => {
const testURL = new URL('https://example.com');
it('generates basic security.txt with required fields', () => {
const result = generateSecurityTxt(
{
contact: 'security@example.com',
},
testURL
);
expect(result).toContain('Canonical: https://example.com/.well-known/security.txt');
expect(result).toContain('Contact: mailto:security@example.com');
expect(result).toContain('Expires: ');
});
it('automatically adds mailto: prefix to email contacts', () => {
const result = generateSecurityTxt(
{
contact: 'security@example.com',
},
testURL
);
expect(result).toContain('Contact: mailto:security@example.com');
expect(result).not.toContain('Contact: security@example.com');
});
it('preserves mailto: prefix if already present', () => {
const result = generateSecurityTxt(
{
contact: 'mailto:security@example.com',
},
testURL
);
expect(result).toContain('Contact: mailto:security@example.com');
// Should not double the prefix
const mailtoCount = (result.match(/mailto:/g) || []).length;
expect(mailtoCount).toBe(1);
});
it('handles URL contacts without modification', () => {
const result = generateSecurityTxt(
{
contact: 'https://example.com/security',
},
testURL
);
expect(result).toContain('Contact: https://example.com/security');
});
it('supports multiple contact methods', () => {
const result = generateSecurityTxt(
{
contact: [
'security@example.com',
'https://example.com/security',
'tel:+1-555-0100',
],
},
testURL
);
expect(result).toContain('Contact: mailto:security@example.com');
expect(result).toContain('Contact: https://example.com/security');
expect(result).toContain('Contact: tel:+1-555-0100');
});
it('auto-calculates expiration date 1 year in future', () => {
const result = generateSecurityTxt(
{
contact: 'security@example.com',
expires: 'auto',
},
testURL
);
expect(result).toContain('Expires: ');
// Extract the date
const match = result.match(/Expires: (.+)/);
expect(match).toBeTruthy();
if (match) {
const expiresDate = new Date(match[1]);
const now = new Date();
const oneYearFromNow = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
// Should be approximately 1 year from now (within 1 minute)
const diff = Math.abs(expiresDate.getTime() - oneYearFromNow.getTime());
expect(diff).toBeLessThan(60 * 1000);
}
});
it('accepts custom expiration date', () => {
const customExpires = '2026-12-31T23:59:59Z';
const result = generateSecurityTxt(
{
contact: 'security@example.com',
expires: customExpires,
},
testURL
);
expect(result).toContain(`Expires: ${customExpires}`);
});
it('includes optional encryption field', () => {
const result = generateSecurityTxt(
{
contact: 'security@example.com',
encryption: 'https://example.com/pgp-key.txt',
},
testURL
);
expect(result).toContain('Encryption: https://example.com/pgp-key.txt');
});
it('supports multiple encryption keys', () => {
const result = generateSecurityTxt(
{
contact: 'security@example.com',
encryption: [
'https://example.com/pgp-key.txt',
'dns:5d2d37ab76d47d36._openpgpkey.example.com',
],
},
testURL
);
expect(result).toContain('Encryption: https://example.com/pgp-key.txt');
expect(result).toContain('Encryption: dns:5d2d37ab76d47d36._openpgpkey.example.com');
});
it('includes acknowledgments page', () => {
const result = generateSecurityTxt(
{
contact: 'security@example.com',
acknowledgments: 'https://example.com/security/hall-of-fame',
},
testURL
);
expect(result).toContain('Acknowledgments: https://example.com/security/hall-of-fame');
});
it('includes preferred languages', () => {
const result = generateSecurityTxt(
{
contact: 'security@example.com',
preferredLanguages: ['en', 'es', 'fr'],
},
testURL
);
expect(result).toContain('Preferred-Languages: en, es, fr');
});
it('includes custom canonical URL', () => {
const result = generateSecurityTxt(
{
contact: 'security@example.com',
canonical: 'https://example.com/custom/.well-known/security.txt',
},
testURL
);
expect(result).toContain('Canonical: https://example.com/custom/.well-known/security.txt');
});
it('includes policy URL', () => {
const result = generateSecurityTxt(
{
contact: 'security@example.com',
policy: 'https://example.com/security/policy',
},
testURL
);
expect(result).toContain('Policy: https://example.com/security/policy');
});
it('includes hiring URL', () => {
const result = generateSecurityTxt(
{
contact: 'security@example.com',
hiring: 'https://example.com/jobs/security',
},
testURL
);
expect(result).toContain('Hiring: https://example.com/jobs/security');
});
it('generates complete RFC 9116 compliant file with all fields', () => {
const result = generateSecurityTxt(
{
contact: ['security@example.com', 'https://example.com/security'],
expires: '2026-12-31T23:59:59Z',
encryption: 'https://example.com/pgp-key.txt',
acknowledgments: 'https://example.com/security/hall-of-fame',
preferredLanguages: ['en', 'es'],
canonical: 'https://example.com/.well-known/security.txt',
policy: 'https://example.com/security/policy',
hiring: 'https://example.com/jobs/security',
},
testURL
);
// Check field order (Canonical should be first)
expect(result.indexOf('Canonical:')).toBeLessThan(result.indexOf('Contact:'));
expect(result.indexOf('Contact:')).toBeLessThan(result.indexOf('Expires:'));
// Check all fields present
expect(result).toContain('Canonical: https://example.com/.well-known/security.txt');
expect(result).toContain('Contact: mailto:security@example.com');
expect(result).toContain('Contact: https://example.com/security');
expect(result).toContain('Expires: 2026-12-31T23:59:59Z');
expect(result).toContain('Encryption: https://example.com/pgp-key.txt');
expect(result).toContain('Acknowledgments: https://example.com/security/hall-of-fame');
expect(result).toContain('Preferred-Languages: en, es');
expect(result).toContain('Policy: https://example.com/security/policy');
expect(result).toContain('Hiring: https://example.com/jobs/security');
});
it('ends with newline', () => {
const result = generateSecurityTxt(
{
contact: 'security@example.com',
},
testURL
);
expect(result.endsWith('\n')).toBe(true);
});
});