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
85 changes: 85 additions & 0 deletions src/modules/account/account.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { createMock, type DeepMocked } from '@golevelup/ts-vitest';
import {
ConflictException,
ForbiddenException,
NotFoundException,
UnprocessableEntityException,
} from '@nestjs/common';
Expand All @@ -17,6 +18,8 @@ import { AddressRepository } from './repositories/address.repository.js';
import { DomainRepository } from './repositories/domain.repository.js';
import { MailAddressKeysRepository } from './repositories/mail-address-keys.repository.js';
import { BridgeClient } from '../infrastructure/bridge/bridge.service.js';
import { PaymentsService } from '../infrastructure/payments/payments.service.js';
import type { Tier } from '../infrastructure/payments/payments.types.js';
import {
newMailAccountAttributes,
newMailAddressKeyBundle,
Expand All @@ -27,6 +30,18 @@ import {
import { MailAddressKeys } from './domain/mail-address-keys.domain.js';
import { MailNotSetupException } from '../provisioning/mail-not-setup.exception.js';

const PLAN_BYTES = 1_000_000_000;
const validTier: Tier = {
id: 't1',
label: 'standard',
productId: 'p1',
billingType: 'monthly',
featuresPerService: {
mail: { enabled: true, addressesPerUser: 3 },
drive: { enabled: true, maxSpaceBytes: PLAN_BYTES },
},
};

describe('AccountService', () => {
let service: AccountService;
let provider: DeepMocked<AccountProvider>;
Expand All @@ -35,6 +50,7 @@ describe('AccountService', () => {
let domains: DeepMocked<DomainRepository>;
let keys: DeepMocked<MailAddressKeysRepository>;
let bridge: DeepMocked<BridgeClient>;
let payments: DeepMocked<PaymentsService>;
let config: DeepMocked<ConfigService>;

beforeEach(async () => {
Expand All @@ -51,6 +67,7 @@ describe('AccountService', () => {
domains = module.get(DomainRepository);
keys = module.get(MailAddressKeysRepository);
bridge = module.get(BridgeClient);
payments = module.get(PaymentsService);
config = module.get(ConfigService);
});

Expand Down Expand Up @@ -343,6 +360,10 @@ describe('AccountService', () => {
keys: newMailAddressKeyBundle(),
};

beforeEach(() => {
payments.getUserTier.mockResolvedValue(validTier);
});

it('when all inputs are valid, then creates account, address, provider link, and stalwart principal', async () => {
const createdAccount = MailAccount.build(
newMailAccountAttributes({
Expand Down Expand Up @@ -413,6 +434,7 @@ describe('AccountService', () => {
accountId: createdAccount.id,
primaryAddress: params.address,
displayName: params.displayName,
quota: PLAN_BYTES,
}),
);
expect(keys.create).toHaveBeenCalledWith({
Expand All @@ -421,6 +443,42 @@ describe('AccountService', () => {
});
});

it('when the plan does not include mail, then throws ForbiddenException and creates nothing', async () => {
payments.getUserTier.mockResolvedValue({
...validTier,
featuresPerService: {
drive: { enabled: true, maxSpaceBytes: PLAN_BYTES },
},
});
domains.findByDomain.mockResolvedValue(domain);
addresses.findByAddress.mockResolvedValue(null);
accounts.findByUserId.mockResolvedValue(null);

await expect(service.provisionAccount(params)).rejects.toThrow(
ForbiddenException,
);
expect(accounts.create).not.toHaveBeenCalled();
expect(provider.createAccount).not.toHaveBeenCalled();
});

it('when the plan has no drive storage allowance, then throws and never provisions an unlimited principal', async () => {
payments.getUserTier.mockResolvedValue({
...validTier,
featuresPerService: {
mail: { enabled: true, addressesPerUser: 3 },
},
});
domains.findByDomain.mockResolvedValue(domain);
addresses.findByAddress.mockResolvedValue(null);
accounts.findByUserId.mockResolvedValue(null);

await expect(service.provisionAccount(params)).rejects.toThrow(
UnprocessableEntityException,
);
expect(accounts.create).not.toHaveBeenCalled();
expect(provider.createAccount).not.toHaveBeenCalled();
});

it('when domain does not exist, then throws NotFoundException', async () => {
domains.findByDomain.mockResolvedValue(null);
addresses.findByAddress.mockResolvedValue(null);
Expand Down Expand Up @@ -628,6 +686,10 @@ describe('AccountService', () => {
});

describe('addAddress', () => {
beforeEach(() => {
payments.getUserTier.mockResolvedValue(validTier);
});

it('when all conditions met, then creates principal and links provider', async () => {
const accountAttrs = newMailAccountAttributes();
const account = MailAccount.build(accountAttrs);
Expand Down Expand Up @@ -669,6 +731,7 @@ describe('AccountService', () => {
primaryAddress: newAddr,
displayName: 'Display Name',
password: 'password123',
quota: PLAN_BYTES,
});
expect(addresses.createProviderLink).toHaveBeenCalledWith({
mailAddressId: newAddressId,
Expand All @@ -686,6 +749,28 @@ describe('AccountService', () => {
);
});

it('when the plan has no drive storage allowance, then throws and never provisions an unlimited principal', async () => {
payments.getUserTier.mockResolvedValue({
...validTier,
featuresPerService: {
mail: { enabled: true, addressesPerUser: 3 },
},
});
accounts.findByUserId.mockResolvedValue(
MailAccount.build(newMailAccountAttributes()),
);
domains.findByDomain.mockResolvedValue(
MailDomain.build(newMailDomainAttributes()),
);
addresses.findByAddress.mockResolvedValue(null);

await expect(
service.addAddress('user-uuid-1', 'a@b.com', 'b.com', 'pass'),
).rejects.toThrow(UnprocessableEntityException);
expect(addresses.create).not.toHaveBeenCalled();
expect(provider.createAccount).not.toHaveBeenCalled();
});

it('when bucket creation fails, then rolls back principal, link, and address', async () => {
const account = MailAccount.build(newMailAccountAttributes());
const domain = MailDomain.build(newMailDomainAttributes());
Expand Down
41 changes: 34 additions & 7 deletions src/modules/account/account.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { randomBytes } from 'node:crypto';
import {
ConflictException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
Expand All @@ -9,6 +10,7 @@ import {
import { ConfigService } from '@nestjs/config';
import dayjs from 'dayjs';
import { BridgeClient } from '../infrastructure/bridge/bridge.service.js';
import { PaymentsService } from '../infrastructure/payments/payments.service.js';
import { MailNotSetupException } from '../provisioning/mail-not-setup.exception.js';
import { AccountProvider } from './account-provider.port.js';
import type { CreateAccountResult } from './account.types.js';
Expand Down Expand Up @@ -48,6 +50,7 @@ export class AccountService {
private readonly domains: DomainRepository,
private readonly keys: MailAddressKeysRepository,
private readonly bridge: BridgeClient,
private readonly payments: PaymentsService,
private readonly config: ConfigService,
) {}

Expand Down Expand Up @@ -167,12 +170,19 @@ export class AccountService {
displayName: string;
keys: MailAddressKeyBundle;
}): Promise<MailAccount> {
const [domainRecord, existingAddress, existingAccount] = await Promise.all([
this.domains.findByDomain(params.domain),
this.addresses.findByAddress(params.address),
this.accounts.findByUserId(params.userId),
]);

const [tier, domainRecord, existingAddress, existingAccount] =
await Promise.all([
this.payments.getUserTier(params.userId),
this.domains.findByDomain(params.domain),
this.addresses.findByAddress(params.address),
this.accounts.findByUserId(params.userId),
]);

if (!tier.featuresPerService.mail?.enabled) {
throw new ForbiddenException(
'Mail access is not available for your current plan',
);
}
if (!domainRecord) {
throw new NotFoundException(`Domain '${params.domain}' not found`);
}
Expand All @@ -185,6 +195,13 @@ export class AccountService {
);
}

const quota = tier.featuresPerService.drive?.maxSpaceBytes;
if (!quota || quota <= 0) {
throw new UnprocessableEntityException(
`Cannot provision mail account for '${params.userId}': plan has no drive storage allowance`,
);
}

let account: MailAccount;
try {
account = await this.accounts.create({
Expand Down Expand Up @@ -224,6 +241,7 @@ export class AccountService {
primaryAddress: params.address,
displayName: params.displayName,
password,
quota,
});
} catch (error) {
await this.accounts.delete(account.id);
Expand Down Expand Up @@ -283,7 +301,8 @@ export class AccountService {
password: string,
displayName?: string,
): Promise<void> {
const [account, domain, existing] = await Promise.all([
const [tier, account, domain, existing] = await Promise.all([
this.payments.getUserTier(userId),
this.accounts.findByUserId(userId),
this.domains.findByDomain(domainName),
this.addresses.findByAddress(address),
Expand All @@ -299,6 +318,13 @@ export class AccountService {
throw new ConflictException(`Address '${address}' already exists`);
}

const quota = tier.featuresPerService.drive?.maxSpaceBytes;
if (!quota || quota <= 0) {
throw new UnprocessableEntityException(
`Cannot add address for '${userId}': plan has no drive storage allowance`,
);
}

const newAddressId = await this.addresses.create({
mailAccountId: account.id,
address,
Expand All @@ -313,6 +339,7 @@ export class AccountService {
primaryAddress: address,
displayName: displayName ?? '',
password,
quota,
});
} catch (error) {
await this.addresses.delete(newAddressId);
Expand Down
28 changes: 1 addition & 27 deletions src/modules/account/user.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,19 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import { createMock, type DeepMocked } from '@golevelup/ts-vitest';
import { ForbiddenException } from '@nestjs/common';
import { UserController } from './user.controller.js';
import { AccountService, type MailAccountStatus } from './account.service.js';
import { MailAccountState, MailAccount } from './domain/mail-account.domain.js';
import { PaymentsService } from '../infrastructure/payments/payments.service.js';
import {
newMailAccountAttributes,
newMailAddressKeyBundle,
newUserPayload,
} from '../../../test/fixtures.js';
import type { CreateMailAccountDto } from './dto/create-mail-account.dto.js';
import type { Tier } from '../infrastructure/payments/payments.types.js';

const tierWith = (mailEnabled: boolean): Tier => ({
id: 't1',
label: 'ultimate',
productId: 'p1',
billingType: 'monthly',
featuresPerService: {
mail: { enabled: mailEnabled, addressesPerUser: 3 },
},
});

describe('UserController', () => {
let controller: UserController;
let accountService: DeepMocked<AccountService>;
let payments: DeepMocked<PaymentsService>;

const buildDto = (): CreateMailAccountDto => ({
address: 'alice',
Expand All @@ -45,7 +31,6 @@ describe('UserController', () => {

controller = module.get(UserController);
accountService = module.get(AccountService);
payments = module.get(PaymentsService);
});

describe('getMailAccount', () => {
Expand Down Expand Up @@ -127,23 +112,12 @@ describe('UserController', () => {
});

describe('createMailAccount', () => {
it('when tier disables mail, then throws ForbiddenException', async () => {
const user = newUserPayload();
payments.getUserTier.mockResolvedValue(tierWith(false));

await expect(
controller.createMailAccount(user, buildDto()),
).rejects.toThrow(ForbiddenException);
expect(accountService.provisionAccount).not.toHaveBeenCalled();
});

it('when all checks pass, then provisions and returns address', async () => {
it('when provisioning succeeds, then returns the address', async () => {
const user = newUserPayload();
const dto = buildDto();
const account = MailAccount.build(
newMailAccountAttributes({ userId: user.uuid }),
);
payments.getUserTier.mockResolvedValue(tierWith(true));
accountService.provisionAccount.mockResolvedValue(account);

const result = await controller.createMailAccount(user, dto);
Expand Down
14 changes: 1 addition & 13 deletions src/modules/account/user.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
Body,
Controller,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
Expand All @@ -23,7 +22,6 @@ import {
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';
import { AccountService } from './account.service.js';
import { CreateMailAccountDto } from './dto/create-mail-account.dto.js';
import { MailAccountGuard } from '../provisioning/provisioning.guard.js';
Expand All @@ -40,10 +38,7 @@ import {
export class UserController {
private readonly logger = new Logger(UserController.name);

constructor(
private readonly accountService: AccountService,
private readonly payments: PaymentsService,
) {}
constructor(private readonly accountService: AccountService) {}

@Get('me/mail-account')
@UseGuards(MailAccountGuard)
Expand Down Expand Up @@ -75,13 +70,6 @@ export class UserController {
@User() user: UserPayload,
@Body() dto: CreateMailAccountDto,
): Promise<CreateMailAccountResponseDto> {
const tier = await this.payments.getUserTier(user.uuid);
if (!tier.featuresPerService.mail?.enabled) {
throw new ForbiddenException(
'Mail access is not available for your current plan',
);
}

const fullAddress = `${dto.address}@${dto.domain}`;

const account = await this.accountService.provisionAccount({
Expand Down
Loading