First public release. - docs: document the audit log, user offboarding, CSV export and the "require a reason for destructive actions" setting across README, docs/ and CLAUDE/AGENTS.md; note retention for panel_audit - docs: fill the publishing placeholders (repo URL, security contact, issue-template links) and add a semantic-versioning policy - move agent/contributor context into CLAUDE/ (AGENTS.md + a README); root CLAUDE.md is now a thin @-import pointer - add a favicon (shield-lock, matching the login screen) to the login page and the app layout - CHANGELOG: promote [Unreleased] to [1.0.0]; keep the earlier internal build as [0.1.0] Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UfuvfwexztdS7LDpGuKsuK
392 lines
16 KiB
PHP
392 lines
16 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Services\PanelSettingsService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
class AttributeConfigTest extends TestCase
|
|
{
|
|
// Konfiguracja panelu jest w tabeli `panel_settings` - każdy test startuje
|
|
// na czystej bazie, czyli na wbudowanych domyślnych.
|
|
use RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
// Panel jest za middleware 'auth:ldap' - loguj udawanego administratora.
|
|
$this->actingAsPanelAdmin();
|
|
}
|
|
|
|
public function test_config_page_renders_attributes_tab(): void
|
|
{
|
|
$this->get('/settings')
|
|
->assertOk()
|
|
->assertSee('pane-attributes')
|
|
->assertSee('Dodaj atrybut');
|
|
}
|
|
|
|
public function test_password_max_age_defaults_to_42_and_is_configurable(): void
|
|
{
|
|
$this->assertSame(42, app(PanelSettingsService::class)->passwordMaxAgeDays());
|
|
|
|
$this->get('/settings')->assertOk()->assertSee('Maksymalny wiek hasła');
|
|
|
|
$this->put('/settings', [
|
|
'reset_password' => ['must_change_password' => '1', 'unlock_account' => '1'],
|
|
'password_policy' => ['max_age_days' => '30'],
|
|
])->assertRedirect();
|
|
|
|
$this->assertSame(30, app(PanelSettingsService::class)->passwordMaxAgeDays());
|
|
}
|
|
|
|
public function test_encryption_key_is_configurable_and_marks_cloud_attributes_encrypted(): void
|
|
{
|
|
$settings = app(PanelSettingsService::class);
|
|
|
|
$this->assertSame('', $settings->encryptionKey());
|
|
|
|
// Domyślnie zaszyfrowane: msDS-cloudExtensionAttribute1/2.
|
|
$byKey = collect($settings->attributes())->keyBy('key');
|
|
$this->assertTrue($byKey['microsoft']['encrypted']);
|
|
$this->assertTrue($byKey['google']['encrypted']);
|
|
$this->assertFalse($byKey['firstName']['encrypted']);
|
|
|
|
$this->get('/settings')->assertOk()->assertSee('Klucz szyfrujący')->assertSee('Tekst szyfrowany');
|
|
|
|
$this->put('/settings', [
|
|
'reset_password' => ['must_change_password' => '1', 'unlock_account' => '1'],
|
|
'password_policy' => ['max_age_days' => '42'],
|
|
'encryption' => ['key' => 'Sekret!42'],
|
|
])->assertRedirect();
|
|
|
|
$this->assertSame('Sekret!42', app(PanelSettingsService::class)->encryptionKey());
|
|
}
|
|
|
|
public function test_attribute_type_drives_editability_and_encryption(): void
|
|
{
|
|
$settings = app(PanelSettingsService::class);
|
|
|
|
$payload = ['attributes' => []];
|
|
foreach ($settings->attributes() as $i => $attr) {
|
|
if ($attr['computed']) {
|
|
$payload['attributes'][$i] = ['key' => $attr['key'], 'label' => $attr['label'], 'column' => $attr['column']];
|
|
|
|
continue;
|
|
}
|
|
$row = [
|
|
'key' => $attr['key'],
|
|
'label' => $attr['label'],
|
|
'ldap' => $attr['ldap'],
|
|
'category' => $attr['category'],
|
|
'type' => $attr['type'],
|
|
'column' => $attr['column'],
|
|
];
|
|
if ($attr['key'] === 'microsoft') {
|
|
$row['type'] = 'text'; // z szyfrowanego na zwykły tekst
|
|
}
|
|
if ($attr['key'] === 'firstName') {
|
|
$row['type'] = 'encrypted'; // zwykły atrybut - typ respektowany
|
|
}
|
|
if ($attr['key'] === 'employeeNumber') {
|
|
$row['type'] = 'number'; // typ liczbowy
|
|
}
|
|
if ($attr['key'] === 'login') {
|
|
$row['type'] = 'text'; // sAMAccountName "na stałe" - ma być zignorowany
|
|
}
|
|
$payload['attributes'][$i] = $row;
|
|
}
|
|
|
|
$this->put('/settings/attributes', $payload)->assertRedirect();
|
|
|
|
$byKey = collect(app(PanelSettingsService::class)->attributes())->keyBy('key');
|
|
|
|
$this->assertSame('text', $byKey['microsoft']['type']);
|
|
$this->assertFalse($byKey['microsoft']['encrypted']);
|
|
|
|
$this->assertSame('encrypted', $byKey['firstName']['type']);
|
|
$this->assertTrue($byKey['firstName']['encrypted']);
|
|
$this->assertTrue($byKey['firstName']['editable']);
|
|
|
|
$this->assertSame('number', $byKey['employeeNumber']['type']);
|
|
$this->assertSame('number', $byKey['employeeNumber']['input']);
|
|
$this->assertTrue($byKey['employeeNumber']['editable']);
|
|
|
|
// sAMAccountName pozostaje systemowy niezależnie od przesłanego typu.
|
|
$this->assertSame('system', $byKey['login']['type']);
|
|
$this->assertFalse($byKey['login']['editable']);
|
|
$this->assertFalse($byKey['login']['encrypted']);
|
|
}
|
|
|
|
public function test_preview_modal_only_exposes_editable_configured_attributes(): void
|
|
{
|
|
$settings = app(PanelSettingsService::class);
|
|
|
|
$groups = $settings->editableAttributesByCategory();
|
|
$keys = collect($groups)->flatten(1)->pluck('key')->all();
|
|
|
|
// Edytowalne pozycje (w tym wygasanie konta - typ "data").
|
|
$this->assertContains('firstName', $keys);
|
|
$this->assertContains('email', $keys);
|
|
$this->assertContains('account_expiry', $keys);
|
|
|
|
// Pozycje systemowe (sAMAccountName, DN) nigdy nie są edytowalne;
|
|
// kolumny wyliczane też nie są polami.
|
|
$this->assertNotContains('login', $keys);
|
|
$this->assertNotContains('distinguishedName', $keys);
|
|
$this->assertNotContains('status', $keys);
|
|
|
|
// Kategoria "Konto AD" pojawia się dzięki edytowalnemu "wygasaniu konta".
|
|
$this->assertArrayHasKey('account', $groups);
|
|
}
|
|
|
|
public function test_system_attributes_are_never_editable_even_if_flagged(): void
|
|
{
|
|
$this->put('/settings/attributes', [
|
|
'attributes' => [
|
|
['key' => 'distinguishedName', 'label' => 'DN', 'ldap' => 'distinguishedName', 'category' => 'account', 'type' => 'text', 'column' => '0'],
|
|
['key' => 'login', 'label' => 'Login', 'ldap' => 'sAMAccountName', 'category' => 'account', 'type' => 'encrypted', 'column' => '1'],
|
|
],
|
|
])->assertRedirect();
|
|
|
|
$byKey = collect(app(PanelSettingsService::class)->attributes())->keyBy('key');
|
|
|
|
// Pozycje systemowe: mimo przesłania edytowalnego typu zostają systemowe.
|
|
$this->assertSame('system', $byKey['distinguishedName']['type']);
|
|
$this->assertSame('system', $byKey['login']['type']);
|
|
$this->assertFalse($byKey['distinguishedName']['editable']);
|
|
$this->assertFalse($byKey['login']['editable']);
|
|
$this->assertSame('sAMAccountName', $byKey['login']['ldap']);
|
|
}
|
|
|
|
public function test_it_persists_added_edited_and_removed_attributes(): void
|
|
{
|
|
$settings = app(PanelSettingsService::class);
|
|
|
|
// Wyjściowo: pełna lista domyślna (24 atrybuty AD + 2 kolumny wyliczane).
|
|
$this->assertCount(26, $settings->attributes());
|
|
// Domyślnie widoczne kolumny listy - wszystkie zarządzane z panelu.
|
|
$this->assertEqualsCanonicalizing(
|
|
['login', 'displayName', 'email', 'status', 'password_expiry'],
|
|
collect($settings->attributeColumns())->pluck('key')->all()
|
|
);
|
|
|
|
$payload = ['attributes' => []];
|
|
foreach ($settings->attributes() as $i => $attr) {
|
|
if ($attr['key'] === 'google') {
|
|
continue; // usunięcie atrybutu niesystemowego
|
|
}
|
|
|
|
if ($attr['computed']) {
|
|
// Formularz dla kolumn wyliczanych wysyła tylko klucz, etykietę i widoczność.
|
|
$payload['attributes'][$i] = [
|
|
'key' => $attr['key'],
|
|
'label' => $attr['label'],
|
|
'column' => $attr['key'] === 'password_expiry' ? false : true,
|
|
];
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($attr['key'] === 'title') {
|
|
$attr['column'] = true; // pokaż jako kolumnę listy
|
|
$attr['category'] = 'address'; // przypnij do innej kategorii
|
|
}
|
|
|
|
if ($attr['key'] === 'login') {
|
|
$attr['ldap'] = 'tampered'; // próba zmiany atrybutu systemowego
|
|
}
|
|
|
|
$payload['attributes'][$i] = $attr;
|
|
}
|
|
|
|
// Nowy, własny atrybut.
|
|
$payload['attributes'][] = [
|
|
'key' => 'badgeId',
|
|
'label' => 'Numer identyfikatora',
|
|
'ldap' => 'extensionAttribute9',
|
|
'category' => 'hr',
|
|
'editable' => true,
|
|
'column' => true,
|
|
];
|
|
|
|
$this->put('/settings/attributes', $payload)
|
|
->assertRedirect();
|
|
|
|
$fresh = app(PanelSettingsService::class);
|
|
$byKey = collect($fresh->attributes())->keyBy('key');
|
|
|
|
// Dodany
|
|
$this->assertTrue($byKey->has('badgeId'));
|
|
$this->assertSame('extensionAttribute9', $byKey['badgeId']['ldap']);
|
|
|
|
// Usunięty
|
|
$this->assertFalse($byKey->has('google'));
|
|
|
|
// Zmiana kolumny + kategorii
|
|
$this->assertTrue($byKey['title']['column']);
|
|
$this->assertSame('address', $byKey['title']['category']);
|
|
|
|
// Atrybut systemowy: nazwa atrybutu AD wymuszona z powrotem, nadal obecny
|
|
$this->assertSame('sAMAccountName', $byKey['login']['ldap']);
|
|
$this->assertTrue($byKey['login']['system']);
|
|
|
|
// Kolumny wyliczane pozostają (nie da się ich usunąć); password_expiry
|
|
// wyłączony jako kolumna, doszły title + badgeId.
|
|
$this->assertEqualsCanonicalizing(
|
|
['login', 'displayName', 'email', 'status', 'title', 'badgeId'],
|
|
collect($fresh->attributeColumns())->pluck('key')->all()
|
|
);
|
|
|
|
// Kolumny wyliczane nie są edytowalne, mają computed=true i nadal istnieją.
|
|
$this->assertTrue($byKey['password_expiry']['computed']);
|
|
$this->assertFalse($byKey['password_expiry']['editable']);
|
|
$this->assertFalse($byKey['password_expiry']['column']);
|
|
$this->assertTrue($byKey['status']['computed']);
|
|
$this->assertFalse($byKey->has('last_logon'));
|
|
}
|
|
|
|
public function test_system_and_computed_attributes_cannot_be_deleted(): void
|
|
{
|
|
// Wysyłamy tylko jeden zwykły atrybut - reszta powinna zostać odtworzona.
|
|
$this->put('/settings/attributes', [
|
|
'attributes' => [
|
|
['key' => 'title', 'label' => 'Stanowisko', 'ldap' => 'title', 'category' => 'org', 'editable' => true, 'column' => false],
|
|
],
|
|
])->assertRedirect();
|
|
|
|
$keys = collect(app(PanelSettingsService::class)->attributes())->pluck('key');
|
|
|
|
foreach (PanelSettingsService::SYSTEM_ATTRIBUTE_KEYS as $systemKey) {
|
|
$this->assertTrue($keys->contains($systemKey), "Brakuje atrybutu systemowego: {$systemKey}");
|
|
}
|
|
|
|
foreach (PanelSettingsService::COMPUTED_ATTRIBUTE_KEYS as $computedKey) {
|
|
$this->assertTrue($keys->contains($computedKey), "Brakuje kolumny wyliczanej: {$computedKey}");
|
|
}
|
|
|
|
foreach (array_keys(PanelSettingsService::FIXED_ATTRIBUTE_KEYS) as $fixedKey) {
|
|
$this->assertTrue($keys->contains($fixedKey), "Brakuje pozycji „na stałe”: {$fixedKey}");
|
|
}
|
|
}
|
|
|
|
public function test_account_expiry_has_fixed_date_type_but_configurable_category(): void
|
|
{
|
|
// Domyślnie: pozycja "na stałe", typ "data" (edytowalna), nie wyliczana.
|
|
$byKey = collect(app(PanelSettingsService::class)->attributes())->keyBy('key');
|
|
$this->assertTrue($byKey['account_expiry']['fixed']);
|
|
$this->assertFalse($byKey['account_expiry']['computed']);
|
|
$this->assertSame('date', $byKey['account_expiry']['type']);
|
|
$this->assertSame('date', $byKey['account_expiry']['input']);
|
|
$this->assertTrue($byKey['account_expiry']['editable']);
|
|
$this->assertSame('accountExpires', $byKey['account_expiry']['ldap']);
|
|
|
|
// Zmieniamy kategorię; próba podmiany typu i atrybutu AD ma być zignorowana.
|
|
$this->put('/settings/attributes', [
|
|
'attributes' => [
|
|
['key' => 'account_expiry', 'label' => 'Wygasanie konta', 'ldap' => 'whenChanged', 'category' => 'hr', 'type' => 'encrypted', 'column' => '1'],
|
|
],
|
|
])->assertRedirect();
|
|
|
|
$byKey = collect(app(PanelSettingsService::class)->attributes())->keyBy('key');
|
|
$this->assertSame('date', $byKey['account_expiry']['type']);
|
|
$this->assertTrue($byKey['account_expiry']['editable']);
|
|
$this->assertSame('hr', $byKey['account_expiry']['category']);
|
|
$this->assertTrue($byKey['account_expiry']['column']);
|
|
$this->assertSame('accountExpires', $byKey['account_expiry']['ldap']);
|
|
$this->assertFalse($byKey['account_expiry']['encrypted']);
|
|
|
|
// Trafia do pól okna "Podgląd / Edycja".
|
|
$groups = app(PanelSettingsService::class)->editableAttributesByCategory();
|
|
$keys = collect($groups)->flatten(1)->pluck('key')->all();
|
|
$this->assertContains('account_expiry', $keys);
|
|
}
|
|
|
|
public function test_attribute_validation_rules_persist_and_are_forced_empty_for_locked_entries(): void
|
|
{
|
|
$settings = app(PanelSettingsService::class);
|
|
|
|
// Domyślnie puste.
|
|
$byKey = collect($settings->attributes())->keyBy('key');
|
|
$this->assertSame('', $byKey['title']['regex']);
|
|
$this->assertFalse($byKey['title']['required']);
|
|
$this->assertSame('', $byKey['title']['help']);
|
|
|
|
$payload = ['attributes' => []];
|
|
foreach ($settings->attributes() as $i => $attr) {
|
|
if ($attr['computed']) {
|
|
$payload['attributes'][$i] = ['key' => $attr['key'], 'label' => $attr['label'], 'column' => $attr['column']];
|
|
|
|
continue;
|
|
}
|
|
$row = [
|
|
'key' => $attr['key'],
|
|
'label' => $attr['label'],
|
|
'ldap' => $attr['ldap'],
|
|
'category' => $attr['category'],
|
|
'type' => $attr['type'],
|
|
'column' => $attr['column'],
|
|
];
|
|
if ($attr['key'] === 'employeeNumber') {
|
|
$row['regex'] = '/^\d{4}$/';
|
|
$row['required'] = '1';
|
|
$row['help'] = 'Cztery cyfry';
|
|
}
|
|
if ($attr['key'] === 'login') {
|
|
// Pozycja systemowa - reguły mają zostać wyzerowane.
|
|
$row['regex'] = '/x/';
|
|
$row['required'] = '1';
|
|
$row['help'] = 'ignorowane';
|
|
}
|
|
$payload['attributes'][$i] = $row;
|
|
}
|
|
|
|
$this->put('/settings/attributes', $payload)->assertRedirect()->assertSessionHasNoErrors();
|
|
|
|
$byKey = collect(app(PanelSettingsService::class)->attributes())->keyBy('key');
|
|
|
|
$this->assertSame('/^\d{4}$/', $byKey['employeeNumber']['regex']);
|
|
$this->assertTrue($byKey['employeeNumber']['required']);
|
|
$this->assertSame('Cztery cyfry', $byKey['employeeNumber']['help']);
|
|
|
|
// Pozycja systemowa: reguły wymuszone na puste.
|
|
$this->assertSame('', $byKey['login']['regex']);
|
|
$this->assertFalse($byKey['login']['required']);
|
|
$this->assertSame('', $byKey['login']['help']);
|
|
}
|
|
|
|
public function test_invalid_validation_regex_is_rejected(): void
|
|
{
|
|
$this->put('/settings/attributes', [
|
|
'attributes' => [
|
|
['key' => 'title', 'label' => 'Stanowisko', 'ldap' => 'title', 'category' => 'org', 'type' => 'text', 'column' => '0', 'regex' => '/[unclosed/'],
|
|
],
|
|
])->assertSessionHasErrors('attributes.0.regex');
|
|
}
|
|
|
|
public function test_require_reason_setting_persists(): void
|
|
{
|
|
$this->assertFalse(app(PanelSettingsService::class)->requireReason());
|
|
|
|
$this->get('/settings')->assertOk()->assertSee(__('config.general.require_reason'));
|
|
|
|
$this->put('/settings', [
|
|
'reset_password' => ['must_change_password' => '1', 'unlock_account' => '1'],
|
|
'password_policy' => ['max_age_days' => '42'],
|
|
'audit' => ['require_reason' => '1'],
|
|
])->assertRedirect();
|
|
|
|
$this->assertTrue(app(PanelSettingsService::class)->requireReason());
|
|
}
|
|
|
|
public function test_general_config_no_longer_exposes_column_picker(): void
|
|
{
|
|
$this->get('/settings')
|
|
->assertOk()
|
|
->assertDontSee('users_table[columns]', escape: false)
|
|
->assertDontSee('Domyślnie widoczne kolumny listy użytkowników');
|
|
}
|
|
}
|