mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
c316e52e65
Allow manage_users administrators to manage user content filters and allow manage_email administrators to share providers they own. Preserve provider ownership and keep system mail designation restricted to superusers. Fixes #1401
273 lines
10 KiB
TypeScript
273 lines
10 KiB
TypeScript
import { BadRequestException, ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
|
import { EmailProviderService } from './email-provider.service';
|
|
import { EmailProviderRepository } from './email-provider.repository';
|
|
import { EmailEncryptionService } from './email-encryption.service';
|
|
import { EmailTransportService } from './email-transport.service';
|
|
import type { RequestUser } from '../../common/types/request-user';
|
|
import { EMPTY_CONTENT_FILTER_RULES, Permission } from '@bookorbit/types';
|
|
|
|
describe('EmailProviderService', () => {
|
|
let service: EmailProviderService;
|
|
let repo: EmailProviderRepository;
|
|
let encryption: EmailEncryptionService;
|
|
let transport: EmailTransportService;
|
|
|
|
const mockUser: RequestUser = {
|
|
id: 1,
|
|
username: 'testuser',
|
|
name: 'Test User',
|
|
email: 'test@example.com',
|
|
active: true,
|
|
isSuperuser: false,
|
|
isDefaultPassword: false,
|
|
tokenVersion: 1,
|
|
settings: {},
|
|
avatarUrl: null,
|
|
provisioningMethod: 'manual',
|
|
permissions: [],
|
|
|
|
contentFilters: EMPTY_CONTENT_FILTER_RULES,
|
|
};
|
|
|
|
const mockAdmin: RequestUser = {
|
|
...mockUser,
|
|
isSuperuser: true,
|
|
|
|
contentFilters: EMPTY_CONTENT_FILTER_RULES,
|
|
};
|
|
|
|
const mockEmailManager: RequestUser = {
|
|
...mockUser,
|
|
permissions: [Permission.ManageEmail],
|
|
};
|
|
|
|
const mockProvider = {
|
|
id: 10,
|
|
userId: 1,
|
|
name: 'Test SMTP',
|
|
host: 'smtp.test.com',
|
|
port: 587,
|
|
username: 'testuser',
|
|
passwordEnc: 'encrypted',
|
|
isShared: false,
|
|
isSystemProvider: false,
|
|
auth: true,
|
|
ssl: false,
|
|
startTls: true,
|
|
tlsRejectUnauthorized: true,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
repo = {
|
|
findAllForUser: vi.fn().mockResolvedValue([mockProvider]),
|
|
findById: vi.fn().mockResolvedValue([mockProvider]),
|
|
insert: vi.fn().mockResolvedValue([mockProvider]),
|
|
update: vi.fn().mockResolvedValue([mockProvider]),
|
|
delete: vi.fn(),
|
|
clearDefault: vi.fn(),
|
|
setDefault: vi.fn().mockResolvedValue([mockProvider]),
|
|
setSharedByOwner: vi.fn().mockResolvedValue([{ ...mockProvider, isShared: true }]),
|
|
setSystemProvider: vi.fn().mockResolvedValue({ ...mockProvider, isSystemProvider: true }),
|
|
clearSystemProvider: vi.fn(),
|
|
} as unknown as EmailProviderRepository;
|
|
|
|
encryption = {
|
|
encrypt: vi.fn().mockReturnValue('encrypted'),
|
|
decrypt: vi.fn().mockReturnValue('decrypted'),
|
|
} as unknown as EmailEncryptionService;
|
|
|
|
transport = {
|
|
buildTransporter: vi.fn(),
|
|
verifyTransporter: vi.fn(),
|
|
} as unknown as EmailTransportService;
|
|
|
|
service = new EmailProviderService(repo, encryption, transport);
|
|
});
|
|
|
|
describe('findAll', () => {
|
|
it('should return all providers for user sanitized', async () => {
|
|
const result = await service.findAll(mockUser);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).not.toHaveProperty('passwordEnc');
|
|
expect(result[0]).toHaveProperty('hasPassword', true);
|
|
});
|
|
});
|
|
|
|
describe('create', () => {
|
|
it('should encrypt password and insert provider', async () => {
|
|
const dto = {
|
|
name: 'New SMTP',
|
|
host: 'new.test.com',
|
|
port: 465,
|
|
password: 'new-password',
|
|
auth: true,
|
|
ssl: true,
|
|
startTls: false,
|
|
tlsRejectUnauthorized: true,
|
|
};
|
|
await service.create(dto, mockUser);
|
|
expect(encryption.encrypt).toHaveBeenCalledWith('new-password');
|
|
expect(repo.insert).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should persist tlsRejectUnauthorized=false when provided', async () => {
|
|
const dto = { name: 'Self-Hosted', host: 'smtp.home.lan', port: 465, auth: true, ssl: true, startTls: false, tlsRejectUnauthorized: false };
|
|
await service.create(dto, mockUser);
|
|
expect(repo.insert).toHaveBeenCalledWith(expect.objectContaining({ tlsRejectUnauthorized: false }));
|
|
});
|
|
|
|
it('should map duplicate provider names to ConflictException', async () => {
|
|
(repo.insert as vi.Mock).mockRejectedValue({ code: '23505' });
|
|
const dto = {
|
|
name: 'New SMTP',
|
|
host: 'new.test.com',
|
|
port: 465,
|
|
password: 'new-password',
|
|
auth: true,
|
|
ssl: true,
|
|
startTls: false,
|
|
tlsRejectUnauthorized: true,
|
|
};
|
|
await expect(service.create(dto, mockUser)).rejects.toThrow(ConflictException);
|
|
});
|
|
});
|
|
|
|
describe('findOne', () => {
|
|
it('should return one provider sanitized', async () => {
|
|
const result = await service.findOne(10, mockUser);
|
|
expect(result.id).toBe(10);
|
|
expect(result).not.toHaveProperty('passwordEnc');
|
|
});
|
|
});
|
|
|
|
describe('update', () => {
|
|
it('should encrypt new password if provided', async () => {
|
|
const dto = { password: 'updated-pass' };
|
|
const result = await service.update(10, dto, mockUser);
|
|
expect(encryption.encrypt).toHaveBeenCalledWith('updated-pass');
|
|
expect(repo.update).toHaveBeenCalledWith(10, 1, expect.objectContaining({ passwordEnc: 'encrypted' }));
|
|
expect(result.id).toBe(10);
|
|
});
|
|
|
|
it('should patch tlsRejectUnauthorized when provided', async () => {
|
|
await service.update(10, { tlsRejectUnauthorized: false }, mockUser);
|
|
expect(repo.update).toHaveBeenCalledWith(10, 1, expect.objectContaining({ tlsRejectUnauthorized: false }));
|
|
});
|
|
|
|
it('should throw NotFoundException if update fails', async () => {
|
|
(repo.update as vi.Mock).mockResolvedValue([]);
|
|
await expect(service.update(10, {}, mockUser)).rejects.toThrow(NotFoundException);
|
|
});
|
|
|
|
it('should map duplicate provider names to ConflictException', async () => {
|
|
(repo.update as vi.Mock).mockRejectedValue({ code: '23505' });
|
|
await expect(service.update(10, { name: 'duplicate' }, mockUser)).rejects.toThrow(ConflictException);
|
|
});
|
|
});
|
|
|
|
describe('remove', () => {
|
|
it('should remove a provider', async () => {
|
|
await service.remove(10, mockUser);
|
|
expect(repo.delete).toHaveBeenCalledWith(10, 1);
|
|
});
|
|
|
|
it('should throw BadRequestException when provider is the system provider', async () => {
|
|
(repo.findById as vi.Mock).mockResolvedValue([{ ...mockProvider, isSystemProvider: true }]);
|
|
await expect(service.remove(10, mockUser)).rejects.toThrow(BadRequestException);
|
|
});
|
|
});
|
|
|
|
describe('setDefault', () => {
|
|
it('should set default provider', async () => {
|
|
const result = await service.setDefault(10, mockUser);
|
|
expect(repo.setDefault).toHaveBeenCalledWith(10, 1);
|
|
expect(result.id).toBe(10);
|
|
});
|
|
});
|
|
|
|
describe('getProviderWithDecryptedPassword', () => {
|
|
it('should return provider with plain password', async () => {
|
|
const result = await service.getProviderWithDecryptedPassword(10, mockUser);
|
|
expect(result.plainPassword).toBe('decrypted');
|
|
});
|
|
});
|
|
|
|
describe('toggleShared', () => {
|
|
it('allows a superuser to toggle sharing', async () => {
|
|
const result = await service.toggleShared(10, mockAdmin);
|
|
expect(result.isShared).toBe(true);
|
|
expect(repo.setSharedByOwner).toHaveBeenCalledWith(10, mockAdmin.id, true);
|
|
});
|
|
|
|
it('allows a manage_email user to toggle sharing on an owned provider', async () => {
|
|
const result = await service.toggleShared(10, mockEmailManager);
|
|
|
|
expect(result.isShared).toBe(true);
|
|
expect(repo.setSharedByOwner).toHaveBeenCalledWith(10, mockEmailManager.id, true);
|
|
});
|
|
|
|
it('rejects a caller without manage_email before looking up the provider', async () => {
|
|
await expect(service.toggleShared(10, mockUser)).rejects.toThrow(ForbiddenException);
|
|
expect(repo.findById).not.toHaveBeenCalled();
|
|
expect(repo.setSharedByOwner).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not let manage_email bypass provider ownership', async () => {
|
|
(repo.findById as vi.Mock).mockResolvedValue([{ ...mockProvider, userId: 99 }]);
|
|
|
|
await expect(service.toggleShared(10, mockEmailManager)).rejects.toThrow(ForbiddenException);
|
|
expect(repo.setSharedByOwner).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('system provider authorization', () => {
|
|
it('keeps setting the system provider restricted to superusers', async () => {
|
|
await expect(service.setSystemProvider(10, mockEmailManager)).rejects.toThrow(ForbiddenException);
|
|
expect(repo.findById).not.toHaveBeenCalled();
|
|
expect(repo.setSystemProvider).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps clearing the system provider restricted to superusers', async () => {
|
|
await expect(service.clearSystemProvider(mockEmailManager)).rejects.toThrow(ForbiddenException);
|
|
expect(repo.clearSystemProvider).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('testConnection', () => {
|
|
it('should return success: true on successful verification', async () => {
|
|
(transport.verifyTransporter as vi.Mock).mockResolvedValue(undefined);
|
|
const result = await service.testConnection(10, mockUser);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should return success: false and error on failure', async () => {
|
|
(transport.verifyTransporter as vi.Mock).mockRejectedValue(new Error('Connection failed'));
|
|
const result = await service.testConnection(10, mockUser);
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Connection failed');
|
|
});
|
|
|
|
it('should pass tlsRejectUnauthorized to the transporter builder', async () => {
|
|
(repo.findById as vi.Mock).mockResolvedValue([{ ...mockProvider, tlsRejectUnauthorized: false }]);
|
|
(transport.verifyTransporter as vi.Mock).mockResolvedValue(undefined);
|
|
await service.testConnection(10, mockUser);
|
|
expect(transport.buildTransporter).toHaveBeenCalledWith(expect.objectContaining({ tlsRejectUnauthorized: false }));
|
|
});
|
|
});
|
|
|
|
describe('getOwnedOrShared', () => {
|
|
it('should throw ForbiddenException if not owner and not shared', async () => {
|
|
const otherUser: RequestUser = { ...mockUser, id: 99, contentFilters: EMPTY_CONTENT_FILTER_RULES };
|
|
(repo.findById as vi.Mock).mockResolvedValue([{ ...mockProvider, userId: 1, isShared: false }]);
|
|
await expect(service.findOne(10, otherUser)).rejects.toThrow(ForbiddenException);
|
|
});
|
|
|
|
it('should allow access if not owner but shared', async () => {
|
|
const otherUser: RequestUser = { ...mockUser, id: 99, contentFilters: EMPTY_CONTENT_FILTER_RULES };
|
|
(repo.findById as vi.Mock).mockResolvedValue([{ ...mockProvider, userId: 1, isShared: true }]);
|
|
const result = await service.findOne(10, otherUser);
|
|
expect(result.id).toBe(10);
|
|
});
|
|
});
|
|
});
|