Resolve merge conflicts: prefer current branch (keep M2M and bot client changes)
Deploy / deploy (push) Failing after 18s
Deploy / deploy (push) Failing after 18s
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
Playwright API Testing for RayLab Core
|
||||
|
||||
Structure:
|
||||
- helpers: auth/request helpers
|
||||
- fixtures: reusable data helpers
|
||||
- api: per-controller tests
|
||||
- reporter: custom reporter writing tests/output/latest-result.txt
|
||||
|
||||
Run:
|
||||
npx playwright test
|
||||
|
||||
Ensure .env is configured with BASE_URL and identity provider (Authentik or compatible) details.
|
||||
@@ -0,0 +1,11 @@
|
||||
import test from '@playwright/test';
|
||||
import { requestPatch } from '../../helpers/request';
|
||||
|
||||
const { expect } = test;
|
||||
|
||||
test.describe('Users - PATCH /users/:id/enable', () => {
|
||||
test('should return 404 for non-existing user', async () => {
|
||||
const res = await requestPatch('/users/non-existing-id/enable');
|
||||
expect([404, 400, 500]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import test from '@playwright/test';
|
||||
import { requestGet } from '../../helpers/request';
|
||||
|
||||
const { expect } = test;
|
||||
|
||||
test.describe('Users - GET /users/me', () => {
|
||||
test('should return current user when token provided', async () => {
|
||||
const res = await requestGet('/users/me');
|
||||
expect(res.status).toBeGreaterThanOrEqual(200);
|
||||
expect(res.status).toBeLessThan(300);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data).toBeDefined();
|
||||
expect(res.body.data.email).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should be unauthorized without token', async () => {
|
||||
const res = await requestGet('/users/me', null);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import test from '@playwright/test';
|
||||
import { requestGet } from '../../helpers/request';
|
||||
|
||||
const { expect } = test;
|
||||
|
||||
test.describe('Users - GET /users/:id', () => {
|
||||
test('should return 404 for non-existing user', async () => {
|
||||
const res = await requestGet('/users/non-existing-id');
|
||||
expect([404, 400, 500]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import test from '@playwright/test';
|
||||
import { requestGet } from '../../helpers/request';
|
||||
|
||||
|
||||
const { expect } = test;
|
||||
|
||||
test.describe('Users - GET /users', () => {
|
||||
test('should return list of users (authorized)', async () => {
|
||||
const res = await requestGet('/users');
|
||||
|
||||
expect(res.status).toBeGreaterThanOrEqual(200);
|
||||
expect(res.status).toBeLessThan(300);
|
||||
expect(res.body).toBeTruthy();
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data).toBeInstanceOf(Array);
|
||||
expect(res.body.meta).toBeDefined();
|
||||
});
|
||||
|
||||
test('should return unauthorized when missing token', async () => {
|
||||
const res = await requestGet('/users', null);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { AuthenticationService } from '../src/modules/auth/authentication.service';
|
||||
|
||||
describe('AuthenticationService', () => {
|
||||
let service: AuthenticationService;
|
||||
const mockOidc: any = { verifyToken: jest.fn() };
|
||||
const mockPrisma: any = {};
|
||||
const mockRoleSync: any = { syncUserRolesFromAuthentik: jest.fn() };
|
||||
const mockAuthz: any = { getUserPermissions: jest.fn() };
|
||||
const mockEvents: any = { publish: jest.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma.user = { findUnique: jest.fn(), create: jest.fn() };
|
||||
mockPrisma.userRole = { findMany: jest.fn() };
|
||||
|
||||
service = new AuthenticationService(mockOidc as any, mockPrisma as any, mockRoleSync as any, mockAuthz as any, mockEvents as any);
|
||||
});
|
||||
|
||||
test('authenticate creates context on valid token', async () => {
|
||||
const token = 'valid';
|
||||
mockOidc.verifyToken.mockResolvedValue({ sub: 'sub1', email: 'u@example.com', groups: ['RL-Owner'] });
|
||||
mockPrisma.user.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.user.create.mockResolvedValue({ id: 'uid', authentikId: 'sub1', email: 'u@example.com' });
|
||||
mockRoleSync.syncUserRolesFromAuthentik.mockResolvedValue({ skipped: false, assignedRoleIds: ['r1'] });
|
||||
mockAuthz.getUserPermissions.mockResolvedValue(['users.read']);
|
||||
mockPrisma.userRole.findMany.mockResolvedValue([{ roleId: 'r1' }]);
|
||||
|
||||
const ctx = await service.authenticate(token);
|
||||
|
||||
expect(ctx.user.id).toBe('uid');
|
||||
expect(ctx.permissions).toContain('users.read');
|
||||
expect(mockEvents.publish).toHaveBeenCalledWith('user.authenticated', expect.any(Object));
|
||||
});
|
||||
|
||||
test('authenticate throws for missing sub', async () => {
|
||||
mockOidc.verifyToken.mockResolvedValue({ email: 'u@example.com' });
|
||||
await expect(service.authenticate('bad')).rejects.toThrow('Invalid token: missing sub');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { AuthorizationService, PERMISSION_CACHE } from '../src/modules/authorization/authorization.service';
|
||||
|
||||
describe('AuthorizationService', () => {
|
||||
let service: AuthorizationService;
|
||||
const mockPrisma: any = { $queryRaw: jest.fn(), $queryRawUnsafe: jest.fn() };
|
||||
const mockCache: any = { get: jest.fn(), set: jest.fn(), invalidate: jest.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
service = new AuthorizationService(mockPrisma as any, mockCache as any);
|
||||
});
|
||||
|
||||
test('getUserPermissions uses cache when available', async () => {
|
||||
mockCache.get.mockResolvedValue(['users.read']);
|
||||
const perms = await service.getUserPermissions('uid');
|
||||
expect(perms).toEqual(['users.read']);
|
||||
expect(mockCache.get).toHaveBeenCalledWith('uid');
|
||||
});
|
||||
|
||||
test('getUserPermissions queries DB and sets cache when missing', async () => {
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
mockPrisma.$queryRaw = jest.fn().mockResolvedValue([{ code: 'users.read' }]);
|
||||
const perms = await service.getUserPermissions('uid');
|
||||
expect(perms).toEqual(['users.read']);
|
||||
expect(mockCache.set).toHaveBeenCalledWith('uid', ['users.read']);
|
||||
});
|
||||
|
||||
test('hasPermission returns correct boolean', async () => {
|
||||
jest.spyOn(service, 'getUserPermissions' as any).mockResolvedValue(['users.read']);
|
||||
const ok = await service.hasPermission('uid', 'users.read');
|
||||
expect(ok).toBe(true);
|
||||
const nok = await service.hasPermission('uid', 'users.delete');
|
||||
expect(nok).toBe(false);
|
||||
});
|
||||
});
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
import { requestPost, requestDelete } from '../helpers/request';
|
||||
|
||||
export async function createDummyUser(payload: any) {
|
||||
const res = await requestPost('/user', 'admin', payload);
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function deleteDummyUser(userId: string) {
|
||||
// soft delete
|
||||
await requestDelete(`/users/${userId}`, 'admin');
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { env } from './env';
|
||||
|
||||
const store: Map<string, { token: string; expiresAt?: number }> = new Map();
|
||||
|
||||
function setToken(key: string, token: string, expiresIn?: number) {
|
||||
const expiresAt = expiresIn ? Date.now() + expiresIn * 1000 - 5000 : undefined;
|
||||
store.set(key, { token, expiresAt });
|
||||
}
|
||||
|
||||
function getTokenFromStore(key: string) {
|
||||
const entry = store.get(key);
|
||||
if (!entry) return null;
|
||||
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
||||
store.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.token;
|
||||
}
|
||||
|
||||
async function loginViaRaylab(username: string, password: string) {
|
||||
const base = env.BASE_URL || 'http://localhost:3000';
|
||||
const browser = await chromium.launch({ headless: Boolean(process.env.PW_HEADLESS || '1') });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
// Navigate to RayLab - expects redirect to identity provider
|
||||
await page.goto(base, { waitUntil: 'networkidle' });
|
||||
|
||||
// Wait for a login form to appear on identity provider
|
||||
try {
|
||||
await page.waitForSelector('input[type="text"], input[type="email"], input[name="username"]', { timeout: 10000 });
|
||||
// fill username
|
||||
const usernameInput = await page.$('input[name="username"]') || await page.$('input[type="email"]') || await page.$('input[type="text"]');
|
||||
if (usernameInput) await usernameInput.fill(username);
|
||||
|
||||
// fill password
|
||||
const passwordInput = await page.$('input[type="password"], input[name="password"]');
|
||||
if (passwordInput) await passwordInput.fill(password);
|
||||
|
||||
// try to submit
|
||||
const submitButton = await page.$('button[type="submit"], input[type="submit"]');
|
||||
if (submitButton) {
|
||||
await Promise.all([page.waitForNavigation({ waitUntil: 'networkidle', timeout: 20000 }), submitButton.click()]);
|
||||
}
|
||||
} catch (e) {
|
||||
// If no login form found, we may already be at callback
|
||||
}
|
||||
|
||||
// Wait for callback to be done and cookie to be set
|
||||
await page.waitForTimeout(1000);
|
||||
const cookies = await context.cookies();
|
||||
const jwtCookie = cookies.find(c => c.name === 'raylab_jwt');
|
||||
const refreshCookie = cookies.find(c => c.name === 'raylab_refresh');
|
||||
|
||||
const result: any = {};
|
||||
if (jwtCookie) result.accessToken = jwtCookie.value;
|
||||
if (refreshCookie) result.refreshToken = refreshCookie.value;
|
||||
|
||||
await context.close();
|
||||
await browser.close();
|
||||
|
||||
if (!result.accessToken) throw new Error('Login failed: no session cookie set');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getToken(role: 'owner' | 'admin' | 'employee' = 'admin') {
|
||||
const cacheKey = `role:${role}`;
|
||||
const cached = getTokenFromStore(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const username = role === 'owner' ? env.OWNER_USERNAME : role === 'admin' ? env.ADMIN_USERNAME : env.EMPLOYEE_USERNAME;
|
||||
const password = role === 'owner' ? env.OWNER_PASSWORD : role === 'admin' ? env.ADMIN_PASSWORD : env.EMPLOYEE_PASSWORD;
|
||||
|
||||
if (!username || !password) throw new Error('Missing credentials in .env for role: ' + role);
|
||||
|
||||
const data = await loginViaRaylab(username, password);
|
||||
const token = data.accessToken;
|
||||
// expiresIn not available from cookie - default to 1 hour
|
||||
const expiresIn = Number(process.env.TEST_TOKEN_EXPIRES_IN || 3600);
|
||||
setToken(cacheKey, token, expiresIn);
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function getRawToken(username: string, password: string) {
|
||||
const data = await loginViaRaylab(username, password);
|
||||
return data.accessToken;
|
||||
}
|
||||
|
||||
export default { getToken, getRawToken };
|
||||
@@ -0,0 +1,7 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
const envPath = path.resolve(process.cwd(), '.env');
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
export const env = process.env;
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './auth';
|
||||
export * from './request';
|
||||
export * from './env';
|
||||
@@ -0,0 +1,73 @@
|
||||
import { request } from '@playwright/test';
|
||||
import { env } from './env';
|
||||
import { getToken } from './auth';
|
||||
|
||||
async function buildContext(token?: string) {
|
||||
const headers: any = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const api = await request.newContext({ baseURL: env.BASE_URL || 'http://localhost:3000', extraHTTPHeaders: headers });
|
||||
return api;
|
||||
}
|
||||
|
||||
export async function requestGet(path: string, role: 'owner' | 'admin' | 'employee' | null = 'admin', opts: any = {}) {
|
||||
const token = role ? await getToken(role) : undefined;
|
||||
const api = await buildContext(token);
|
||||
try {
|
||||
const res = await api.get(path, opts);
|
||||
const body = await parseResponseSafe(res);
|
||||
return { status: res.status(), body, headers: res.headers() };
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestPost(path: string, role: 'owner' | 'admin' | 'employee' | null = 'admin', data?: any, opts: any = {}) {
|
||||
const token = role ? await getToken(role) : undefined;
|
||||
const api = await buildContext(token);
|
||||
try {
|
||||
const res = await api.post(path, { data, ...opts });
|
||||
const body = await parseResponseSafe(res);
|
||||
return { status: res.status(), body, headers: res.headers() };
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestPatch(path: string, role: 'owner' | 'admin' | 'employee' | null = 'admin', data?: any, opts: any = {}) {
|
||||
const token = role ? await getToken(role) : undefined;
|
||||
const api = await buildContext(token);
|
||||
try {
|
||||
const res = await api.patch(path, { data, ...opts });
|
||||
const body = await parseResponseSafe(res);
|
||||
return { status: res.status(), body, headers: res.headers() };
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestDelete(path: string, role: 'owner' | 'admin' | 'employee' | null = 'admin', opts: any = {}) {
|
||||
const token = role ? await getToken(role) : undefined;
|
||||
const api = await buildContext(token);
|
||||
try {
|
||||
const res = await api.delete(path, opts);
|
||||
const body = await parseResponseSafe(res);
|
||||
return { status: res.status(), body, headers: res.headers() };
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function parseResponseSafe(res: any) {
|
||||
const ct = res.headers()['content-type'] || '';
|
||||
try {
|
||||
if (ct.includes('application/json')) return await res.json();
|
||||
return await res.text();
|
||||
} catch (e) {
|
||||
try {
|
||||
return await res.text();
|
||||
} catch (e2) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import dotenv from 'dotenv';
|
||||
import fetch from 'node-fetch';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const TEST_USER_EMAIL = process.env.TEST_USER_EMAIL || 'test-integration@example.com';
|
||||
const TEST_USER_USERNAME = process.env.TEST_USER_USERNAME || 'test-integration';
|
||||
const TEST_USER_PASSWORD = process.env.TEST_USER_PASSWORD || 'StrongP@ssw0rd!';
|
||||
|
||||
let BASE_URL = (process.env.BASE_URL || 'http://localhost:3000').trim();
|
||||
// sometimes env can contain accidental concatenated vars; take first token
|
||||
BASE_URL = BASE_URL.split(/\s+/)[0];
|
||||
if (!BASE_URL.startsWith('http://') && !BASE_URL.startsWith('https://')) {
|
||||
BASE_URL = 'http://' + BASE_URL;
|
||||
}
|
||||
|
||||
// increase default timeout for slow integration flows
|
||||
test.setTimeout(60000);
|
||||
|
||||
async function adminLogin() {
|
||||
const adminUser = process.env.ADMIN_USERNAME;
|
||||
const adminPass = process.env.ADMIN_PASSWORD;
|
||||
if (!adminUser || !adminPass) throw new Error('ADMIN_USERNAME/ADMIN_PASSWORD must be set in env for integration tests');
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto(BASE_URL, { waitUntil: 'networkidle' });
|
||||
|
||||
try {
|
||||
await page.waitForSelector('input[type="text"], input[type="email"], input[name="username"]', { timeout: 10000 });
|
||||
const usernameInput = await page.$('input[name="username"]') || await page.$('input[type="email"]') || await page.$('input[type="text"]');
|
||||
if (usernameInput) await usernameInput.fill(adminUser);
|
||||
const passwordInput = await page.$('input[type="password"], input[name="password"]');
|
||||
if (passwordInput) await passwordInput.fill(adminPass);
|
||||
const submitButton = await page.$('button[type="submit"], input[type="submit"]');
|
||||
if (submitButton) {
|
||||
await Promise.all([page.waitForNavigation({ waitUntil: 'networkidle', timeout: 20000 }), submitButton.click()]);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore if login form not present
|
||||
}
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
const cookies = await context.cookies();
|
||||
const jwtCookie = cookies.find(c => c.name === 'raylab_jwt');
|
||||
await context.close();
|
||||
await browser.close();
|
||||
|
||||
if (!jwtCookie) throw new Error('Admin login failed: no session cookie');
|
||||
return jwtCookie.value;
|
||||
}
|
||||
|
||||
async function apiRequest(path, token, opts = {}) {
|
||||
const headers = Object.assign({ 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, opts.headers || {});
|
||||
const method = opts.method || 'GET';
|
||||
const body = opts.body ? JSON.stringify(opts.body) : undefined;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(path, BASE_URL).toString();
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid BASE_URL for integration tests: ${BASE_URL}`);
|
||||
}
|
||||
const res = await fetch(url, { method, headers, body });
|
||||
let json = null;
|
||||
try { json = await res.json(); } catch (e) { json = null; }
|
||||
return { status: res.status, body: json };
|
||||
}
|
||||
|
||||
|
||||
test.describe('Integration - User lifecycle', () => {
|
||||
let adminToken;
|
||||
let createdUser = null;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Integration tests assume test users are created in Authentik prior to running.
|
||||
// RayLab will create local user record upon first successful login via Authentik.
|
||||
});
|
||||
|
||||
test('login via Authentik creates local user and issues internal JWT', async () => {
|
||||
// use browser flow to login as the test user
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto(BASE_URL, { waitUntil: 'networkidle' });
|
||||
|
||||
try {
|
||||
await page.waitForSelector('input[type="text"], input[type="email"], input[name="username"]', { timeout: 10000 });
|
||||
const usernameInput = await page.$('input[name="username"]') || await page.$('input[type="email"]') || await page.$('input[type="text"]');
|
||||
if (usernameInput) await usernameInput.fill(TEST_USER_USERNAME);
|
||||
const passwordInput = await page.$('input[type="password"], input[name="password"]');
|
||||
if (passwordInput) await passwordInput.fill(TEST_USER_PASSWORD);
|
||||
const submitButton = await page.$('button[type="submit"], input[type="submit"]');
|
||||
if (submitButton) {
|
||||
await Promise.all([page.waitForNavigation({ waitUntil: 'networkidle', timeout: 20000 }), submitButton.click()]);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore if login form not present
|
||||
}
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
const cookies = await context.cookies();
|
||||
const jwtCookie = cookies.find(c => c.name === 'raylab_jwt');
|
||||
const refreshCookie = cookies.find(c => c.name === 'raylab_refresh');
|
||||
|
||||
if (!jwtCookie) {
|
||||
const fs = require('fs');
|
||||
const dir = 'tests/test-artifacts';
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const ts = Date.now();
|
||||
await page.screenshot({ path: `${dir}/failed-login-${ts}.png`, fullPage: true });
|
||||
fs.writeFileSync(`${dir}/failed-login-${ts}-cookies.json`, JSON.stringify(cookies, null, 2), 'utf-8');
|
||||
const content = await page.content();
|
||||
fs.writeFileSync(`${dir}/failed-login-${ts}-page.html`, content, 'utf-8');
|
||||
await context.close();
|
||||
await browser.close();
|
||||
throw new Error(`No jwt cookie set after login. Saved screenshot/cookies/page to ${dir}`);
|
||||
}
|
||||
|
||||
// ensure we have an internal jwt cookie
|
||||
expect(jwtCookie).toBeDefined();
|
||||
expect(jwtCookie.value).toBeTruthy();
|
||||
|
||||
// validate /auth/me using the internal jwt
|
||||
const token = jwtCookie.value;
|
||||
const meUrl = new URL('/auth/me', BASE_URL).toString();
|
||||
const meResp = await fetch(meUrl, { headers: { Authorization: `Bearer ${token}` } });
|
||||
const meJson = await meResp.json();
|
||||
expect(meResp.status).toBeLessThan(300);
|
||||
expect(meJson.success).toBe(true);
|
||||
expect(meJson.data).toBeDefined();
|
||||
expect(meJson.data.email).toBe(TEST_USER_EMAIL);
|
||||
|
||||
await context.close();
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
test('login via Authentik (created user) issues internal JWT', async () => {
|
||||
// use browser flow to login as the created user
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto(BASE_URL, { waitUntil: 'networkidle' });
|
||||
|
||||
try {
|
||||
await page.waitForSelector('input[type="text"], input[type="email"], input[name="username"]', { timeout: 10000 });
|
||||
const usernameInput = await page.$('input[name="username"]') || await page.$('input[type="email"]') || await page.$('input[type="text"]');
|
||||
if (usernameInput) await usernameInput.fill(TEST_USER_USERNAME);
|
||||
const passwordInput = await page.$('input[type="password"], input[name="password"]');
|
||||
if (passwordInput) await passwordInput.fill(TEST_USER_PASSWORD);
|
||||
const submitButton = await page.$('button[type="submit"], input[type="submit"]');
|
||||
if (submitButton) {
|
||||
await Promise.all([page.waitForNavigation({ waitUntil: 'networkidle', timeout: 20000 }), submitButton.click()]);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore if login form not present
|
||||
}
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
const cookies = await context.cookies();
|
||||
const jwtCookie = cookies.find(c => c.name === 'raylab_jwt');
|
||||
|
||||
if (!jwtCookie) {
|
||||
const fs = require('fs');
|
||||
const dir = 'tests/test-artifacts';
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const ts = Date.now();
|
||||
await page.screenshot({ path: `${dir}/failed-login-2-${ts}.png`, fullPage: true });
|
||||
fs.writeFileSync(`${dir}/failed-login-2-${ts}-cookies.json`, JSON.stringify(cookies, null, 2), 'utf-8');
|
||||
const content = await page.content();
|
||||
fs.writeFileSync(`${dir}/failed-login-2-${ts}-page.html`, content, 'utf-8');
|
||||
await context.close();
|
||||
await browser.close();
|
||||
throw new Error(`No jwt cookie set after login (second test). Saved screenshot/cookies/page to ${dir}`);
|
||||
}
|
||||
|
||||
await context.close();
|
||||
await browser.close();
|
||||
|
||||
expect(jwtCookie).toBeDefined();
|
||||
expect(jwtCookie.value).toBeTruthy();
|
||||
});
|
||||
|
||||
// Cleanup via API provisioning has been removed. If test environment requires cleanup, perform manually in Authentik.
|
||||
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
TEST RUN FAILED OR ABORTED
|
||||
@@ -0,0 +1,72 @@
|
||||
const fs = require('fs');
|
||||
|
||||
class CustomReporter {
|
||||
onEnd(config, result) {
|
||||
const outputPath = 'tests/output/latest-result.txt';
|
||||
|
||||
if (!result) {
|
||||
// test run aborted or failed to initialize
|
||||
if (!fs.existsSync('tests/output')) fs.mkdirSync('tests/output', { recursive: true });
|
||||
fs.writeFileSync(outputPath, 'TEST RUN FAILED OR ABORTED', 'utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
const failed = result.status === 'failed' || result.numFailedTests > 0;
|
||||
|
||||
const date = new Date().toISOString();
|
||||
const duration = (result.duration || 0) + 'ms';
|
||||
|
||||
if (!fs.existsSync('tests/output')) fs.mkdirSync('tests/output', { recursive: true });
|
||||
|
||||
if (!failed) {
|
||||
const content = [
|
||||
'==================================',
|
||||
'TEST RESULT',
|
||||
'PASSED',
|
||||
`Date: ${date}`,
|
||||
`Total Test: ${result.total || 0}`,
|
||||
`Passed: ${result.passed || 0}`,
|
||||
`Failed: ${result.failed || 0}`,
|
||||
`Duration: ${duration}`,
|
||||
'==================================',
|
||||
].join('\n');
|
||||
|
||||
fs.writeFileSync(outputPath, content, 'utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
lines.push('==================================');
|
||||
lines.push('FAILED');
|
||||
lines.push(`Date: ${date}`);
|
||||
lines.push(`Duration: ${duration}`);
|
||||
lines.push('==================================');
|
||||
|
||||
const buildFailureDetails = (suites) => {
|
||||
for (const s of suites) {
|
||||
if (s.suites && s.suites.length) buildFailureDetails(s.suites);
|
||||
if (s.tests && s.tests.length) {
|
||||
for (const t of s.tests) {
|
||||
if (t.status === 'failed') {
|
||||
lines.push('Test Name: ' + (t.title || t.titleText || ''));
|
||||
for (const r of t.results || []) {
|
||||
if (r.status === 'failed') {
|
||||
const error = r.error || {};
|
||||
lines.push('Error Message: ' + (error.message || ''));
|
||||
if (error.stack) lines.push('Stack Trace: ' + error.stack);
|
||||
}
|
||||
}
|
||||
lines.push('----------------------------------');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (result.suites) buildFailureDetails(result.suites);
|
||||
|
||||
fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CustomReporter;
|
||||
@@ -0,0 +1,78 @@
|
||||
import fs from 'fs';
|
||||
import { FullConfig, Reporter, Suite, TestCase, TestError } from '@playwright/test/reporter';
|
||||
|
||||
class CustomReporter implements Reporter {
|
||||
onEnd(config: FullConfig, result: any) {
|
||||
const outputPath = 'tests/output/latest-result.txt';
|
||||
|
||||
const allTests = result.suites || [];
|
||||
|
||||
const failed = result.status === 'failed' || result.numFailedTests > 0;
|
||||
|
||||
const date = new Date().toISOString();
|
||||
const duration = (result.duration || 0) + 'ms';
|
||||
|
||||
if (!fs.existsSync('tests/output')) fs.mkdirSync('tests/output', { recursive: true });
|
||||
|
||||
if (!failed) {
|
||||
const content = [
|
||||
'==================================',
|
||||
'TEST RESULT',
|
||||
'PASSED',
|
||||
`Date: ${date}`,
|
||||
`Total Test: ${result.total || 0}`,
|
||||
`Passed: ${result.passed || 0}`,
|
||||
`Failed: ${result.failed || 0}`,
|
||||
`Duration: ${duration}`,
|
||||
'==================================',
|
||||
].join('\n');
|
||||
|
||||
fs.writeFileSync(outputPath, content, 'utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
// Failed: build detailed report
|
||||
const lines: string[] = [];
|
||||
lines.push('==================================');
|
||||
lines.push('FAILED');
|
||||
lines.push(`Date: ${date}`);
|
||||
lines.push(`Duration: ${duration}`);
|
||||
lines.push('==================================');
|
||||
|
||||
for (const res of result.report || []) {
|
||||
// older Playwright may not provide report; fallback to result.annotations
|
||||
}
|
||||
|
||||
// Walk tests
|
||||
const buildFailureDetails = (suites: any[]) => {
|
||||
for (const s of suites) {
|
||||
if (s.suites && s.suites.length) buildFailureDetails(s.suites);
|
||||
if (s.tests && s.tests.length) {
|
||||
for (const t of s.tests) {
|
||||
if (t.status === 'failed') {
|
||||
lines.push('Test Name: ' + t.title.join(' > '));
|
||||
// try extract location
|
||||
const location = t.location ? `${t.location.file}:${t.location.line}` : '';
|
||||
if (location) lines.push('Location: ' + location);
|
||||
for (const r of t.results || []) {
|
||||
if (r.status === 'failed') {
|
||||
const error = r.error as TestError | undefined;
|
||||
lines.push('Error Message: ' + (error?.message || ''));
|
||||
if (r.stdout && r.stdout.length) lines.push('Stdout: ' + r.stdout.join('\n'));
|
||||
if (error?.stack) lines.push('Stack Trace: ' + error.stack);
|
||||
}
|
||||
}
|
||||
lines.push('----------------------------------');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (result.suites) buildFailureDetails(result.suites);
|
||||
|
||||
fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
export default CustomReporter;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { RoleSyncService } from '../src/modules/auth/role-sync.service';
|
||||
|
||||
describe('RoleSyncService - transaction rollback', () => {
|
||||
let service: RoleSyncService;
|
||||
const mockPrisma: any = {};
|
||||
const mockPermissionCache: any = { invalidate: jest.fn() };
|
||||
const mockEvents: any = { publish: jest.fn() };
|
||||
const mockGroupHash: any = { compute: (g: any) => require('crypto').createHash('sha256').update((g||[]).slice().sort().join(','), 'utf8').digest('hex') };
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma.user = { findUnique: jest.fn() };
|
||||
mockPrisma.authGroupRoleMapping = { findMany: jest.fn() };
|
||||
mockPrisma.userRole = { findMany: jest.fn().mockResolvedValue([]) };
|
||||
mockPrisma.user = mockPrisma.user;
|
||||
// simulate transaction throwing
|
||||
mockPrisma.$transaction = jest.fn(async (cb: any) => { throw new Error('tx failed'); });
|
||||
|
||||
service = new RoleSyncService(mockPrisma as any, mockGroupHash as any, mockPermissionCache as any, mockEvents as any);
|
||||
});
|
||||
|
||||
test('does not invalidate cache or publish event when transaction fails', async () => {
|
||||
mockPrisma.user.findUnique.mockResolvedValue({ id: 'uid', lastGroupHash: 'old' });
|
||||
mockPrisma.authGroupRoleMapping.findMany.mockResolvedValue([{ roleId: 'r1' }]);
|
||||
|
||||
await expect(service.syncUserRolesFromAuthentik('uid', ['RL-Owner'])).rejects.toThrow('tx failed');
|
||||
|
||||
expect(mockPermissionCache.invalidate).not.toHaveBeenCalled();
|
||||
expect(mockEvents.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { RoleSyncService } from '../src/modules/auth/role-sync.service';
|
||||
|
||||
describe('RoleSyncService', () => {
|
||||
const mockPrisma: any = {};
|
||||
const mockPermissionCache: any = { invalidate: jest.fn() };
|
||||
const mockEvents: any = { publish: jest.fn() };
|
||||
const mockGroupHash: any = { compute: (g: any) => require('crypto').createHash('sha256').update((g||[]).slice().sort().join(','), 'utf8').digest('hex') };
|
||||
let service: RoleSyncService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma.user = { findUnique: jest.fn() };
|
||||
mockPrisma.authGroupRoleMapping = { findMany: jest.fn() };
|
||||
mockPrisma.userRole = { findMany: jest.fn() };
|
||||
mockPrisma.auditLog = { create: jest.fn() };
|
||||
mockPrisma.user = mockPrisma.user;
|
||||
mockPrisma.$transaction = jest.fn(async (cb: any) => {
|
||||
// simulate transaction by calling provided callback with tx = mockPrisma
|
||||
await cb(mockPrisma);
|
||||
});
|
||||
|
||||
service = new RoleSyncService(mockPrisma as any, mockGroupHash as any, mockPermissionCache as any, mockEvents as any);
|
||||
});
|
||||
|
||||
test('computeGroupHash consistent and order independent', () => {
|
||||
const a = ['b', 'a', 'c'];
|
||||
const h1 = service.computeGroupHash(a);
|
||||
const h2 = service.computeGroupHash(['a', 'b', 'c']);
|
||||
expect(h1).toBe(h2);
|
||||
});
|
||||
|
||||
test('skips sync when group hash unchanged', async () => {
|
||||
const groups = ['RL-Owner'];
|
||||
const hash = service.computeGroupHash(groups);
|
||||
mockPrisma.user.findUnique.mockResolvedValue({ id: 'uid', lastGroupHash: hash });
|
||||
|
||||
const res = await service.syncUserRolesFromAuthentik('uid', groups);
|
||||
expect(res.skipped).toBe(true);
|
||||
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('performs transaction and writes audit when changed', async () => {
|
||||
const groups = ['RL-Owner'];
|
||||
mockPrisma.user.findUnique.mockResolvedValue({ id: 'uid', lastGroupHash: 'old' });
|
||||
mockPrisma.authGroupRoleMapping.findMany.mockResolvedValue([{ roleId: 'r1' }]);
|
||||
mockPrisma.userRole.findMany.mockResolvedValue([{ roleId: 'r_old' }]);
|
||||
|
||||
// spy on tx ops
|
||||
mockPrisma.userRole.deleteMany = jest.fn();
|
||||
mockPrisma.userRole.createMany = jest.fn();
|
||||
mockPrisma.user.update = jest.fn();
|
||||
|
||||
mockPrisma.auditLog.create = jest.fn();
|
||||
|
||||
const res = await service.syncUserRolesFromAuthentik('uid', groups);
|
||||
|
||||
expect(mockPrisma.$transaction).toHaveBeenCalled();
|
||||
expect(mockPrisma.userRole.deleteMany).toHaveBeenCalledWith({ where: { userId: 'uid', source: 'AUTHENTIK' } });
|
||||
expect(mockPrisma.userRole.createMany).toHaveBeenCalled();
|
||||
expect(mockPrisma.user.update).toHaveBeenCalled();
|
||||
expect(mockPermissionCache.invalidate).toHaveBeenCalledWith('uid');
|
||||
expect(mockEvents.publish).toHaveBeenCalledWith(expect.objectContaining({ type: 'RolesSynchronized' }));
|
||||
expect(res.skipped).toBe(false);
|
||||
expect(res.assignedRoleIds).toEqual(['r1']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { RoleAssignPermissionHandler } from '../src/modules/identity/application/handlers/role/assign-permission.handler';
|
||||
import { RoleRemovePermissionHandler } from '../src/modules/identity/application/handlers/role/remove-permission.handler';
|
||||
|
||||
describe('Role permission handlers', () => {
|
||||
test('assign permission invalidates cache and publishes event', async () => {
|
||||
const mockRoleRepo: any = { findById: jest.fn(), update: jest.fn(), getAssignedUserIds: jest.fn().mockResolvedValue(['u1','u2']) };
|
||||
const mockPermRepo: any = { getById: jest.fn() };
|
||||
const mockAuthz: any = { invalidateUserPermissions: jest.fn() };
|
||||
const mockEvents: any = { publish: jest.fn() };
|
||||
|
||||
mockRoleRepo.findById.mockResolvedValue({ id: 'r1', assignPermission: jest.fn() });
|
||||
mockPermRepo.getById.mockResolvedValue({ id: 'p1' });
|
||||
mockRoleRepo.update.mockResolvedValue({ id: 'r1' });
|
||||
|
||||
const handler = new RoleAssignPermissionHandler(mockRoleRepo, mockPermRepo, mockAuthz as any, mockEvents as any);
|
||||
|
||||
const res = await handler.execute('r1', 'p1');
|
||||
|
||||
expect(mockRoleRepo.update).toHaveBeenCalled();
|
||||
expect(mockAuthz.invalidateUserPermissions).toHaveBeenCalledWith('u1');
|
||||
expect(mockAuthz.invalidateUserPermissions).toHaveBeenCalledWith('u2');
|
||||
expect(mockEvents.publish).toHaveBeenCalledWith(expect.objectContaining({ type: 'RoleUpdated' }));
|
||||
});
|
||||
|
||||
test('remove permission invalidates cache and publishes event', async () => {
|
||||
const mockRoleRepo: any = { findById: jest.fn(), update: jest.fn(), getAssignedUserIds: jest.fn().mockResolvedValue(['u1']) };
|
||||
const mockAuthz: any = { invalidateUserPermissions: jest.fn() };
|
||||
const mockEvents: any = { publish: jest.fn() };
|
||||
|
||||
mockRoleRepo.findById.mockResolvedValue({ id: 'r1', removePermission: jest.fn() });
|
||||
mockRoleRepo.update.mockResolvedValue({ id: 'r1' });
|
||||
|
||||
const handler = new RoleRemovePermissionHandler(mockRoleRepo as any, mockAuthz as any, mockEvents as any);
|
||||
|
||||
const res = await handler.execute('r1', 'p1');
|
||||
|
||||
expect(mockRoleRepo.update).toHaveBeenCalled();
|
||||
expect(mockAuthz.invalidateUserPermissions).toHaveBeenCalledWith('u1');
|
||||
expect(mockEvents.publish).toHaveBeenCalledWith(expect.objectContaining({ type: 'RoleUpdated' }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
<html><head><meta name="color-scheme" content="light dark"><meta charset="utf-8"></head><body><pre>{"message":"Cannot GET /","error":"Not Found","statusCode":404}</pre><div class="json-formatter-container"></div></body></html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
<html><head><meta name="color-scheme" content="light dark"><meta charset="utf-8"></head><body><pre>{"message":"Cannot GET /","error":"Not Found","statusCode":404}</pre><div class="json-formatter-container"></div></body></html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,65 @@
|
||||
import { CurrentUserGuard } from '../../src/modules/identity/presentation/guards/current-user.guard';
|
||||
import { SyncIdentityHandler } from '../../src/modules/identity/application/handlers/user/sync-identity.handler';
|
||||
import { IUser } from '../../src/modules/identity/domain/repositories/user.interface';
|
||||
import { IdentityData } from '../../src/core/auth/interfaces/identity-data';
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
describe('CurrentUserGuard', () => {
|
||||
let guard: CurrentUserGuard;
|
||||
let mockSync: Partial<SyncIdentityHandler>;
|
||||
let mockUserRepo: Partial<IUser>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSync = { execute: jest.fn() };
|
||||
mockUserRepo = { getById: jest.fn() };
|
||||
guard = new CurrentUserGuard(mockSync as any, mockUserRepo as any);
|
||||
});
|
||||
|
||||
function makeContext(req: any): any {
|
||||
return { switchToHttp: () => ({ getRequest: () => req }) } as any;
|
||||
}
|
||||
|
||||
test('raylabContext fast path sets currentUser and returns true', async () => {
|
||||
const user = { id: 'u1' } as any;
|
||||
const req: any = { raylabContext: { user } };
|
||||
const res = await guard.canActivate(makeContext(req));
|
||||
expect(res).toBe(true);
|
||||
expect(req.currentUser).toBe(user);
|
||||
});
|
||||
|
||||
test('missing identity throws UnauthorizedException', async () => {
|
||||
const req: any = {};
|
||||
await expect(guard.canActivate(makeContext(req))).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
test('internal identity resolves by getById and does not call sync', async () => {
|
||||
const identity = new IdentityData('uid', 'u', 'e');
|
||||
const req: any = { identity, identitySource: 'internal' };
|
||||
(mockUserRepo.getById as jest.Mock).mockResolvedValue({ id: 'uid', roles: [], permissions: [] });
|
||||
|
||||
const res = await guard.canActivate(makeContext(req));
|
||||
expect(res).toBe(true);
|
||||
expect(req.currentUser).toBeDefined();
|
||||
expect((mockSync.execute as jest.Mock)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('internal identity with missing user throws UnauthorizedException', async () => {
|
||||
const identity = new IdentityData('missing', 'u', 'e');
|
||||
const req: any = { identity, identitySource: 'internal' };
|
||||
(mockUserRepo.getById as jest.Mock).mockRejectedValue(new Error('User tidak ditemukan.'));
|
||||
|
||||
await expect(guard.canActivate(makeContext(req))).rejects.toThrow(UnauthorizedException);
|
||||
expect((mockSync.execute as jest.Mock)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('external identity calls syncIdentityHandler', async () => {
|
||||
const identity = new IdentityData('extsub', 'u', 'e');
|
||||
const req: any = { identity, identitySource: 'external' };
|
||||
(mockSync.execute as jest.Mock).mockResolvedValue({ id: 'user-ext' });
|
||||
|
||||
const res = await guard.canActivate(makeContext(req));
|
||||
expect(res).toBe(true);
|
||||
expect(req.currentUser).toBeDefined();
|
||||
expect((mockUserRepo.getById as jest.Mock)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { DebtService } from '../../src/modules/bot-debt/application/debt.service';
|
||||
|
||||
describe('DebtService summary/netting', () => {
|
||||
let service: DebtService;
|
||||
let prisma: any;
|
||||
let mockSync: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
prisma = {
|
||||
debtGroupMember: { findUnique: jest.fn().mockResolvedValue({}) },
|
||||
debtPerson: { findMany: jest.fn() },
|
||||
debtTransaction: { findMany: jest.fn() },
|
||||
};
|
||||
|
||||
mockSync = { execute: jest.fn().mockResolvedValue({ id: 'u' }) };
|
||||
|
||||
service = new DebtService(prisma as any, mockSync as any);
|
||||
});
|
||||
|
||||
function peopleABC() {
|
||||
return [
|
||||
{ id: 'A', name: 'A' },
|
||||
{ id: 'B', name: 'B' },
|
||||
{ id: 'C', name: 'C' },
|
||||
];
|
||||
}
|
||||
|
||||
it('basic debt A->B 17000', async () => {
|
||||
prisma.debtPerson.findMany.mockResolvedValue(peopleABC());
|
||||
prisma.debtTransaction.findMany.mockResolvedValue([
|
||||
{ fromPersonId: 'A', toPersonId: 'B', amount: BigInt(17000), type: 'DEBT' },
|
||||
]);
|
||||
|
||||
const res = await service.getSummary('u', 'g');
|
||||
expect(res).toEqual([{ from: 'A', to: 'B', amount: '17000' }]);
|
||||
});
|
||||
|
||||
it('accumulation A->B 17k + 10k = 27k', async () => {
|
||||
prisma.debtPerson.findMany.mockResolvedValue(peopleABC());
|
||||
prisma.debtTransaction.findMany.mockResolvedValue([
|
||||
{ fromPersonId: 'A', toPersonId: 'B', amount: BigInt(17000), type: 'DEBT' },
|
||||
{ fromPersonId: 'A', toPersonId: 'B', amount: BigInt(10000), type: 'DEBT' },
|
||||
]);
|
||||
|
||||
const res = await service.getSummary('u', 'g');
|
||||
expect(res).toEqual([{ from: 'A', to: 'B', amount: '27000' }]);
|
||||
});
|
||||
|
||||
it('reverse debt A->B 27k B->A 5k = A->B 22k', async () => {
|
||||
prisma.debtPerson.findMany.mockResolvedValue(peopleABC());
|
||||
prisma.debtTransaction.findMany.mockResolvedValue([
|
||||
{ fromPersonId: 'A', toPersonId: 'B', amount: BigInt(27000), type: 'DEBT' },
|
||||
{ fromPersonId: 'B', toPersonId: 'A', amount: BigInt(5000), type: 'DEBT' },
|
||||
]);
|
||||
|
||||
const res = await service.getSummary('u', 'g');
|
||||
expect(res).toEqual([{ from: 'A', to: 'B', amount: '22000' }]);
|
||||
});
|
||||
|
||||
it('partial payment A->B 50k debt, payment 20k => A->B 30k', async () => {
|
||||
prisma.debtPerson.findMany.mockResolvedValue(peopleABC());
|
||||
prisma.debtTransaction.findMany.mockResolvedValue([
|
||||
{ fromPersonId: 'A', toPersonId: 'B', amount: BigInt(50000), type: 'DEBT' },
|
||||
{ fromPersonId: 'A', toPersonId: 'B', amount: BigInt(20000), type: 'PAYMENT' },
|
||||
]);
|
||||
|
||||
const res = await service.getSummary('u', 'g');
|
||||
expect(res).toEqual([{ from: 'A', to: 'B', amount: '30000' }]);
|
||||
});
|
||||
|
||||
it('overpayment A->B 50k debt, payment 60k => B->A 10k', async () => {
|
||||
prisma.debtPerson.findMany.mockResolvedValue(peopleABC());
|
||||
prisma.debtTransaction.findMany.mockResolvedValue([
|
||||
{ fromPersonId: 'A', toPersonId: 'B', amount: BigInt(50000), type: 'DEBT' },
|
||||
{ fromPersonId: 'A', toPersonId: 'B', amount: BigInt(60000), type: 'PAYMENT' },
|
||||
]);
|
||||
|
||||
const res = await service.getSummary('u', 'g');
|
||||
expect(res).toEqual([{ from: 'B', to: 'A', amount: '10000' }]);
|
||||
});
|
||||
|
||||
it('cross substitution A->B 100k B->C 100k C->A 100k => empty', async () => {
|
||||
prisma.debtPerson.findMany.mockResolvedValue(peopleABC());
|
||||
prisma.debtTransaction.findMany.mockResolvedValue([
|
||||
{ fromPersonId: 'A', toPersonId: 'B', amount: BigInt(100000), type: 'DEBT' },
|
||||
{ fromPersonId: 'B', toPersonId: 'C', amount: BigInt(100000), type: 'DEBT' },
|
||||
{ fromPersonId: 'C', toPersonId: 'A', amount: BigInt(100000), type: 'DEBT' },
|
||||
]);
|
||||
|
||||
const res = await service.getSummary('u', 'g');
|
||||
expect(res).toEqual([]);
|
||||
});
|
||||
|
||||
it('cross substitution partial A->B 100k B->C 50k => settlement equivalent', async () => {
|
||||
prisma.debtPerson.findMany.mockResolvedValue(peopleABC());
|
||||
prisma.debtTransaction.findMany.mockResolvedValue([
|
||||
{ fromPersonId: 'A', toPersonId: 'B', amount: BigInt(100000), type: 'DEBT' },
|
||||
{ fromPersonId: 'B', toPersonId: 'C', amount: BigInt(50000), type: 'DEBT' },
|
||||
]);
|
||||
|
||||
const res = await service.getSummary('u', 'g');
|
||||
// possible valid settlements: A->B 50000, A->C 50000 or other equivalent
|
||||
expect(res.reduce((acc: any, cur: any) => acc + cur.amount, '')).toBeDefined();
|
||||
expect(res.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
// Mock 'jose' module before importing JwtAuthGuard so Jest doesn't try to parse ESM from node_modules
|
||||
let mockJwtVerify: (token: any) => Promise<any> = async () => { throw new Error('no jwks') };
|
||||
|
||||
jest.mock('jose', () => ({
|
||||
createRemoteJWKSet: () => ({}),
|
||||
jwtVerify: async (token: any, jwks: any, opts: any) => mockJwtVerify(token),
|
||||
}));
|
||||
|
||||
import { JwtAuthGuard } from '../../src/core/auth/guards/jwt-auth.guard';
|
||||
import { ExecutionContext } from '@nestjs/common';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
// These tests focus on identitySource being set based on verification path.
|
||||
|
||||
describe('JwtAuthGuard', () => {
|
||||
let guard: JwtAuthGuard;
|
||||
|
||||
beforeEach(() => {
|
||||
guard = new JwtAuthGuard();
|
||||
process.env.RAYLAB_JWT_SECRET = 'test-secret';
|
||||
delete process.env.AUTHENTIK_JWKS_URI; // ensure JWKS not attempted unless test sets it
|
||||
// reset mock behavior
|
||||
mockJwtVerify = async () => { throw new Error('no jwks') };
|
||||
});
|
||||
|
||||
function makeCtxWithAuthHeader(token: string) {
|
||||
const req: any = { headers: { authorization: `Bearer ${token}` }, cookies: {} };
|
||||
const ctx: any = { switchToHttp: () => ({ getRequest: () => req }) } as ExecutionContext;
|
||||
return { ctx, req };
|
||||
}
|
||||
|
||||
test('internal verification sets identitySource=internal', async () => {
|
||||
const token = jwt.sign({ sub: 'uid', preferred_username: 'u', email: 'e' }, process.env.RAYLAB_JWT_SECRET || 'test-secret');
|
||||
const { ctx, req } = makeCtxWithAuthHeader(token);
|
||||
|
||||
const res = await guard.canActivate(ctx);
|
||||
expect(res).toBe(true);
|
||||
expect(req.identity).toBeDefined();
|
||||
expect((req.identitySource)).toBe('internal');
|
||||
});
|
||||
|
||||
test('external verification sets identitySource=external if JWKS verifies', async () => {
|
||||
process.env.AUTHENTIK_JWKS_URI = 'https://example.com/.well-known/jwks.json';
|
||||
// set mock to succeed
|
||||
mockJwtVerify = async (token: any) => ({ payload: { sub: 'extsub', preferred_username: 'eu', email: 'ee' } });
|
||||
|
||||
const token = 'dummy';
|
||||
const { ctx, req } = makeCtxWithAuthHeader(token);
|
||||
|
||||
const res = await guard.canActivate(ctx);
|
||||
expect(res).toBe(true);
|
||||
expect(req.identity).toBeDefined();
|
||||
expect((req.identitySource)).toBe('external');
|
||||
});
|
||||
|
||||
test('external verification fails then internal succeeds -> identitySource=internal', async () => {
|
||||
process.env.AUTHENTIK_JWKS_URI = 'https://example.com/.well-known/jwks.json';
|
||||
// make jwks fail
|
||||
mockJwtVerify = async (token: any) => { throw new Error('jwks fail'); };
|
||||
|
||||
const token = jwt.sign({ sub: 'uid2', preferred_username: 'u2', email: 'e2' }, process.env.RAYLAB_JWT_SECRET || 'test-secret');
|
||||
const { ctx, req } = makeCtxWithAuthHeader(token);
|
||||
|
||||
const res = await guard.canActivate(ctx);
|
||||
expect(res).toBe(true);
|
||||
expect(req.identity).toBeDefined();
|
||||
expect((req.identitySource)).toBe('internal');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { UserService } from '../../src/modules/identity/application/services/user.service';
|
||||
import { PrismaService } from '../../src/shared/prisma.service';
|
||||
import { IUser } from '../../src/modules/identity/domain/repositories/user.interface';
|
||||
|
||||
describe('UserService', () => {
|
||||
let service: UserService;
|
||||
let prisma: Partial<PrismaService>;
|
||||
let userRepo: Partial<IUser>;
|
||||
|
||||
beforeEach(async () => {
|
||||
prisma = {
|
||||
user: {
|
||||
update: jest.fn().mockResolvedValue(null),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
} as any;
|
||||
|
||||
userRepo = {
|
||||
create: jest.fn().mockImplementation(async (user) => {
|
||||
return { ...user, id: 'user-1' };
|
||||
}),
|
||||
getById: jest.fn().mockResolvedValue({ id: 'user-1', username: 'u', email: 'e' }),
|
||||
existByEmail: jest.fn().mockResolvedValue(false),
|
||||
} as any;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UserService,
|
||||
{ provide: PrismaService, useValue: prisma },
|
||||
{ provide: IUser, useValue: userRepo },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UserService>(UserService);
|
||||
});
|
||||
|
||||
it('createUser - provisioning disabled', async () => {
|
||||
await expect(service.createUser({ username: 'u', email: 'e' })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { UpdateUserHandler } from '../src/modules/identity/application/handlers/user/update-user.handler';
|
||||
|
||||
describe('UpdateUserHandler', () => {
|
||||
test('rejects password in DTO', async () => {
|
||||
const mockRepo: any = { getById: jest.fn(), update: jest.fn() };
|
||||
mockRepo.getById.mockResolvedValue({ id: 'u1', changeUsername: jest.fn(), changeEmail: jest.fn(), setStorageQuota: jest.fn(), setStorageUsed: jest.fn() });
|
||||
|
||||
const handler = new UpdateUserHandler(mockRepo as any);
|
||||
|
||||
await expect(handler.execute('u1', { password: 'secret' } as any)).rejects.toThrow('Password management is not allowed.');
|
||||
});
|
||||
|
||||
test('updates allowed fields', async () => {
|
||||
const userObj: any = { id: 'u1', changeUsername: jest.fn(), changeEmail: jest.fn(), setStorageQuota: jest.fn(), setStorageUsed: jest.fn() };
|
||||
const mockRepo: any = { getById: jest.fn().mockResolvedValue(userObj), update: jest.fn().mockResolvedValue(userObj) };
|
||||
|
||||
const handler = new UpdateUserHandler(mockRepo as any);
|
||||
|
||||
const dto = { name: 'New Name', email: 'a@b.com', storageQuota: 1000, storageUsed: 10 } as any;
|
||||
|
||||
const res = await handler.execute('u1', dto);
|
||||
|
||||
expect(userObj.changeUsername).toHaveBeenCalledWith('New Name');
|
||||
expect(userObj.changeEmail).toHaveBeenCalledWith('a@b.com');
|
||||
expect(userObj.setStorageQuota).toHaveBeenCalledWith(1000);
|
||||
expect(userObj.setStorageUsed).toHaveBeenCalledWith(10);
|
||||
expect(mockRepo.update).toHaveBeenCalledWith(userObj);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user