AtAuth
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});