feat(identity): redesign identity module and introduce RBAC foundation

- Redesign the Identity module with a richer domain model.
- Extend the User entity to support username, Authentik integration, activity tracking, and storage information.
- Add Role and Permission domain models with many-to-many relationships.
- Implement RBAC foundation using UserRole, RolePermission, and UserPermission mappings.
- Add user storage quota and usage fields with default values.
- Introduce Authentik identifiers and synchronization metadata.
- Refactor user domain logic for role and permission management.
- Update Prisma schema to support the new identity architecture.
- Improve JWT authentication and permission guard integration.
- Update repositories, handlers, controllers, mappers, DTOs, and Swagger configuration.
- Refresh environment configuration and project dependencies.
This commit is contained in:
Rayyan
2026-08-02 00:25:36 +07:00
parent fdbfb34842
commit 7ce0de4e91
132 changed files with 7754 additions and 2037 deletions
+12
View File
@@ -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.
View File
View File
+11
View File
@@ -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);
});
});
+20
View File
@@ -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);
});
});
+11
View File
@@ -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);
});
});
+23
View File
@@ -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);
});
});
+11
View File
@@ -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');
}
+91
View File
@@ -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 };
+7
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
export * from './auth';
export * from './request';
export * from './env';
+73
View File
@@ -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;
}
}
}
+188
View File
@@ -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.
});
View File
+1
View File
@@ -0,0 +1 @@
TEST RUN FAILED OR ABORTED
+72
View File
@@ -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;
+78
View File
@@ -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 @@
[]
@@ -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

+41
View File
@@ -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();
});
});