Files
ADconsle/tests/Feature/ConfigBackupTest.php
T
kzbkowskiandClaude Sonnet 5 b313c126ce
CI / PHP 8.2 (push) Failing after 1m40s
CI / PHP 8.3 (push) Failing after 55s
CI / PHP 8.4 (push) Failing after 1m11s
Release image / image (push) Successful in 2m33s
ADconsole v1.1.0
Config backup, a Docker dev/prod split, and a Gitea-built release image.

Added
- /settings -> General -> "Configuration backup": JSON export/import of the
  panel's general settings + attribute definitions
  (App\Services\ConfigBackupService); audited as config.export / config.import.
  Export can leave out the attribute-encryption key. No AD data included.
- docker/compose.dev.yaml: live-reload dev stack that bind-mounts the working
  tree. docker/php/Dockerfile is now multi-stage (base -> dev / prod).
- .github/workflows/release.yml: on every vX.Y.Z tag, builds the prod image and
  pushes gitea.kzbikowski.pl/kzbkowski/adconsole:{X.Y.Z,X.Y,latest}.

Changed
- docker/compose.yaml: self-contained prod image (app code + composer install
  --no-dev baked in via COPY); persistent state in docker/data/{app_storage,db};
  defaults to the released image (ADCONSOLE_VERSION pins a tag, ADCONSOLE_IMAGE
  replaces the ref). Apache now honours public/.htaccess (AllowOverride All).
  Run from the repo root with --env-file .env.
- Repo root now holds only README.md; CHANGELOG / CONTRIBUTING / SECURITY /
  CODE_OF_CONDUCT moved under docs/.

Upgrade notes: docs/upgrading.md#v110--docker-layout. Full log: docs/CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JKT9p3bfP8RZesExdYCS52
2026-09-10 09:13:04 +02:00

176 lines
6.5 KiB
PHP

<?php
namespace Tests\Feature;
use App\Services\ConfigBackupService;
use App\Services\PanelSettingsService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Tests\TestCase;
class ConfigBackupTest extends TestCase
{
// Konfiguracja panelu jest w bazie - każdy test startuje na czystej,
// czyli na wbudowanych domyślnych.
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->actingAsPanelAdmin();
}
/** @param array<string, mixed> $overrides */
protected function backupFile(array $overrides = []): UploadedFile
{
$payload = array_replace(
app(ConfigBackupService::class)->export(),
$overrides,
);
return UploadedFile::fake()->createWithContent(
'adconsole-config.json',
json_encode($payload, JSON_UNESCAPED_UNICODE),
);
}
public function test_export_downloads_a_json_backup_with_the_expected_shape(): void
{
$res = $this->get('/settings/export')->assertOk();
$this->assertStringContainsString('attachment', $res->headers->get('content-disposition'));
$this->assertStringContainsString('adconsole-config-', $res->headers->get('content-disposition'));
$res->assertJsonPath('format', 'adconsole-config')
->assertJsonPath('version', 1)
->assertJsonStructure(['format', 'version', 'exported_at', 'settings', 'attributes']);
$data = $res->json();
$this->assertSame(42, $data['settings']['password_policy']['max_age_days']);
// Pełna lista domyślna: 24 atrybuty AD + 2 kolumny wyliczane.
$this->assertCount(26, $data['attributes']);
}
public function test_export_can_leave_out_the_encryption_key(): void
{
$this->put('/settings', [
'reset_password' => ['must_change_password' => '1', 'unlock_account' => '1'],
'password_policy' => ['max_age_days' => '42'],
'encryption' => ['key' => 'TopSecret!'],
])->assertRedirect();
$this->get('/settings/export')
->assertJsonPath('settings.encryption.key', 'TopSecret!')
->assertJsonPath('encryption_key_included', true);
$this->get('/settings/export?exclude_key=1')
->assertJsonPath('settings.encryption.key', '')
->assertJsonPath('encryption_key_included', false);
}
public function test_import_restores_general_settings_and_attributes(): void
{
// Zmień konfigurację, zrób kopię, przywróć domyślne, wczytaj kopię.
$this->put('/settings', [
'reset_password' => ['must_change_password' => '0', 'unlock_account' => '0'],
'password_policy' => ['max_age_days' => '17'],
'audit' => ['require_reason' => '1'],
])->assertRedirect();
$this->put('/settings/attributes', [
'attributes' => [
['key' => 'title', 'label' => 'Stanowisko', 'ldap' => 'title', 'category' => 'org', 'type' => 'text', 'column' => '1'],
['key' => 'badgeId', 'label' => 'Identyfikator', 'ldap' => 'extensionAttribute9', 'category' => 'hr', 'type' => 'text', 'column' => '1'],
],
])->assertRedirect();
$file = $this->backupFile();
// Wróć do domyślnych.
app(PanelSettingsService::class)->save(app(PanelSettingsService::class)->defaults());
app(PanelSettingsService::class)->saveAttributes([]);
$this->assertSame(42, app(PanelSettingsService::class)->passwordMaxAgeDays());
$this->post('/settings/import', ['config_file' => $file])
->assertRedirect()
->assertSessionHas('status')
->assertSessionHasNoErrors();
$fresh = app(PanelSettingsService::class);
$this->assertSame(17, $fresh->passwordMaxAgeDays());
$this->assertTrue($fresh->requireReason());
$byKey = collect($fresh->attributes())->keyBy('key');
$this->assertTrue($byKey->has('badgeId'));
$this->assertSame('extensionAttribute9', $byKey['badgeId']['ldap']);
$this->assertTrue($byKey['title']['column']);
// Pozycje systemowe / wyliczane zostają odtworzone przez saveAttributes().
$this->assertTrue($byKey->has('login'));
$this->assertTrue($byKey->has('status'));
}
public function test_import_writes_an_audit_entry(): void
{
$this->post('/settings/import', ['config_file' => $this->backupFile()])
->assertRedirect();
$this->assertDatabaseHas('panel_audit', ['action' => 'config.import']);
}
public function test_import_rejects_a_file_that_is_not_an_adconsole_backup(): void
{
$file = UploadedFile::fake()->createWithContent('nope.json', json_encode(['hello' => 'world']));
$this->post('/settings/import', ['config_file' => $file])
->assertRedirect()
->assertSessionHasErrors('config_file');
$this->assertDatabaseMissing('panel_audit', ['action' => 'config.import']);
}
public function test_import_rejects_a_file_that_is_not_valid_json(): void
{
$file = UploadedFile::fake()->createWithContent('broken.json', '{ not json ');
$this->post('/settings/import', ['config_file' => $file])
->assertRedirect()
->assertSessionHasErrors('config_file');
}
public function test_import_rejects_a_backup_from_a_newer_schema_version(): void
{
$file = $this->backupFile(['version' => 999]);
$this->post('/settings/import', ['config_file' => $file])
->assertRedirect()
->assertSessionHasErrors('config_file');
}
public function test_import_sanitises_out_of_range_values_from_a_hand_edited_file(): void
{
$file = $this->backupFile([
'settings' => [
'password_policy' => ['max_age_days' => 999999, 'generator_length' => 2],
'audit' => ['require_reason' => 'yes'],
],
]);
$this->post('/settings/import', ['config_file' => $file])->assertRedirect()->assertSessionHasNoErrors();
$fresh = app(PanelSettingsService::class);
$this->assertSame(3650, $fresh->passwordMaxAgeDays());
$this->assertSame(8, $fresh->passwordGenerator()['length']);
$this->assertTrue($fresh->requireReason());
}
public function test_settings_page_shows_the_backup_card(): void
{
$this->get('/settings')
->assertOk()
->assertSee(__('config.backup.heading'))
->assertSee(route('config.export'), escape: false);
}
}