Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,16 @@ export default () => ({

secrets: {
jwt: process.env.JWT_SECRET,
gateway: process.env.GATEWAY_PUBLIC_SECRET,
drivePublicGateway: process.env.GATEWAY_PUBLIC_SECRET,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be better to use, e.g mailPublicGateway rather than drive...?

bridgePrivateGateway: process.env.BRIDGE_PRIVATE_GATEWAY_SECRET,
},

apis: {
payments: {
url: process.env.PAYMENTS_API_URL ?? '',
},
bridge: {
url: process.env.BRIDGE_API_URL ?? '',
},
},
});
13 changes: 13 additions & 0 deletions src/modules/email/email.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
newSearchEmailDto,
newEncryptionBlock,
newEncryptedWrappedKey,
newMailQuota,
} from '../../../test/fixtures.js';
import { ENCRYPTED_PREFIX, packEnvelope } from './email-encryption.js';

Expand Down Expand Up @@ -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);
});
});
});
4 changes: 3 additions & 1 deletion src/modules/gateway/gateway-jwt.strategy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/modules/gateway/gateway-jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export class GatewayJwtStrategy extends PassportStrategy(
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: Buffer.from(
configService.getOrThrow<string>('secrets.gateway'),
configService.getOrThrow<string>('secrets.drivePublicGateway'),
'base64',
).toString('utf8'),
algorithms: ['RS256'],
Expand Down
10 changes: 10 additions & 0 deletions src/modules/infrastructure/bridge/bridge.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
103 changes: 103 additions & 0 deletions src/modules/infrastructure/bridge/bridge.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<JwtService>;
let httpRequest: ReturnType<typeof vi.fn>;

beforeEach(async () => {
httpRequest = vi.fn();

const configService = createMock<ConfigService>();
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<JwtService>() },
],
}).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',
);
});
});
});
103 changes: 103 additions & 0 deletions src/modules/infrastructure/bridge/bridge.service.ts
Original file line number Diff line number Diff line change
@@ -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<string>('apis.bridge.url');
this.signingKey = Buffer.from(
this.configService.getOrThrow<string>('secrets.bridgePrivateGateway'),
'base64',
).toString('utf8');
this.isProduction = this.configService.getOrThrow<boolean>('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<UserStorage> {
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';
}
}
4 changes: 4 additions & 0 deletions src/modules/infrastructure/bridge/bridge.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface UserStorage {
driveUsed: number;
planQuota: number;
}
9 changes: 7 additions & 2 deletions src/modules/infrastructure/jmap/jmap-mail.provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
});

Expand Down
6 changes: 5 additions & 1 deletion src/modules/infrastructure/jmap/jmap.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading