From 2063d81e6044c6425ce17b09a11afe4cc7fd8d38 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 3 Nov 2025 08:19:38 -0700 Subject: [PATCH] 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. --- tests/canary.test.ts | 345 +++++++++++++++++++++++++++++++++++++++++ tests/security.test.ts | 241 ++++++++++++++++++++++++++++ 2 files changed, 586 insertions(+) create mode 100644 tests/canary.test.ts create mode 100644 tests/security.test.ts diff --git a/tests/canary.test.ts b/tests/canary.test.ts new file mode 100644 index 0000000..5e3bea3 --- /dev/null +++ b/tests/canary.test.ts @@ -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); + }); +}); diff --git a/tests/security.test.ts b/tests/security.test.ts new file mode 100644 index 0000000..f5063c9 --- /dev/null +++ b/tests/security.test.ts @@ -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); + }); +});