ADconsole v1.0.0
CI / PHP 8.2 (push) Failing after 2m46s
CI / PHP 8.3 (push) Failing after 59s
CI / PHP 8.4 (push) Failing after 1m7s

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
This commit is contained in:
kzbkowskiandClaude Sonnet 5 committed 2026-09-08 23:14:39 +02:00
commit 1126d96271
174 files changed
+27113

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
<?php
namespace Tests\Unit;
use App\Services\AttributeCipher;
use App\Services\PanelSettingsService;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class AttributeCipherTest extends TestCase
{
private function cipher(string $key): AttributeCipher
{
$settings = new class($key) extends PanelSettingsService
{
public function __construct(private string $k)
{
parent::__construct();
}
public function encryptionKey(): string
{
return $this->k;
}
};
return new AttributeCipher($settings);
}
#[Test]
public function it_round_trips_a_value_when_a_key_is_configured(): void
{
$cipher = $this->cipher('TajneHaslo123');
$encrypted = $cipher->encrypt('p.zielinski.secret');
$this->assertNotSame('p.zielinski.secret', $encrypted);
$this->assertSame('p.zielinski.secret', $cipher->decrypt($encrypted));
}
#[Test]
public function it_does_not_re_encrypt_an_already_encrypted_value(): void
{
$cipher = $this->cipher('TajneHaslo123');
$encrypted = $cipher->encrypt('abc');
$this->assertSame($encrypted, $cipher->encrypt($encrypted));
}
#[Test]
public function it_passes_values_through_unchanged_when_no_key_is_configured(): void
{
$cipher = $this->cipher('');
$this->assertSame('abc', $cipher->encrypt('abc'));
$this->assertSame('abc', $cipher->decrypt('abc'));
$this->assertFalse($cipher->isConfigured());
}
#[Test]
public function it_returns_the_raw_value_when_it_cannot_be_decrypted(): void
{
$cipher = $this->cipher('TajneHaslo123');
// Wartość nie w naszym formacie (np. wpisana ręcznie / inny klucz).
$this->assertSame('not-our-ciphertext', $cipher->decrypt('not-our-ciphertext'));
}
#[Test]
public function null_and_empty_values_are_left_alone(): void
{
$cipher = $this->cipher('TajneHaslo123');
$this->assertNull($cipher->encrypt(null));
$this->assertSame('', $cipher->encrypt(''));
$this->assertNull($cipher->decrypt(null));
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace Tests\Unit;
use App\Models\AuditLog;
use App\Services\AuditLogger;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class AuditLoggerTest extends TestCase
{
use RefreshDatabase;
public function test_it_writes_an_entry_with_the_acting_admin(): void
{
$this->actingAsPanelAdmin();
app(AuditLogger::class)->log('user.disable', [
'target_type' => 'user',
'target' => 'jkowalski',
'reason' => 'porzucone konto',
]);
$entry = AuditLog::query()->latest('id')->first();
$this->assertSame('user.disable', $entry->action);
$this->assertSame('tester', $entry->actor);
$this->assertSame('jkowalski', $entry->target);
$this->assertSame('porzucone konto', $entry->reason);
$this->assertNotNull($entry->created_at);
}
public function test_it_falls_back_to_system_actor_without_a_session(): void
{
app(AuditLogger::class)->log('config.settings');
$this->assertDatabaseHas('panel_audit', ['action' => 'config.settings', 'actor' => 'system']);
}
public function test_a_write_failure_does_not_bubble_up(): void
{
// Tabela zniknęła - log nie może powstać, ale metoda nie może rzucić.
Schema::drop('panel_audit');
app(AuditLogger::class)->log('user.delete', ['target' => 'x']);
$this->assertTrue(true);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Tests\Unit;
use App\Services\LdapAttributeHelper;
use Carbon\Carbon;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class LdapAttributeHelperTest extends TestCase
{
#[Test]
public function it_passes_through_already_casted_date_objects(): void
{
// LdapRecord dla modeli AD sam rzutuje pola typu pwdLastSet na obiekty
// daty - helper nie może ich mangować do null.
$date = Carbon::parse('2026-09-03 11:26:45');
$result = LdapAttributeHelper::windowsTimestampToCarbon($date);
$this->assertInstanceOf(Carbon::class, $result);
$this->assertSame('2026-09-03 11:26:45', $result->format('Y-m-d H:i:s'));
}
#[Test]
public function it_converts_raw_windows_filetime_strings(): void
{
// 133697178050000000 = 2024-09-02 02:30:05 UTC
$result = LdapAttributeHelper::windowsTimestampToCarbon('133697178050000000');
$this->assertInstanceOf(Carbon::class, $result);
$this->assertSame('2024-09-02', $result->format('Y-m-d'));
}
#[Test]
public function it_returns_null_for_zero_and_never_values(): void
{
$this->assertNull(LdapAttributeHelper::windowsTimestampToCarbon('0'));
$this->assertNull(LdapAttributeHelper::windowsTimestampToCarbon('9223372036854775807'));
$this->assertNull(LdapAttributeHelper::windowsTimestampToCarbon(''));
$this->assertNull(LdapAttributeHelper::windowsTimestampToCarbon(null));
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace Tests\Unit;
use App\Ldap\Rules\MemberOfLoginGroup;
use App\Ldap\User;
use LdapRecord\Models\Model;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class MemberOfLoginGroupTest extends TestCase
{
/**
* Wariant reguły z podmienioną ścieżką "grup zagnieżdżonych", żeby test
* był deterministyczny i nie wołał LDAP.
*/
private function rule(array $nestedGroupDns = []): MemberOfLoginGroup
{
return new class($nestedGroupDns) extends MemberOfLoginGroup
{
public function __construct(private array $nested) {}
protected function nestedGroupDns(Model $user): array
{
return $this->nested;
}
};
}
private function user(array $memberOf): User
{
return new User(['memberof' => $memberOf]);
}
#[Test]
public function it_rejects_everyone_when_no_group_is_configured(): void
{
config(['ldap.auth.login_group' => '']);
$this->assertFalse(
$this->rule()->passes($this->user(['CN=Anything,DC=corp,DC=example,DC=com']))
);
}
#[Test]
public function it_accepts_direct_membership_by_dn_ignoring_case_and_spacing(): void
{
config(['ldap.auth.login_group' => 'CN=ADconsole,OU=Aplikacje,OU=Grupy,DC=example,DC=com']);
$user = $this->user([
'CN=Domain Users,CN=Users,DC=example,DC=com',
'cn=adconsole, ou=aplikacje, ou=grupy, dc=example, dc=com',
]);
$this->assertTrue($this->rule()->passes($user));
}
#[Test]
public function it_accepts_membership_configured_by_cn_only(): void
{
config(['ldap.auth.login_group' => 'ADconsole']);
$user = $this->user(['CN=ADconsole,OU=Aplikacje,OU=Grupy,DC=example,DC=com']);
$this->assertTrue($this->rule()->passes($user));
}
#[Test]
public function it_accepts_nested_membership(): void
{
config(['ldap.auth.login_group' => 'CN=ADconsole,OU=Aplikacje,OU=Grupy,DC=example,DC=com']);
$user = $this->user(['CN=Helpdesk,OU=Grupy,DC=example,DC=com']);
$rule = $this->rule(['CN=ADconsole,OU=Aplikacje,OU=Grupy,DC=example,DC=com']);
$this->assertTrue($rule->passes($user));
}
#[Test]
public function it_rejects_a_user_who_is_not_a_member(): void
{
config(['ldap.auth.login_group' => 'CN=ADconsole,OU=Aplikacje,OU=Grupy,DC=example,DC=com']);
$user = $this->user([
'CN=Domain Users,CN=Users,DC=example,DC=com',
'CN=Helpdesk,OU=Grupy,DC=example,DC=com',
]);
$this->assertFalse($this->rule()->passes($user));
}
}
+197
View File
@@ -0,0 +1,197 @@
<?php
namespace Tests\Unit;
use App\Services\OuTreeService;
use Mockery;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class OuTreeServiceTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// Drzewo jest cache'owane w pliku - czyścimy przed każdym testem,
// żeby mock LDAP faktycznie był odpytywany.
OuTreeService::flush();
}
#[Test]
public function it_limits_tree_to_default_user_and_computer_ous(): void
{
config([
'ldap.defaults.user_ou' => 'OU=oddzial,DC=example,DC=com',
'ldap.defaults.computer_ou' => 'OU=Komputery,DC=example,DC=com',
]);
$userOu = new class('OU=oddzial,DC=example,DC=com', 'oddzial')
{
public function __construct(public string $dn, public string $name) {}
public function getDn(): string
{
return $this->dn;
}
public function getFirstAttribute(string $attribute): ?string
{
return $attribute === 'ou' ? $this->name : null;
}
};
$nestedUserOu = new class('OU=Users,OU=oddzial,DC=example,DC=com', 'Users')
{
public function __construct(public string $dn, public string $name) {}
public function getDn(): string
{
return $this->dn;
}
public function getFirstAttribute(string $attribute): ?string
{
return $attribute === 'ou' ? $this->name : null;
}
};
$computerOu = new class('OU=Komputery,DC=example,DC=com', 'Komputery')
{
public function __construct(public string $dn, public string $name) {}
public function getDn(): string
{
return $this->dn;
}
public function getFirstAttribute(string $attribute): ?string
{
return $attribute === 'ou' ? $this->name : null;
}
};
$computerChild = new class('OU=Workstations,OU=Komputery,DC=example,DC=com', 'Workstations')
{
public function __construct(public string $dn, public string $name) {}
public function getDn(): string
{
return $this->dn;
}
public function getFirstAttribute(string $attribute): ?string
{
return $attribute === 'ou' ? $this->name : null;
}
};
$externalOu = new class('OU=External,DC=example,DC=com', 'External')
{
public function __construct(public string $dn, public string $name) {}
public function getDn(): string
{
return $this->dn;
}
public function getFirstAttribute(string $attribute): ?string
{
return $attribute === 'ou' ? $this->name : null;
}
};
$query = Mockery::mock();
$query->shouldReceive('select')->with(['ou', 'distinguishedname'])->andReturnSelf();
$query->shouldReceive('get')->twice()->andReturn([
$userOu,
$nestedUserOu,
$computerOu,
$computerChild,
$externalOu,
]);
Mockery::mock('alias:\LdapRecord\Models\ActiveDirectory\OrganizationalUnit')
->shouldReceive('query')->twice()->andReturn($query);
$service = new OuTreeService;
$userTree = $service->tree('users');
$computerTree = $service->tree('computers');
$this->assertCount(1, $userTree);
$this->assertSame('oddzial', $userTree[0]['name']);
$this->assertCount(1, $userTree[0]['children']);
$this->assertSame('Users', $userTree[0]['children'][0]['name']);
$this->assertCount(1, $computerTree);
$this->assertSame('Komputery', $computerTree[0]['name']);
$this->assertCount(1, $computerTree[0]['children']);
$this->assertSame('Workstations', $computerTree[0]['children'][0]['name']);
Mockery::close();
}
#[Test]
public function it_counts_actual_users_inside_each_ou_subtree(): void
{
config([
'ldap.defaults.user_ou' => 'OU=Users,DC=example,DC=com',
'ldap.defaults.computer_ou' => 'OU=Komputery,DC=example,DC=com',
'ldap.defaults.groups_ou' => 'OU=Grupy,DC=example,DC=com',
]);
$rootOu = new class('OU=Users,DC=example,DC=com', 'Users')
{
public function __construct(public string $dn, public string $name) {}
public function getDn(): string
{
return $this->dn;
}
public function getFirstAttribute(string $attribute): ?string
{
return $attribute === 'ou' ? $this->name : null;
}
};
$childOu = new class('OU=Developers,OU=Users,DC=example,DC=com', 'Developers')
{
public function __construct(public string $dn, public string $name) {}
public function getDn(): string
{
return $this->dn;
}
public function getFirstAttribute(string $attribute): ?string
{
return $attribute === 'ou' ? $this->name : null;
}
};
$ouQuery = Mockery::mock();
$ouQuery->shouldReceive('select')->with(['ou', 'distinguishedname'])->andReturnSelf();
$ouQuery->shouldReceive('get')->once()->andReturn([$rootOu, $childOu]);
Mockery::mock('alias:\LdapRecord\Models\ActiveDirectory\OrganizationalUnit')
->shouldReceive('query')->once()->andReturn($ouQuery);
// countObjectsInOu() zwraca liczbę obiektów w CAŁYM poddrzewie danego OU
// (zapytanie rekursywne). Liczba przy OU nadrzędnym NIE może być sumą
// z dziećmi - inaczej te same obiekty są liczone wielokrotnie
// (błąd: root pokazywał 313 zamiast 121).
$service = Mockery::mock(OuTreeService::class)->makePartial()->shouldAllowMockingProtectedMethods();
$service->shouldReceive('countObjectsInOu')->with('OU=Users,DC=example,DC=com', 'users')->once()->andReturn(5);
$service->shouldReceive('countObjectsInOu')->with('OU=Developers,OU=Users,DC=example,DC=com', 'users')->once()->andReturn(2);
$tree = $service->tree('users');
$this->assertCount(1, $tree);
$this->assertSame(5, $tree[0]['count'], 'Licznik OU nadrzędnego = liczba w całym poddrzewie, bez podwójnego sumowania.');
$this->assertSame(2, $tree[0]['children'][0]['count']);
Mockery::close();
}
}