AtAuth
at main 378 lines 12 kB view raw
1/** 2 * Database Service - Proxy Methods Tests 3 */ 4import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 5import crypto from 'crypto'; 6import { DatabaseService } from './database.js'; 7 8describe('Proxy Sessions', () => { 9 let db: DatabaseService; 10 11 beforeEach(() => { 12 db = new DatabaseService(':memory:'); 13 }); 14 15 afterEach(() => { 16 db.close(); 17 }); 18 19 function makeSession(overrides: Partial<{ 20 id: string; did: string; handle: string; 21 created_at: number; expires_at: number; last_activity: number; 22 user_agent: string; ip_address: string; 23 }> = {}) { 24 const now = Math.floor(Date.now() / 1000); 25 return { 26 id: overrides.id || crypto.randomBytes(16).toString('base64url'), 27 did: overrides.did || 'did:plc:test123', 28 handle: overrides.handle || 'test.bsky.social', 29 created_at: overrides.created_at || now, 30 expires_at: overrides.expires_at || now + 604800, 31 last_activity: overrides.last_activity || now, 32 user_agent: overrides.user_agent, 33 ip_address: overrides.ip_address, 34 }; 35 } 36 37 it('should create and retrieve a proxy session', () => { 38 const session = makeSession({ ip_address: '1.2.3.4', user_agent: 'TestAgent/1.0' }); 39 db.createProxySession(session); 40 41 const retrieved = db.getProxySession(session.id); 42 expect(retrieved).not.toBeNull(); 43 expect(retrieved!.id).toBe(session.id); 44 expect(retrieved!.did).toBe(session.did); 45 expect(retrieved!.handle).toBe(session.handle); 46 expect(retrieved!.ip_address).toBe('1.2.3.4'); 47 expect(retrieved!.user_agent).toBe('TestAgent/1.0'); 48 }); 49 50 it('should return null for non-existent session', () => { 51 expect(db.getProxySession('nonexistent')).toBeNull(); 52 }); 53 54 it('should update session activity timestamp', () => { 55 const session = makeSession(); 56 db.createProxySession(session); 57 58 // Wait a tick so timestamp differs 59 const before = db.getProxySession(session.id)!.last_activity; 60 db.updateProxySessionActivity(session.id); 61 const after = db.getProxySession(session.id)!.last_activity; 62 63 expect(after).toBeGreaterThanOrEqual(before); 64 }); 65 66 it('should delete a proxy session', () => { 67 const session = makeSession(); 68 db.createProxySession(session); 69 expect(db.getProxySession(session.id)).not.toBeNull(); 70 71 db.deleteProxySession(session.id); 72 expect(db.getProxySession(session.id)).toBeNull(); 73 }); 74 75 it('should delete all sessions for a user', () => { 76 const did = 'did:plc:userA'; 77 db.createProxySession(makeSession({ id: 's1', did })); 78 db.createProxySession(makeSession({ id: 's2', did })); 79 db.createProxySession(makeSession({ id: 's3', did: 'did:plc:userB' })); 80 81 const deleted = db.deleteProxySessionsForUser(did); 82 expect(deleted).toBe(2); 83 expect(db.getProxySession('s1')).toBeNull(); 84 expect(db.getProxySession('s2')).toBeNull(); 85 expect(db.getProxySession('s3')).not.toBeNull(); 86 }); 87 88 it('should clean up expired proxy sessions', () => { 89 const now = Math.floor(Date.now() / 1000); 90 db.createProxySession(makeSession({ id: 'expired1', expires_at: now - 100 })); 91 db.createProxySession(makeSession({ id: 'expired2', expires_at: now - 1 })); 92 db.createProxySession(makeSession({ id: 'active', expires_at: now + 3600 })); 93 94 const deleted = db.cleanupExpiredProxySessions(); 95 expect(deleted).toBe(2); 96 expect(db.getProxySession('expired1')).toBeNull(); 97 expect(db.getProxySession('expired2')).toBeNull(); 98 expect(db.getProxySession('active')).not.toBeNull(); 99 }); 100 101 it('should list active proxy sessions', () => { 102 const now = Math.floor(Date.now() / 1000); 103 db.createProxySession(makeSession({ id: 'a1', did: 'did:plc:u1', expires_at: now + 3600 })); 104 db.createProxySession(makeSession({ id: 'a2', did: 'did:plc:u2', expires_at: now + 3600 })); 105 db.createProxySession(makeSession({ id: 'expired', did: 'did:plc:u1', expires_at: now - 10 })); 106 107 const all = db.getAllProxySessions(); 108 expect(all).toHaveLength(2); 109 110 const filtered = db.getAllProxySessions('did:plc:u1'); 111 expect(filtered).toHaveLength(1); 112 expect(filtered[0].id).toBe('a1'); 113 }); 114 115 it('should respect limit on getAllProxySessions', () => { 116 const now = Math.floor(Date.now() / 1000); 117 for (let i = 0; i < 5; i++) { 118 db.createProxySession(makeSession({ id: `s${i}`, expires_at: now + 3600 })); 119 } 120 121 const limited = db.getAllProxySessions(undefined, 3); 122 expect(limited).toHaveLength(3); 123 }); 124}); 125 126describe('Proxy Allowed Origins', () => { 127 let db: DatabaseService; 128 129 beforeEach(() => { 130 db = new DatabaseService(':memory:'); 131 }); 132 133 afterEach(() => { 134 db.close(); 135 }); 136 137 it('should add and list allowed origins', () => { 138 db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); 139 db.addProxyAllowedOrigin('https://chat.example.com', 'Element'); 140 141 const origins = db.listProxyAllowedOrigins(); 142 expect(origins).toHaveLength(2); 143 // Sorted by name ASC 144 expect(origins[0].name).toBe('Element'); 145 expect(origins[1].name).toBe('SearXNG'); 146 }); 147 148 it('should return created origin with id', () => { 149 const created = db.addProxyAllowedOrigin('https://test.example.com', 'Test'); 150 expect(created.id).toBeGreaterThan(0); 151 expect(created.origin).toBe('https://test.example.com'); 152 expect(created.name).toBe('Test'); 153 expect(created.created_at).toBeGreaterThan(0); 154 }); 155 156 it('should reject duplicate origins', () => { 157 db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); 158 expect(() => { 159 db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG Copy'); 160 }).toThrow(/UNIQUE/); 161 }); 162 163 it('should remove an allowed origin', () => { 164 const created = db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); 165 db.removeProxyAllowedOrigin(created.id); 166 expect(db.listProxyAllowedOrigins()).toHaveLength(0); 167 }); 168 169 it('should check if origin is allowed', () => { 170 db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); 171 172 expect(db.isProxyOriginAllowed('https://search.example.com')).toBe(true); 173 expect(db.isProxyOriginAllowed('https://evil.example.com')).toBe(false); 174 }); 175}); 176 177describe('Proxy Auth Requests', () => { 178 let db: DatabaseService; 179 180 beforeEach(() => { 181 db = new DatabaseService(':memory:'); 182 }); 183 184 afterEach(() => { 185 db.close(); 186 }); 187 188 it('should save and retrieve an auth request', () => { 189 const now = Math.floor(Date.now() / 1000); 190 const req = { 191 id: 'auth-req-123', 192 redirect_uri: 'https://search.example.com/path', 193 created_at: now, 194 expires_at: now + 600, 195 }; 196 db.saveProxyAuthRequest(req); 197 198 const retrieved = db.getProxyAuthRequest('auth-req-123'); 199 expect(retrieved).not.toBeNull(); 200 expect(retrieved!.redirect_uri).toBe('https://search.example.com/path'); 201 expect(retrieved!.expires_at).toBe(now + 600); 202 }); 203 204 it('should return null for non-existent auth request', () => { 205 expect(db.getProxyAuthRequest('nonexistent')).toBeNull(); 206 }); 207 208 it('should delete an auth request', () => { 209 const now = Math.floor(Date.now() / 1000); 210 db.saveProxyAuthRequest({ 211 id: 'del-me', 212 redirect_uri: 'https://example.com', 213 created_at: now, 214 expires_at: now + 600, 215 }); 216 217 db.deleteProxyAuthRequest('del-me'); 218 expect(db.getProxyAuthRequest('del-me')).toBeNull(); 219 }); 220 221 it('should clean up expired auth requests', () => { 222 const now = Math.floor(Date.now() / 1000); 223 db.saveProxyAuthRequest({ 224 id: 'expired1', 225 redirect_uri: 'https://example.com', 226 created_at: now - 700, 227 expires_at: now - 100, 228 }); 229 db.saveProxyAuthRequest({ 230 id: 'active', 231 redirect_uri: 'https://example.com', 232 created_at: now, 233 expires_at: now + 600, 234 }); 235 236 const deleted = db.cleanupExpiredProxyAuthRequests(); 237 expect(deleted).toBe(1); 238 expect(db.getProxyAuthRequest('expired1')).toBeNull(); 239 expect(db.getProxyAuthRequest('active')).not.toBeNull(); 240 }); 241}); 242 243describe('Proxy Access Rules', () => { 244 let db: DatabaseService; 245 246 beforeEach(() => { 247 db = new DatabaseService(':memory:'); 248 }); 249 250 afterEach(() => { 251 db.close(); 252 }); 253 254 it('should create and list access rules', () => { 255 const rule = db.createProxyAccessRule({ 256 origin_id: null, 257 rule_type: 'allow', 258 subject_type: 'handle_pattern', 259 subject_value: '*.example.com', 260 description: 'PDS users', 261 }); 262 263 expect(rule.id).toBeGreaterThan(0); 264 expect(rule.rule_type).toBe('allow'); 265 expect(rule.subject_value).toBe('*.example.com'); 266 267 const rules = db.listProxyAccessRules(); 268 expect(rules).toHaveLength(1); 269 expect(rules[0].description).toBe('PDS users'); 270 }); 271 272 it('should delete an access rule', () => { 273 const rule = db.createProxyAccessRule({ 274 origin_id: null, 275 rule_type: 'allow', 276 subject_type: 'did', 277 subject_value: 'did:plc:test123', 278 description: null, 279 }); 280 281 db.deleteProxyAccessRule(rule.id); 282 expect(db.listProxyAccessRules()).toHaveLength(0); 283 }); 284 285 it('should filter rules by origin_id', () => { 286 const origin = db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); 287 288 db.createProxyAccessRule({ 289 origin_id: origin.id, 290 rule_type: 'allow', 291 subject_type: 'handle_pattern', 292 subject_value: '*', 293 description: 'Origin rule', 294 }); 295 296 db.createProxyAccessRule({ 297 origin_id: null, 298 rule_type: 'allow', 299 subject_type: 'handle_pattern', 300 subject_value: '*.example.com', 301 description: 'Global rule', 302 }); 303 304 // Filter by origin ID should include origin-specific + global rules 305 const filtered = db.listProxyAccessRules(origin.id); 306 expect(filtered).toHaveLength(2); 307 308 // All rules 309 const all = db.listProxyAccessRules(); 310 expect(all).toHaveLength(2); 311 }); 312 313 it('should partition rules for access check', () => { 314 const origin = db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); 315 316 db.createProxyAccessRule({ 317 origin_id: origin.id, 318 rule_type: 'allow', 319 subject_type: 'handle_pattern', 320 subject_value: '*.example.com', 321 description: null, 322 }); 323 db.createProxyAccessRule({ 324 origin_id: origin.id, 325 rule_type: 'deny', 326 subject_type: 'did', 327 subject_value: 'did:plc:banned', 328 description: null, 329 }); 330 db.createProxyAccessRule({ 331 origin_id: null, 332 rule_type: 'allow', 333 subject_type: 'handle_pattern', 334 subject_value: '*', 335 description: null, 336 }); 337 338 const result = db.getProxyAccessRulesForCheck(origin.id); 339 expect(result.denyRules).toHaveLength(1); 340 expect(result.denyRules[0].subject_value).toBe('did:plc:banned'); 341 expect(result.originAllowRules).toHaveLength(1); 342 expect(result.originAllowRules[0].subject_value).toBe('*.example.com'); 343 expect(result.globalAllowRules).toHaveLength(1); 344 expect(result.globalAllowRules[0].subject_value).toBe('*'); 345 }); 346 347 it('should cascade delete rules when origin is removed', () => { 348 const origin = db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); 349 350 db.createProxyAccessRule({ 351 origin_id: origin.id, 352 rule_type: 'allow', 353 subject_type: 'handle_pattern', 354 subject_value: '*', 355 description: null, 356 }); 357 358 db.createProxyAccessRule({ 359 origin_id: null, 360 rule_type: 'allow', 361 subject_type: 'handle_pattern', 362 subject_value: '*', 363 description: 'Global survives', 364 }); 365 366 db.removeProxyAllowedOrigin(origin.id); 367 368 const rules = db.listProxyAccessRules(); 369 expect(rules).toHaveLength(1); 370 expect(rules[0].description).toBe('Global survives'); 371 }); 372 373 it('should look up origin ID by origin URL', () => { 374 const origin = db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); 375 expect(db.getOriginIdByOrigin('https://search.example.com')).toBe(origin.id); 376 expect(db.getOriginIdByOrigin('https://nonexistent.example.com')).toBeNull(); 377 }); 378});