test: add database and rls policy verification test suite
This commit is contained in:
parent
f6b8f019cd
commit
a2238bd7e3
4 changed files with 8034 additions and 3 deletions
7767
package-lock.json
generated
Normal file
7767
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
33
package.json
Normal file
33
package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"name": "tmp_app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test:rls": "node prisma/test-rls.js",
|
||||
"test": "npm run test:rls"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"forgejo-mcp": "^1.2.0",
|
||||
"mcp-git": "^0.0.4",
|
||||
"next": "16.2.9",
|
||||
"pg": "^8.21.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.9",
|
||||
"prisma": "^7.8.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
-- RLS Policies and Seeding Script for Hoteles Estelar Variable Remuneration System
|
||||
SET app.current_user_role = 'ADMIN';
|
||||
|
||||
-- ==========================================
|
||||
-- 1. SEED DATA
|
||||
|
|
@ -85,7 +86,10 @@ CREATE POLICY audit_logs_insert_policy ON "audit_logs"
|
|||
FOR INSERT WITH CHECK (true);
|
||||
|
||||
CREATE POLICY audit_logs_select_policy ON "audit_logs"
|
||||
FOR SELECT USING (current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA'));
|
||||
FOR SELECT USING (
|
||||
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA')
|
||||
OR user_id = NULLIF(current_setting('app.current_user_id', true), '')::integer
|
||||
);
|
||||
|
||||
|
||||
-- B. Regions Policies
|
||||
|
|
@ -115,8 +119,14 @@ CREATE POLICY hotels_modify_policy ON "hotels"
|
|||
CREATE POLICY users_select_policy ON "users"
|
||||
FOR SELECT USING (
|
||||
current_setting('app.current_user_role', true) IN ('ADMIN', 'ANALISTA', 'DIRECTOR')
|
||||
OR hotel_id = NULLIF(current_setting('app.current_hotel_id', true), '')::integer
|
||||
OR hotel_id IN (SELECT id FROM hotels WHERE region_id = NULLIF(current_setting('app.current_region_id', true), '')::integer)
|
||||
OR (
|
||||
current_setting('app.current_user_role', true) = 'GERENTE'
|
||||
AND hotel_id = NULLIF(current_setting('app.current_hotel_id', true), '')::integer
|
||||
)
|
||||
OR (
|
||||
current_setting('app.current_user_role', true) = 'LIDER'
|
||||
AND hotel_id IN (SELECT id FROM hotels WHERE region_id = NULLIF(current_setting('app.current_region_id', true), '')::integer)
|
||||
)
|
||||
OR id = NULLIF(current_setting('app.current_user_id', true), '')::integer
|
||||
);
|
||||
|
||||
|
|
|
|||
221
prisma/test-rls.js
Normal file
221
prisma/test-rls.js
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
require('dotenv').config();
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { PrismaPg } = require('@prisma/adapter-pg');
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const dbUrl = new URL(process.env.DATABASE_URL);
|
||||
const pool = new Pool({
|
||||
host: dbUrl.hostname,
|
||||
port: dbUrl.port ? parseInt(dbUrl.port) : 5432,
|
||||
user: decodeURIComponent(dbUrl.username),
|
||||
password: decodeURIComponent(dbUrl.password),
|
||||
database: dbUrl.pathname.substring(1).split('?')[0],
|
||||
ssl: false
|
||||
});
|
||||
const adapter = new PrismaPg(pool);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
async function runTests() {
|
||||
console.log("=== STARTING RLS AND SEED TEST SUITE ===");
|
||||
|
||||
// 1. Fetch seeded data to map IDs (Run as ADMIN to bypass RLS)
|
||||
const { users, hotels, regions } = await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'ADMIN';`);
|
||||
const users = await tx.user.findMany({ include: { hotel: true } });
|
||||
const hotels = await tx.hotel.findMany();
|
||||
const regions = await tx.region.findMany();
|
||||
return { users, hotels, regions };
|
||||
});
|
||||
|
||||
const adminUser = users.find(u => u.username === 'admin');
|
||||
const gerenteMde = users.find(u => u.username === 'gerente_mde');
|
||||
const liderCtg = users.find(u => u.username === 'lider_ctg');
|
||||
const colaboradorMde = users.find(u => u.username === 'colaborador_mde');
|
||||
|
||||
if (!adminUser || !gerenteMde || !liderCtg || !colaboradorMde) {
|
||||
console.error("Error: Could not find all required seeded users. Run database seeding first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Seeded users found:
|
||||
- Admin ID: ${adminUser.id}
|
||||
- Gerente MDE ID: ${gerenteMde.id} (Hotel ID: ${gerenteMde.hotelId})
|
||||
- Lider CTG ID: ${liderCtg.id} (Hotel ID: ${liderCtg.hotelId})
|
||||
- Colaborador MDE ID: ${colaboradorMde.id} (Hotel ID: ${colaboradorMde.hotelId})`);
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition, message) {
|
||||
if (condition) {
|
||||
console.log(` ✓ PASS: ${message}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.error(` ✗ FAIL: ${message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to run query with context
|
||||
async function runWithContext(user, queryFn) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
if (user) {
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_id = '${user.id}';`);
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = '${user.role}';`);
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_hotel_id = '${user.hotelId}';`);
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_region_id = '${user.hotel.regionId}';`);
|
||||
} else {
|
||||
// Clear context
|
||||
await tx.$executeRawUnsafe(`RESET ALL;`);
|
||||
}
|
||||
return queryFn(tx);
|
||||
});
|
||||
}
|
||||
|
||||
// --- TEST 1: User Table Read Restrictions ---
|
||||
console.log("\nRunning TEST 1: User Table Read Restrictions...");
|
||||
try {
|
||||
// Colaborador should only see their own user record
|
||||
const colabUsers = await runWithContext(colaboradorMde, tx => tx.user.findMany());
|
||||
assert(
|
||||
colabUsers.length === 1 && colabUsers[0].id === colaboradorMde.id,
|
||||
"Colaborador can only select their own user record"
|
||||
);
|
||||
|
||||
// Gerente should see all users in their hotel
|
||||
const gerenteUsers = await runWithContext(gerenteMde, tx => tx.user.findMany());
|
||||
const allInMdeHotel = users.filter(u => u.hotelId === gerenteMde.hotelId);
|
||||
const visibleToGerenteInMde = gerenteUsers.every(u => u.hotelId === gerenteMde.hotelId);
|
||||
assert(
|
||||
gerenteUsers.length === allInMdeHotel.length && visibleToGerenteInMde,
|
||||
`Gerente MDE sees exactly ${allInMdeHotel.length} users in their hotel and no others`
|
||||
);
|
||||
|
||||
// Admin should see all users
|
||||
const adminUsers = await runWithContext(adminUser, tx => tx.user.findMany());
|
||||
assert(
|
||||
adminUsers.length === users.length,
|
||||
"Admin sees all users in the system"
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error in TEST 1:", err);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// --- TEST 2: Sales Results RLS ---
|
||||
console.log("\nRunning TEST 2: Sales Results RLS...");
|
||||
try {
|
||||
// Clean up existing sales results to isolate the test
|
||||
await runWithContext(adminUser, tx => tx.salesResult.deleteMany());
|
||||
|
||||
// Create sales results as Admin (bypass RLS)
|
||||
const saleColab = await runWithContext(adminUser, tx => tx.salesResult.create({
|
||||
data: {
|
||||
source: 'EXCEL',
|
||||
hotelId: colaboradorMde.hotelId,
|
||||
userId: colaboradorMde.id,
|
||||
period: '2026-06',
|
||||
amount: 5000.00,
|
||||
salesCount: 10,
|
||||
idempotencyKey: 'test-key-colab-1',
|
||||
uploadedBy: adminUser.id
|
||||
}
|
||||
}));
|
||||
|
||||
const saleOther = await runWithContext(adminUser, tx => tx.salesResult.create({
|
||||
data: {
|
||||
source: 'EXCEL',
|
||||
hotelId: liderCtg.hotelId,
|
||||
userId: liderCtg.id,
|
||||
period: '2026-06',
|
||||
amount: 8000.00,
|
||||
salesCount: 15,
|
||||
idempotencyKey: 'test-key-other-1',
|
||||
uploadedBy: adminUser.id
|
||||
}
|
||||
}));
|
||||
|
||||
// Colaborador queries sales results
|
||||
const colabSales = await runWithContext(colaboradorMde, tx => tx.salesResult.findMany());
|
||||
assert(
|
||||
colabSales.length === 1 && colabSales[0].id === saleColab.id,
|
||||
"Colaborador can only select their own sales results"
|
||||
);
|
||||
|
||||
// Gerente queries sales results
|
||||
const gerenteSales = await runWithContext(gerenteMde, tx => tx.salesResult.findMany());
|
||||
assert(
|
||||
gerenteSales.length === 1 && gerenteSales[0].id === saleColab.id,
|
||||
"Gerente can only select sales results for their hotel"
|
||||
);
|
||||
|
||||
// Admin queries sales results
|
||||
const adminSales = await runWithContext(adminUser, tx => tx.salesResult.findMany());
|
||||
assert(
|
||||
adminSales.length === 2,
|
||||
"Admin can select all sales results"
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error in TEST 2:", err);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// --- TEST 3: Audit Log Immutability ---
|
||||
console.log("\nRunning TEST 3: Audit Log Immutability...");
|
||||
try {
|
||||
// Create audit log entry
|
||||
const log = await runWithContext(adminUser, tx => tx.auditLog.create({
|
||||
data: {
|
||||
userId: adminUser.id,
|
||||
action: 'CREATE',
|
||||
targetTable: 'compensation_plans',
|
||||
targetId: 1,
|
||||
ipAddress: '127.0.0.1'
|
||||
}
|
||||
}));
|
||||
|
||||
// Try to update audit log as Admin
|
||||
let updateFailed = false;
|
||||
try {
|
||||
await runWithContext(adminUser, tx => tx.auditLog.update({
|
||||
where: { id: log.id },
|
||||
data: { ipAddress: '8.8.8.8' }
|
||||
}));
|
||||
} catch (e) {
|
||||
updateFailed = true;
|
||||
}
|
||||
assert(updateFailed, "AuditLog update is blocked by RLS even for admin");
|
||||
|
||||
// Try to delete audit log as Admin
|
||||
let deleteFailed = false;
|
||||
try {
|
||||
await runWithContext(adminUser, tx => tx.auditLog.delete({
|
||||
where: { id: log.id }
|
||||
}));
|
||||
} catch (e) {
|
||||
deleteFailed = true;
|
||||
}
|
||||
assert(deleteFailed, "AuditLog delete is blocked by RLS even for admin");
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error in TEST 3:", err);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// Clean up test sales results and audit logs
|
||||
console.log("\nCleaning up test records...");
|
||||
await runWithContext(adminUser, tx => tx.salesResult.deleteMany());
|
||||
await runWithContext(adminUser, tx => tx.auditLog.deleteMany());
|
||||
|
||||
console.log(`\n=== TEST RESULTS: ${passed} PASSED, ${failed} FAILED ===`);
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
runTests().catch(err => {
|
||||
console.error("Fatal test runner error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Reference in a new issue