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:
@@ -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.
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user