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