File size: 28,022 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
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HttpException } from '@nestjs/common';
import type { Request, Response } from 'express';

vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4'), logWarn: vi.fn() }));

import { getClientIp } from '../../../src/services/auditLog';
const getClientIpMock = vi.mocked(getClientIp);

import { OauthPublicController } from '../../../src/nest/oauth/oauth-public.controller';
import { OauthApiController } from '../../../src/nest/oauth/oauth-api.controller';
import { RateLimitService } from '../../../src/nest/auth/rate-limit.service';
import type { OauthService } from '../../../src/nest/oauth/oauth.service';
import type { User } from '../../../src/types';

function osvc(o: Partial<OauthService> = {}): OauthService {
  return { mcpEnabled: vi.fn().mockReturnValue(true), mcpSafeUrl: vi.fn().mockReturnValue('https://app'), ...o } as unknown as OauthService;
}
function rl(): RateLimitService { return new RateLimitService(); }
function makeRes() {
  const res = {
    statusCode: 200, headers: {} as Record<string, string>, body: undefined as unknown, ended: false,
    status: vi.fn((c: number) => { res.statusCode = c; return res; }),
    json: vi.fn((b: unknown) => { res.body = b; return res; }),
    set: vi.fn((k: string, v: string) => { res.headers[k] = v; return res; }),
    end: vi.fn(() => { res.ended = true; return res; }),
  };
  return res as unknown as Response & { statusCode: number; headers: Record<string, string>; body: unknown; ended: boolean };
}
function thrown(fn: () => unknown): { status: number; body: unknown } {
  try { fn(); } catch (err) {
    expect(err).toBeInstanceOf(HttpException);
    const e = err as HttpException;
    return { status: e.getStatus(), body: e.getResponse() };
  }
  throw new Error('expected throw');
}

const user = { id: 1, email: 'u@example.test' } as User;
beforeEach(() => vi.clearAllMocks());

describe('OauthPublicController /token', () => {
  function reqWith(body: Record<string, string>): Request { return { ip: '7.7.7.7', body } as Request; }

  it('404 (empty) when MCP is disabled', () => {
    const res = makeRes();
    new OauthPublicController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).token(reqWith({}), res);
    expect(res.statusCode).toBe(404);
    expect(res.ended).toBe(true);
  });

  it('sets no-store headers + 401 without client_id', () => {
    const res = makeRes();
    new OauthPublicController(osvc(), rl()).token(reqWith({}), res);
    expect(res.headers['Cache-Control']).toBe('no-store');
    expect(res.statusCode).toBe(401);
    expect(res.body).toEqual({ error: 'invalid_client', error_description: 'client_id is required' });
  });

  it('authorization_code: invalid_grant on a bad code, success issues tokens', () => {
    const bad = makeRes();
    new OauthPublicController(osvc({ consumeAuthCode: vi.fn().mockReturnValue(null) }), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), bad);
    expect(bad.statusCode).toBe(400);
    expect(bad.body).toEqual({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });

    const ok = makeRes();
    const svc = osvc({
      consumeAuthCode: vi.fn().mockReturnValue({ clientId: 'c', redirectUri: 'u', userId: 1, scopes: ['s'], codeChallenge: 'cc', resource: null }),
      authenticateClient: vi.fn().mockReturnValue({ id: 'c' }),
      verifyPKCE: vi.fn().mockReturnValue(true),
      issueTokens: vi.fn().mockReturnValue({ access_token: 'at', token_type: 'Bearer' }),
    });
    new OauthPublicController(svc, rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), ok);
    expect(ok.body).toEqual({ access_token: 'at', token_type: 'Bearer' });
  });

  it('authorization_code: maps client_id / redirect_uri / resource mismatches + pkce + client auth', () => {
    const base = { grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' };
    const mk = (pending: Record<string, unknown>, extra: Partial<OauthService> = {}, body = base) => {
      const res = makeRes();
      new OauthPublicController(osvc({ consumeAuthCode: vi.fn().mockReturnValue(pending), authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), verifyPKCE: vi.fn().mockReturnValue(true), ...extra }), rl()).token(reqWith(body), res);
      return res;
    };
    expect(mk({ clientId: 'OTHER', redirectUri: 'u', userId: 1 }).statusCode).toBe(400); // client_id mismatch
    expect(mk({ clientId: 'c', redirectUri: 'OTHER', userId: 1 }).statusCode).toBe(400); // redirect_uri mismatch
    expect(mk({ clientId: 'c', redirectUri: 'u', userId: 1, resource: 'https://a' }, {}, { ...base, resource: 'https://b' }).statusCode).toBe(400); // resource mismatch
    expect(mk({ clientId: 'c', redirectUri: 'u', userId: 1 }, { authenticateClient: vi.fn().mockReturnValue(null) }).statusCode).toBe(401); // bad client secret
    expect(mk({ clientId: 'c', redirectUri: 'u', userId: 1, codeChallenge: 'cc' }, { verifyPKCE: vi.fn().mockReturnValue(false) }).statusCode).toBe(400); // pkce fail
  });

  it('authorization_code: 400 when code/redirect/verifier missing', () => {
    const res = makeRes();
    new OauthPublicController(osvc(), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c' }), res);
    expect(res.statusCode).toBe(400);
    expect(res.body).toEqual({ error: 'invalid_request', error_description: 'code, redirect_uri, and code_verifier are required' });
  });

  it('refresh_token: 400 without a refresh_token, maps a service error, success', () => {
    const miss = makeRes();
    new OauthPublicController(osvc(), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c' }), miss);
    expect(miss.statusCode).toBe(400);
    const err = makeRes();
    new OauthPublicController(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_grant', status: 400 }) }), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), err);
    expect(err.body).toEqual({ error: 'invalid_grant', error_description: 'Refresh token is invalid or expired' });
    const ok = makeRes();
    new OauthPublicController(osvc({ refreshTokens: vi.fn().mockReturnValue({ tokens: { access_token: 'new' } }) }), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), ok);
    expect(ok.body).toEqual({ access_token: 'new' });
  });

  it('client_credentials: 401 without secret, invalid_scope for a disallowed scope', () => {
    const noSecret = makeRes();
    new OauthPublicController(osvc(), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c' }), noSecret);
    expect(noSecret.statusCode).toBe(401);
    const badScope = makeRes();
    new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a"]' }) }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', scope: 'a zzz' }), badScope);
    expect(badScope.statusCode).toBe(400);
    expect(badScope.body).toEqual({ error: 'invalid_scope', error_description: 'Scopes not allowed for this client: zzz' });
  });

  it('client_credentials: unauthorized_client for a public client, else issues a token', () => {
    const pub = makeRes();
    new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: true, user_id: null, allows_client_credentials: false, allowed_scopes: '[]' }) }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), pub);
    expect(pub.statusCode).toBe(400);
    expect(pub.body).toEqual({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' });

    const ok = makeRes();
    new OauthPublicController(osvc({
      authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a","b"]' }),
      issueClientCredentialsToken: vi.fn().mockReturnValue({ access_token: 'cc_at' }),
    }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), ok);
    expect(ok.body).toEqual({ access_token: 'cc_at' });
  });

  it('unsupported grant -> 400', () => {
    const res = makeRes();
    new OauthPublicController(osvc(), rl()).token(reqWith({ grant_type: 'password', client_id: 'c' }), res);
    expect(res.statusCode).toBe(400);
    expect(res.body).toEqual({ error: 'unsupported_grant_type', error_description: 'Unsupported grant_type: password' });
  });

  it('429 when the token bucket is exhausted (per ip|client)', () => {
    const s = rl();
    for (let i = 0; i < 30; i++) s.check('oauth_token', '7.7.7.7|c', 30, 60000, Date.now());
    const res = makeRes();
    new OauthPublicController(osvc(), s).token(reqWith({ client_id: 'c' }), res);
    expect(res.statusCode).toBe(429);
  });

  it('falls back to {} when the body is not an object', () => {
    const res = makeRes();
    new OauthPublicController(osvc(), rl()).token({ ip: '7.7.7.7', body: 'not-an-object' } as unknown as Request, res);
    // no client_id in the {} fallback -> 401
    expect(res.statusCode).toBe(401);
    expect(res.body).toEqual({ error: 'invalid_client', error_description: 'client_id is required' });
  });

  it('authorization_code: invalid client secret writes an audit + 401', () => {
    const res = makeRes();
    new OauthPublicController(osvc({
      consumeAuthCode: vi.fn().mockReturnValue({ clientId: 'c', redirectUri: 'u', userId: 1, scopes: ['s'], codeChallenge: 'cc', resource: null }),
      authenticateClient: vi.fn().mockReturnValue(null),
    }), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), res);
    expect(res.statusCode).toBe(401);
    expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' });
  });

  it('refresh_token: invalid_client maps to its specific 401 message', () => {
    const res = makeRes();
    new OauthPublicController(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_client', status: 401 }) }), rl())
      .token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), res);
    expect(res.statusCode).toBe(401);
    expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' });
  });

  it('refresh_token: defaults the status to 400 when the service omits it', () => {
    const res = makeRes();
    new OauthPublicController(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_grant' }) }), rl())
      .token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), res);
    expect(res.statusCode).toBe(400);
  });

  it('client_credentials: 401 when the client cannot be authenticated', () => {
    const res = makeRes();
    new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl())
      .token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), res);
    expect(res.statusCode).toBe(401);
    expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' });
  });

  it('client_credentials: honours a valid requested scope subset', () => {
    const res = makeRes();
    const issueClientCredentialsToken = vi.fn().mockReturnValue({ access_token: 'cc_at' });
    new OauthPublicController(osvc({
      authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a","b"]' }),
      issueClientCredentialsToken,
    }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', scope: 'a' }), res);
    expect(res.body).toEqual({ access_token: 'cc_at' });
    expect(issueClientCredentialsToken).toHaveBeenCalledWith('c', 1, ['a'], expect.any(String));
  });

  it('client_credentials: derives the audience from an explicit resource', () => {
    const res = makeRes();
    const issueClientCredentialsToken = vi.fn().mockReturnValue({ access_token: 'cc_at' });
    new OauthPublicController(osvc({
      authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a"]' }),
      issueClientCredentialsToken,
    }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', resource: 'https://aud/' }), res);
    // trailing slashes are trimmed, not the mcpSafeUrl fallback
    expect(issueClientCredentialsToken).toHaveBeenCalledWith('c', 1, ['a'], 'https://aud');
  });

  it('logs a dash for a missing ip on the authorization_code client-auth failure', () => {
    getClientIpMock.mockReturnValueOnce(undefined);
    const res = makeRes();
    new OauthPublicController(osvc({
      consumeAuthCode: vi.fn().mockReturnValue({ clientId: 'c', redirectUri: 'u', userId: 1, scopes: ['s'], codeChallenge: 'cc', resource: null }),
      authenticateClient: vi.fn().mockReturnValue(null),
    }), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), res);
    expect(res.statusCode).toBe(401);
  });

  it('logs a dash for a missing ip on the refresh invalid_client failure', () => {
    getClientIpMock.mockReturnValueOnce(undefined);
    const res = makeRes();
    new OauthPublicController(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_client', status: 401 }) }), rl())
      .token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), res);
    expect(res.statusCode).toBe(401);
  });

  it('logs a dash for a missing ip on the client_credentials auth failure', () => {
    getClientIpMock.mockReturnValueOnce(undefined);
    const res = makeRes();
    new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl())
      .token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), res);
    expect(res.statusCode).toBe(401);
  });
});

describe('OauthPublicController /userinfo + /revoke', () => {
  it('userinfo: 401 challenge without a Bearer, returns claims with a valid token', () => {
    const r1 = makeRes();
    new OauthPublicController(osvc(), rl()).userinfo(undefined, r1);
    expect(r1.statusCode).toBe(401);
    expect(r1.headers['WWW-Authenticate']).toBe('Bearer realm="TREK MCP"');
    const r2 = makeRes();
    new OauthPublicController(osvc({ getUserByAccessToken: vi.fn().mockReturnValue({ user: { id: 1, email: 'a@b.c', username: 'u' } }) }), rl()).userinfo('Bearer tok', r2);
    expect(r2.body).toEqual({ sub: '1', email: 'a@b.c', email_verified: true, preferred_username: 'u' });
  });

  it('userinfo: 404 empty when MCP is disabled', () => {
    const res = makeRes();
    new OauthPublicController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).userinfo('Bearer tok', res);
    expect(res.statusCode).toBe(404);
    expect(res.ended).toBe(true);
  });

  it('userinfo: 401 with the error challenge when the token is unknown', () => {
    const res = makeRes();
    new OauthPublicController(osvc({ getUserByAccessToken: vi.fn().mockReturnValue(null) }), rl()).userinfo('Bearer tok', res);
    expect(res.statusCode).toBe(401);
    expect(res.headers['WWW-Authenticate']).toBe('Bearer realm="TREK MCP", error="invalid_token"');
    expect(res.body).toEqual({ error: 'invalid_token' });
  });

  it('revoke: 400 without token/client, always 200 once authenticated', () => {
    const r1 = makeRes();
    new OauthPublicController(osvc(), rl()).revoke({ ip: '1', body: { client_id: 'c' } } as Request, r1);
    expect(r1.statusCode).toBe(400);
    const r2 = makeRes();
    const revokeToken = vi.fn();
    new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), revokeToken }), rl()).revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, r2);
    expect(r2.statusCode).toBe(200);
    expect(r2.body).toEqual({});
    expect(revokeToken).toHaveBeenCalled();
  });

  it('revoke: 404 empty when MCP is disabled', () => {
    const res = makeRes();
    new OauthPublicController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).revoke({ ip: '1', body: {} } as Request, res);
    expect(res.statusCode).toBe(404);
    expect(res.ended).toBe(true);
  });

  it('revoke: 429 when the per-ip bucket is exhausted', () => {
    const s = rl();
    for (let i = 0; i < 10; i++) s.check('oauth_revoke', '1', 10, 60000, Date.now());
    const res = makeRes();
    new OauthPublicController(osvc(), s).revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, res);
    expect(res.statusCode).toBe(429);
  });

  it('revoke: falls back to a default ip key and {} body when both are missing', () => {
    const res = makeRes();
    new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), revokeToken: vi.fn() }), rl())
      .revoke({ body: undefined } as unknown as Request, res);
    // body fell back to {} -> token/client missing -> 400
    expect(res.statusCode).toBe(400);
  });

  it('revoke: 401 when the client credentials are invalid', () => {
    const res = makeRes();
    new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl())
      .revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, res);
    expect(res.statusCode).toBe(401);
    expect(res.body).toEqual({ error: 'invalid_client', error_description: 'Invalid client credentials' });
  });

  it('revoke: logs a dash for a missing ip on the invalid-client failure', () => {
    getClientIpMock.mockReturnValueOnce(undefined);
    const res = makeRes();
    new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue(null) }), rl())
      .revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, res);
    expect(res.statusCode).toBe(401);
  });
});

describe('OauthApiController', () => {
  const req = { ip: '1.2.3.4', user: undefined as unknown } as Request;
  function makeRes2() { const r = { statusCode: 200, ended: false, status: vi.fn((c: number) => { r.statusCode = c; return r; }), end: vi.fn(() => { r.ended = true; }) }; return r as unknown as Response & { statusCode: number; ended: boolean }; }

  it('validate: 404 empty when MCP off, loginRequired when anonymous + valid', () => {
    const off = makeRes2();
    new OauthApiController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).validate({ ...req } as Request, {}, off);
    expect(off.statusCode).toBe(404);
    expect(off.ended).toBe(true);
    const anon = makeRes2();
    const r = new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true }) }), rl()).validate({ ...req, user: undefined } as Request, {}, anon);
    expect(r).toEqual({ valid: true, loginRequired: true });
  });

  it('authorize: denied returns a redirect with access_denied, approved issues a code', () => {
    const denied = new OauthApiController(osvc(), rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req);
    expect((denied as { redirect: string }).redirect).toContain('error=access_denied');
    const svc = osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'], resource: null }), saveConsent: vi.fn(), createAuthCode: vi.fn().mockReturnValue('the_code') });
    const ok = new OauthApiController(svc, rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req);
    expect((ok as { redirect: string }).redirect).toContain('code=the_code');
  });

  it('clients/sessions: 403 when MCP off, else CRUD', () => {
    expect(thrown(() => new OauthApiController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).listClients(user))).toEqual({ status: 403, body: { error: 'MCP is not enabled' } });
    expect(new OauthApiController(osvc({ listOAuthClients: vi.fn().mockReturnValue([{ id: 'c1' }]) }), rl()).listClients(user)).toEqual({ clients: [{ id: 'c1' }] });
    expect(new OauthApiController(osvc({ createOAuthClient: vi.fn().mockReturnValue({ client_id: 'c1', client_secret: 's' }) }), rl()).createClient(user, { name: 'CLI', allowed_scopes: ['a'] }, req)).toEqual({ client_id: 'c1', client_secret: 's' });
    expect(new OauthApiController(osvc({ deleteOAuthClient: vi.fn().mockReturnValue({}) }), rl()).deleteClient(user, 'c1', req)).toEqual({ success: true });
    expect(new OauthApiController(osvc({ listOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) }), rl()).listSessions(user)).toEqual({ sessions: [{ id: 1 }] });
    expect(new OauthApiController(osvc({ revokeSession: vi.fn().mockReturnValue({}) }), rl()).revokeSession(user, '1', req)).toEqual({ success: true });
  });

  it('rotate maps a service error, else returns the new secret', () => {
    expect(thrown(() => new OauthApiController(osvc({ rotateOAuthClientSecret: vi.fn().mockReturnValue({ error: 'not_found', status: 404 }) }), rl()).rotateClient(user, 'c1', req))).toEqual({ status: 404, body: { error: 'not_found' } });
    expect(new OauthApiController(osvc({ rotateOAuthClientSecret: vi.fn().mockReturnValue({ client_secret: 'new' }) }), rl()).rotateClient(user, 'c1', req)).toEqual({ client_secret: 'new' });
  });

  it('validate: anonymous + invalid returns a generic error; create maps a service error', () => {
    const res = makeRes2();
    const anon = new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: false, error: 'x' }) }), rl()).validate({ ...req, user: undefined } as Request, {}, res);
    expect(anon).toEqual({ valid: false, error: 'invalid_request', error_description: 'Invalid authorization request' });
    expect(thrown(() => new OauthApiController(osvc({ createOAuthClient: vi.fn().mockReturnValue({ error: 'invalid_redirect_uri', status: 400 }) }), rl()).createClient(user, { name: 'X', allowed_scopes: ['a'] }, req))).toEqual({ status: 400, body: { error: 'invalid_redirect_uri' } });
  });

  it('authorize: 400 when re-validation fails, 503 when the auth code cannot be issued', () => {
    expect(thrown(() => new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: false, error: 'invalid_scope', error_description: 'bad' }) }), rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req))).toEqual({ status: 400, body: { error: 'invalid_scope', error_description: 'bad' } });
    expect(thrown(() => new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'], resource: null }), saveConsent: vi.fn(), createAuthCode: vi.fn().mockReturnValue(null) }), rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req))).toEqual({ status: 503, body: { error: 'server_error', error_description: 'Authorization server is temporarily unavailable' } });
  });

  it('validate: 429 when the per-ip bucket is exhausted', () => {
    const s = rl();
    for (let i = 0; i < 30; i++) s.check('oauth_validate', '1.2.3.4', 30, 60000, Date.now());
    const res = makeRes2();
    expect(thrown(() => new OauthApiController(osvc(), s).validate({ ...req } as Request, {}, res))).toEqual({
      status: 429,
      body: { error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' },
    });
  });

  it('validate: falls back to the "unknown" rate-limit key when req.ip is absent', () => {
    const res = makeRes2();
    const out = new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true }) }), rl())
      .validate({ user: undefined } as unknown as Request, {}, res);
    expect(out).toEqual({ valid: true, loginRequired: true });
  });

  it('validate: forwards the resource + returns the raw result for a logged-in user', () => {
    const res = makeRes2();
    const validateAuthorizeRequest = vi.fn().mockReturnValue({ valid: true, scopes: ['s'] });
    const out = new OauthApiController(osvc({ validateAuthorizeRequest }), rl())
      .validate({ ...req, user: { id: 9 } } as unknown as Request, { resource: 'https://r' }, res);
    expect(out).toEqual({ valid: true, scopes: ['s'] });
    expect(validateAuthorizeRequest).toHaveBeenCalledWith(expect.objectContaining({ resource: 'https://r' }), 9);
  });

  it('authorize: 403 when MCP is disabled', () => {
    expect(thrown(() => new OauthApiController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl())
      .authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req)))
      .toEqual({ status: 403, body: { error: 'MCP is not enabled' } });
  });

  it('authorize: carries the state through both the denied and approved redirects', () => {
    const denied = new OauthApiController(osvc(), rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', state: 'xyz', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req);
    expect((denied as { redirect: string }).redirect).toContain('state=xyz');

    const svc = osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'], resource: 'https://aud' }), saveConsent: vi.fn(), createAuthCode: vi.fn().mockReturnValue('the_code') });
    const ok = new OauthApiController(svc, rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', state: 'xyz', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req);
    expect((ok as { redirect: string }).redirect).toContain('code=the_code');
    expect((ok as { redirect: string }).redirect).toContain('state=xyz');
  });

  it('client/session errors default the status to 400 when the service omits it', () => {
    expect(thrown(() => new OauthApiController(osvc({ createOAuthClient: vi.fn().mockReturnValue({ error: 'bad' }) }), rl()).createClient(user, { name: 'X', allowed_scopes: ['a'] }, req)))
      .toEqual({ status: 400, body: { error: 'bad' } });
    expect(thrown(() => new OauthApiController(osvc({ rotateOAuthClientSecret: vi.fn().mockReturnValue({ error: 'bad' }) }), rl()).rotateClient(user, 'c1', req)))
      .toEqual({ status: 400, body: { error: 'bad' } });
    expect(thrown(() => new OauthApiController(osvc({ deleteOAuthClient: vi.fn().mockReturnValue({ error: 'not_found', status: 404 }) }), rl()).deleteClient(user, 'c1', req)))
      .toEqual({ status: 404, body: { error: 'not_found' } });
    expect(thrown(() => new OauthApiController(osvc({ deleteOAuthClient: vi.fn().mockReturnValue({ error: 'bad' }) }), rl()).deleteClient(user, 'c1', req)))
      .toEqual({ status: 400, body: { error: 'bad' } });
    expect(thrown(() => new OauthApiController(osvc({ revokeSession: vi.fn().mockReturnValue({ error: 'not_found', status: 404 }) }), rl()).revokeSession(user, '1', req)))
      .toEqual({ status: 404, body: { error: 'not_found' } });
    expect(thrown(() => new OauthApiController(osvc({ revokeSession: vi.fn().mockReturnValue({ error: 'bad' }) }), rl()).revokeSession(user, '1', req)))
      .toEqual({ status: 400, body: { error: 'bad' } });
  });

  it('sessions: 403 when MCP is off on the list', () => {
    expect(thrown(() => new OauthApiController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).listSessions(user)))
      .toEqual({ status: 403, body: { error: 'MCP is not enabled' } });
  });
});