feat: add routes, lang, tests, stubs, docs, and docker configurations
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
use App\Models\PasswordHistory;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\PasswordPolicyService;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
beforeEach(function () {
|
||||
$ref = new ReflectionClass(SystemConfigService::class);
|
||||
$prop = $ref->getProperty('resolvedSettings');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, null);
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
function setSetting(string $key, mixed $value): void
|
||||
{
|
||||
app(SystemConfigService::class)->update([$key => $value]);
|
||||
}
|
||||
|
||||
test('isPasswordExpired returns false when expiry is disabled', function () {
|
||||
setSetting('password_expiry_days', 0);
|
||||
$user = User::factory()->create([
|
||||
'password_changed_at' => now()->subYears(5),
|
||||
]);
|
||||
|
||||
expect(PasswordPolicyService::isPasswordExpired($user))->toBeFalse();
|
||||
});
|
||||
|
||||
test('isPasswordExpired uses password_changed_at when present', function () {
|
||||
setSetting('password_expiry_days', 30);
|
||||
$expired = User::factory()->create(['password_changed_at' => now()->subDays(31)]);
|
||||
$fresh = User::factory()->create(['password_changed_at' => now()->subDays(5)]);
|
||||
|
||||
expect(PasswordPolicyService::isPasswordExpired($expired))->toBeTrue();
|
||||
expect(PasswordPolicyService::isPasswordExpired($fresh))->toBeFalse();
|
||||
});
|
||||
|
||||
test('isPasswordExpired falls back to created_at when password_changed_at is null', function () {
|
||||
setSetting('password_expiry_days', 30);
|
||||
$user = User::factory()->create();
|
||||
DB::table('users')->where('id', $user->id)->update([
|
||||
'password_changed_at' => null,
|
||||
'created_at' => now()->subDays(40),
|
||||
]);
|
||||
|
||||
expect(PasswordPolicyService::isPasswordExpired($user->fresh()))->toBeTrue();
|
||||
});
|
||||
|
||||
test('checkHistory is a no-op when history count is zero', function () {
|
||||
setSetting('password_history_count', 0);
|
||||
$user = User::factory()->create();
|
||||
PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('old-pass')]);
|
||||
|
||||
PasswordPolicyService::checkHistory($user, 'old-pass');
|
||||
})->throwsNoExceptions();
|
||||
|
||||
test('checkHistory throws when reusing a recent password', function () {
|
||||
setSetting('password_history_count', 3);
|
||||
$user = User::factory()->create();
|
||||
PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('reused-pass')]);
|
||||
|
||||
PasswordPolicyService::checkHistory($user, 'reused-pass');
|
||||
})->throws(ValidationException::class);
|
||||
|
||||
test('checkHistory passes when new password is different from history', function () {
|
||||
setSetting('password_history_count', 3);
|
||||
$user = User::factory()->create();
|
||||
PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('old-1')]);
|
||||
PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('old-2')]);
|
||||
|
||||
PasswordPolicyService::checkHistory($user, 'totally-different');
|
||||
})->throwsNoExceptions();
|
||||
|
||||
test('checkHistory only inspects the most recent N entries', function () {
|
||||
setSetting('password_history_count', 2);
|
||||
$user = User::factory()->create();
|
||||
|
||||
DB::table('password_histories')->insert([
|
||||
['user_id' => $user->id, 'password' => Hash::make('ancient'), 'created_at' => now()->subDays(10), 'updated_at' => now()->subDays(10)],
|
||||
['user_id' => $user->id, 'password' => Hash::make('recent-1'), 'created_at' => now()->subDays(2), 'updated_at' => now()->subDays(2)],
|
||||
['user_id' => $user->id, 'password' => Hash::make('recent-2'), 'created_at' => now()->subDay(), 'updated_at' => now()->subDay()],
|
||||
]);
|
||||
|
||||
PasswordPolicyService::checkHistory($user, 'ancient');
|
||||
})->throwsNoExceptions();
|
||||
|
||||
test('recordPasswordChange creates history row and stamps password_changed_at', function () {
|
||||
setSetting('password_history_count', 5);
|
||||
$user = User::factory()->create(['password_changed_at' => null]);
|
||||
|
||||
PasswordPolicyService::recordPasswordChange($user, Hash::make('new-pass'));
|
||||
|
||||
expect($user->fresh()->password_changed_at)->not->toBeNull();
|
||||
expect(PasswordHistory::where('user_id', $user->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('getRules respects min/max length from settings', function () {
|
||||
setSetting('password_min_length', 10);
|
||||
setSetting('password_max_length', 50);
|
||||
|
||||
$rules = PasswordPolicyService::getRules();
|
||||
|
||||
expect($rules)->toBeInstanceOf(Password::class);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
use App\Services\System\BackupManagementService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use League\Flysystem\UnableToCreateDirectory;
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::flush();
|
||||
$this->service = app(BackupManagementService::class);
|
||||
});
|
||||
|
||||
test('checkRequirements returns ok shape with binary status', function () {
|
||||
$result = $this->service->checkRequirements();
|
||||
|
||||
expect($result)->toHaveKey('status');
|
||||
expect($result['status'])->toBeBool();
|
||||
|
||||
if (isset($result['binary'])) {
|
||||
expect($result['binary'])->toBeIn(['pg_dump', 'mysqldump']);
|
||||
}
|
||||
});
|
||||
|
||||
test('testConnection on local disk succeeds', function () {
|
||||
// Storage::fake may fail in Docker containers with restricted permissions
|
||||
try {
|
||||
Storage::fake('local');
|
||||
} catch (UnableToCreateDirectory $e) {
|
||||
$this->markTestSkipped('Cannot create fake disk in this environment: '.$e->getMessage());
|
||||
}
|
||||
Config::set('backup.backup.destination.disks', ['local']);
|
||||
|
||||
$result = $this->service->testConnection();
|
||||
|
||||
expect($result['success'])->toBeTrue();
|
||||
expect($result['message'])->toContain('Successfully');
|
||||
});
|
||||
|
||||
test('parseBytes round-trips through formatBytes for whole units', function () {
|
||||
$ref = new ReflectionMethod(BackupManagementService::class, 'parseBytes');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($this->service, '1 KB'))->toBe(1024.0);
|
||||
expect($ref->invoke($this->service, '1 MB'))->toBe(1048576.0);
|
||||
expect($ref->invoke($this->service, '2 GB'))->toBe(2.0 * 1024 * 1024 * 1024);
|
||||
expect($ref->invoke($this->service, '512 B'))->toBe(512.0);
|
||||
});
|
||||
|
||||
test('parseBytes returns raw float when no unit suffix', function () {
|
||||
$ref = new ReflectionMethod(BackupManagementService::class, 'parseBytes');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($this->service, '4096'))->toBe(4096.0);
|
||||
});
|
||||
|
||||
test('parseBytes ignores unknown units', function () {
|
||||
$ref = new ReflectionMethod(BackupManagementService::class, 'parseBytes');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($this->service, '10 XB'))->toBe(10.0);
|
||||
});
|
||||
|
||||
test('parseBytes handles fractional values', function () {
|
||||
$ref = new ReflectionMethod(BackupManagementService::class, 'parseBytes');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($this->service, '1.5 KB'))->toBe(1536.0);
|
||||
});
|
||||
|
||||
test('formatBytes private helper renders KB/MB/GB units', function () {
|
||||
$ref = new ReflectionMethod(BackupManagementService::class, 'formatBytes');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($this->service, 1024))->toBe('1 KB');
|
||||
expect($ref->invoke($this->service, 1024 * 1024))->toBe('1 MB');
|
||||
expect($ref->invoke($this->service, 1024 * 1024 * 1024))->toBe('1 GB');
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
use App\Models\SystemSetting;
|
||||
use App\Models\SystemSettingRevision;
|
||||
use App\Models\User;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
beforeEach(function () {
|
||||
$reset = function () {
|
||||
$ref = new ReflectionClass(SystemConfigService::class);
|
||||
$prop = $ref->getProperty('resolvedSettings');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, null);
|
||||
};
|
||||
$reset();
|
||||
Cache::flush();
|
||||
$this->service = app(SystemConfigService::class);
|
||||
});
|
||||
|
||||
test('definitions returns non-empty array with expected meta keys', function () {
|
||||
$defs = SystemConfigService::definitions();
|
||||
|
||||
expect($defs)->toBeArray()->not->toBeEmpty();
|
||||
expect($defs)->toHaveKey('app_name');
|
||||
|
||||
foreach ($defs as $key => $meta) {
|
||||
expect($meta)->toHaveKeys(['type', 'group', 'is_public']);
|
||||
}
|
||||
});
|
||||
|
||||
test('all returns definition defaults when DB is empty', function () {
|
||||
$all = $this->service->all();
|
||||
|
||||
expect($all['app_name'])->toBe('Laravel');
|
||||
expect($all['regional_timezone'])->toBe('Asia/Jakarta');
|
||||
});
|
||||
|
||||
test('all returns DB values when row exists', function () {
|
||||
SystemSetting::create([
|
||||
'key' => 'app_name',
|
||||
'value' => 'CustomApp',
|
||||
'type' => 'string',
|
||||
'group' => 'branding',
|
||||
'is_public' => true,
|
||||
]);
|
||||
|
||||
$all = $this->service->all(forceRefresh: true);
|
||||
|
||||
expect($all['app_name'])->toBe('CustomApp');
|
||||
});
|
||||
|
||||
test('get returns default for unknown key', function () {
|
||||
expect($this->service->get('nonexistent_key', 'fallback'))->toBe('fallback');
|
||||
});
|
||||
|
||||
test('get returns value for known key', function () {
|
||||
expect($this->service->get('app_name'))->toBe('Laravel');
|
||||
});
|
||||
|
||||
test('getPublicSettings only includes is_public=true keys', function () {
|
||||
$public = $this->service->getPublicSettings();
|
||||
|
||||
expect($public)->toHaveKey('app_name');
|
||||
|
||||
foreach ($public as $key => $value) {
|
||||
$meta = SystemConfigService::definitions()[$key];
|
||||
expect($meta['is_public'])->toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
test('grouped returns settings keyed by group', function () {
|
||||
$grouped = $this->service->grouped();
|
||||
|
||||
expect($grouped)->toHaveKey('branding');
|
||||
expect($grouped['branding'])->toHaveKey('app_name');
|
||||
});
|
||||
|
||||
test('update creates new setting row', function () {
|
||||
$this->service->update(['app_name' => 'Updated']);
|
||||
|
||||
$this->assertDatabaseHas('system_settings', [
|
||||
'key' => 'app_name',
|
||||
'value' => 'Updated',
|
||||
]);
|
||||
});
|
||||
|
||||
test('update overwrites existing setting row', function () {
|
||||
SystemSetting::create([
|
||||
'key' => 'app_name',
|
||||
'value' => 'Old',
|
||||
'type' => 'string',
|
||||
'group' => 'branding',
|
||||
'is_public' => true,
|
||||
]);
|
||||
|
||||
$this->service->update(['app_name' => 'New']);
|
||||
|
||||
expect(SystemSetting::where('key', 'app_name')->count())->toBe(1);
|
||||
expect(SystemSetting::where('key', 'app_name')->first()->value)->toBe('New');
|
||||
});
|
||||
|
||||
test('update writes a revision row', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->service->update(['app_name' => 'Revisioned'], actorId: $user->id);
|
||||
|
||||
$rev = SystemSettingRevision::where('key', 'app_name')->first();
|
||||
|
||||
expect($rev)->not->toBeNull();
|
||||
expect($rev->changed_by)->toBe($user->id);
|
||||
expect($rev->new_value)->toContain('Revisioned');
|
||||
});
|
||||
|
||||
test('update does not write revision when value unchanged', function () {
|
||||
SystemSetting::create([
|
||||
'key' => 'app_name',
|
||||
'value' => 'Same',
|
||||
'type' => 'string',
|
||||
'group' => 'branding',
|
||||
'is_public' => true,
|
||||
]);
|
||||
|
||||
$this->service->update(['app_name' => 'Same']);
|
||||
|
||||
expect(SystemSettingRevision::where('key', 'app_name')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('update serializes bool values to 1 or 0', function () {
|
||||
$this->service->update(['enable_landing_page' => false]);
|
||||
|
||||
$row = SystemSetting::where('key', 'enable_landing_page')->first();
|
||||
expect($row->value)->toBe('0');
|
||||
|
||||
$this->service->update(['enable_landing_page' => true]);
|
||||
$row->refresh();
|
||||
expect($row->value)->toBe('1');
|
||||
});
|
||||
|
||||
test('update clears the cache after writing', function () {
|
||||
Cache::put('system_settings.all', ['app_name' => 'StaleCached'], 60);
|
||||
|
||||
$this->service->update(['app_name' => 'Fresh']);
|
||||
|
||||
expect(Cache::has('system_settings.all'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('update normalizes bool input from string', function () {
|
||||
$this->service->update(['enable_landing_page' => 'false']);
|
||||
|
||||
$row = SystemSetting::where('key', 'enable_landing_page')->first();
|
||||
expect($row->value)->toBe('0');
|
||||
});
|
||||
|
||||
test('update records request IP and user agent in revision', function () {
|
||||
$request = Request::create('/system-settings', 'POST', server: [
|
||||
'REMOTE_ADDR' => '203.0.113.7',
|
||||
'HTTP_USER_AGENT' => 'pest-test-agent',
|
||||
]);
|
||||
|
||||
$this->service->update(['app_name' => 'Tracked'], request: $request);
|
||||
|
||||
$rev = SystemSettingRevision::where('key', 'app_name')->first();
|
||||
expect($rev->changed_ip)->toBe('203.0.113.7');
|
||||
expect($rev->changed_agent)->toBe('pest-test-agent');
|
||||
});
|
||||
Reference in New Issue
Block a user