Initial push of Plunk Next
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
import {describe, it, expect, beforeEach} from 'vitest';
|
||||
import {ActionSchemas} from '@plunk/shared';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
import {
|
||||
ErrorCode,
|
||||
NotFound,
|
||||
ValidationError,
|
||||
NotAuthenticated,
|
||||
NotAllowed,
|
||||
RateLimitError,
|
||||
ConflictError,
|
||||
BadRequest,
|
||||
HttpException,
|
||||
} from '../../exceptions/index.js';
|
||||
import {EmailService} from '../../services/EmailService.js';
|
||||
|
||||
/**
|
||||
* Integration tests for Actions API endpoints (/v1/send, /v1/track)
|
||||
* Tests error handling, validation, and business logic for public API
|
||||
*/
|
||||
describe('Actions API Integration Tests', () => {
|
||||
let projectId: string;
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
beforeEach(async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
projectId = project.id;
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// ERROR RESPONSE STRUCTURE
|
||||
// ========================================
|
||||
describe('Error Response Structure', () => {
|
||||
it('should have standardized error response format', () => {
|
||||
// Document the expected error response structure
|
||||
const expectedErrorResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR', // Machine-readable
|
||||
message: 'Request validation failed', // Human-readable
|
||||
statusCode: 422,
|
||||
requestId: expect.any(String), // For tracking
|
||||
errors: expect.any(Array), // Field-level errors (for validation)
|
||||
suggestion: expect.any(String), // Helpful tip
|
||||
},
|
||||
timestamp: expect.any(String), // ISO timestamp
|
||||
};
|
||||
|
||||
// Verify structure
|
||||
expect(expectedErrorResponse.success).toBe(false);
|
||||
expect(expectedErrorResponse.error.code).toBeDefined();
|
||||
expect(expectedErrorResponse.error.message).toBeDefined();
|
||||
expect(expectedErrorResponse.error.statusCode).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// VALIDATION ERRORS (422)
|
||||
// ========================================
|
||||
describe('Validation Error Handling', () => {
|
||||
it('should validate email format in requests', () => {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: 'not-an-email',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate required fields for /v1/send', () => {
|
||||
|
||||
|
||||
const result = ActionSchemas.send.safeParse({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors.some(e => e.path.includes('to'))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate subject and body required when no template', () => {
|
||||
|
||||
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: '[email protected]',
|
||||
// Missing subject, body, and template
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors.some(e => e.message.includes('template'))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate required fields for /v1/track', () => {
|
||||
|
||||
|
||||
const result = ActionSchemas.track.safeParse({
|
||||
email: '[email protected]',
|
||||
// Missing event name
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors.some(e => e.path.includes('event'))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// CUSTOM HTTP EXCEPTIONS
|
||||
// ========================================
|
||||
describe('Custom HTTP Exception Types', () => {
|
||||
it('should structure NotFound errors correctly', () => {
|
||||
|
||||
|
||||
const error = new NotFound('Template', 'abc-123');
|
||||
|
||||
expect(error.code).toBe(404);
|
||||
expect(error.message).toContain('Template');
|
||||
expect(error.message).toContain('abc-123');
|
||||
expect(error.errorCode).toBe(ErrorCode.TEMPLATE_NOT_FOUND);
|
||||
expect(error.details).toEqual({resource: 'Template', id: 'abc-123'});
|
||||
});
|
||||
|
||||
it('should map resources to specific error codes', () => {
|
||||
|
||||
|
||||
const testCases = [
|
||||
{resource: 'contact', expectedCode: ErrorCode.CONTACT_NOT_FOUND},
|
||||
{resource: 'template', expectedCode: ErrorCode.TEMPLATE_NOT_FOUND},
|
||||
{resource: 'campaign', expectedCode: ErrorCode.CAMPAIGN_NOT_FOUND},
|
||||
{resource: 'workflow', expectedCode: ErrorCode.WORKFLOW_NOT_FOUND},
|
||||
{resource: 'unknown', expectedCode: ErrorCode.RESOURCE_NOT_FOUND},
|
||||
];
|
||||
|
||||
for (const {resource, expectedCode} of testCases) {
|
||||
const error = new NotFound(resource);
|
||||
expect(error.errorCode).toBe(expectedCode);
|
||||
}
|
||||
});
|
||||
|
||||
it('should structure ValidationError with field details', () => {
|
||||
|
||||
|
||||
const fieldErrors = [
|
||||
{field: 'email', message: 'Invalid email format', code: 'invalid_email'},
|
||||
{field: 'data.firstName', message: 'Required field', code: 'required'},
|
||||
];
|
||||
|
||||
const error = new ValidationError(fieldErrors);
|
||||
|
||||
expect(error.code).toBe(422);
|
||||
expect(error.errorCode).toBe(ErrorCode.VALIDATION_ERROR);
|
||||
expect(error.errors).toEqual(fieldErrors);
|
||||
});
|
||||
|
||||
it('should structure NotAuthenticated errors', () => {
|
||||
|
||||
|
||||
const error = new NotAuthenticated();
|
||||
|
||||
expect(error.code).toBe(401);
|
||||
expect(error.errorCode).toBe(ErrorCode.UNAUTHORIZED);
|
||||
});
|
||||
|
||||
it('should structure NotAllowed errors', () => {
|
||||
|
||||
|
||||
const error = new NotAllowed('Cannot perform action', 'Insufficient permissions');
|
||||
|
||||
expect(error.code).toBe(403);
|
||||
expect(error.errorCode).toBe(ErrorCode.FORBIDDEN);
|
||||
expect(error.details).toEqual({reason: 'Insufficient permissions'});
|
||||
});
|
||||
|
||||
it('should structure RateLimitError', () => {
|
||||
|
||||
|
||||
const error = new RateLimitError('Too many requests', 60);
|
||||
|
||||
expect(error.code).toBe(429);
|
||||
expect(error.errorCode).toBe(ErrorCode.RATE_LIMIT_EXCEEDED);
|
||||
expect(error.details).toEqual({retryAfter: 60});
|
||||
});
|
||||
|
||||
it('should structure ConflictError', () => {
|
||||
|
||||
|
||||
const error = new ConflictError('Contact exists', {email: '[email protected]'});
|
||||
|
||||
expect(error.code).toBe(409);
|
||||
expect(error.errorCode).toBe(ErrorCode.CONFLICT);
|
||||
expect(error.details).toEqual({email: '[email protected]'});
|
||||
});
|
||||
|
||||
it('should structure BadRequest errors', () => {
|
||||
|
||||
|
||||
const error = new BadRequest('Invalid format', ErrorCode.INVALID_REQUEST_BODY, {
|
||||
expected: 'JSON',
|
||||
});
|
||||
|
||||
expect(error.code).toBe(400);
|
||||
expect(error.errorCode).toBe(ErrorCode.INVALID_REQUEST_BODY);
|
||||
expect(error.details).toEqual({expected: 'JSON'});
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// BUSINESS LOGIC ERRORS
|
||||
// ========================================
|
||||
describe('Business Logic Error Scenarios', () => {
|
||||
it('should reject marketing template sent to unsubscribed contact', async () => {
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: false,
|
||||
});
|
||||
|
||||
const marketingTemplate = await factories.createTemplate({
|
||||
projectId,
|
||||
type: 'MARKETING',
|
||||
});
|
||||
|
||||
|
||||
await expect(
|
||||
EmailService.sendTransactionalEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
templateId: marketingTemplate.id,
|
||||
subject: 'Marketing',
|
||||
body: 'Buy now!',
|
||||
from: '[email protected]',
|
||||
}),
|
||||
).rejects.toThrow(/cannot send marketing template to unsubscribed contact/i);
|
||||
});
|
||||
|
||||
it('should return NotFound when template does not exist', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
const nonExistentId = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
const template = await prisma.template.findUnique({
|
||||
where: {id: nonExistentId, projectId},
|
||||
});
|
||||
|
||||
expect(template).toBeNull();
|
||||
|
||||
// In actual API, this would trigger NotFound exception
|
||||
|
||||
const error = new NotFound('Template', nonExistentId);
|
||||
|
||||
expect(error.code).toBe(404);
|
||||
expect(error.errorCode).toBe(ErrorCode.TEMPLATE_NOT_FOUND);
|
||||
});
|
||||
|
||||
it('should handle billing limit exceeded', () => {
|
||||
|
||||
|
||||
const error = new HttpException(429, 'Billing limit exceeded');
|
||||
|
||||
expect(error.code).toBe(429);
|
||||
expect(error.message).toContain('Billing limit exceeded');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// ERROR CODE COVERAGE
|
||||
// ========================================
|
||||
describe('ErrorCode Enum Coverage', () => {
|
||||
it('should have all expected error codes defined', () => {
|
||||
const expectedCodes = [
|
||||
// Auth (401, 403)
|
||||
'UNAUTHORIZED',
|
||||
'INVALID_CREDENTIALS',
|
||||
'MISSING_AUTH',
|
||||
'INVALID_API_KEY',
|
||||
'FORBIDDEN',
|
||||
'PROJECT_ACCESS_DENIED',
|
||||
'PROJECT_DISABLED',
|
||||
|
||||
// Resources (404, 409)
|
||||
'RESOURCE_NOT_FOUND',
|
||||
'CONTACT_NOT_FOUND',
|
||||
'TEMPLATE_NOT_FOUND',
|
||||
'CAMPAIGN_NOT_FOUND',
|
||||
'WORKFLOW_NOT_FOUND',
|
||||
'CONFLICT',
|
||||
|
||||
// Validation (400, 422)
|
||||
'BAD_REQUEST',
|
||||
'VALIDATION_ERROR',
|
||||
'INVALID_EMAIL',
|
||||
'INVALID_REQUEST_BODY',
|
||||
'MISSING_REQUIRED_FIELD',
|
||||
|
||||
// Limits (429, 402)
|
||||
'RATE_LIMIT_EXCEEDED',
|
||||
'BILLING_LIMIT_EXCEEDED',
|
||||
'UPGRADE_REQUIRED',
|
||||
|
||||
// Server (500+)
|
||||
'INTERNAL_SERVER_ERROR',
|
||||
'DATABASE_ERROR',
|
||||
'EXTERNAL_SERVICE_ERROR',
|
||||
];
|
||||
|
||||
for (const code of expectedCodes) {
|
||||
expect(ErrorCode[code as keyof typeof ErrorCode]).toBe(code);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import {describe, it, expect, beforeEach, beforeAll} from 'vitest';
|
||||
import {CampaignStatus, CampaignAudienceType} from '@plunk/db';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
// Note: To run these integration tests, you need to:
|
||||
// 1. Have the API server running or import the app instance
|
||||
// 2. For now, these tests demonstrate the pattern for integration testing
|
||||
|
||||
describe('Campaigns API Integration Tests', () => {
|
||||
let projectId: string;
|
||||
let _authToken: string;
|
||||
let _apiUrl: string;
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
beforeAll(() => {
|
||||
// In a real setup, you'd either:
|
||||
// 1. Import the Express app instance
|
||||
// 2. Or use the actual API server URL
|
||||
_apiUrl = process.env.TEST_API_URL || 'http://localhost:3000';
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
projectId = project.id;
|
||||
|
||||
// Generate auth token (you'd need to implement this based on your auth system)
|
||||
// This is a placeholder - adjust based on your actual auth implementation
|
||||
_authToken = 'test-jwt-token';
|
||||
});
|
||||
|
||||
describe('POST /campaigns', () => {
|
||||
it('should create a new campaign', async () => {
|
||||
const campaignData = {
|
||||
name: 'Test Campaign',
|
||||
subject: 'Test Subject',
|
||||
body: '<p>Test Body</p>',
|
||||
from: '[email protected]',
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
};
|
||||
|
||||
// Example of how the integration test would look
|
||||
// Uncomment when you have the app instance available:
|
||||
/*
|
||||
const response = await request(app)
|
||||
.post('/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(campaignData)
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.name).toBe('Test Campaign');
|
||||
expect(response.body.status).toBe(CampaignStatus.DRAFT);
|
||||
*/
|
||||
|
||||
// For now, just verify the factory can create the data
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
...campaignData,
|
||||
});
|
||||
|
||||
expect(campaign.name).toBe('Test Campaign');
|
||||
});
|
||||
|
||||
it('should validate required fields', async () => {
|
||||
// Test that API validates required fields
|
||||
// This would fail without name, subject, etc.
|
||||
|
||||
const _invalidData = {
|
||||
from: '[email protected]',
|
||||
};
|
||||
|
||||
// When integrated with supertest:
|
||||
/*
|
||||
await request(app)
|
||||
.post('/campaigns')
|
||||
.set('Authorization', `Bearer ${_authToken}`)
|
||||
.send(_invalidData)
|
||||
.expect(400);
|
||||
*/
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /campaigns', () => {
|
||||
it('should list campaigns with pagination', async () => {
|
||||
// Create test campaigns using bulk insert to avoid memory issues
|
||||
const campaignData = Array.from({length: 25}, (_, i) => ({
|
||||
projectId,
|
||||
name: `Campaign ${i}`,
|
||||
subject: 'Test Subject',
|
||||
body: '<p>Test Body</p>',
|
||||
from: '[email protected]',
|
||||
status: 'DRAFT' as const,
|
||||
}));
|
||||
|
||||
await prisma.campaign.createMany({data: campaignData});
|
||||
|
||||
// When integrated:
|
||||
/*
|
||||
const response = await request(app)
|
||||
.get('/campaigns')
|
||||
.query({ page: 1, pageSize: 10 })
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.campaigns).toHaveLength(10);
|
||||
expect(response.body.total).toBe(25);
|
||||
expect(response.body.totalPages).toBe(3);
|
||||
*/
|
||||
|
||||
const campaigns = await prisma.campaign.findMany({
|
||||
where: {projectId},
|
||||
take: 10,
|
||||
});
|
||||
|
||||
expect(campaigns.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('should filter campaigns by status', async () => {
|
||||
await factories.createCampaign({projectId, status: CampaignStatus.DRAFT});
|
||||
await factories.createCampaign({projectId, status: CampaignStatus.COMPLETED});
|
||||
|
||||
// When integrated:
|
||||
/*
|
||||
const response = await request(app)
|
||||
.get('/campaigns')
|
||||
.query({ status: CampaignStatus.DRAFT })
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.campaigns.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
|
||||
*/
|
||||
|
||||
const draftCampaigns = await prisma.campaign.findMany({
|
||||
where: {projectId, status: CampaignStatus.DRAFT},
|
||||
});
|
||||
|
||||
expect(draftCampaigns.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /campaigns/:id', () => {
|
||||
it('should update a draft campaign', async () => {
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
name: 'Original Name',
|
||||
});
|
||||
|
||||
// When integrated:
|
||||
/*
|
||||
const response = await request(app)
|
||||
.put(`/campaigns/${campaign.id}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ name: 'Updated Name' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.name).toBe('Updated Name');
|
||||
*/
|
||||
|
||||
const updated = await prisma.campaign.update({
|
||||
where: {id: campaign.id},
|
||||
data: {name: 'Updated Name'},
|
||||
});
|
||||
|
||||
expect(updated.name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('should not update a completed campaign', async () => {
|
||||
const _campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.COMPLETED,
|
||||
});
|
||||
|
||||
// When integrated:
|
||||
/*
|
||||
await request(app)
|
||||
.put(`/campaigns/${_campaign.id}`)
|
||||
.set('Authorization', `Bearer ${_authToken}`)
|
||||
.send({ name: 'New Name' })
|
||||
.expect(400);
|
||||
*/
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /campaigns/:id', () => {
|
||||
it('should delete a draft campaign', async () => {
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
});
|
||||
|
||||
// When integrated:
|
||||
/*
|
||||
await request(app)
|
||||
.delete(`/campaigns/${campaign.id}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(204);
|
||||
|
||||
const deleted = await prisma.campaign.findUnique({ where: { id: campaign.id } });
|
||||
expect(deleted).toBeNull();
|
||||
*/
|
||||
|
||||
await prisma.campaign.delete({where: {id: campaign.id}});
|
||||
|
||||
const deleted = await prisma.campaign.findUnique({where: {id: campaign.id}});
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
import {describe, it, expect, beforeEach, vi} from 'vitest';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
import {DomainService} from '../../services/DomainService.js';
|
||||
import * as SESService from '../../services/SESService.js';
|
||||
|
||||
/**
|
||||
* Integration tests for Domain verification and ownership checks
|
||||
* Tests the security feature that prevents domains from being linked to multiple projects
|
||||
* unless the user is a member of the project that owns the domain
|
||||
*/
|
||||
describe('Domain Verification and Ownership Tests', () => {
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Mock SES service to avoid external AWS calls
|
||||
beforeEach(() => {
|
||||
vi.spyOn(SESService, 'verifyDomain').mockResolvedValue(['token1', 'token2', 'token3']);
|
||||
vi.spyOn(SESService, 'getDomainVerificationAttributes').mockResolvedValue({
|
||||
status: 'Success',
|
||||
tokens: ['token1', 'token2', 'token3'],
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// DOMAIN OWNERSHIP CHECKS
|
||||
// ========================================
|
||||
describe('Domain Ownership Verification', () => {
|
||||
it('should allow adding a domain that does not exist yet', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
const domain = 'new-domain.com';
|
||||
|
||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, 'any-user-id');
|
||||
|
||||
expect(ownershipCheck.exists).toBe(false);
|
||||
|
||||
// Should be able to add the domain
|
||||
const newDomain = await DomainService.addDomain(project.id, domain);
|
||||
expect(newDomain.domain).toBe(domain);
|
||||
expect(newDomain.projectId).toBe(project.id);
|
||||
});
|
||||
|
||||
it('should detect when a domain already exists', async () => {
|
||||
const {user, project} = await factories.createUserWithProject();
|
||||
const domain = 'existing-domain.com';
|
||||
|
||||
// First project adds the domain
|
||||
await DomainService.addDomain(project.id, domain);
|
||||
|
||||
// Check ownership - user IS a member of the project
|
||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user.id);
|
||||
|
||||
expect(ownershipCheck.exists).toBe(true);
|
||||
expect(ownershipCheck.projectId).toBe(project.id);
|
||||
expect(ownershipCheck.projectName).toBe(project.name);
|
||||
expect(ownershipCheck.isMember).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect when a domain exists but user is not a member', async () => {
|
||||
const {project: project1} = await factories.createUserWithProject();
|
||||
const {user: user2} = await factories.createUserWithProject();
|
||||
const domain = 'other-project-domain.com';
|
||||
|
||||
// Project 1 adds the domain
|
||||
await DomainService.addDomain(project1.id, domain);
|
||||
|
||||
// Check ownership for User 2 (not a member of Project 1)
|
||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user2.id);
|
||||
|
||||
expect(ownershipCheck.exists).toBe(true);
|
||||
expect(ownershipCheck.projectId).toBe(project1.id);
|
||||
expect(ownershipCheck.isMember).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow access when user is a member of the project that owns the domain', async () => {
|
||||
const {user, project} = await factories.createUserWithProject();
|
||||
const domain = 'shared-domain.com';
|
||||
|
||||
// User 1 adds the domain to their project
|
||||
await DomainService.addDomain(project.id, domain);
|
||||
|
||||
// Create another user and add them to the same project
|
||||
const user2 = await factories.createUser({email: '[email protected]'});
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
userId: user2.id,
|
||||
projectId: project.id,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
});
|
||||
|
||||
// Check ownership for User 2 (who is now a member)
|
||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user2.id);
|
||||
|
||||
expect(ownershipCheck.exists).toBe(true);
|
||||
expect(ownershipCheck.isMember).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// PREVENTING UNAUTHORIZED DOMAIN LINKING
|
||||
// ========================================
|
||||
describe('Preventing Unauthorized Domain Linking', () => {
|
||||
it('should prevent linking a domain to another project when user is not a member', async () => {
|
||||
const {project: project1} = await factories.createUserWithProject();
|
||||
const {user: user2, project: project2} = await factories.createUserWithProject();
|
||||
const domain = 'protected-domain.com';
|
||||
|
||||
// Project 1 adds the domain
|
||||
await DomainService.addDomain(project1.id, domain);
|
||||
|
||||
// User 2 tries to link the same domain to Project 2
|
||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user2.id);
|
||||
|
||||
// The controller would use this check to deny access
|
||||
expect(ownershipCheck.exists).toBe(true);
|
||||
expect(ownershipCheck.isMember).toBe(false);
|
||||
|
||||
// Verify the domain is still only linked to Project 1
|
||||
const domains = await prisma.domain.findMany({
|
||||
where: {domain},
|
||||
});
|
||||
|
||||
expect(domains).toHaveLength(1);
|
||||
expect(domains[0].projectId).toBe(project1.id);
|
||||
});
|
||||
|
||||
it('should allow member to see they can access domain from the original project', async () => {
|
||||
const {user, project: project1} = await factories.createUserWithProject();
|
||||
const domain = 'member-domain.com';
|
||||
|
||||
// Add domain to project 1
|
||||
await DomainService.addDomain(project1.id, domain);
|
||||
|
||||
// User creates a second project (same user, different project)
|
||||
const project2 = await factories.createProject();
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: project2.id,
|
||||
role: 'OWNER',
|
||||
},
|
||||
});
|
||||
|
||||
// User tries to link the domain to project 2
|
||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user.id);
|
||||
|
||||
// Should indicate that domain exists and user IS a member
|
||||
expect(ownershipCheck.exists).toBe(true);
|
||||
expect(ownershipCheck.isMember).toBe(true);
|
||||
expect(ownershipCheck.projectId).toBe(project1.id);
|
||||
expect(ownershipCheck.projectName).toBe(project1.name);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// DOMAIN VERIFICATION STATUS
|
||||
// ========================================
|
||||
describe('Domain Verification Status', () => {
|
||||
it('should create unverified domain when first added', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
const domain = 'unverified-domain.com';
|
||||
|
||||
const newDomain = await DomainService.addDomain(project.id, domain);
|
||||
|
||||
expect(newDomain.verified).toBe(false);
|
||||
expect(newDomain.dkimTokens).toEqual(['token1', 'token2', 'token3']);
|
||||
});
|
||||
|
||||
it('should prevent using unverified domain for sending emails', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
const domain = 'unverified-domain.com';
|
||||
|
||||
await DomainService.addDomain(project.id, domain);
|
||||
|
||||
// Try to verify email domain
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain(`sender@${domain}`, project.id),
|
||||
).rejects.toThrow(/not verified/i);
|
||||
});
|
||||
|
||||
it('should allow using verified domain for sending emails', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
const domain = 'verified-domain.com';
|
||||
|
||||
const newDomain = await DomainService.addDomain(project.id, domain);
|
||||
|
||||
// Manually mark as verified (simulating DNS verification)
|
||||
await prisma.domain.update({
|
||||
where: {id: newDomain.id},
|
||||
data: {verified: true},
|
||||
});
|
||||
|
||||
// Should not throw error
|
||||
const verifiedDomain = await DomainService.verifyEmailDomain(`sender@${domain}`, project.id);
|
||||
|
||||
expect(verifiedDomain.verified).toBe(true);
|
||||
expect(verifiedDomain.domain).toBe(domain);
|
||||
});
|
||||
|
||||
it('should prevent using domain from different project', async () => {
|
||||
const {project: project1} = await factories.createUserWithProject();
|
||||
const {project: project2} = await factories.createUserWithProject();
|
||||
const domain = 'project1-domain.com';
|
||||
|
||||
const newDomain = await DomainService.addDomain(project1.id, domain);
|
||||
|
||||
// Mark as verified
|
||||
await prisma.domain.update({
|
||||
where: {id: newDomain.id},
|
||||
data: {verified: true},
|
||||
});
|
||||
|
||||
// Try to use from different project
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain(`sender@${domain}`, project2.id),
|
||||
).rejects.toThrow(/belongs to a different project/i);
|
||||
});
|
||||
|
||||
it('should prevent using unregistered domain', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
const domain = 'not-registered.com';
|
||||
|
||||
// Try to use domain that was never added
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain(`sender@${domain}`, project.id),
|
||||
).rejects.toThrow(/not registered/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// MULTIPLE USERS AND PROJECTS
|
||||
// ========================================
|
||||
describe('Multiple Users and Projects Scenarios', () => {
|
||||
it('should handle 3 users: owner, member, and non-member', async () => {
|
||||
const {user: owner, project} = await factories.createUserWithProject();
|
||||
const member = await factories.createUser({email: '[email protected]'});
|
||||
const nonMember = await factories.createUser({email: '[email protected]'});
|
||||
const domain = 'team-domain.com';
|
||||
|
||||
// Owner adds domain
|
||||
await DomainService.addDomain(project.id, domain);
|
||||
|
||||
// Add member to project
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
userId: member.id,
|
||||
projectId: project.id,
|
||||
role: 'MEMBER',
|
||||
},
|
||||
});
|
||||
|
||||
// Check ownership for each user
|
||||
const ownerCheck = await DomainService.checkDomainOwnership(domain, owner.id);
|
||||
const memberCheck = await DomainService.checkDomainOwnership(domain, member.id);
|
||||
const nonMemberCheck = await DomainService.checkDomainOwnership(domain, nonMember.id);
|
||||
|
||||
expect(ownerCheck.isMember).toBe(true);
|
||||
expect(memberCheck.isMember).toBe(true);
|
||||
expect(nonMemberCheck.isMember).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle user with multiple projects', async () => {
|
||||
const {user, project: project1} = await factories.createUserWithProject();
|
||||
const project2 = await factories.createProject();
|
||||
const domain = 'multi-project-domain.com';
|
||||
|
||||
// Add user to second project
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: project2.id,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
});
|
||||
|
||||
// Add domain to project 1
|
||||
await DomainService.addDomain(project1.id, domain);
|
||||
|
||||
// User is member of both projects, but domain belongs to project 1
|
||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user.id);
|
||||
|
||||
expect(ownershipCheck.exists).toBe(true);
|
||||
expect(ownershipCheck.isMember).toBe(true);
|
||||
expect(ownershipCheck.projectId).toBe(project1.id);
|
||||
});
|
||||
|
||||
it('should handle domain being removed and re-added', async () => {
|
||||
const {user, project} = await factories.createUserWithProject();
|
||||
const domain = 'reusable-domain.com';
|
||||
|
||||
// Add domain
|
||||
const domain1 = await DomainService.addDomain(project.id, domain);
|
||||
|
||||
// Remove domain
|
||||
await DomainService.removeDomain(domain1.id);
|
||||
|
||||
// Check ownership - should not exist
|
||||
const checkAfterRemoval = await DomainService.checkDomainOwnership(domain, user.id);
|
||||
expect(checkAfterRemoval.exists).toBe(false);
|
||||
|
||||
// Re-add domain
|
||||
const domain2 = await DomainService.addDomain(project.id, domain);
|
||||
|
||||
expect(domain2.domain).toBe(domain);
|
||||
expect(domain2.projectId).toBe(project.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// ROLE-BASED RESTRICTIONS
|
||||
// ========================================
|
||||
describe('Role-Based Domain Access', () => {
|
||||
it('should verify that only ADMIN and OWNER can add domains', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
const regularMember = await factories.createUser({email: '[email protected]'});
|
||||
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
userId: regularMember.id,
|
||||
projectId: project.id,
|
||||
role: 'MEMBER',
|
||||
},
|
||||
});
|
||||
|
||||
// The controller checks for role: { in: ['ADMIN', 'OWNER'] }
|
||||
// This test verifies the database structure supports this check
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: regularMember.id,
|
||||
projectId: project.id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(membership).toBeNull();
|
||||
});
|
||||
|
||||
it('should verify that ADMIN and OWNER roles can add domains', async () => {
|
||||
const {user: owner, project} = await factories.createUserWithProject();
|
||||
const admin = await factories.createUser({email: '[email protected]'});
|
||||
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
projectId: project.id,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
});
|
||||
|
||||
// Verify both owner and admin have required roles
|
||||
const ownerMembership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: owner.id,
|
||||
projectId: project.id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const adminMembership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: admin.id,
|
||||
projectId: project.id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(ownerMembership).not.toBeNull();
|
||||
expect(adminMembership).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// EDGE CASES
|
||||
// ========================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle case-sensitive domain names', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
// Domains are typically case-insensitive in DNS, but stored as-is in DB
|
||||
const domain1 = await DomainService.addDomain(project.id, 'Example.com');
|
||||
|
||||
expect(domain1.domain).toBe('Example.com');
|
||||
});
|
||||
|
||||
it('should handle subdomain vs root domain', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
const rootDomain = await DomainService.addDomain(project.id, 'example.com');
|
||||
const subDomain = await DomainService.addDomain(project.id, 'mail.example.com');
|
||||
|
||||
expect(rootDomain.domain).toBe('example.com');
|
||||
expect(subDomain.domain).toBe('mail.example.com');
|
||||
|
||||
// Both should be separate entries
|
||||
const domains = await prisma.domain.findMany({
|
||||
where: {projectId: project.id},
|
||||
});
|
||||
|
||||
expect(domains).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle invalid email format in verifyEmailDomain', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('not-an-email', project.id)).rejects.toThrow(
|
||||
/invalid email format/i,
|
||||
);
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('multiple@[email protected]', project.id)).rejects.toThrow(
|
||||
/invalid email format/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// GET PROJECT DOMAINS
|
||||
// ========================================
|
||||
describe('Get Project Domains', () => {
|
||||
it('should return all domains for a project', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await DomainService.addDomain(project.id, 'domain1.com');
|
||||
await DomainService.addDomain(project.id, 'domain2.com');
|
||||
await DomainService.addDomain(project.id, 'domain3.com');
|
||||
|
||||
const domains = await DomainService.getProjectDomains(project.id);
|
||||
|
||||
expect(domains).toHaveLength(3);
|
||||
expect(domains.map(d => d.domain).sort()).toEqual(['domain1.com', 'domain2.com', 'domain3.com']);
|
||||
});
|
||||
|
||||
it('should return empty array for project with no domains', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
const domains = await DomainService.getProjectDomains(project.id);
|
||||
|
||||
expect(domains).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not return domains from other projects', async () => {
|
||||
const {project: project1} = await factories.createUserWithProject();
|
||||
const {project: project2} = await factories.createUserWithProject();
|
||||
|
||||
await DomainService.addDomain(project1.id, 'project1-domain.com');
|
||||
await DomainService.addDomain(project2.id, 'project2-domain.com');
|
||||
|
||||
const project1Domains = await DomainService.getProjectDomains(project1.id);
|
||||
const project2Domains = await DomainService.getProjectDomains(project2.id);
|
||||
|
||||
expect(project1Domains).toHaveLength(1);
|
||||
expect(project1Domains[0].domain).toBe('project1-domain.com');
|
||||
|
||||
expect(project2Domains).toHaveLength(1);
|
||||
expect(project2Domains[0].domain).toBe('project2-domain.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user