From 1ea1512b8f0b23b7e032546c0b290be34f583cdb Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:50:38 -0600 Subject: [PATCH 1/2] fix: set quota during user account provisioning --- src/modules/account/account.service.spec.ts | 57 +++++++++++++++++++++ src/modules/account/account.service.ts | 30 ++++++++--- src/modules/account/user.controller.spec.ts | 28 +--------- src/modules/account/user.controller.ts | 14 +---- 4 files changed, 83 insertions(+), 46 deletions(-) diff --git a/src/modules/account/account.service.spec.ts b/src/modules/account/account.service.spec.ts index 242db28..ff62fcc 100644 --- a/src/modules/account/account.service.spec.ts +++ b/src/modules/account/account.service.spec.ts @@ -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'; @@ -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, @@ -35,6 +38,7 @@ describe('AccountService', () => { let domains: DeepMocked; let keys: DeepMocked; let bridge: DeepMocked; + let payments: DeepMocked; let config: DeepMocked; beforeEach(async () => { @@ -51,6 +55,7 @@ describe('AccountService', () => { domains = module.get(DomainRepository); keys = module.get(MailAddressKeysRepository); bridge = module.get(BridgeClient); + payments = module.get(PaymentsService); config = module.get(ConfigService); }); @@ -342,6 +347,21 @@ describe('AccountService', () => { displayName: 'Alice Smith', keys: newMailAddressKeyBundle(), }; + 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 }, + }, + }; + + 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( @@ -413,6 +433,7 @@ describe('AccountService', () => { accountId: createdAccount.id, primaryAddress: params.address, displayName: params.displayName, + quota: PLAN_BYTES, }), ); expect(keys.create).toHaveBeenCalledWith({ @@ -421,6 +442,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); diff --git a/src/modules/account/account.service.ts b/src/modules/account/account.service.ts index b877420..7c62fbd 100644 --- a/src/modules/account/account.service.ts +++ b/src/modules/account/account.service.ts @@ -1,6 +1,7 @@ import { randomBytes } from 'node:crypto'; import { ConflictException, + ForbiddenException, Injectable, Logger, NotFoundException, @@ -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'; @@ -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, ) {} @@ -167,12 +170,19 @@ export class AccountService { displayName: string; keys: MailAddressKeyBundle; }): Promise { - 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`); } @@ -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({ @@ -224,6 +241,7 @@ export class AccountService { primaryAddress: params.address, displayName: params.displayName, password, + quota, }); } catch (error) { await this.accounts.delete(account.id); diff --git a/src/modules/account/user.controller.spec.ts b/src/modules/account/user.controller.spec.ts index 16c557e..ae8ccb0 100644 --- a/src/modules/account/user.controller.spec.ts +++ b/src/modules/account/user.controller.spec.ts @@ -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; - let payments: DeepMocked; const buildDto = (): CreateMailAccountDto => ({ address: 'alice', @@ -45,7 +31,6 @@ describe('UserController', () => { controller = module.get(UserController); accountService = module.get(AccountService); - payments = module.get(PaymentsService); }); describe('getMailAccount', () => { @@ -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); diff --git a/src/modules/account/user.controller.ts b/src/modules/account/user.controller.ts index d6b3f31..ac174c3 100644 --- a/src/modules/account/user.controller.ts +++ b/src/modules/account/user.controller.ts @@ -1,7 +1,6 @@ import { Body, Controller, - ForbiddenException, Get, HttpCode, HttpStatus, @@ -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'; @@ -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) @@ -75,13 +70,6 @@ export class UserController { @User() user: UserPayload, @Body() dto: CreateMailAccountDto, ): Promise { - 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({ From 6b224cdc01e28b4210751fa331b369ed3dbf4c14 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:19:38 -0600 Subject: [PATCH 2/2] fix: enforce drive storage allowance during address addition --- src/modules/account/account.service.spec.ts | 50 ++++++++++++++++----- src/modules/account/account.service.ts | 11 ++++- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/modules/account/account.service.spec.ts b/src/modules/account/account.service.spec.ts index ff62fcc..4e3b916 100644 --- a/src/modules/account/account.service.spec.ts +++ b/src/modules/account/account.service.spec.ts @@ -30,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; @@ -347,17 +359,6 @@ describe('AccountService', () => { displayName: 'Alice Smith', keys: newMailAddressKeyBundle(), }; - 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 }, - }, - }; beforeEach(() => { payments.getUserTier.mockResolvedValue(validTier); @@ -685,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); @@ -726,6 +731,7 @@ describe('AccountService', () => { primaryAddress: newAddr, displayName: 'Display Name', password: 'password123', + quota: PLAN_BYTES, }); expect(addresses.createProviderLink).toHaveBeenCalledWith({ mailAddressId: newAddressId, @@ -743,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()); diff --git a/src/modules/account/account.service.ts b/src/modules/account/account.service.ts index 7c62fbd..8299acb 100644 --- a/src/modules/account/account.service.ts +++ b/src/modules/account/account.service.ts @@ -301,7 +301,8 @@ export class AccountService { password: string, displayName?: string, ): Promise { - 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), @@ -317,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, @@ -331,6 +339,7 @@ export class AccountService { primaryAddress: address, displayName: displayName ?? '', password, + quota, }); } catch (error) { await this.addresses.delete(newAddressId);