301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
import { User } from '../../src/modules/auth/domain/entities/User';
|
|
import { Organization } from '../../src/modules/auth/domain/entities/Organization';
|
|
import { Email } from '../../src/modules/auth/domain/value-objects/Email';
|
|
import { Role } from '../../src/modules/auth/domain/value-objects/Role';
|
|
import { RegisterCommand } from '../../src/modules/auth/application/commands/RegisterCommand';
|
|
import { LoginCommand } from '../../src/modules/auth/application/commands/LoginCommand';
|
|
import { IUserRepository } from '../../src/modules/auth/domain/ports/IUserRepository';
|
|
import { ISessionRepository, AuthSession } from '../../src/modules/auth/domain/ports/ISessionRepository';
|
|
import { EventBus } from '../../src/shared/application/EventBus';
|
|
import { DomainEvent } from '../../src/shared/domain/DomainEvent';
|
|
import { EventHandler } from '../../src/shared/application/EventHandler';
|
|
import { defineAbilityFor } from '../../src/modules/auth/infrastructure/casl/AbilityFactory';
|
|
|
|
// ─── Mock EventBus ─────────────────────────────────────────────────────────────
|
|
|
|
class MockEventBus implements EventBus {
|
|
published: DomainEvent[] = [];
|
|
async publish(event: DomainEvent): Promise<void> { this.published.push(event); }
|
|
subscribe(_name: string, _handler: EventHandler): void {}
|
|
}
|
|
|
|
// ─── Mock User Repository ──────────────────────────────────────────────────────
|
|
|
|
class InMemoryUserRepository implements IUserRepository {
|
|
private store = new Map<string, User>();
|
|
|
|
async save(user: User): Promise<void> {
|
|
this.store.set(user.id.toString(), user);
|
|
}
|
|
async findById(id: string): Promise<User | undefined> {
|
|
return this.store.get(id);
|
|
}
|
|
async findByEmail(email: string): Promise<User | undefined> {
|
|
return Array.from(this.store.values()).find(u => u.email.value === email);
|
|
}
|
|
async findAll(): Promise<User[]> {
|
|
return Array.from(this.store.values());
|
|
}
|
|
async count(): Promise<number> {
|
|
return this.store.size;
|
|
}
|
|
}
|
|
|
|
// ─── Mock Session Repository ───────────────────────────────────────────────────
|
|
|
|
class InMemorySessionRepository implements ISessionRepository {
|
|
private store = new Map<string, AuthSession>();
|
|
|
|
async save(session: AuthSession): Promise<void> {
|
|
this.store.set(session.token, session);
|
|
}
|
|
async findByToken(token: string): Promise<AuthSession | undefined> {
|
|
return this.store.get(token);
|
|
}
|
|
async deleteByToken(token: string): Promise<void> {
|
|
this.store.delete(token);
|
|
}
|
|
async deleteExpired(): Promise<void> {
|
|
const now = new Date();
|
|
for (const [token, session] of this.store) {
|
|
if (session.expiresAt < now) this.store.delete(token);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Tests: Email Value Object ─────────────────────────────────────────────────
|
|
|
|
describe('Email value object', () => {
|
|
it('creates valid email', () => {
|
|
const email = Email.create('Test@Example.COM');
|
|
expect(email.value).toBe('test@example.com');
|
|
});
|
|
|
|
it('throws for invalid email', () => {
|
|
expect(() => Email.create('not-an-email')).toThrow('Invalid email address');
|
|
});
|
|
|
|
it('equals by value', () => {
|
|
const a = Email.create('user@example.com');
|
|
const b = Email.create('user@example.com');
|
|
expect(a.equals(b)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── Tests: Role Value Object ──────────────────────────────────────────────────
|
|
|
|
describe('Role value object', () => {
|
|
it('creates valid roles', () => {
|
|
expect(Role.owner().value).toBe('owner');
|
|
expect(Role.admin().value).toBe('admin');
|
|
expect(Role.member().value).toBe('member');
|
|
expect(Role.viewer().value).toBe('viewer');
|
|
});
|
|
|
|
it('throws for invalid role', () => {
|
|
expect(() => Role.create('superadmin')).toThrow('Invalid role');
|
|
});
|
|
|
|
it('checks role type', () => {
|
|
expect(Role.owner().isOwner()).toBe(true);
|
|
expect(Role.admin().isAdmin()).toBe(true);
|
|
expect(Role.member().isMember()).toBe(true);
|
|
expect(Role.viewer().isViewer()).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── Tests: User Aggregate ─────────────────────────────────────────────────────
|
|
|
|
describe('User aggregate', () => {
|
|
it('creates user and emits UserCreated event', () => {
|
|
const user = User.create({
|
|
email: Email.create('alice@example.com'),
|
|
name: 'Alice',
|
|
passwordHash: 'hash',
|
|
role: Role.owner(),
|
|
});
|
|
|
|
expect(user.email.value).toBe('alice@example.com');
|
|
expect(user.name).toBe('Alice');
|
|
expect(user.role.isOwner()).toBe(true);
|
|
expect(user.domainEvents).toHaveLength(1);
|
|
expect(user.domainEvents[0]!.eventName).toBe('auth.user.created');
|
|
});
|
|
|
|
it('assigns user to org', () => {
|
|
const user = User.create({
|
|
email: Email.create('bob@example.com'),
|
|
name: 'Bob',
|
|
passwordHash: 'hash',
|
|
role: Role.member(),
|
|
});
|
|
user.assignToOrg('org-123');
|
|
expect(user.orgId).toBe('org-123');
|
|
});
|
|
});
|
|
|
|
// ─── Tests: Organization Aggregate ────────────────────────────────────────────
|
|
|
|
describe('Organization aggregate', () => {
|
|
it('creates org with slug and emits OrgCreated', () => {
|
|
const org = Organization.create({ name: 'Acme Corp', slug: 'acme-corp' });
|
|
expect(org.name).toBe('Acme Corp');
|
|
expect(org.slug).toBe('acme-corp');
|
|
expect(org.domainEvents[0]!.eventName).toBe('auth.org.created');
|
|
});
|
|
|
|
it('slugifies name correctly', () => {
|
|
expect(Organization.slugify('My Awesome Org!')).toBe('my-awesome-org');
|
|
expect(Organization.slugify(' hello world ')).toBe('hello-world');
|
|
});
|
|
});
|
|
|
|
// ─── Tests: RegisterCommand ────────────────────────────────────────────────────
|
|
|
|
describe('RegisterCommand', () => {
|
|
let userRepo: InMemoryUserRepository;
|
|
let eventBus: MockEventBus;
|
|
let registerCommand: RegisterCommand;
|
|
|
|
beforeEach(() => {
|
|
userRepo = new InMemoryUserRepository();
|
|
eventBus = new MockEventBus();
|
|
registerCommand = new RegisterCommand(userRepo, eventBus, async (p) => `hash:${p}`);
|
|
});
|
|
|
|
it('registers a new user successfully', async () => {
|
|
const result = await registerCommand.execute({
|
|
email: 'alice@example.com',
|
|
password: 'password123',
|
|
name: 'Alice',
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) {
|
|
expect(result.value.email).toBe('alice@example.com');
|
|
expect(result.value.role).toBe('member');
|
|
}
|
|
expect(await userRepo.count()).toBe(1);
|
|
expect(eventBus.published).toHaveLength(1);
|
|
expect(eventBus.published[0]!.eventName).toBe('auth.user.created');
|
|
});
|
|
|
|
it('fails if email already registered', async () => {
|
|
await registerCommand.execute({ email: 'alice@example.com', password: 'pass1234', name: 'Alice' });
|
|
const result = await registerCommand.execute({ email: 'alice@example.com', password: 'pass5678', name: 'Alice2' });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error).toContain('already registered');
|
|
});
|
|
|
|
it('fails if password too short', async () => {
|
|
const result = await registerCommand.execute({ email: 'bob@test.com', password: 'short', name: 'Bob' });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error).toContain('8 characters');
|
|
});
|
|
|
|
it('fails with invalid email', async () => {
|
|
const result = await registerCommand.execute({ email: 'not-an-email', password: 'password123', name: 'Bob' });
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it('registers with custom role', async () => {
|
|
const result = await registerCommand.execute({
|
|
email: 'admin@example.com',
|
|
password: 'password123',
|
|
name: 'Admin',
|
|
role: 'owner',
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.value.role).toBe('owner');
|
|
});
|
|
});
|
|
|
|
// ─── Tests: LoginCommand ───────────────────────────────────────────────────────
|
|
|
|
describe('LoginCommand', () => {
|
|
let userRepo: InMemoryUserRepository;
|
|
let sessionRepo: InMemorySessionRepository;
|
|
let eventBus: MockEventBus;
|
|
let registerCommand: RegisterCommand;
|
|
let loginCommand: LoginCommand;
|
|
|
|
beforeEach(async () => {
|
|
userRepo = new InMemoryUserRepository();
|
|
sessionRepo = new InMemorySessionRepository();
|
|
eventBus = new MockEventBus();
|
|
registerCommand = new RegisterCommand(userRepo, eventBus, async (p) => `hash:${p}`);
|
|
loginCommand = new LoginCommand(
|
|
userRepo,
|
|
sessionRepo,
|
|
eventBus,
|
|
async (password, hash) => hash === `hash:${password}`
|
|
);
|
|
|
|
await registerCommand.execute({ email: 'alice@example.com', password: 'password123', name: 'Alice' });
|
|
eventBus.published = [];
|
|
});
|
|
|
|
it('logs in with correct credentials', async () => {
|
|
const result = await loginCommand.execute({ email: 'alice@example.com', password: 'password123' });
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) {
|
|
expect(result.value.sessionToken).toBeTruthy();
|
|
expect(result.value.role).toBe('member');
|
|
}
|
|
expect(eventBus.published[0]!.eventName).toBe('auth.user.logged_in');
|
|
});
|
|
|
|
it('fails with wrong password', async () => {
|
|
const result = await loginCommand.execute({ email: 'alice@example.com', password: 'wrongpass' });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error).toBe('Invalid credentials');
|
|
});
|
|
|
|
it('fails with unknown email', async () => {
|
|
const result = await loginCommand.execute({ email: 'nobody@example.com', password: 'password123' });
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it('creates session in repository', async () => {
|
|
const result = await loginCommand.execute({ email: 'alice@example.com', password: 'password123' });
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) {
|
|
const session = await sessionRepo.findByToken(result.value.sessionToken);
|
|
expect(session).toBeDefined();
|
|
expect(session!.userId).toBeTruthy();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Tests: CASL AbilityFactory ────────────────────────────────────────────────
|
|
|
|
describe('CASL AbilityFactory', () => {
|
|
it('owner can manage all', () => {
|
|
const ability = defineAbilityFor('owner');
|
|
expect(ability.can('manage', 'all')).toBe(true);
|
|
});
|
|
|
|
it('admin can manage sessions but not delete org or manage license', () => {
|
|
const ability = defineAbilityFor('admin');
|
|
expect(ability.can('create', 'Session')).toBe(true);
|
|
expect(ability.can('delete', 'Organization')).toBe(false);
|
|
expect(ability.can('manage', 'License')).toBe(false);
|
|
expect(ability.can('read', 'License')).toBe(true);
|
|
});
|
|
|
|
it('member can create sessions/findings/reports and read all', () => {
|
|
const ability = defineAbilityFor('member');
|
|
expect(ability.can('create', 'Session')).toBe(true);
|
|
expect(ability.can('read', 'Session')).toBe(true);
|
|
expect(ability.can('delete', 'Session')).toBe(false);
|
|
expect(ability.can('update', 'Finding')).toBe(true);
|
|
});
|
|
|
|
it('viewer can only read', () => {
|
|
const ability = defineAbilityFor('viewer');
|
|
expect(ability.can('read', 'Session')).toBe(true);
|
|
expect(ability.can('create', 'Session')).toBe(false);
|
|
expect(ability.can('delete', 'Finding')).toBe(false);
|
|
});
|
|
});
|