test: backend test suite — 8 spec files covering vault encryption, credentials, SSH keys, auth service, controller, guards
63 tests across 8 spec files, all passing. Removes 2 stale stub files from backend/test/ that were incompatible with the current async EncryptionService and 3-argument AuthService constructor. New suite lives in src/ co-located with source files per NestJS convention. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f01e357647
commit
63315f94c4
43
backend/src/__mocks__/prisma.mock.ts
Normal file
43
backend/src/__mocks__/prisma.mock.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
// backend/src/__mocks__/prisma.mock.ts
|
||||||
|
export const mockPrismaService = {
|
||||||
|
user: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
findFirst: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
credential: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
sshKey: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
connectionLog: {
|
||||||
|
create: jest.fn(),
|
||||||
|
updateMany: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createMockPrisma() {
|
||||||
|
// Deep clone to prevent test bleed — re-attach jest.fn() since JSON.parse loses functions
|
||||||
|
const models = Object.keys(mockPrismaService) as Array<keyof typeof mockPrismaService>;
|
||||||
|
const mock: any = {};
|
||||||
|
for (const model of models) {
|
||||||
|
mock[model] = {};
|
||||||
|
const methods = Object.keys(mockPrismaService[model]);
|
||||||
|
for (const method of methods) {
|
||||||
|
mock[model][method] = jest.fn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
30
backend/src/auth/admin.guard.spec.ts
Normal file
30
backend/src/auth/admin.guard.spec.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// backend/src/auth/admin.guard.spec.ts
|
||||||
|
import { AdminGuard } from './admin.guard';
|
||||||
|
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||||
|
|
||||||
|
function createMockContext(user: any): ExecutionContext {
|
||||||
|
return {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({ user }),
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AdminGuard', () => {
|
||||||
|
const guard = new AdminGuard();
|
||||||
|
|
||||||
|
it('should allow request when user has admin role', () => {
|
||||||
|
const ctx = createMockContext({ role: 'admin' });
|
||||||
|
expect(guard.canActivate(ctx)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ForbiddenException for non-admin role', () => {
|
||||||
|
const ctx = createMockContext({ role: 'user' });
|
||||||
|
expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ForbiddenException when user is undefined (unauthenticated)', () => {
|
||||||
|
const ctx = createMockContext(undefined);
|
||||||
|
expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
99
backend/src/auth/auth.controller.spec.ts
Normal file
99
backend/src/auth/auth.controller.spec.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
// backend/src/auth/auth.controller.spec.ts
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
|
||||||
|
describe('AuthController', () => {
|
||||||
|
let controller: AuthController;
|
||||||
|
let authService: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
authService = {
|
||||||
|
login: jest.fn(),
|
||||||
|
getProfile: jest.fn(),
|
||||||
|
totpSetup: jest.fn(),
|
||||||
|
listUsers: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [
|
||||||
|
{ provide: AuthService, useValue: authService },
|
||||||
|
{ provide: JwtService, useValue: { sign: jest.fn(), verify: jest.fn() } },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get(AuthController);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('should set httpOnly cookie and return user without access_token in body', async () => {
|
||||||
|
authService.login.mockResolvedValue({
|
||||||
|
access_token: 'jwt-token',
|
||||||
|
user: { id: 1, email: 'test@test.com', displayName: 'Test', role: 'admin' },
|
||||||
|
});
|
||||||
|
const res = { cookie: jest.fn() };
|
||||||
|
|
||||||
|
const result = await controller.login(
|
||||||
|
{ email: 'test@test.com', password: 'pass' } as any,
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.cookie).toHaveBeenCalledWith(
|
||||||
|
'wraith_token',
|
||||||
|
'jwt-token',
|
||||||
|
expect.objectContaining({
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
path: '/',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ user: expect.objectContaining({ email: 'test@test.com' }) });
|
||||||
|
// Token must NOT be in the response body (C-2 security requirement)
|
||||||
|
expect(result).not.toHaveProperty('access_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass through requires_totp without setting cookie', async () => {
|
||||||
|
authService.login.mockResolvedValue({ requires_totp: true });
|
||||||
|
const res = { cookie: jest.fn() };
|
||||||
|
|
||||||
|
const result = await controller.login(
|
||||||
|
{ email: 'test@test.com', password: 'pass' } as any,
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.cookie).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({ requires_totp: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logout', () => {
|
||||||
|
it('should clear the wraith_token cookie', () => {
|
||||||
|
const res = { clearCookie: jest.fn() };
|
||||||
|
const result = controller.logout(res);
|
||||||
|
expect(res.clearCookie).toHaveBeenCalledWith('wraith_token', { path: '/' });
|
||||||
|
expect(result).toEqual({ message: 'Logged out' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ws-ticket', () => {
|
||||||
|
it('should issue a 64-char hex ticket', () => {
|
||||||
|
const req = { user: { sub: 1, email: 'test@test.com', role: 'admin' } };
|
||||||
|
const result = controller.issueWsTicket(req);
|
||||||
|
expect(result).toHaveProperty('ticket');
|
||||||
|
expect(result.ticket).toHaveLength(64); // 32 random bytes → 64 hex chars
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should consume ticket exactly once (single-use)', () => {
|
||||||
|
const req = { user: { sub: 5, email: 'once@test.com', role: 'user' } };
|
||||||
|
const { ticket } = controller.issueWsTicket(req);
|
||||||
|
|
||||||
|
const first = AuthController.consumeWsTicket(ticket);
|
||||||
|
expect(first).toEqual(expect.objectContaining({ sub: 5, email: 'once@test.com' }));
|
||||||
|
|
||||||
|
const second = AuthController.consumeWsTicket(ticket);
|
||||||
|
expect(second).toBeNull(); // Ticket is deleted after first use
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
204
backend/src/auth/auth.service.spec.ts
Normal file
204
backend/src/auth/auth.service.spec.ts
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
// backend/src/auth/auth.service.spec.ts
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { EncryptionService } from '../vault/encryption.service';
|
||||||
|
import {
|
||||||
|
UnauthorizedException,
|
||||||
|
BadRequestException,
|
||||||
|
NotFoundException,
|
||||||
|
ForbiddenException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import * as argon2 from 'argon2';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
let service: AuthService;
|
||||||
|
let prisma: any;
|
||||||
|
let jwt: any;
|
||||||
|
let encryption: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
prisma = {
|
||||||
|
user: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
jwt = { sign: jest.fn().mockReturnValue('mock-jwt-token') };
|
||||||
|
encryption = {
|
||||||
|
encrypt: jest.fn().mockResolvedValue('v2:encrypted'),
|
||||||
|
decrypt: jest.fn().mockResolvedValue('decrypted-secret'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
{ provide: PrismaService, useValue: prisma },
|
||||||
|
{ provide: JwtService, useValue: jwt },
|
||||||
|
{ provide: EncryptionService, useValue: encryption },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get(AuthService);
|
||||||
|
// onModuleInit pre-computes the dummy hash for timing-safe login
|
||||||
|
await service.onModuleInit();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('should return access_token and user for valid Argon2id credentials', async () => {
|
||||||
|
const hash = await argon2.hash('correct-password', { type: argon2.argon2id });
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
email: 'test@test.com',
|
||||||
|
passwordHash: hash,
|
||||||
|
displayName: 'Test User',
|
||||||
|
role: 'admin',
|
||||||
|
totpEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.login('test@test.com', 'correct-password');
|
||||||
|
expect(result).toHaveProperty('access_token', 'mock-jwt-token');
|
||||||
|
expect((result as any).user.email).toBe('test@test.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException for wrong password', async () => {
|
||||||
|
const hash = await argon2.hash('correct', { type: argon2.argon2id });
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
email: 'test@test.com',
|
||||||
|
passwordHash: hash,
|
||||||
|
totpEnabled: false,
|
||||||
|
});
|
||||||
|
await expect(service.login('test@test.com', 'wrong')).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException for non-existent user (constant time defense)', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(service.login('nobody@test.com', 'pass')).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-upgrade bcrypt hash to Argon2id on successful login', async () => {
|
||||||
|
const bcryptHash = await bcrypt.hash('password', 10);
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
email: 'legacy@test.com',
|
||||||
|
passwordHash: bcryptHash,
|
||||||
|
displayName: 'Legacy User',
|
||||||
|
role: 'user',
|
||||||
|
totpEnabled: false,
|
||||||
|
});
|
||||||
|
prisma.user.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
await service.login('legacy@test.com', 'password');
|
||||||
|
|
||||||
|
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { id: 1 },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
passwordHash: expect.stringContaining('$argon2id$'),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return { requires_totp: true } when TOTP enabled but no code provided', async () => {
|
||||||
|
const hash = await argon2.hash('pass', { type: argon2.argon2id });
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
email: 'totp@test.com',
|
||||||
|
passwordHash: hash,
|
||||||
|
totpEnabled: true,
|
||||||
|
totpSecret: 'v2:encrypted-secret',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.login('totp@test.com', 'pass');
|
||||||
|
expect(result).toEqual({ requires_totp: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createUser', () => {
|
||||||
|
it('should hash password with Argon2id before storage', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(null);
|
||||||
|
prisma.user.create.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
email: 'new@test.com',
|
||||||
|
displayName: null,
|
||||||
|
role: 'user',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.createUser({ email: 'new@test.com', password: 'StrongPass1!' });
|
||||||
|
|
||||||
|
const createCall = prisma.user.create.mock.calls[0][0];
|
||||||
|
expect(createCall.data.passwordHash).toMatch(/^\$argon2id\$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestException for duplicate email', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue({ id: 1 });
|
||||||
|
await expect(
|
||||||
|
service.createUser({ email: 'dup@test.com', password: 'pass' }),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('adminDeleteUser', () => {
|
||||||
|
it('should throw ForbiddenException on self-deletion attempt', async () => {
|
||||||
|
await expect(service.adminDeleteUser(1, 1)).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete a different user', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue({ id: 2 });
|
||||||
|
prisma.user.delete.mockResolvedValue({ id: 2 });
|
||||||
|
await service.adminDeleteUser(2, 1);
|
||||||
|
expect(prisma.user.delete).toHaveBeenCalledWith({ where: { id: 2 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException when target user does not exist', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(service.adminDeleteUser(99, 1)).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('totpSetup', () => {
|
||||||
|
it('should encrypt TOTP secret before storage and return secret + qrCode', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue({ id: 1, email: 'test@test.com', totpEnabled: false });
|
||||||
|
prisma.user.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
const result = await service.totpSetup(1);
|
||||||
|
|
||||||
|
expect(encryption.encrypt).toHaveBeenCalled();
|
||||||
|
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ totpSecret: 'v2:encrypted' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toHaveProperty('secret');
|
||||||
|
expect(result).toHaveProperty('qrCode');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestException if TOTP already enabled', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue({ id: 1, totpEnabled: true });
|
||||||
|
await expect(service.totpSetup(1)).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateProfile', () => {
|
||||||
|
it('should throw BadRequestException when changing password without providing current password', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue({ id: 1, passwordHash: 'hash' });
|
||||||
|
await expect(
|
||||||
|
service.updateProfile(1, { newPassword: 'new-password' }),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('adminUpdateUser', () => {
|
||||||
|
it('should throw ForbiddenException when admin tries to remove their own admin role', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue({ id: 1, role: 'admin' });
|
||||||
|
await expect(service.adminUpdateUser(1, 1, { role: 'user' })).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
13
backend/src/auth/jwt-auth.guard.spec.ts
Normal file
13
backend/src/auth/jwt-auth.guard.spec.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// backend/src/auth/jwt-auth.guard.spec.ts
|
||||||
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
|
||||||
|
describe('JwtAuthGuard', () => {
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(new JwtAuthGuard()).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be an instance of JwtAuthGuard', () => {
|
||||||
|
const guard = new JwtAuthGuard();
|
||||||
|
expect(guard).toBeInstanceOf(JwtAuthGuard);
|
||||||
|
});
|
||||||
|
});
|
||||||
56
backend/src/auth/ws-auth.guard.spec.ts
Normal file
56
backend/src/auth/ws-auth.guard.spec.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
// backend/src/auth/ws-auth.guard.spec.ts
|
||||||
|
import { WsAuthGuard } from './ws-auth.guard';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
|
||||||
|
describe('WsAuthGuard', () => {
|
||||||
|
let guard: WsAuthGuard;
|
||||||
|
let jwt: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jwt = {
|
||||||
|
verify: jest.fn().mockReturnValue({ sub: 1, email: 'test@test.com' }),
|
||||||
|
};
|
||||||
|
guard = new WsAuthGuard(jwt as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should authenticate via httpOnly cookie', () => {
|
||||||
|
const req = { headers: { cookie: 'wraith_token=valid-jwt; other=stuff' }, url: '/ws' };
|
||||||
|
const result = guard.validateClient({}, req);
|
||||||
|
expect(jwt.verify).toHaveBeenCalledWith('valid-jwt');
|
||||||
|
expect(result).toEqual({ sub: 1, email: 'test@test.com' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should authenticate via single-use WS ticket', () => {
|
||||||
|
const originalConsume = AuthController.consumeWsTicket;
|
||||||
|
AuthController.consumeWsTicket = jest.fn().mockReturnValue({
|
||||||
|
sub: 42,
|
||||||
|
email: 'ticket@test.com',
|
||||||
|
role: 'user',
|
||||||
|
});
|
||||||
|
const req = { url: '/api/ws/terminal?ticket=abc123', headers: {} };
|
||||||
|
const result = guard.validateClient({}, req);
|
||||||
|
expect(AuthController.consumeWsTicket).toHaveBeenCalledWith('abc123');
|
||||||
|
expect(result).toEqual({ sub: 42, email: 'ticket@test.com' });
|
||||||
|
// Restore
|
||||||
|
AuthController.consumeWsTicket = originalConsume;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to legacy URL token when no cookie or ticket', () => {
|
||||||
|
const req = { url: '/api/ws/terminal?token=legacy-jwt', headers: {} };
|
||||||
|
guard.validateClient({}, req);
|
||||||
|
expect(jwt.verify).toHaveBeenCalledWith('legacy-jwt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when no credentials are present', () => {
|
||||||
|
const req = { url: '/api/ws/terminal', headers: {} };
|
||||||
|
const result = guard.validateClient({}, req);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when JWT verification fails', () => {
|
||||||
|
jwt.verify.mockImplementation(() => { throw new Error('invalid signature'); });
|
||||||
|
const req = { headers: { cookie: 'wraith_token=bad-jwt' }, url: '/ws' };
|
||||||
|
const result = guard.validateClient({}, req);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
178
backend/src/vault/credentials.service.spec.ts
Normal file
178
backend/src/vault/credentials.service.spec.ts
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
// backend/src/vault/credentials.service.spec.ts
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { CredentialsService } from './credentials.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { EncryptionService } from './encryption.service';
|
||||||
|
import { NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||||
|
|
||||||
|
describe('CredentialsService', () => {
|
||||||
|
let service: CredentialsService;
|
||||||
|
let prisma: any;
|
||||||
|
let encryption: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
prisma = {
|
||||||
|
credential: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
encryption = {
|
||||||
|
encrypt: jest.fn().mockResolvedValue('v2:mock:encrypted'),
|
||||||
|
decrypt: jest.fn().mockResolvedValue('decrypted-password'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
CredentialsService,
|
||||||
|
{ provide: PrismaService, useValue: prisma },
|
||||||
|
{ provide: EncryptionService, useValue: encryption },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get(CredentialsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findAll', () => {
|
||||||
|
it('should query by userId and exclude encryptedValue from select', async () => {
|
||||||
|
prisma.credential.findMany.mockResolvedValue([{ id: 1, name: 'test' }]);
|
||||||
|
await service.findAll(1);
|
||||||
|
expect(prisma.credential.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { userId: 1 },
|
||||||
|
select: expect.objectContaining({ id: true, name: true }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// encryptedValue must never appear in the list response (H-10)
|
||||||
|
const call = prisma.credential.findMany.mock.calls[0][0];
|
||||||
|
expect(call.select.encryptedValue).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findOne', () => {
|
||||||
|
it('should return credential for owner', async () => {
|
||||||
|
prisma.credential.findUnique.mockResolvedValue({ id: 1, userId: 1, name: 'cred', sshKey: null, hosts: [] });
|
||||||
|
const result = await service.findOne(1, 1);
|
||||||
|
expect(result.name).toBe('cred');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ForbiddenException when userId does not match', async () => {
|
||||||
|
prisma.credential.findUnique.mockResolvedValue({ id: 1, userId: 2, name: 'cred', sshKey: null, hosts: [] });
|
||||||
|
await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException for missing credential', async () => {
|
||||||
|
prisma.credential.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(service.findOne(99, 1)).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('should encrypt password before storage', async () => {
|
||||||
|
prisma.credential.create.mockResolvedValue({ id: 1 });
|
||||||
|
await service.create(1, {
|
||||||
|
name: 'test',
|
||||||
|
username: 'admin',
|
||||||
|
password: 'secret',
|
||||||
|
type: 'password' as any,
|
||||||
|
});
|
||||||
|
expect(encryption.encrypt).toHaveBeenCalledWith('secret');
|
||||||
|
expect(prisma.credential.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ encryptedValue: 'v2:mock:encrypted' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store null encryptedValue when no password provided', async () => {
|
||||||
|
prisma.credential.create.mockResolvedValue({ id: 2 });
|
||||||
|
await service.create(1, { name: 'ssh-only', type: 'ssh_key' as any, sshKeyId: 5 });
|
||||||
|
expect(encryption.encrypt).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.credential.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ encryptedValue: null }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decryptForConnection', () => {
|
||||||
|
it('should decrypt password credential', async () => {
|
||||||
|
prisma.credential.findUnique.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
domain: null,
|
||||||
|
encryptedValue: 'v2:encrypted',
|
||||||
|
sshKey: null,
|
||||||
|
sshKeyId: null,
|
||||||
|
});
|
||||||
|
const result = await service.decryptForConnection(1);
|
||||||
|
expect(result.password).toBe('decrypted-password');
|
||||||
|
expect(result.username).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should decrypt SSH key credential with passphrase', async () => {
|
||||||
|
prisma.credential.findUnique.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
username: 'root',
|
||||||
|
domain: null,
|
||||||
|
encryptedValue: null,
|
||||||
|
sshKey: { encryptedPrivateKey: 'v2:key', passphraseEncrypted: 'v2:pass' },
|
||||||
|
sshKeyId: 5,
|
||||||
|
});
|
||||||
|
encryption.decrypt
|
||||||
|
.mockResolvedValueOnce('private-key-content')
|
||||||
|
.mockResolvedValueOnce('passphrase');
|
||||||
|
const result = await service.decryptForConnection(1);
|
||||||
|
expect(result.sshKey).toEqual({ privateKey: 'private-key-content', passphrase: 'passphrase' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException for orphaned SSH key reference', async () => {
|
||||||
|
prisma.credential.findUnique.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
name: 'orphan',
|
||||||
|
username: 'root',
|
||||||
|
domain: null,
|
||||||
|
encryptedValue: null,
|
||||||
|
sshKey: null,
|
||||||
|
sshKeyId: 99,
|
||||||
|
});
|
||||||
|
await expect(service.decryptForConnection(1)).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException for credential with no auth method', async () => {
|
||||||
|
prisma.credential.findUnique.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
name: 'empty',
|
||||||
|
username: 'root',
|
||||||
|
domain: null,
|
||||||
|
encryptedValue: null,
|
||||||
|
sshKey: null,
|
||||||
|
sshKeyId: null,
|
||||||
|
});
|
||||||
|
await expect(service.decryptForConnection(1)).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException for missing credential', async () => {
|
||||||
|
prisma.credential.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(service.decryptForConnection(99)).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remove', () => {
|
||||||
|
it('should delete owned credential', async () => {
|
||||||
|
prisma.credential.findUnique.mockResolvedValue({ id: 1, userId: 1, sshKey: null, hosts: [] });
|
||||||
|
prisma.credential.delete.mockResolvedValue({ id: 1 });
|
||||||
|
await service.remove(1, 1);
|
||||||
|
expect(prisma.credential.delete).toHaveBeenCalledWith({ where: { id: 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ForbiddenException when removing non-owned credential', async () => {
|
||||||
|
prisma.credential.findUnique.mockResolvedValue({ id: 1, userId: 2, sshKey: null, hosts: [] });
|
||||||
|
await expect(service.remove(1, 1)).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
85
backend/src/vault/encryption.service.spec.ts
Normal file
85
backend/src/vault/encryption.service.spec.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
// backend/src/vault/encryption.service.spec.ts
|
||||||
|
|
||||||
|
// Set test encryption key before importing the service — constructor reads process.env.ENCRYPTION_KEY
|
||||||
|
process.env.ENCRYPTION_KEY = 'a'.repeat(64); // 64 hex chars = 32 bytes
|
||||||
|
|
||||||
|
import { EncryptionService } from './encryption.service';
|
||||||
|
|
||||||
|
describe('EncryptionService', () => {
|
||||||
|
let service: EncryptionService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
service = new EncryptionService();
|
||||||
|
await service.onModuleInit();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encrypt/decrypt round-trip', () => {
|
||||||
|
it('should encrypt and decrypt a string', async () => {
|
||||||
|
const plaintext = 'my-secret-password';
|
||||||
|
const encrypted = await service.encrypt(plaintext);
|
||||||
|
expect(encrypted).toMatch(/^v2:/);
|
||||||
|
const decrypted = await service.decrypt(encrypted);
|
||||||
|
expect(decrypted).toBe(plaintext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce different ciphertexts for the same plaintext (random salt + IV)', async () => {
|
||||||
|
const plaintext = 'same-input';
|
||||||
|
const a = await service.encrypt(plaintext);
|
||||||
|
const b = await service.encrypt(plaintext);
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string', async () => {
|
||||||
|
const encrypted = await service.encrypt('');
|
||||||
|
const decrypted = await service.decrypt(encrypted);
|
||||||
|
expect(decrypted).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unicode and emoji', async () => {
|
||||||
|
const plaintext = '密码 пароль 🔐';
|
||||||
|
const encrypted = await service.encrypt(plaintext);
|
||||||
|
const decrypted = await service.decrypt(encrypted);
|
||||||
|
expect(decrypted).toBe(plaintext);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('v2 format structure', () => {
|
||||||
|
it('should produce a 5-part colon-delimited ciphertext', async () => {
|
||||||
|
const encrypted = await service.encrypt('test');
|
||||||
|
const parts = encrypted.split(':');
|
||||||
|
expect(parts[0]).toBe('v2');
|
||||||
|
expect(parts).toHaveLength(5); // v2:salt:iv:authTag:ciphertext
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isV1', () => {
|
||||||
|
it('should detect v1 format', () => {
|
||||||
|
expect(service.isV1('v1:abc123:def456:ghi789')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not classify v2 as v1', () => {
|
||||||
|
expect(service.isV1('v2:abc:def:ghi:jkl')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('upgradeToV2', () => {
|
||||||
|
it('should return null for already-v2 ciphertext', async () => {
|
||||||
|
const v2 = await service.encrypt('test');
|
||||||
|
const result = await service.upgradeToV2(v2);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should throw on unknown encryption version', async () => {
|
||||||
|
await expect(service.decrypt('v3:bad:data')).rejects.toThrow('Unknown encryption version');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on tampered ciphertext (auth tag mismatch)', async () => {
|
||||||
|
const encrypted = await service.encrypt('test');
|
||||||
|
// Corrupt the last 4 hex chars of the ciphertext segment
|
||||||
|
const tampered = encrypted.slice(0, -4) + 'dead';
|
||||||
|
await expect(service.decrypt(tampered)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
147
backend/src/vault/ssh-keys.service.spec.ts
Normal file
147
backend/src/vault/ssh-keys.service.spec.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
// backend/src/vault/ssh-keys.service.spec.ts
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { SshKeysService } from './ssh-keys.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { EncryptionService } from './encryption.service';
|
||||||
|
import { NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||||
|
|
||||||
|
describe('SshKeysService', () => {
|
||||||
|
let service: SshKeysService;
|
||||||
|
let prisma: any;
|
||||||
|
let encryption: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
prisma = {
|
||||||
|
sshKey: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
encryption = {
|
||||||
|
encrypt: jest.fn().mockResolvedValue('v2:mock:encrypted'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
SshKeysService,
|
||||||
|
{ provide: PrismaService, useValue: prisma },
|
||||||
|
{ provide: EncryptionService, useValue: encryption },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get(SshKeysService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findAll', () => {
|
||||||
|
it('should return keys without private key data', async () => {
|
||||||
|
prisma.sshKey.findMany.mockResolvedValue([{ id: 1, name: 'key1' }]);
|
||||||
|
await service.findAll(1);
|
||||||
|
const call = prisma.sshKey.findMany.mock.calls[0][0];
|
||||||
|
expect(call.select.encryptedPrivateKey).toBeUndefined();
|
||||||
|
expect(call.select.passphraseEncrypted).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findOne', () => {
|
||||||
|
it('should return key for owner without encrypted private key field', async () => {
|
||||||
|
prisma.sshKey.findUnique.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
userId: 1,
|
||||||
|
name: 'key1',
|
||||||
|
keyType: 'ed25519',
|
||||||
|
fingerprint: 'SHA256:abc',
|
||||||
|
publicKey: null,
|
||||||
|
credentials: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
encryptedPrivateKey: 'v2:secret',
|
||||||
|
});
|
||||||
|
const result = await service.findOne(1, 1);
|
||||||
|
expect(result.name).toBe('key1');
|
||||||
|
// findOne strips encryptedPrivateKey from the returned object
|
||||||
|
expect((result as any).encryptedPrivateKey).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ForbiddenException for non-owner', async () => {
|
||||||
|
prisma.sshKey.findUnique.mockResolvedValue({ id: 1, userId: 2, name: 'key1', credentials: [] });
|
||||||
|
await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException for missing key', async () => {
|
||||||
|
prisma.sshKey.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(service.findOne(99, 1)).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('should encrypt both private key and passphrase', async () => {
|
||||||
|
prisma.sshKey.create.mockResolvedValue({ id: 1 });
|
||||||
|
await service.create(1, {
|
||||||
|
name: 'my-key',
|
||||||
|
privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\ndata\n-----END OPENSSH PRIVATE KEY-----',
|
||||||
|
passphrase: 'secret',
|
||||||
|
});
|
||||||
|
expect(encryption.encrypt).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect RSA key type', async () => {
|
||||||
|
prisma.sshKey.create.mockResolvedValue({ id: 1 });
|
||||||
|
await service.create(1, {
|
||||||
|
name: 'rsa-key',
|
||||||
|
privateKey: '-----BEGIN RSA PRIVATE KEY-----\ndata\n-----END RSA PRIVATE KEY-----',
|
||||||
|
});
|
||||||
|
expect(prisma.sshKey.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ keyType: 'rsa' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect ed25519 key type from OPENSSH header', async () => {
|
||||||
|
prisma.sshKey.create.mockResolvedValue({ id: 1 });
|
||||||
|
await service.create(1, {
|
||||||
|
name: 'ed25519-key',
|
||||||
|
privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\ndata\n-----END OPENSSH PRIVATE KEY-----',
|
||||||
|
});
|
||||||
|
expect(prisma.sshKey.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ keyType: 'ed25519' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect ECDSA key type', async () => {
|
||||||
|
prisma.sshKey.create.mockResolvedValue({ id: 1 });
|
||||||
|
await service.create(1, {
|
||||||
|
name: 'ecdsa-key',
|
||||||
|
privateKey: '-----BEGIN EC PRIVATE KEY-----\ndata\n-----END EC PRIVATE KEY-----',
|
||||||
|
});
|
||||||
|
expect(prisma.sshKey.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ keyType: 'ecdsa' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remove', () => {
|
||||||
|
it('should delete owned key', async () => {
|
||||||
|
prisma.sshKey.findUnique.mockResolvedValue({ id: 1, userId: 1 });
|
||||||
|
prisma.sshKey.delete.mockResolvedValue({ id: 1 });
|
||||||
|
await service.remove(1, 1);
|
||||||
|
expect(prisma.sshKey.delete).toHaveBeenCalledWith({ where: { id: 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ForbiddenException for non-owner', async () => {
|
||||||
|
prisma.sshKey.findUnique.mockResolvedValue({ id: 1, userId: 2 });
|
||||||
|
await expect(service.remove(1, 1)).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException for missing key', async () => {
|
||||||
|
prisma.sshKey.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(service.remove(99, 1)).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,56 +0,0 @@
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
|
||||||
import { AuthService } from '../src/auth/auth.service';
|
|
||||||
import * as bcrypt from 'bcrypt';
|
|
||||||
|
|
||||||
describe('AuthService', () => {
|
|
||||||
let service: AuthService;
|
|
||||||
const mockPrisma = {
|
|
||||||
user: {
|
|
||||||
findUnique: jest.fn(),
|
|
||||||
count: jest.fn(),
|
|
||||||
create: jest.fn(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const mockJwt = {
|
|
||||||
sign: jest.fn().mockReturnValue('mock-jwt-token'),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
service = new AuthService(mockPrisma as any, mockJwt as any);
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns token for valid credentials', async () => {
|
|
||||||
const hash = await bcrypt.hash('password123', 10);
|
|
||||||
mockPrisma.user.findUnique.mockResolvedValue({
|
|
||||||
id: 1,
|
|
||||||
email: 'admin@wraith.local',
|
|
||||||
passwordHash: hash,
|
|
||||||
displayName: 'Admin',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.login('admin@wraith.local', 'password123');
|
|
||||||
expect(result).toEqual({
|
|
||||||
access_token: 'mock-jwt-token',
|
|
||||||
user: { id: 1, email: 'admin@wraith.local', displayName: 'Admin' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws on wrong password', async () => {
|
|
||||||
const hash = await bcrypt.hash('correct', 10);
|
|
||||||
mockPrisma.user.findUnique.mockResolvedValue({
|
|
||||||
id: 1,
|
|
||||||
email: 'admin@wraith.local',
|
|
||||||
passwordHash: hash,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(service.login('admin@wraith.local', 'wrong'))
|
|
||||||
.rejects.toThrow('Invalid credentials');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws on unknown user', async () => {
|
|
||||||
mockPrisma.user.findUnique.mockResolvedValue(null);
|
|
||||||
await expect(service.login('nobody@wraith.local', 'pass'))
|
|
||||||
.rejects.toThrow('Invalid credentials');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
import { EncryptionService } from '../src/vault/encryption.service';
|
|
||||||
|
|
||||||
describe('EncryptionService', () => {
|
|
||||||
let service: EncryptionService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// 32-byte key as 64-char hex string
|
|
||||||
process.env.ENCRYPTION_KEY = 'a'.repeat(64);
|
|
||||||
service = new EncryptionService();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('encrypts and decrypts a string', () => {
|
|
||||||
const plaintext = 'my-secret-password';
|
|
||||||
const encrypted = service.encrypt(plaintext);
|
|
||||||
expect(encrypted).not.toEqual(plaintext);
|
|
||||||
expect(encrypted.startsWith('v1:')).toBe(true);
|
|
||||||
expect(service.decrypt(encrypted)).toEqual(plaintext);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('produces different ciphertext for same plaintext (random IV)', () => {
|
|
||||||
const plaintext = 'same-input';
|
|
||||||
const a = service.encrypt(plaintext);
|
|
||||||
const b = service.encrypt(plaintext);
|
|
||||||
expect(a).not.toEqual(b);
|
|
||||||
expect(service.decrypt(a)).toEqual(plaintext);
|
|
||||||
expect(service.decrypt(b)).toEqual(plaintext);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws on tampered ciphertext', () => {
|
|
||||||
const encrypted = service.encrypt('test');
|
|
||||||
const parts = encrypted.split(':');
|
|
||||||
parts[3] = 'ff' + parts[3].slice(2); // tamper ciphertext
|
|
||||||
expect(() => service.decrypt(parts.join(':'))).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles empty string', () => {
|
|
||||||
const encrypted = service.encrypt('');
|
|
||||||
expect(service.decrypt(encrypted)).toEqual('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles unicode', () => {
|
|
||||||
const plaintext = 'p@$$w0rd-日本語-🔑';
|
|
||||||
const encrypted = service.encrypt(plaintext);
|
|
||||||
expect(service.decrypt(encrypted)).toEqual(plaintext);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue
Block a user