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
176 lines
6.5 KiB
PHP
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);
|
|
}
|
|
}
|