File size: 3,823 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
/**
 * OIDC e2e — exercises the migrated /api/auth/oidc flow with the real cookie
 * service. The OIDC service + auth toggles are mocked; this proves the flow is
 * unauthenticated, the sso-disabled 403, the login redirect, and that /exchange
 * sets the httpOnly trek_session cookie from a valid auth code.
 */
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';

vi.mock('../../src/services/notifications', () => ({ getAppUrl: () => 'https://app' }));

const { toggles } = vi.hoisted(() => ({ toggles: { oidc_login: true } }));
vi.mock('../../src/services/authService', () => ({ resolveAuthToggles: () => toggles }));

const { oidcSvc } = vi.hoisted(() => ({
  oidcSvc: {
    getOidcConfig: vi.fn(), discover: vi.fn(), createState: vi.fn(), consumeState: vi.fn(), createAuthCode: vi.fn(),
    consumeAuthCode: vi.fn(), exchangeCodeForToken: vi.fn(), getUserInfo: vi.fn(), verifyIdToken: vi.fn(),
    findOrCreateUser: vi.fn(), touchLastLogin: vi.fn(), generateToken: vi.fn(), frontendUrl: (p: string) => 'https://app' + p,
  },
}));
vi.mock('../../src/services/oidcService', () => oidcSvc);

import { OidcModule } from '../../src/nest/oidc/oidc.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';

describe('OIDC e2e (real cookie service)', () => {
  let server: Server;
  let app: Awaited<ReturnType<typeof build>>;

  async function build() {
    const moduleRef = await Test.createTestingModule({ imports: [OidcModule] }).compile();
    const nest = moduleRef.createNestApplication();
    nest.use(cookieParser());
    nest.useGlobalFilters(new TrekExceptionFilter());
    await nest.init();
    return nest;
  }

  beforeAll(async () => {
    vi.spyOn(console, 'error').mockImplementation(() => {});
    app = await build();
    server = app.getHttpServer();
    oidcSvc.getOidcConfig.mockReturnValue({ issuer: 'https://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null });
    oidcSvc.discover.mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui', issuer: 'https://idp' });
    oidcSvc.createState.mockReturnValue({ state: 'st', codeChallenge: 'cc' });
    oidcSvc.consumeAuthCode.mockReturnValue({ token: 'jwt.value' });
  });

  beforeEach(() => { toggles.oidc_login = true; });

  afterAll(async () => {
    await app.close();
  });

  it('GET /login is unauthenticated and redirects (302) to the provider', async () => {
    const res = await request(server).get('/api/auth/oidc/login').redirects(0);
    expect(res.status).toBe(302);
    expect(res.headers.location).toContain('https://idp/auth?');
  });

  it('GET /login returns 403 when SSO is disabled', async () => {
    toggles.oidc_login = false;
    const res = await request(server).get('/api/auth/oidc/login');
    expect(res.status).toBe(403);
    expect(res.body).toEqual({ error: 'SSO login is disabled.' });
  });

  it('GET /exchange 400 without a code', async () => {
    const res = await request(server).get('/api/auth/oidc/exchange');
    expect(res.status).toBe(400);
    expect(res.body).toEqual({ error: 'Code required' });
  });

  it('GET /exchange sets the httpOnly trek_session cookie + returns the token', async () => {
    oidcSvc.consumeAuthCode.mockReturnValue({ token: 'jwt.value' });
    const res = await request(server).get('/api/auth/oidc/exchange').query({ code: 'good' });
    expect(res.status).toBe(200);
    expect(res.body).toEqual({ token: 'jwt.value' });
    const setCookie = res.headers['set-cookie'] as unknown as string[];
    expect(setCookie.some((c) => c.startsWith('trek_session=') && /HttpOnly/i.test(c))).toBe(true);
  });
});