Permitir consulta pública dos ecrãs reais com dados privados protegidos
tests / ci (push) Canceled after 0s
tests / ci (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\PublicNavigation;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['public_navigation.enabled' => true]);
|
||||
$this->withoutVite();
|
||||
});
|
||||
|
||||
test('anonymous visitors enter the real dashboard without creating an authenticated identity', function () {
|
||||
$this->get('/')->assertRedirect('/dashboard');
|
||||
$this->get('/dashboard')->assertInertia(fn (Assert $p) => $p->component('dashboard')->where('auth.user', null)->where('company', null)->where('stats.total', 0)->where('publicNavigation.dataScope', 'none'));
|
||||
$this->assertGuest('web');
|
||||
expect(User::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('all allowlisted real screens support direct anonymous navigation without business queries', function () {
|
||||
$queries = [];
|
||||
DB::listen(function ($query) use (&$queries) {
|
||||
$queries[] = $query->sql;
|
||||
});
|
||||
$fixtures = [];
|
||||
foreach (PublicNavigation::PAGES as $path => $component) {
|
||||
$response = $this->get($path)->assertOk()->assertInertia(fn (Assert $p) => $p->component($component)->where('auth.user', null));
|
||||
$fixtures[$component] = $response->original->getData()['page'];
|
||||
$this->assertGuest('web');
|
||||
}
|
||||
expect($queries)->toBe([]);
|
||||
file_put_contents(storage_path('app/private/public-navigation-fixtures.json'), json_encode($fixtures, JSON_THROW_ON_ERROR));
|
||||
});
|
||||
|
||||
test('anonymous searches pagination and filters stay empty and never select either organisation', function () {
|
||||
foreach (['Empresa reservada A', 'Empresa reservada B'] as $name) {
|
||||
$tenant = Tenant::query()->create(['name' => $name]);
|
||||
User::factory()->create(['tenant_id' => $tenant->id, 'name' => 'Nome confidencial']);
|
||||
}
|
||||
$this->get('/requisicoes?search=Empresa&page=2&status=pending')->assertInertia(fn (Assert $p) => $p->where('filters.search', 'Empresa')->where('requisitions.data', [])->where('requisitions.current_page', 2))->assertDontSee('Nome confidencial')->assertDontSee('Empresa reservada');
|
||||
$this->get('/stock?search=Nome&warehouse_id=1')->assertInertia(fn (Assert $p) => $p->where('items', [])->where('filters.search', 'Nome'));
|
||||
});
|
||||
|
||||
test('sensitive records access settings exports and every business write remain authenticated', function () {
|
||||
foreach (['/pessoas/cadastro', '/pessoas/1', '/fornecedores/1/dados-bancarios', '/administracao/parametrizacao', '/relatorios', '/relatorios/compras.csv', '/settings/profile'] as $path) {
|
||||
$this->get($path)->assertRedirect();
|
||||
}
|
||||
$this->get('/api/supplier-payables')->assertUnauthorized();
|
||||
foreach (['/requisicoes', '/pessoas', '/stock/artigos', '/administracao/parametrizacao/tax'] as $path) {
|
||||
$this->postJson($path, [])->assertUnauthorized();
|
||||
}
|
||||
});
|
||||
|
||||
test('authenticated sessions retain their original controllers and permissions and disabled mode remains private', function () {
|
||||
$tenant = Tenant::query()->create(['name' => 'Empresa autenticada']);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id, 'purchasing_role' => PurchasingRole::Requester]);
|
||||
$this->actingAs($user)->get('/dashboard')->assertInertia(fn (Assert $p) => $p->where('company.name', 'Empresa autenticada')->missing('publicNavigation'));
|
||||
$this->get('/administracao/parametrizacao')->assertForbidden();
|
||||
auth()->logout();
|
||||
config(['public_navigation.enabled' => false]);
|
||||
$this->get('/dashboard')->assertRedirect();
|
||||
});
|
||||
|
||||
test('every authenticated mutation route remains inaccessible to an anonymous visitor', function () {
|
||||
$checked = 0;
|
||||
foreach (Route::getRoutes() as $route) {
|
||||
if (! collect($route->gatherMiddleware())->contains(fn ($middleware) => $middleware === 'auth' || str_starts_with($middleware, 'auth:'))) {
|
||||
continue;
|
||||
}
|
||||
$uri = '/'.preg_replace('/\{[^}]+\}/', '1', $route->uri());
|
||||
foreach (array_intersect($route->methods(), ['POST', 'PUT', 'PATCH', 'DELETE']) as $method) {
|
||||
$response = $this->json($method, $uri, []);
|
||||
expect(in_array($response->status(), [401, 404], true))->toBeTrue($method.' '.$uri.' must remain protected');
|
||||
$checked++;
|
||||
}
|
||||
}
|
||||
expect($checked)->toBeGreaterThan(50);
|
||||
$this->assertGuest('web');
|
||||
});
|
||||
@@ -1,9 +1,18 @@
|
||||
{
|
||||
"synthetic_only": true,
|
||||
"origin": "manual fixture, not model output",
|
||||
"claims": [
|
||||
{"text": "A requisição fictícia está em aprovação; não foi aprovada.", "source_ids": ["request"]},
|
||||
{"text": "Existe uma proposta fictícia registada; a comparação ainda não está pronta.", "source_ids": ["comparison"]},
|
||||
{"text": "Não consta ordem de compra no cenário fictício.", "source_ids": ["order"]}
|
||||
]
|
||||
"synthetic_only": true,
|
||||
"origin": "manual fixture, not model output",
|
||||
"claims": [
|
||||
{
|
||||
"text": "A requisição fictícia está em aprovação; não foi aprovada.",
|
||||
"source_ids": ["request"]
|
||||
},
|
||||
{
|
||||
"text": "Existe uma proposta fictícia registada; a comparação ainda não está pronta.",
|
||||
"source_ids": ["comparison"]
|
||||
},
|
||||
{
|
||||
"text": "Não consta ordem de compra no cenário fictício.",
|
||||
"source_ids": ["order"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { App } from '@inertiajs/react';
|
||||
import type { Page } from '@inertiajs/core';
|
||||
import type { ComponentType } from 'react';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { procurementNavigation } from '@/lib/procurement-navigation';
|
||||
|
||||
const fixtures = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
'../../storage/app/private/public-navigation-fixtures.json',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
) as Record<string, Page>;
|
||||
const modules = import.meta.glob<{
|
||||
default: ComponentType<Record<string, unknown>>;
|
||||
}>('../../resources/js/pages/**/*.tsx', { eager: true });
|
||||
test.each(Object.entries(fixtures))(
|
||||
'real public page %s renders with the actual application shell and no authenticated user',
|
||||
(name, initialPage) => {
|
||||
const Screen = modules[`../../resources/js/pages/${name}.tsx`]?.default;
|
||||
expect(Screen).toBeDefined();
|
||||
const Component = (props: Record<string, unknown>) => (
|
||||
<AppLayout>
|
||||
<Screen {...props} />
|
||||
</AppLayout>
|
||||
);
|
||||
const html = renderToString(
|
||||
<App initialPage={initialPage} initialComponent={Component} />,
|
||||
);
|
||||
expect(html).toContain('Consulta pública de desenvolvimento');
|
||||
expect(html).not.toContain('/consulta?');
|
||||
expect(html).toContain('/dashboard');
|
||||
},
|
||||
);
|
||||
test('public menu links only to the explicit public screens and never grants a staff role', () => {
|
||||
const paths = (
|
||||
fixtures.dashboard.props.publicNavigation as { paths: string[] }
|
||||
).paths;
|
||||
const groups = procurementNavigation('', false, paths);
|
||||
expect(groups.length).toBeGreaterThan(3);
|
||||
for (const entry of groups.flatMap((group) => group.entries))
|
||||
expect(paths).toContain(new URL(entry.path, 'http://local').pathname);
|
||||
expect(
|
||||
groups
|
||||
.flatMap((group) => group.entries)
|
||||
.some((entry) => entry.path.startsWith('/administracao')),
|
||||
).toBe(false);
|
||||
expect(fixtures.dashboard.props.auth).toEqual({ user: null });
|
||||
});
|
||||
@@ -6,19 +6,63 @@ import RequisitionForm from '@/pages/requisitions/form';
|
||||
import Catalogue from '@/pages/stock/catalogue';
|
||||
|
||||
const page = {
|
||||
component: 'requisitions/form', url: '/requisicoes/1/editar', version: 'catalogue',
|
||||
props: { errors: {} }, clearHistory: false, encryptHistory: true,
|
||||
component: 'requisitions/form',
|
||||
url: '/requisicoes/1/editar',
|
||||
version: 'catalogue',
|
||||
props: { errors: {} },
|
||||
clearHistory: false,
|
||||
encryptHistory: true,
|
||||
} satisfies Page;
|
||||
|
||||
test('saved article version remains selected when the published source has changed', () => {
|
||||
const initialPage = { ...page, props: { ...page.props, costCenters: [],
|
||||
requisition: { id: 1, cost_center_id: 1, priority: 'normal', needed_by: '2026-11-10', justification: 'Pedido', items: [{
|
||||
id: 4, catalogue_entry_id: 7, catalogue_snapshot: { version: 1, code: 'ART-7' },
|
||||
description: 'Nome original', category: 'Material', purchase_type: 'consumable', quantity: '2', unit: 'cx', unit_price_minor: 1000, tax_rate: '16',
|
||||
}] },
|
||||
catalogue: { ready: true, search: '', more: false, items: [{ id: 7, version: 2, stock_item_id: 3, code: 'ART-7', description: 'Nome actualizado', unit: 'cx', category: 'Material', purchase_type: 'consumable' }] },
|
||||
} };
|
||||
const html = renderToString(<App initialPage={initialPage} initialComponent={RequisitionForm} />);
|
||||
const initialPage = {
|
||||
...page,
|
||||
props: {
|
||||
...page.props,
|
||||
costCenters: [],
|
||||
requisition: {
|
||||
id: 1,
|
||||
cost_center_id: 1,
|
||||
priority: 'normal',
|
||||
needed_by: '2026-11-10',
|
||||
justification: 'Pedido',
|
||||
items: [
|
||||
{
|
||||
id: 4,
|
||||
catalogue_entry_id: 7,
|
||||
catalogue_snapshot: { version: 1, code: 'ART-7' },
|
||||
description: 'Nome original',
|
||||
category: 'Material',
|
||||
purchase_type: 'consumable',
|
||||
quantity: '2',
|
||||
unit: 'cx',
|
||||
unit_price_minor: 1000,
|
||||
tax_rate: '16',
|
||||
},
|
||||
],
|
||||
},
|
||||
catalogue: {
|
||||
ready: true,
|
||||
search: '',
|
||||
more: false,
|
||||
items: [
|
||||
{
|
||||
id: 7,
|
||||
version: 2,
|
||||
stock_item_id: 3,
|
||||
code: 'ART-7',
|
||||
description: 'Nome actualizado',
|
||||
unit: 'cx',
|
||||
category: 'Material',
|
||||
purchase_type: 'consumable',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const html = renderToString(
|
||||
<App initialPage={initialPage} initialComponent={RequisitionForm} />,
|
||||
);
|
||||
expect(html).toMatch(/<option value="7:1" selected="">/);
|
||||
expect(html).toContain('<option value="7:2">');
|
||||
expect(html).toContain('value="Nome original"');
|
||||
@@ -26,15 +70,42 @@ test('saved article version remains selected when the published source has chang
|
||||
expect(html).toContain('Preenchimento livre');
|
||||
});
|
||||
|
||||
test.each([true, false])('catalogue editing follows authority (%s)', (canManage) => {
|
||||
const initialPage = { ...page, component: 'stock/catalogue', props: { ...page.props,
|
||||
ready: true, canManage, filters: {}, records: { total: 1, prev_page_url: null, next_page_url: null, data: [{
|
||||
id: 3, code: 'ART-7', name: 'Artigo existente', unit: 'cx', stock_basis: 'a'.repeat(64), version: 1,
|
||||
category: 'Material', purchase_type: 'consumable', published: true, source_current: true,
|
||||
}] },
|
||||
} };
|
||||
const html = renderToString(<App initialPage={initialPage} initialComponent={Catalogue} />);
|
||||
expect(html).toContain('Artigo existente');
|
||||
expect(html.includes('Guardar versão do catálogo')).toBe(canManage);
|
||||
});
|
||||
|
||||
test.each([true, false])(
|
||||
'catalogue editing follows authority (%s)',
|
||||
(canManage) => {
|
||||
const initialPage = {
|
||||
...page,
|
||||
component: 'stock/catalogue',
|
||||
props: {
|
||||
...page.props,
|
||||
ready: true,
|
||||
canManage,
|
||||
filters: {},
|
||||
records: {
|
||||
total: 1,
|
||||
prev_page_url: null,
|
||||
next_page_url: null,
|
||||
data: [
|
||||
{
|
||||
id: 3,
|
||||
code: 'ART-7',
|
||||
name: 'Artigo existente',
|
||||
unit: 'cx',
|
||||
stock_basis: 'a'.repeat(64),
|
||||
version: 1,
|
||||
category: 'Material',
|
||||
purchase_type: 'consumable',
|
||||
published: true,
|
||||
source_current: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const html = renderToString(
|
||||
<App initialPage={initialPage} initialComponent={Catalogue} />,
|
||||
);
|
||||
expect(html).toContain('Artigo existente');
|
||||
expect(html.includes('Guardar versão do catálogo')).toBe(canManage);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user