AtAuth
at main 207 lines 6.4 kB view raw
1import { describe, it, expect, afterEach } from 'vitest'; 2import { 3 parseOAuthState, 4 isValidAppId, 5 requireHttpsInProduction, 6 validateGatewayUrl, 7 validateCallbackUrl, 8 isValidDid, 9 isValidHandle, 10} from './validation'; 11 12describe('parseOAuthState', () => { 13 it('parses valid state object', () => { 14 const state = JSON.stringify({ returnTo: '/dashboard', nonce: 'abc123' }); 15 const parsed = parseOAuthState(state); 16 17 expect(parsed).not.toBeNull(); 18 expect(parsed?.returnTo).toBe('/dashboard'); 19 expect(parsed?.nonce).toBe('abc123'); 20 }); 21 22 it('returns null for non-string input', () => { 23 expect(parseOAuthState(null)).toBeNull(); 24 expect(parseOAuthState(undefined)).toBeNull(); 25 expect(parseOAuthState(123)).toBeNull(); 26 expect(parseOAuthState({})).toBeNull(); 27 }); 28 29 it('returns null for invalid JSON', () => { 30 expect(parseOAuthState('not json')).toBeNull(); 31 expect(parseOAuthState('{invalid')).toBeNull(); 32 }); 33 34 it('returns null for array', () => { 35 expect(parseOAuthState('[]')).toBeNull(); 36 expect(parseOAuthState('[1,2,3]')).toBeNull(); 37 }); 38 39 it('returns null for oversized state', () => { 40 const huge = JSON.stringify({ data: 'x'.repeat(5000) }); 41 expect(parseOAuthState(huge)).toBeNull(); 42 }); 43 44 it('returns null for deeply nested state', () => { 45 const deep = JSON.stringify({ a: { b: { c: { d: { e: 'too deep' } } } } }); 46 expect(parseOAuthState(deep)).toBeNull(); 47 }); 48 49 it('returns null for non-string returnTo', () => { 50 expect(parseOAuthState(JSON.stringify({ returnTo: 123 }))).toBeNull(); 51 expect(parseOAuthState(JSON.stringify({ returnTo: {} }))).toBeNull(); 52 }); 53 54 it('returns null for non-string nonce', () => { 55 expect(parseOAuthState(JSON.stringify({ nonce: 123 }))).toBeNull(); 56 }); 57 58 it('returns null for dangerous returnTo schemes', () => { 59 expect(parseOAuthState(JSON.stringify({ returnTo: 'javascript:alert(1)' }))).toBeNull(); 60 expect(parseOAuthState(JSON.stringify({ returnTo: 'data:text/html,<script>' }))).toBeNull(); 61 expect(parseOAuthState(JSON.stringify({ returnTo: 'vbscript:msgbox' }))).toBeNull(); 62 }); 63 64 it('allows relative URLs in returnTo', () => { 65 const state = parseOAuthState(JSON.stringify({ returnTo: '/dashboard' })); 66 expect(state?.returnTo).toBe('/dashboard'); 67 }); 68 69 it('allows https URLs in returnTo', () => { 70 const state = parseOAuthState(JSON.stringify({ returnTo: 'https://example.com/page' })); 71 expect(state?.returnTo).toBe('https://example.com/page'); 72 }); 73}); 74 75describe('isValidAppId', () => { 76 it('accepts valid app IDs', () => { 77 expect(isValidAppId('myapp')).toBe(true); 78 expect(isValidAppId('my-app')).toBe(true); 79 expect(isValidAppId('my_app')).toBe(true); 80 expect(isValidAppId('MyApp123')).toBe(true); 81 }); 82 83 it('rejects non-string input', () => { 84 expect(isValidAppId(null)).toBe(false); 85 expect(isValidAppId(undefined)).toBe(false); 86 expect(isValidAppId(123)).toBe(false); 87 }); 88 89 it('rejects empty string', () => { 90 expect(isValidAppId('')).toBe(false); 91 }); 92 93 it('rejects oversized app ID', () => { 94 expect(isValidAppId('a'.repeat(65))).toBe(false); 95 }); 96 97 it('rejects invalid characters', () => { 98 expect(isValidAppId('my app')).toBe(false); 99 expect(isValidAppId('my.app')).toBe(false); 100 expect(isValidAppId('my@app')).toBe(false); 101 }); 102}); 103 104describe('isValidDid', () => { 105 it('accepts did:plc format', () => { 106 expect(isValidDid('did:plc:abc123xyz')).toBe(true); 107 }); 108 109 it('accepts did:web format', () => { 110 expect(isValidDid('did:web:example.com')).toBe(true); 111 }); 112 113 it('rejects non-string', () => { 114 expect(isValidDid(null)).toBe(false); 115 expect(isValidDid(123)).toBe(false); 116 }); 117 118 it('rejects invalid DID methods', () => { 119 expect(isValidDid('did:key:abc')).toBe(false); 120 expect(isValidDid('did:ethr:0x123')).toBe(false); 121 }); 122 123 it('rejects malformed DIDs', () => { 124 expect(isValidDid('not-a-did')).toBe(false); 125 expect(isValidDid('did:plc:')).toBe(false); 126 }); 127}); 128 129describe('isValidHandle', () => { 130 it('accepts valid handles', () => { 131 expect(isValidHandle('alice.bsky.social')).toBe(true); 132 expect(isValidHandle('user123.example.com')).toBe(true); 133 }); 134 135 it('rejects non-string', () => { 136 expect(isValidHandle(null)).toBe(false); 137 expect(isValidHandle(123)).toBe(false); 138 }); 139 140 it('rejects too short handles', () => { 141 expect(isValidHandle('ab')).toBe(false); // 2 chars, below minimum of 3 142 }); 143 144 it('rejects handles with consecutive dots', () => { 145 expect(isValidHandle('alice..social')).toBe(false); 146 }); 147 148 it('rejects uppercase handles', () => { 149 expect(isValidHandle('Alice.bsky.social')).toBe(false); 150 }); 151}); 152 153describe('requireHttpsInProduction', () => { 154 const originalEnv = process.env.NODE_ENV; 155 156 afterEach(() => { 157 process.env.NODE_ENV = originalEnv; 158 }); 159 160 it('allows HTTP in development', () => { 161 process.env.NODE_ENV = 'development'; 162 expect(() => requireHttpsInProduction('http://localhost:3000')).not.toThrow(); 163 }); 164 165 it('throws for HTTP in production', () => { 166 process.env.NODE_ENV = 'production'; 167 expect(() => requireHttpsInProduction('http://example.com')).toThrow(/HTTPS/); 168 }); 169 170 it('allows HTTPS in production', () => { 171 process.env.NODE_ENV = 'production'; 172 expect(() => requireHttpsInProduction('https://example.com')).not.toThrow(); 173 }); 174 175 it('throws for invalid URL', () => { 176 process.env.NODE_ENV = 'production'; 177 expect(() => requireHttpsInProduction('not-a-url')).toThrow(/Invalid/); 178 }); 179}); 180 181describe('validateGatewayUrl', () => { 182 const originalEnv = process.env.NODE_ENV; 183 184 afterEach(() => { 185 process.env.NODE_ENV = originalEnv; 186 }); 187 188 it('validates gateway URL', () => { 189 process.env.NODE_ENV = 'production'; 190 expect(() => validateGatewayUrl('https://auth.example.com')).not.toThrow(); 191 expect(() => validateGatewayUrl('http://auth.example.com')).toThrow(); 192 }); 193}); 194 195describe('validateCallbackUrl', () => { 196 const originalEnv = process.env.NODE_ENV; 197 198 afterEach(() => { 199 process.env.NODE_ENV = originalEnv; 200 }); 201 202 it('validates callback URL', () => { 203 process.env.NODE_ENV = 'production'; 204 expect(() => validateCallbackUrl('https://app.example.com/callback')).not.toThrow(); 205 expect(() => validateCallbackUrl('http://app.example.com/callback')).toThrow(); 206 }); 207});