From 945bfb29ef6c73090458f62e0ae7b0af49f544ee Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 5 Nov 2026 17:47:33 +1100 Subject: [PATCH] Fix M2M client initialization for opportunity billing lookups --- src/shared/modules/global/m2m.service.spec.ts | 98 +++++++++++++++++++ src/shared/modules/global/m2m.service.ts | 28 ++---- 2 files changed, 107 insertions(+), 19 deletions(-) diff --git a/src/shared/modules/global/m2m.service.spec.ts b/src/shared/modules/global/m2m.service.spec.ts index 91ef684..8f10ae8 100644 --- a/src/shared/modules/global/m2m.service.spec.ts +++ b/src/shared/modules/global/m2m.service.spec.ts @@ -1,13 +1,111 @@ +import { createServer, Server } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { LoggerService } from './logger.service'; import { Scope } from 'src/shared/enums/scopes.enum'; import { M2MService } from './m2m.service'; describe('M2MService', () => { let service: M2MService; + const originalEnv = process.env; beforeEach(() => { + process.env = { + ...originalEnv, + AUTH0_URL: 'https://auth.example.test/oauth/token', + AUTH0_AUDIENCE: 'https://api.example.test/', + AUTH0_PROXY_SERVER_URL: 'https://proxy.example.test/token', + AUTH0_CLIENT_ID: 'test-client', + AUTH0_CLIENT_SECRET: 'test-secret', + }; service = new M2MService(); }); + afterEach(() => { + process.env = originalEnv; + jest.restoreAllMocks(); + }); + + describe('getM2MToken with the installed tc-core library', () => { + let server: Server; + let requests: { url?: string; body: Record }[]; + let responseStatus: number; + + beforeEach(async () => { + requests = []; + responseStatus = 200; + server = createServer((request, response) => { + let body = ''; + request.setEncoding('utf8'); + request.on('data', (chunk: string) => { + body += chunk; + }); + request.on('end', () => { + requests.push({ + url: request.url, + body: JSON.parse(body) as Record, + }); + response.writeHead(responseStatus, { + 'Content-Type': 'application/json', + }); + response.end( + JSON.stringify( + responseStatus === 200 + ? { access_token: 'test-machine-token', expires_in: 3600 } + : { error: 'invalid_client' }, + ), + ); + }); + }); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + const { port } = server.address() as AddressInfo; + process.env.AUTH0_PROXY_SERVER_URL = `http://127.0.0.1:${port}/token`; + service = new M2MService(); + }); + + afterEach(async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }); + }); + + it('acquires and caches a token through the configured Auth0 proxy', async () => { + await expect(service.getM2MToken()).resolves.toBe('test-machine-token'); + await expect(service.getM2MToken()).resolves.toBe('test-machine-token'); + expect(requests).toEqual([ + { + url: '/token', + body: { + grant_type: 'client_credentials', + client_id: 'test-client', + client_secret: 'test-secret', + audience: 'https://api.example.test/', + auth0_url: 'https://auth.example.test/oauth/token', + }, + }, + ]); + }); + + it('rejects missing credentials before making a token request', async () => { + delete process.env.AUTH0_CLIENT_SECRET; + await expect(service.getM2MToken()).rejects.toThrow( + 'M2M credentials are not configured.', + ); + expect(requests).toHaveLength(0); + }); + + it('preserves the service error when the token endpoint rejects credentials', async () => { + responseStatus = 401; + jest.spyOn(LoggerService.prototype, 'error').mockImplementation(() => {}); + await expect(service.getM2MToken()).rejects.toThrow( + 'Failed to retrieve M2M token.', + ); + expect(requests).toHaveLength(1); + }); + }); + describe('expandScopes', () => { it('expands hierarchy for all:projects', () => { const expandedScopes = service.expandScopes([Scope.PROJECTS_ALL]); diff --git a/src/shared/modules/global/m2m.service.ts b/src/shared/modules/global/m2m.service.ts index 348e9b1..2e17999 100644 --- a/src/shared/modules/global/m2m.service.ts +++ b/src/shared/modules/global/m2m.service.ts @@ -1,4 +1,5 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { createM2MClient, type M2MClient } from 'tc-core-library-js'; import { Scope, SCOPE_HIERARCHY, @@ -13,10 +14,6 @@ import { LoggerService } from './logger.service'; * Provides M2M token acquisition through tc-core/Auth0 and scope utilities * used to classify machine tokens and evaluate scope-based access. */ -// tc-core-library-js is CommonJS-only. -// eslint-disable-next-line @typescript-eslint/no-require-imports -const tcCore = require('tc-core-library-js'); - type TokenPayload = Record; @Injectable() @@ -28,34 +25,27 @@ type TokenPayload = Record; */ export class M2MService { private readonly logger = LoggerService.forRoot('M2MService'); - private readonly m2mClient: any; + private readonly m2mClient: M2MClient; // TODO (security): AUTH0_CLIENT_ID and AUTH0_CLIENT_SECRET are read at call-time in getM2MToken() rather than validated at startup. A missing secret will only surface at runtime when a token is first requested. - // TODO (quality): m2mClient is typed as 'any'. Define an interface for the tc-core M2M client to enable type safety. /** - * Creates the tc-core M2M client when tc-core auth bindings are available. + * Creates the tc-core M2M client using its supported top-level factory. */ constructor() { - if (tcCore?.auth?.m2m) { - this.m2mClient = tcCore.createM2MClient({ - AUTH0_URL: process.env.AUTH0_URL, - AUTH0_AUDIENCE: process.env.AUTH0_AUDIENCE, - AUTH0_PROXY_SERVER_URL: process.env.AUTH0_PROXY_SERVER_URL, - }); - } + this.m2mClient = createM2MClient({ + AUTH0_URL: process.env.AUTH0_URL, + AUTH0_AUDIENCE: process.env.AUTH0_AUDIENCE, + AUTH0_PROXY_SERVER_URL: process.env.AUTH0_PROXY_SERVER_URL, + }); } /** * Fetches an M2M token from Auth0 through tc-core. * * @returns {Promise} Machine token string. - * @throws {InternalServerErrorException} When the client is not initialized, credentials are missing, or token retrieval fails. + * @throws {InternalServerErrorException} When credentials are missing or token retrieval fails. */ async getM2MToken(): Promise { - if (!this.m2mClient) { - throw new InternalServerErrorException('M2M client is not initialized.'); - } - const clientId = process.env.AUTH0_CLIENT_ID; const clientSecret = process.env.AUTH0_CLIENT_SECRET;