File size: 36,738 Bytes
1f5ea39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
/**
 * Unit tests for server/src/services/oauthService.ts.
 */
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import crypto from 'crypto';

const { testDb, dbMock } = vi.hoisted(() => {
  const Database = require('better-sqlite3');
  const db = new Database(':memory:');
  db.exec('PRAGMA journal_mode = WAL');
  db.exec('PRAGMA foreign_keys = ON');
  db.exec('PRAGMA busy_timeout = 5000');
  const mock = {
    db,
    closeDb: () => {},
    reinitialize: () => {},
    getPlaceWithTags: () => null,
    canAccessTrip: (tripId: any, userId: number) =>
      db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
    isOwner: (tripId: any, userId: number) =>
      !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
  };
  return { testDb: db, dbMock: mock };
});

vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
  JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
  ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
  updateJwtSecret: () => {},
}));
vi.mock('../../../src/services/apiKeyCrypto', () => ({
  encrypt_api_key: (v: string) => v,
  decrypt_api_key: (v: string) => v,
  maybe_encrypt_api_key: (v: string) => v,
}));
vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn(), sessions: new Map() }));
vi.mock('../../../src/demo/demo-reset', () => ({ saveBaseline: vi.fn() }));
vi.mock('../../../src/services/adminService', () => ({
  isAddonEnabled: vi.fn().mockReturnValue(true),
  getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
}));

import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
// PKCE helper β€” generates a valid code_verifier + code_challenge pair (RFC 7636)
function makePkce() {
  const verifier = crypto.randomBytes(32).toString('base64url');   // 43 chars
  const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); // 43 chars
  return { verifier, challenge };
}

import {
  createOAuthClient,
  listOAuthClients,
  deleteOAuthClient,
  rotateOAuthClientSecret,
  createAuthCode,
  consumeAuthCode,
  issueTokens,
  getUserByAccessToken,
  refreshTokens,
  revokeToken,
  listOAuthSessions,
  revokeSession,
  validateAuthorizeRequest,
  verifyPKCE,
  authenticateClient,
  saveConsent,
  getConsent,
  isConsentSufficient,
} from '../../../src/services/oauthService';
import { isAddonEnabled } from '../../../src/services/adminService';

beforeAll(() => {
  createTables(testDb);
  runMigrations(testDb);
});

beforeEach(() => {
  resetTestDb(testDb);
  // Clear oauth tables manually since they're not in the standard reset list
  testDb.exec('DELETE FROM oauth_tokens');
  testDb.exec('DELETE FROM oauth_consents');
  testDb.exec('DELETE FROM oauth_clients');
  vi.mocked(isAddonEnabled).mockReturnValue(true);
});

afterAll(() => {
  testDb.close();
});

// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------

function makeClient(
  userId: number,
  overrides: Partial<{ name: string; redirectUris: string[]; scopes: string[] }> = {}
) {
  return createOAuthClient(
    userId,
    overrides.name ?? 'Test Client',
    overrides.redirectUris ?? ['https://example.com/callback'],
    overrides.scopes ?? ['trips:read'],
  );
}

// ---------------------------------------------------------------------------
// createOAuthClient
// ---------------------------------------------------------------------------

describe('createOAuthClient', () => {
  it('creates a client successfully and returns client_secret only on creation', () => {
    const { user } = createUser(testDb);
    const result = makeClient(user.id);
    expect(result.error).toBeUndefined();
    expect(result.client).toBeDefined();
    expect(typeof result.client!.client_secret).toBe('string');
    expect((result.client!.client_secret as string).startsWith('trekcs_')).toBe(true);
  });

  it('client_id is a UUID', () => {
    const { user } = createUser(testDb);
    const result = makeClient(user.id);
    expect(result.client!.client_id).toMatch(
      /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
    );
  });

  it('returns 400 error if name is empty', () => {
    const { user } = createUser(testDb);
    const result = createOAuthClient(user.id, '', ['https://example.com/cb'], ['trips:read']);
    expect(result.status).toBe(400);
    expect(result.error).toContain('Name');
  });

  it('returns 400 error if name exceeds 100 characters', () => {
    const { user } = createUser(testDb);
    const longName = 'A'.repeat(101);
    const result = createOAuthClient(user.id, longName, ['https://example.com/cb'], ['trips:read']);
    expect(result.status).toBe(400);
    expect(result.error).toContain('100');
  });

  it('returns 400 error if no redirect URIs provided', () => {
    const { user } = createUser(testDb);
    const result = createOAuthClient(user.id, 'Test', [], ['trips:read']);
    expect(result.status).toBe(400);
    expect(result.error).toContain('redirect URI');
  });

  it('returns 400 error if more than 10 redirect URIs provided', () => {
    const { user } = createUser(testDb);
    const uris = Array.from({ length: 11 }, (_, i) => `https://example${i}.com/cb`);
    const result = createOAuthClient(user.id, 'Test', uris, ['trips:read']);
    expect(result.status).toBe(400);
    expect(result.error).toContain('10');
  });

  it('returns 400 error for invalid URI format', () => {
    const { user } = createUser(testDb);
    const result = createOAuthClient(user.id, 'Test', ['not-a-url'], ['trips:read']);
    expect(result.status).toBe(400);
    expect(result.error).toContain('Invalid redirect URI');
  });

  it('returns 400 error for non-https URI (not localhost)', () => {
    const { user } = createUser(testDb);
    const result = createOAuthClient(user.id, 'Test', ['http://example.com/cb'], ['trips:read']);
    expect(result.status).toBe(400);
    expect(result.error).toContain('HTTPS');
  });

  it('allows http://localhost redirect URI', () => {
    const { user } = createUser(testDb);
    const result = createOAuthClient(user.id, 'Test', ['http://localhost:3000/callback'], ['trips:read']);
    expect(result.error).toBeUndefined();
    expect(result.client).toBeDefined();
  });

  it('allows http://127.0.0.1 redirect URI', () => {
    const { user } = createUser(testDb);
    const result = createOAuthClient(user.id, 'Test', ['http://127.0.0.1:5000/callback'], ['trips:read']);
    expect(result.error).toBeUndefined();
    expect(result.client).toBeDefined();
  });

  it('returns 400 error if no scopes provided', () => {
    const { user } = createUser(testDb);
    const result = createOAuthClient(user.id, 'Test', ['https://example.com/cb'], []);
    expect(result.status).toBe(400);
    expect(result.error).toContain('scope');
  });

  it('returns 400 error for invalid scopes', () => {
    const { user } = createUser(testDb);
    const result = createOAuthClient(user.id, 'Test', ['https://example.com/cb'], ['invalid:scope']);
    expect(result.status).toBe(400);
    expect(result.error).toContain('Invalid scopes');
  });

  it('enforces max 10 clients per user', () => {
    const { user } = createUser(testDb);
    for (let i = 0; i < 10; i++) {
      const r = makeClient(user.id, { name: `Client ${i}` });
      expect(r.error).toBeUndefined();
    }
    const eleventh = makeClient(user.id, { name: 'Eleventh' });
    expect(eleventh.status).toBe(400);
    expect(eleventh.error).toContain('10');
  });
});

// ---------------------------------------------------------------------------
// listOAuthClients
// ---------------------------------------------------------------------------

describe('listOAuthClients', () => {
  it('returns empty array for user with no clients', () => {
    const { user } = createUser(testDb);
    expect(listOAuthClients(user.id)).toEqual([]);
  });

  it('returns created clients with redirect_uris and allowed_scopes as arrays', () => {
    const { user } = createUser(testDb);
    makeClient(user.id, { name: 'Client A', redirectUris: ['https://a.com/cb'], scopes: ['trips:read', 'budget:read'] });
    const clients = listOAuthClients(user.id);
    expect(clients).toHaveLength(1);
    expect(clients[0].name).toBe('Client A');
    expect(Array.isArray(clients[0].redirect_uris)).toBe(true);
    expect(Array.isArray(clients[0].allowed_scopes)).toBe(true);
    expect(clients[0].allowed_scopes).toContain('trips:read');
  });
});

// ---------------------------------------------------------------------------
// deleteOAuthClient
// ---------------------------------------------------------------------------

describe('deleteOAuthClient', () => {
  it('deletes own client successfully', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientRowId = created.client!.id as string;
    const result = deleteOAuthClient(user.id, clientRowId);
    expect(result.success).toBe(true);
    expect(listOAuthClients(user.id)).toHaveLength(0);
  });

  it('returns 404 for non-existent client', () => {
    const { user } = createUser(testDb);
    const result = deleteOAuthClient(user.id, 'non-existent-id');
    expect(result.status).toBe(404);
  });

  it("returns 404 for another user's client", () => {
    const { user: owner } = createUser(testDb);
    const { user: other } = createUser(testDb);
    const created = makeClient(owner.id);
    const result = deleteOAuthClient(other.id, created.client!.id as string);
    expect(result.status).toBe(404);
  });
});

// ---------------------------------------------------------------------------
// rotateOAuthClientSecret
// ---------------------------------------------------------------------------

describe('rotateOAuthClientSecret', () => {
  it('rotates secret and returns new client_secret starting with trekcs_', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const oldSecret = created.client!.client_secret as string;
    const result = rotateOAuthClientSecret(user.id, created.client!.id as string);
    expect(result.error).toBeUndefined();
    expect(result.client_secret).toBeDefined();
    expect((result.client_secret as string).startsWith('trekcs_')).toBe(true);
    expect(result.client_secret).not.toBe(oldSecret);
  });

  it('returns 404 for non-existent client', () => {
    const { user } = createUser(testDb);
    const result = rotateOAuthClientSecret(user.id, 'non-existent-id');
    expect(result.status).toBe(404);
  });

  it('revokes old tokens after rotation', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;
    const { access_token } = issueTokens(clientId, user.id, ['trips:read']);
    expect(getUserByAccessToken(access_token)).not.toBeNull();

    rotateOAuthClientSecret(user.id, created.client!.id as string);

    expect(getUserByAccessToken(access_token)).toBeNull();
  });
});

// ---------------------------------------------------------------------------
// createAuthCode + consumeAuthCode
// ---------------------------------------------------------------------------

describe('createAuthCode + consumeAuthCode', () => {
  it('create code and consume it once returns the pending entry', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;

    const code = createAuthCode({
      clientId,
      userId: user.id,
      redirectUri: 'https://example.com/callback',
      scopes: ['trips:read'],
      codeChallenge: 'abc123',
      codeChallengeMethod: 'S256',
    });

    const entry = consumeAuthCode(code);
    expect(entry).not.toBeNull();
    expect(entry!.userId).toBe(user.id);
    expect(entry!.clientId).toBe(clientId);
  });

  it('returns null for non-existent code', () => {
    expect(consumeAuthCode('does-not-exist')).toBeNull();
  });

  it('consuming same code twice returns null (one-time use)', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;

    const code = createAuthCode({
      clientId,
      userId: user.id,
      redirectUri: 'https://example.com/callback',
      scopes: ['trips:read'],
      codeChallenge: 'abc123',
      codeChallengeMethod: 'S256',
    });

    consumeAuthCode(code);
    expect(consumeAuthCode(code)).toBeNull();
  });
});

// ---------------------------------------------------------------------------
// issueTokens + getUserByAccessToken
// ---------------------------------------------------------------------------

describe('issueTokens + getUserByAccessToken', () => {
  it('issues tokens with correct prefixes', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;

    const tokens = issueTokens(clientId, user.id, ['trips:read']);
    expect(tokens.access_token.startsWith('trekoa_')).toBe(true);
    expect(tokens.refresh_token.startsWith('trekrf_')).toBe(true);
    expect(tokens.token_type).toBe('Bearer');
    expect(typeof tokens.expires_in).toBe('number');
  });

  it('getUserByAccessToken returns user and scopes for a valid token', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;

    const { access_token } = issueTokens(clientId, user.id, ['trips:read', 'budget:write']);
    const info = getUserByAccessToken(access_token);
    expect(info).not.toBeNull();
    expect(info!.user.email).toBe(user.email);
    expect(info!.scopes).toContain('trips:read');
    expect(info!.scopes).toContain('budget:write');
  });

  it('getUserByAccessToken returns null for unknown token', () => {
    expect(getUserByAccessToken('trekoa_unknown')).toBeNull();
  });

  it('getUserByAccessToken returns null for revoked token', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;

    const { access_token } = issueTokens(clientId, user.id, ['trips:read']);
    revokeToken(access_token, clientId);
    expect(getUserByAccessToken(access_token)).toBeNull();
  });
});

// ---------------------------------------------------------------------------
// refreshTokens
// ---------------------------------------------------------------------------

describe('refreshTokens', () => {
  it('exchanges a refresh token for a new token pair', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;
    const rawSecret = created.client!.client_secret as string;

    const { refresh_token } = issueTokens(clientId, user.id, ['trips:read']);
    const result = refreshTokens(refresh_token, clientId, rawSecret);
    expect(result.error).toBeUndefined();
    expect(result.tokens).toBeDefined();
    expect(result.tokens!.access_token.startsWith('trekoa_')).toBe(true);
  });

  it('old tokens are revoked after refresh (rotation)', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;
    const rawSecret = created.client!.client_secret as string;

    const { access_token, refresh_token } = issueTokens(clientId, user.id, ['trips:read']);
    refreshTokens(refresh_token, clientId, rawSecret);
    expect(getUserByAccessToken(access_token)).toBeNull();
  });

  it('returns invalid_grant for unknown refresh token', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;
    const rawSecret = created.client!.client_secret as string;

    const result = refreshTokens('trekrf_unknown', clientId, rawSecret);
    expect(result.error).toBe('invalid_grant');
    expect(result.status).toBe(400);
  });

  it('returns invalid_grant for revoked token', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;
    const rawSecret = created.client!.client_secret as string;

    const { access_token, refresh_token } = issueTokens(clientId, user.id, ['trips:read']);
    revokeToken(access_token, clientId);
    const result = refreshTokens(refresh_token, clientId, rawSecret);
    expect(result.error).toBe('invalid_grant');
  });

  it('returns invalid_client for wrong client_secret', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;

    const { refresh_token } = issueTokens(clientId, user.id, ['trips:read']);
    const result = refreshTokens(refresh_token, clientId, 'wrong-secret');
    expect(result.error).toBe('invalid_client');
    expect(result.status).toBe(401);
  });
});

// ---------------------------------------------------------------------------
// revokeToken
// ---------------------------------------------------------------------------

describe('revokeToken', () => {
  it('after revoking access token, getUserByAccessToken returns null', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;

    const { access_token } = issueTokens(clientId, user.id, ['trips:read']);
    expect(getUserByAccessToken(access_token)).not.toBeNull();

    revokeToken(access_token, clientId);
    expect(getUserByAccessToken(access_token)).toBeNull();
  });
});

// ---------------------------------------------------------------------------
// listOAuthSessions + revokeSession
// ---------------------------------------------------------------------------

describe('listOAuthSessions + revokeSession', () => {
  it('lists active sessions', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;

    issueTokens(clientId, user.id, ['trips:read']);
    const sessions = listOAuthSessions(user.id);
    expect(sessions).toHaveLength(1);
    expect(sessions[0].client_id).toBe(clientId);
  });

  it('revoked session is not listed', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;

    const { access_token } = issueTokens(clientId, user.id, ['trips:read']);
    revokeToken(access_token, clientId);
    const sessions = listOAuthSessions(user.id);
    expect(sessions).toHaveLength(0);
  });

  it('revokeSession returns 404 for unknown session', () => {
    const { user } = createUser(testDb);
    const result = revokeSession(user.id, 99999);
    expect(result.status).toBe(404);
  });

  it('revokeSession by session id removes session from list', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;

    issueTokens(clientId, user.id, ['trips:read']);
    const sessions = listOAuthSessions(user.id);
    const sessionId = sessions[0].id as number;

    const result = revokeSession(user.id, sessionId);
    expect(result.success).toBe(true);
    expect(listOAuthSessions(user.id)).toHaveLength(0);
  });
});

// ---------------------------------------------------------------------------
// validateAuthorizeRequest
// ---------------------------------------------------------------------------

describe('validateAuthorizeRequest', () => {
  // Use a proper 43-char S256 code_challenge to pass H1 format validation
  const { challenge: VALID_CHALLENGE } = makePkce();

  function makeParams(overrides: Partial<{
    response_type: string;
    client_id: string;
    redirect_uri: string;
    scope: string;
    code_challenge: string;
    code_challenge_method: string;
  }> = {}) {
    return {
      response_type: 'code',
      client_id: '',
      redirect_uri: 'https://example.com/callback',
      scope: 'trips:read',
      code_challenge: VALID_CHALLENGE,
      code_challenge_method: 'S256',
      ...overrides,
    };
  }

  it('returns mcp_disabled when isAddonEnabled returns false', () => {
    vi.mocked(isAddonEnabled).mockReturnValue(false);
    const result = validateAuthorizeRequest(makeParams({ client_id: 'x' }), null);
    expect(result.valid).toBe(false);
    expect(result.error).toBe('mcp_disabled');
  });

  it('requires response_type=code', () => {
    const { user } = createUser(testDb);
    const result = validateAuthorizeRequest(makeParams({ response_type: 'token', client_id: 'x' }), user.id);
    expect(result.valid).toBe(false);
    expect(result.error).toBe('unsupported_response_type');
  });

  it('requires PKCE with S256', () => {
    const { user } = createUser(testDb);
    const result = validateAuthorizeRequest(makeParams({ client_id: 'x', code_challenge_method: 'plain' }), user.id);
    expect(result.valid).toBe(false);
    expect(result.error).toBe('invalid_request');
  });

  it('requires valid client_id', () => {
    const { user } = createUser(testDb);
    const result = validateAuthorizeRequest(makeParams({ client_id: 'nonexistent' }), user.id);
    expect(result.valid).toBe(false);
    expect(result.error).toBe('invalid_client');
  });

  it('validates redirect_uri against registered URIs', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id, { redirectUris: ['https://example.com/callback'] });
    const clientId = created.client!.client_id as string;

    const result = validateAuthorizeRequest(
      makeParams({ client_id: clientId, redirect_uri: 'https://evil.com/callback' }),
      user.id
    );
    expect(result.valid).toBe(false);
    expect(result.error).toBe('invalid_redirect_uri');
  });

  it('validates scope against client allowed_scopes', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id, { scopes: ['trips:read'] });
    const clientId = created.client!.client_id as string;

    const result = validateAuthorizeRequest(
      makeParams({ client_id: clientId, scope: 'budget:write' }),
      user.id
    );
    expect(result.valid).toBe(false);
    expect(result.error).toBe('invalid_scope');
  });

  it('returns loginRequired when userId is null', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;

    const result = validateAuthorizeRequest(makeParams({ client_id: clientId }), null);
    expect(result.valid).toBe(true);
    expect(result.loginRequired).toBe(true);
  });

  it('returns consentRequired=true when consent not yet saved', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;

    const result = validateAuthorizeRequest(makeParams({ client_id: clientId }), user.id);
    expect(result.valid).toBe(true);
    expect(result.consentRequired).toBe(true);
  });

  it('returns consentRequired=false when consent already saved and sufficient', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;

    saveConsent(clientId, user.id, ['trips:read']);
    const result = validateAuthorizeRequest(makeParams({ client_id: clientId }), user.id);
    expect(result.valid).toBe(true);
    expect(result.consentRequired).toBe(false);
  });
});

// ---------------------------------------------------------------------------
// verifyPKCE
// ---------------------------------------------------------------------------

describe('verifyPKCE', () => {
  it('returns true for valid code_verifier / code_challenge pair (SHA256 base64url)', () => {
    const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
    const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
    expect(verifyPKCE(verifier, challenge)).toBe(true);
  });

  it('returns false for wrong verifier', () => {
    const verifier = 'correct-verifier';
    const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
    expect(verifyPKCE('wrong-verifier', challenge)).toBe(false);
  });
});

// ---------------------------------------------------------------------------
// authenticateClient
// ---------------------------------------------------------------------------

describe('authenticateClient', () => {
  it('returns client row for correct credentials', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;
    const rawSecret = created.client!.client_secret as string;

    const client = authenticateClient(clientId, rawSecret);
    expect(client).not.toBeNull();
    expect(client!.client_id).toBe(clientId);
  });

  it('returns null for wrong secret', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;

    expect(authenticateClient(clientId, 'wrong-secret')).toBeNull();
  });

  it('returns null for unknown client_id', () => {
    expect(authenticateClient('unknown-client-id', 'any-secret')).toBeNull();
  });
});

// ---------------------------------------------------------------------------
// saveConsent + getConsent + isConsentSufficient
// ---------------------------------------------------------------------------

describe('saveConsent + getConsent + isConsentSufficient', () => {
  it('saves and retrieves consent', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;

    saveConsent(clientId, user.id, ['trips:read', 'budget:write']);
    const consent = getConsent(clientId, user.id);
    expect(consent).not.toBeNull();
    expect(consent).toContain('trips:read');
    expect(consent).toContain('budget:write');
  });

  it('isConsentSufficient returns true when all requested scopes are in existing', () => {
    expect(isConsentSufficient(['trips:read', 'budget:write'], ['trips:read'])).toBe(true);
    expect(isConsentSufficient(['trips:read', 'budget:write'], ['trips:read', 'budget:write'])).toBe(true);
  });

  it('isConsentSufficient returns false when some scopes are missing', () => {
    expect(isConsentSufficient(['trips:read'], ['trips:read', 'budget:write'])).toBe(false);
    expect(isConsentSufficient([], ['trips:read'])).toBe(false);
  });
});

// ---------------------------------------------------------------------------
// M5 β€” saveConsent unions instead of replacing
// ---------------------------------------------------------------------------

describe('saveConsent β€” scope union (M5)', () => {
  it('unioning scopes: approving B after A leaves both in consent', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id, { scopes: ['trips:read', 'budget:write'] });
    const clientId = created.client!.client_id as string;

    saveConsent(clientId, user.id, ['trips:read']);
    saveConsent(clientId, user.id, ['budget:write']);

    const consent = getConsent(clientId, user.id);
    expect(consent).toContain('trips:read');
    expect(consent).toContain('budget:write');
  });

  it('re-approving a superset scope still preserves previously-consented scopes', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id, { scopes: ['trips:read', 'trips:write'] });
    const clientId = created.client!.client_id as string;

    saveConsent(clientId, user.id, ['trips:read', 'trips:write']);
    // approve only trips:read on a later request
    saveConsent(clientId, user.id, ['trips:read']);

    const consent = getConsent(clientId, user.id);
    // trips:write should NOT be removed (union semantics)
    expect(consent).toContain('trips:read');
    expect(consent).toContain('trips:write');
  });

  it('consent is sufficient after sequential approvals β€” no re-prompt needed', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id, { scopes: ['trips:read', 'budget:write'] });
    const clientId = created.client!.client_id as string;

    saveConsent(clientId, user.id, ['trips:read']);
    saveConsent(clientId, user.id, ['budget:write']);

    // Should not require consent again for either scope
    expect(isConsentSufficient(getConsent(clientId, user.id)!, ['trips:read'])).toBe(true);
    expect(isConsentSufficient(getConsent(clientId, user.id)!, ['budget:write'])).toBe(true);
    expect(isConsentSufficient(getConsent(clientId, user.id)!, ['trips:read', 'budget:write'])).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// C2 β€” getUserByAccessToken returns clientId
// ---------------------------------------------------------------------------

describe('getUserByAccessToken β€” includes clientId (C2)', () => {
  it('returns clientId matching the issuing OAuth client', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;

    const { access_token } = issueTokens(clientId, user.id, ['trips:read']);
    const info = getUserByAccessToken(access_token);
    expect(info).not.toBeNull();
    expect(info!.clientId).toBe(clientId);
  });
});

// ---------------------------------------------------------------------------
// C3 β€” Refresh token replay detection and chain revocation
// ---------------------------------------------------------------------------

describe('refreshTokens β€” replay detection (C3)', () => {
  it('replaying a revoked refresh token returns invalid_grant', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;
    const rawSecret = created.client!.client_secret as string;

    // Issue tokens, then rotate once (old token becomes revoked)
    const { refresh_token: firstRefresh } = issueTokens(clientId, user.id, ['trips:read']);
    const rotateResult = refreshTokens(firstRefresh, clientId, rawSecret);
    expect(rotateResult.error).toBeUndefined();
    const { refresh_token: secondRefresh } = rotateResult.tokens!;

    // Replay the FIRST (now revoked) refresh token
    const replayResult = refreshTokens(firstRefresh, clientId, rawSecret);
    expect(replayResult.error).toBe('invalid_grant');
    expect(replayResult.status).toBe(400);
  });

  it('replaying a revoked token also revokes the entire rotation chain', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;
    const rawSecret = created.client!.client_secret as string;

    // Issue β†’ rotate once
    const { refresh_token: first } = issueTokens(clientId, user.id, ['trips:read']);
    const r1 = refreshTokens(first, clientId, rawSecret);
    const { access_token: access2, refresh_token: second } = r1.tokens!;

    // Replay first (revoked) refresh token β†’ chain revoke
    refreshTokens(first, clientId, rawSecret);

    // The rotated access token should also be dead now
    expect(getUserByAccessToken(access2)).toBeNull();

    // The second refresh token should also be revoked
    const r2 = refreshTokens(second, clientId, rawSecret);
    expect(r2.error).toBe('invalid_grant');
  });

  it('new rotation chain after replay is independent', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;
    const rawSecret = created.client!.client_secret as string;

    const { refresh_token: first } = issueTokens(clientId, user.id, ['trips:read']);
    // Rotate once
    const r1 = refreshTokens(first, clientId, rawSecret);
    const { refresh_token: second } = r1.tokens!;
    // Rotate again on the second token
    const r2 = refreshTokens(second, clientId, rawSecret);
    expect(r2.error).toBeUndefined();
    const { refresh_token: third } = r2.tokens!;

    // Replay the first revoked token β†’ revokes chain containing first+second+third
    refreshTokens(first, clientId, rawSecret);

    // third should now be revoked too (it's in the same chain)
    const r3 = refreshTokens(third, clientId, rawSecret);
    expect(r3.error).toBe('invalid_grant');
  });
});

// ---------------------------------------------------------------------------
// H1 β€” PKCE code_challenge / code_verifier format validation
// ---------------------------------------------------------------------------

describe('verifyPKCE β€” format validation (H1)', () => {
  it('returns false for a code_verifier that is too short (< 43 chars)', () => {
    const { challenge } = makePkce();
    expect(verifyPKCE('short', challenge)).toBe(false);
  });

  it('returns false for a code_verifier that is too long (> 128 chars)', () => {
    const { challenge } = makePkce();
    const longVerifier = 'a'.repeat(129);
    expect(verifyPKCE(longVerifier, challenge)).toBe(false);
  });

  it('returns false for a code_verifier with invalid characters', () => {
    const { challenge } = makePkce();
    const badVerifier = 'A'.repeat(42) + ' '; // space is not allowed
    expect(verifyPKCE(badVerifier, challenge)).toBe(false);
  });

  it('returns true for a valid 43-char verifier matching its challenge', () => {
    const { verifier, challenge } = makePkce();
    expect(verifyPKCE(verifier, challenge)).toBe(true);
  });
});

describe('validateAuthorizeRequest β€” PKCE format (H1)', () => {
  it('returns invalid_request when code_challenge is shorter than 43 chars', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;

    const result = validateAuthorizeRequest({
      response_type: 'code',
      client_id: clientId,
      redirect_uri: 'https://example.com/callback',
      scope: 'trips:read',
      code_challenge: 'tooshort',
      code_challenge_method: 'S256',
    }, user.id);
    expect(result.valid).toBe(false);
    expect(result.error).toBe('invalid_request');
  });

  it('returns invalid_request when code_challenge contains invalid characters', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;

    // 43 chars but includes '=' which is not base64url
    const badChallenge = '='.repeat(43);
    const result = validateAuthorizeRequest({
      response_type: 'code',
      client_id: clientId,
      redirect_uri: 'https://example.com/callback',
      scope: 'trips:read',
      code_challenge: badChallenge,
      code_challenge_method: 'S256',
    }, user.id);
    expect(result.valid).toBe(false);
    expect(result.error).toBe('invalid_request');
  });
});

// ---------------------------------------------------------------------------
// H3 β€” validateAuthorizeRequest: loginRequired response strips client info
// ---------------------------------------------------------------------------

describe('validateAuthorizeRequest β€” unauthenticated strips client info (H3)', () => {
  it('loginRequired response does not include client.name or allowed_scopes', () => {
    const { user } = createUser(testDb);
    const created = makeClient(user.id);
    const clientId = created.client!.client_id as string;
    const { challenge } = makePkce();

    const result = validateAuthorizeRequest({
      response_type: 'code',
      client_id: clientId,
      redirect_uri: 'https://example.com/callback',
      scope: 'trips:read',
      code_challenge: challenge,
      code_challenge_method: 'S256',
    }, null /* unauthenticated */);

    expect(result.valid).toBe(true);
    expect(result.loginRequired).toBe(true);
    // Must NOT expose client metadata to unauthenticated callers
    expect(result.client).toBeUndefined();
    expect(result.scopes).toBeUndefined();
  });
});