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 }) diff --git a/src/escrow/escrow.service.ts b/src/escrow/escrow.service.ts index 6d2dd289..c252993f 100644 --- a/src/escrow/escrow.service.ts +++ b/src/escrow/escrow.service.ts @@ -584,20 +584,14 @@ export class EscrowService { recipients: Array<[string, number]>, manager?: EntityManager, ): Promise { - return this.invokeOnLockedEscrow(escrow, operation, () => - this.soroban.invoke( - 'release', - // `release(issue_id: u64, recipients)` — u64-typed on-chain (#301). - [u64(this.onChainKeyFor(escrow)), recipients], - this.contractOpts(escrow), - ), return this.invokeOnLockedEscrow( escrow, operation, () => this.soroban.invoke( 'release', - [this.onChainKeyFor(escrow), recipients], + // `release(issue_id: u64, recipients)` — u64-typed on-chain (#301). + [u64(this.onChainKeyFor(escrow)), recipients], this.contractOpts(escrow), ), manager, diff --git a/src/github/github-webhooks.service.spec.ts b/src/github/github-webhooks.service.spec.ts index 45bb6fe8..a35ac3be 100644 --- a/src/github/github-webhooks.service.spec.ts +++ b/src/github/github-webhooks.service.spec.ts @@ -869,6 +869,9 @@ describe('GithubWebhooksService', () => { expect(bountiesService.markInReview).not.toHaveBeenCalled(); expect(bountiesService.markMergedAndRelease).not.toHaveBeenCalled(); + }); + }); + // #308: a redelivered X-GitHub-Delivery used to hit the unique constraint // on webhook_events.deliveryId and escape handleEvent as a 500, so every // redelivery of that event failed forever. diff --git a/src/sponsors/sponsors.controller.ts b/src/sponsors/sponsors.controller.ts index b7a48cca..c097bf1e 100644 --- a/src/sponsors/sponsors.controller.ts +++ b/src/sponsors/sponsors.controller.ts @@ -26,25 +26,6 @@ function validatePage(limit?: number, offset?: number) { throw new BadRequestException('offset must be >= 0'); } return { limit, offset }; -/** - * Parse optional limit/offset query params, rejecting NaN, negatives, - * non-integers, and limits above MAX_PAGE_LIMIT with a 400 (#280). - */ -function parsePagination(limit?: string, offset?: string) { - const parse = (name: string, raw: string | undefined, min: number, max: number) => { - if (raw === undefined || raw === '') return undefined; - const n = Number(raw); - if (!Number.isInteger(n) || n < min || n > max) { - throw new BadRequestException( - `${name} must be an integer between ${min} and ${max}`, - ); - } - return n; - }; - return { - limit: parse('limit', limit, 1, MAX_PAGE_LIMIT), - offset: parse('offset', offset, 0, Number.MAX_SAFE_INTEGER), - }; } @ApiTags('sponsors') @@ -73,7 +54,6 @@ export class SponsorsController { ) { this.assertOwnsSponsor(user, id); return this.sponsorsService.dashboard(id, validatePage(limit, offset)); - return this.sponsorsService.dashboard(id, parsePagination(limit, offset)); } @ApiBearerAuth() @@ -91,6 +71,5 @@ export class SponsorsController { id, validatePage(limit, offset), ); - return this.sponsorsService.milestoneProgress(id, parsePagination(limit, offset)); } }