diff --git a/src/auth/strategies/github.strategy.spec.ts b/src/auth/strategies/github.strategy.spec.ts new file mode 100644 index 00000000..acbc18c1 --- /dev/null +++ b/src/auth/strategies/github.strategy.spec.ts @@ -0,0 +1,46 @@ +import { GithubStrategy } from './github.strategy'; +import { Profile } from 'passport-github2'; + +describe('GithubStrategy.validate (#594)', () => { + const strategy = new GithubStrategy({ + get: () => ({ + clientId: 'id', + clientSecret: 'secret', + oauthCallbackUrl: 'http://localhost/cb', + }), + } as never); + + it('maps the passport-github2 profile into the upsert input', () => { + const profile = { + id: 'gh-1', + username: 'alice', + emails: [{ value: 'alice@example.com' }], + photos: [{ value: 'https://avatars/1' }], + } as unknown as Profile; + const done = jest.fn(); + + strategy.validate('at', 'rt', profile, done); + + expect(done).toHaveBeenCalledWith( + null, + expect.objectContaining({ + githubId: 'gh-1', + username: 'alice', + email: 'alice@example.com', + avatarUrl: 'https://avatars/1', + accessToken: 'at', + refreshToken: 'rt', + }), + ); + }); + + it('nulls email/avatar when the profile has none', () => { + const profile = { id: 'gh-2', username: 'bob' } as unknown as Profile; + const done = jest.fn(); + strategy.validate('at', 'rt', profile, done); + expect(done).toHaveBeenCalledWith( + null, + expect.objectContaining({ email: null, avatarUrl: null }), + ); + }); +}); diff --git a/src/auth/strategies/jwt.strategy.spec.ts b/src/auth/strategies/jwt.strategy.spec.ts new file mode 100644 index 00000000..eac08bc5 --- /dev/null +++ b/src/auth/strategies/jwt.strategy.spec.ts @@ -0,0 +1,69 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { Request } from 'express'; +import { cookieExtractor, JwtStrategy } from './jwt.strategy'; + +function reqWithCookie(header?: string): Request { + return { headers: header ? { cookie: header } : {} } as unknown as Request; +} + +describe('cookieExtractor (#593)', () => { + it('returns the access_token cookie value', () => { + const req = reqWithCookie('access_token=abc123; other=x'); + expect(cookieExtractor(req)).toBe('abc123'); + }); + + it('prefers req.cookies when populated', () => { + const req = { + cookies: { access_token: 'from-cookies' }, + } as unknown as Request; + expect(cookieExtractor(req)).toBe('from-cookies'); + }); + + it('decodes a URI-encoded token', () => { + const req = reqWithCookie('access_token=abc%2Fdef'); + expect(cookieExtractor(req)).toBe('abc/def'); + }); + + it('returns null for a malformed (un-decodable) token', () => { + const req = reqWithCookie('access_token=%E0%A4%A'); // invalid UTF-8 escape + expect(cookieExtractor(req)).toBeNull(); + }); + + it('returns null when no cookie header or request', () => { + expect(cookieExtractor(reqWithCookie())).toBeNull(); + expect(cookieExtractor(null as unknown as Request)).toBeNull(); + }); +}); + +describe('JwtStrategy.validate (#593)', () => { + const usersService = { findOneRaw: jest.fn() }; + let strategy: JwtStrategy; + + beforeEach(() => { + jest.clearAllMocks(); + strategy = new JwtStrategy( + { get: () => ({ secret: 'test' }) } as never, + usersService as never, + ); + }); + + it('rejects a payload with no sub', async () => { + await expect( + strategy.validate({ sub: '', username: 'x' }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('rejects a token whose user no longer exists', async () => { + usersService.findOneRaw.mockRejectedValue(new Error('not found')); + await expect( + strategy.validate({ sub: 'u1', username: 'x' }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('returns the identity for a valid token', async () => { + usersService.findOneRaw.mockResolvedValue({ id: 'u1' }); + await expect( + strategy.validate({ sub: 'u1', username: 'alice' }), + ).resolves.toEqual({ userId: 'u1', username: 'alice' }); + }); +}); diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts index bf22c5a6..138eb8c9 100644 --- a/src/auth/strategies/jwt.strategy.ts +++ b/src/auth/strategies/jwt.strategy.ts @@ -11,7 +11,9 @@ export interface JwtPayload { username: string; } -function cookieExtractor(req: Request): string | null { +// Exported for the spec (#593) — the cookie-parsing + decodeURIComponent +// guard are security-relevant (a malformed cookie must return null, not throw). +export function cookieExtractor(req: Request): string | null { if (!req) return null; // If cookie-parser is installed, req.cookies will be populated. const cookies = (req as unknown as { cookies?: Record })