diff --git a/src/config/configuration.ts b/src/config/configuration.ts index dcfeee1..27fcdcf 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -30,12 +30,16 @@ export default () => ({ secrets: { jwt: process.env.JWT_SECRET, - gateway: process.env.GATEWAY_PUBLIC_SECRET, + drivePublicGateway: process.env.GATEWAY_PUBLIC_SECRET, + bridgePrivateGateway: process.env.BRIDGE_PRIVATE_GATEWAY_SECRET, }, apis: { payments: { url: process.env.PAYMENTS_API_URL ?? '', }, + bridge: { + url: process.env.BRIDGE_API_URL ?? '', + }, }, }); diff --git a/src/modules/email/email.service.spec.ts b/src/modules/email/email.service.spec.ts index 392cf68..8d97f71 100644 --- a/src/modules/email/email.service.spec.ts +++ b/src/modules/email/email.service.spec.ts @@ -14,6 +14,7 @@ import { newSearchEmailDto, newEncryptionBlock, newEncryptedWrappedKey, + newMailQuota, } from '../../../test/fixtures.js'; import { ENCRYPTED_PREFIX, packEnvelope } from './email-encryption.js'; @@ -362,4 +363,16 @@ describe('EmailService', () => { ); }); }); + + describe('getQuota', () => { + it('when called, then delegates to mail provider and returns result', async () => { + const quota = newMailQuota(); + provider.getQuota.mockResolvedValue(quota); + + const result = await service.getQuota(userEmail); + + expect(provider.getQuota).toHaveBeenCalledWith(userEmail); + expect(result).toBe(quota); + }); + }); }); diff --git a/src/modules/gateway/gateway-jwt.strategy.spec.ts b/src/modules/gateway/gateway-jwt.strategy.spec.ts index 2da6e28..989b35a 100644 --- a/src/modules/gateway/gateway-jwt.strategy.spec.ts +++ b/src/modules/gateway/gateway-jwt.strategy.spec.ts @@ -25,7 +25,9 @@ describe('GatewayJwtStrategy', () => { }); it('when constructed, then reads gateway secret from config', () => { - expect(configService.getOrThrow).toHaveBeenCalledWith('secrets.gateway'); + expect(configService.getOrThrow).toHaveBeenCalledWith( + 'secrets.drivePublicGateway', + ); }); it('when validate is called, then returns true', () => { diff --git a/src/modules/gateway/gateway-jwt.strategy.ts b/src/modules/gateway/gateway-jwt.strategy.ts index 990924a..e8b9e88 100644 --- a/src/modules/gateway/gateway-jwt.strategy.ts +++ b/src/modules/gateway/gateway-jwt.strategy.ts @@ -17,7 +17,7 @@ export class GatewayJwtStrategy extends PassportStrategy( jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: Buffer.from( - configService.getOrThrow('secrets.gateway'), + configService.getOrThrow('secrets.drivePublicGateway'), 'base64', ).toString('utf8'), algorithms: ['RS256'], diff --git a/src/modules/infrastructure/bridge/bridge.module.ts b/src/modules/infrastructure/bridge/bridge.module.ts new file mode 100644 index 0000000..757c017 --- /dev/null +++ b/src/modules/infrastructure/bridge/bridge.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { BridgeClient } from './bridge.service.js'; + +@Module({ + imports: [JwtModule.register({})], + providers: [BridgeClient], + exports: [BridgeClient], +}) +export class BridgeModule {} diff --git a/src/modules/infrastructure/bridge/bridge.service.spec.ts b/src/modules/infrastructure/bridge/bridge.service.spec.ts new file mode 100644 index 0000000..956dcad --- /dev/null +++ b/src/modules/infrastructure/bridge/bridge.service.spec.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { createMock, type DeepMocked } from '@golevelup/ts-vitest'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { BridgeClient, BridgeApiError } from './bridge.service.js'; + +describe('BridgeClient', () => { + let service: BridgeClient; + let jwtService: DeepMocked; + let httpRequest: ReturnType; + + beforeEach(async () => { + httpRequest = vi.fn(); + + const configService = createMock(); + configService.getOrThrow.mockImplementation((key: string) => { + if (key === 'apis.bridge.url') return 'http://bridge.test'; + if (key === 'secrets.bridgePrivateGateway') + return Buffer.from('test-key').toString('base64'); + if (key === 'isProduction') return false; + throw new Error(`unknown key: ${key}`); + }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BridgeClient, + { provide: ConfigService, useValue: configService }, + { provide: JwtService, useValue: createMock() }, + ], + }).compile(); + + service = module.get(BridgeClient); + jwtService = module.get(JwtService); + ( + service as unknown as { httpClient: { request: typeof httpRequest } } + ).httpClient = { + request: httpRequest, + }; + }); + + describe('reportMailUsage', () => { + it('when Bridge returns 200, then signs a gateway token, PUTs usage, and returns storage', async () => { + const storage = { driveUsed: 1024, planQuota: 5368709120 }; + jwtService.sign.mockReturnValue('signed-jwt'); + httpRequest.mockResolvedValue({ + statusCode: 200, + body: { text: () => Promise.resolve(JSON.stringify(storage)) }, + }); + + const result = await service.reportMailUsage('user-1', 512); + + expect(result).toStrictEqual(storage); + expect(jwtService.sign).toHaveBeenCalledWith( + { payload: { uuid: 'user-1' } }, + { + secret: 'test-key', + algorithm: 'RS256', + expiresIn: '1m', + allowInsecureKeySizes: true, + }, + ); + expect(httpRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'PUT', + path: '/v2/gateway/users/user-1/mail-usage', + body: JSON.stringify({ mailUsedBytes: 512 }), + headers: expect.objectContaining({ + authorization: 'Bearer signed-jwt', + }) as unknown, + }), + ); + }); + + it('when Bridge returns a non-200 status, then throws BridgeApiError with statusCode and details', async () => { + jwtService.sign.mockReturnValue('signed-jwt'); + httpRequest.mockResolvedValue({ + statusCode: 500, + body: { text: () => Promise.resolve('internal error') }, + }); + + const error: unknown = await service + .reportMailUsage('user-1', 512) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(BridgeApiError); + if (!(error instanceof BridgeApiError)) { + throw new Error('expected BridgeApiError'); + } + expect(error.statusCode).toBe(500); + expect(error.details).toBe('internal error'); + }); + + it('when the HTTP request throws, then the error propagates', async () => { + jwtService.sign.mockReturnValue('signed-jwt'); + httpRequest.mockRejectedValue(new Error('network failure')); + + await expect(service.reportMailUsage('user-1', 512)).rejects.toThrow( + 'network failure', + ); + }); + }); +}); diff --git a/src/modules/infrastructure/bridge/bridge.service.ts b/src/modules/infrastructure/bridge/bridge.service.ts new file mode 100644 index 0000000..aad8d4f --- /dev/null +++ b/src/modules/infrastructure/bridge/bridge.service.ts @@ -0,0 +1,103 @@ +import { + Injectable, + Logger, + type OnModuleDestroy, + type OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { Client } from 'undici'; +import type { UserStorage } from './bridge.types.js'; + +@Injectable() +export class BridgeClient implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(BridgeClient.name); + private readonly baseUrl: string; + private readonly origin: string; + private readonly basePath: string; + private readonly signingKey: string; + private readonly isProduction: boolean; + private httpClient!: Client; + + constructor( + private readonly configService: ConfigService, + private readonly jwtService: JwtService, + ) { + this.baseUrl = this.configService.getOrThrow('apis.bridge.url'); + this.signingKey = Buffer.from( + this.configService.getOrThrow('secrets.bridgePrivateGateway'), + 'base64', + ).toString('utf8'); + this.isProduction = this.configService.getOrThrow('isProduction'); + const parsed = new URL(this.baseUrl); + this.origin = parsed.origin; + this.basePath = + parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/$/, ''); + } + + onModuleInit() { + this.httpClient = new Client(this.origin, { + allowH2: true, + keepAliveTimeout: 30_000, + pipelining: 1, + }); + this.logger.log(`Bridge client initialized targeting ${this.baseUrl}`); + } + + async onModuleDestroy() { + await this.httpClient.close(); + } + + async reportMailUsage( + userUuid: string, + mailUsedBytes: number, + ): Promise { + const token = this.signGatewayToken(userUuid); + + const { statusCode, body } = await this.httpClient.request({ + method: 'PUT', + path: `${this.basePath}/v2/gateway/users/${encodeURIComponent(userUuid)}/mail-usage`, + headers: { + 'content-type': 'application/json', + accept: 'application/json', + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ mailUsedBytes }), + }); + + const text = await body.text(); + + if (statusCode !== 200) { + throw new BridgeApiError( + `Failed to report mail usage for user '${userUuid}': HTTP ${statusCode}`, + statusCode, + text, + ); + } + + return JSON.parse(text) as UserStorage; + } + + private signGatewayToken(userUuid: string): string { + return this.jwtService.sign( + { payload: { uuid: userUuid } }, + { + secret: this.signingKey, + algorithm: 'RS256', + expiresIn: '1m', + ...(this.isProduction ? null : { allowInsecureKeySizes: true }), + }, + ); + } +} + +export class BridgeApiError extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly details: string, + ) { + super(message); + this.name = 'BridgeApiError'; + } +} diff --git a/src/modules/infrastructure/bridge/bridge.types.ts b/src/modules/infrastructure/bridge/bridge.types.ts new file mode 100644 index 0000000..5468bef --- /dev/null +++ b/src/modules/infrastructure/bridge/bridge.types.ts @@ -0,0 +1,4 @@ +export interface UserStorage { + driveUsed: number; + planQuota: number; +} diff --git a/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts b/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts index 8c2a4f9..e4ce83a 100644 --- a/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts +++ b/src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts @@ -3,7 +3,7 @@ import { Readable } from 'node:stream'; import { Test, type TestingModule } from '@nestjs/testing'; import { createMock, type DeepMocked } from '@golevelup/ts-vitest'; import { JmapMailProvider } from './jmap-mail.provider.js'; -import { JmapService } from './jmap.service.js'; +import { JmapService, JMAP_QUOTA_CAPABILITIES } from './jmap.service.js'; import { newJmapMailbox, newJmapEmail, @@ -587,12 +587,17 @@ describe('JmapMailProvider', () => { }); describe('getQuota', () => { - it('when quota exists, then returns used and limit from octets quota', async () => { + it('when quota exists, then calls Quota/get with quota capabilities and returns mapped result', async () => { const quota = newJmapQuota({ used: 500_000, hardLimit: 1_000_000 }); jmapService.request.mockResolvedValue(jmapResponse({ list: [quota] })); const result = await provider.getQuota('user@test.com'); + expect(jmapService.request).toHaveBeenCalledWith( + 'user@test.com', + [['Quota/get', { accountId }, 'r0']], + JMAP_QUOTA_CAPABILITIES, + ); expect(result).toEqual({ used: 500_000, limit: 1_000_000 }); }); diff --git a/src/modules/infrastructure/jmap/jmap.service.spec.ts b/src/modules/infrastructure/jmap/jmap.service.spec.ts index 926757e..eb2c51f 100644 --- a/src/modules/infrastructure/jmap/jmap.service.spec.ts +++ b/src/modules/infrastructure/jmap/jmap.service.spec.ts @@ -72,7 +72,11 @@ describe('JMAP service', () => { const result = await service.uploadAttachment({ userEmail, - blob: { buffer: Buffer.from('binary'), mimeType: 'image/jpeg' }, + blob: { + name: 'image.jpg', + buffer: Buffer.from('binary'), + mimeType: 'image/jpeg', + }, }); expect(result).toEqual({