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
70 changes: 70 additions & 0 deletions src/modules/account/account.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ import {
newMailAccountAttributes,
newMailAddressKeyBundle,
newMailAddressAttributes,
newMailAddressKeysAttributes,
newMailDomainAttributes,
} from '../../../test/fixtures.js';
import { MailAddressKeys } from './domain/mail-address-keys.domain.js';
import { MailNotSetupException } from '../provisioning/mail-not-setup.exception.js';

describe('AccountService', () => {
let service: AccountService;
Expand Down Expand Up @@ -88,6 +91,73 @@ describe('AccountService', () => {
});
});

describe('getAddressKeys', () => {
it('when address belongs to user, then returns the key bundle', async () => {
const addr = newMailAddressAttributes();
const account = MailAccount.build(
newMailAccountAttributes({ addresses: [addr] }),
);
const keysAttrs = newMailAddressKeysAttributes({
mailAddressId: addr.id,
});
accounts.findByUserId.mockResolvedValue(account);
keys.findByAddressId.mockResolvedValue(MailAddressKeys.build(keysAttrs));

const result = await service.getAddressKeys(account.userId, addr.address);

expect(keys.findByAddressId).toHaveBeenCalledWith(addr.id);
expect(result).toStrictEqual({
address: addr.address,
publicKey: keysAttrs.publicKey,
encryptionPrivateKey: keysAttrs.encryptionPrivateKey,
recoveryPrivateKey: keysAttrs.recoveryPrivateKey,
salt: keysAttrs.salt,
});
});

it('when account does not exist, then throws MailNotSetupException', async () => {
accounts.findByUserId.mockResolvedValue(null);

await expect(
service.getAddressKeys('unknown', 'a@b.com'),
).rejects.toThrow(MailNotSetupException);
});

it('when account has no addresses, then throws MailNotSetupException', async () => {
const account = MailAccount.build(
newMailAccountAttributes({ addresses: [] }),
);
accounts.findByUserId.mockResolvedValue(account);

await expect(
service.getAddressKeys(account.userId, 'a@b.com'),
).rejects.toThrow(MailNotSetupException);
});

it('when address is not on the account, then throws NotFoundException', async () => {
const account = MailAccount.build(newMailAccountAttributes());
accounts.findByUserId.mockResolvedValue(account);

await expect(
service.getAddressKeys(account.userId, 'someone-else@example.com'),
).rejects.toThrow(NotFoundException);
expect(keys.findByAddressId).not.toHaveBeenCalled();
});

it('when keys are missing for the address, then throws NotFoundException', async () => {
const addr = newMailAddressAttributes();
const account = MailAccount.build(
newMailAccountAttributes({ addresses: [addr] }),
);
accounts.findByUserId.mockResolvedValue(account);
keys.findByAddressId.mockResolvedValue(null);

await expect(
service.getAddressKeys(account.userId, addr.address),
).rejects.toThrow(NotFoundException);
});
});

describe('findAccount', () => {
it('when account exists, then returns it', async () => {
const account = MailAccount.build(newMailAccountAttributes());
Expand Down
33 changes: 33 additions & 0 deletions src/modules/account/account.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
NotFoundException,
UnprocessableEntityException,
} from '@nestjs/common';
import { MailNotSetupException } from '../provisioning/mail-not-setup.exception.js';
import { AccountProvider } from './account-provider.port.js';
import { MailAccount } from './domain/mail-account.domain.js';
import { MailDomain } from './domain/mail-domain.domain.js';
Expand Down Expand Up @@ -49,6 +50,38 @@ export class AccountService {
return this.addresses.findUserIdByAddress(address);
}

async getAddressKeys(
userId: string,
address: string,
): Promise<MailAddressKeyBundle & { address: string }> {
const account = await this.accounts.findByUserId(userId);
if (!account || account.addresses.length === 0) {
throw new MailNotSetupException();
}

const addressRecord = account.addresses.find((a) => a.address === address);
if (!addressRecord) {
throw new NotFoundException(
`Address '${address}' not found for this account`,
);
}

const keys = await this.keys.findByAddressId(addressRecord.id);
if (!keys) {
throw new NotFoundException(
`No encryption keys for address '${address}'`,
);
}

return {
address: addressRecord.address,
publicKey: keys.publicKey,
encryptionPrivateKey: keys.encryptionPrivateKey,
recoveryPrivateKey: keys.recoveryPrivateKey,
salt: keys.salt,
};
}

async provisionAccount(params: {
userId: string;
address: string;
Expand Down
8 changes: 8 additions & 0 deletions src/modules/account/dto/get-mail-account-keys.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail } from 'class-validator';

export class GetMailAccountKeysDto {
@ApiProperty({ example: 'alice@inxt.eu' })
@IsEmail()
address!: string;
}
18 changes: 18 additions & 0 deletions src/modules/account/user.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,24 @@ describe('UserController', () => {
payments = module.get(PaymentsService);
});

describe('getMailAccountKeys', () => {
it('when called, then forwards to AccountService and returns the bundle', async () => {
const user = newUserPayload();
const bundle = { address: 'alice@inxt.eu', ...newMailAddressKeyBundle() };
accountService.getAddressKeys.mockResolvedValue(bundle);

const result = await controller.getMailAccountKeys(user, {
address: bundle.address,
});

expect(accountService.getAddressKeys).toHaveBeenCalledWith(
user.uuid,
bundle.address,
);
expect(result).toBe(bundle);
});
});

describe('createMailAccount', () => {
it('when tier disables mail, then throws ForbiddenException', async () => {
const user = newUserPayload();
Expand Down
14 changes: 14 additions & 0 deletions src/modules/account/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import {
Body,
Controller,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
Logger,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { GetMailAccountKeysDto } from './dto/get-mail-account-keys.dto.js';
import { User } from '../auth/decorators/user.decorator.js';
import type { UserPayload } from '../auth/jwt-payload.dto.js';
import { PaymentsService } from '../infrastructure/payments/payments.service.js';
Expand Down Expand Up @@ -64,4 +67,15 @@ export class UserController {
domain: dto.domain,
};
}

@Get('me/mail-account/keys')
@ApiOperation({
summary: 'Get encryption keys and salt for one of the caller`s addresses',
})
async getMailAccountKeys(
@User() user: UserPayload,
@Query() query: GetMailAccountKeysDto,
) {
return this.accountService.getAddressKeys(user.uuid, query.address);
}
}
13 changes: 13 additions & 0 deletions src/modules/provisioning/mail-not-setup.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ForbiddenException } from '@nestjs/common';

export const MAIL_NOT_SETUP_CODE = 'MAIL_NOT_SETUP';

export class MailNotSetupException extends ForbiddenException {
constructor() {
super({
statusCode: 403,
code: MAIL_NOT_SETUP_CODE,
message: 'Mail account has not been set up',
});
}
}
8 changes: 2 additions & 6 deletions src/modules/provisioning/provisioning.guard.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import {
type CanActivate,
type ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import type { Request } from 'express';
import { AccountService } from '../account/account.service.js';
import type { UserPayload } from '../auth/jwt-payload.dto.js';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../auth/decorators/public.decorator.js';
import { MailNotSetupException } from './mail-not-setup.exception.js';
import { SKIP_MAIL_ACCOUNT_CHECK_KEY } from './skip-mail-account-check.decorator.js';

@Injectable()
Expand Down Expand Up @@ -41,11 +41,7 @@ export class MailAccountGuard implements CanActivate {
const account = await this.accountService.findAccount(user.uuid);

if (!account) {
throw new ForbiddenException({
statusCode: 403,
code: 'MAIL_NOT_SETUP',
message: 'Mail account has not been set up',
});
throw new MailNotSetupException();
}

return true;
Expand Down
Loading