You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
pixelfed/tests/Feature/Config/ReadWritePrecedenceTest.php

455 lines
16 KiB
PHP

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
use App\Models\ConfigCache as ConfigCacheModel;
use App\Services\ConfigCacheService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Env;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
uses(LazilyRefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| ConfigCacheService read/write precedence (Task 8)
|--------------------------------------------------------------------------
|
| Exercises get()/put() across the three-list ownership model. There is no
| enable_cc dimension — the master switch has been removed, so precedence is
| computed purely from the list + live env presence/validity.
|
| Two lists only: ENVCONFIG (env-bound) and ADMINONLY (no env var). ENVBOUND
| was removed — every env-bound key now follows the ENVCONFIG rule.
|
| get(): (env valid / absent / invalid) × (DB row present/absent)
| - unlisted → config($key), no row persisted
| - env present+valid → config($key) regardless of any DB row
| - env absent → DB row if present else config fallback
| - env invalid → treated absent (DB row else config fallback)
| - ADMINONLY (no env) → DB row if present else config fallback
|
| put(): no-op vs write
| - unlisted / env-present (locked) → no-op (no row written)
| - env-absent / ADMINONLY → write
| - protected key write → encrypted at rest; get() decrypts back
|
| Real KEYS keys are reused so the test tracks the actual registry:
| ENV-BOUND string : filesystems.disks.s3.region (AWS_DEFAULT_REGION)
| ENV-BOUND enum : captcha.driver (CAPTCHA_DRIVER, in:hcaptcha,turnstile,cap)
| ADMINONLY : uikit.custom.css (no env var)
| PROTECTED : captcha.hcaptcha.secret (CAPTCHA_H_SECRET, env-bound + secret)
|
| Env presence is driven the same way as EnvPresenceAndValidityTest: mutate
| the process env across every source the Env repository reads, then reset the
| cached (immutable) repository via reflection so the next Env::get() re-reads.
|
| Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 7.1, 7.2, 7.3, 7.4
|
*/
const RW_UNLISTED_KEY = 'this.key.is.not.cached';
const RW_ENVBOUND_KEY = 'filesystems.disks.s3.region';
const RW_ENVBOUND_VAR = 'AWS_DEFAULT_REGION';
const RW_ENVCONFIG_KEY = 'captcha.driver';
const RW_ENVCONFIG_VAR = 'CAPTCHA_DRIVER';
const RW_ADMINONLY_KEY = 'uikit.custom.css';
const RW_PROTECTED_KEY = 'captcha.hcaptcha.secret';
const RW_PROTECTED_VAR = 'CAPTCHA_H_SECRET';
/**
* Reset Illuminate\Support\Env's cached (immutable) repository so the next
* Env::get() re-reads the process environment.
*/
function rwResetEnvRepository(): void
{
$ref = new ReflectionClass(Env::class);
$prop = $ref->getProperty('repository');
$prop->setAccessible(true);
$prop->setValue(null, null);
}
/**
* Set (or clear when $value === null) an env var across every source the Env
* repository reads, then reset the cached repository.
*/
function rwSetProcessEnv(string $name, ?string $value): void
{
if ($value === null) {
putenv($name);
unset($_ENV[$name], $_SERVER[$name]);
} else {
putenv("{$name}={$value}");
$_ENV[$name] = $value;
$_SERVER[$name] = $value;
}
rwResetEnvRepository();
}
/**
* Forget the memoized 12h cache entry for a key so the next get() re-resolves
* from the DB row / config fallback.
*/
function rwForget(string $key): void
{
Cache::forget(ConfigCacheService::CACHE_KEY.$key);
}
beforeEach(function () {
$this->originalEnv = [
RW_ENVBOUND_VAR => getenv(RW_ENVBOUND_VAR),
RW_ENVCONFIG_VAR => getenv(RW_ENVCONFIG_VAR),
RW_PROTECTED_VAR => getenv(RW_PROTECTED_VAR),
];
$this->originalConfig = [
RW_ENVBOUND_KEY => Config::get(RW_ENVBOUND_KEY),
RW_ENVCONFIG_KEY => Config::get(RW_ENVCONFIG_KEY),
RW_ADMINONLY_KEY => Config::get(RW_ADMINONLY_KEY),
RW_PROTECTED_KEY => Config::get(RW_PROTECTED_KEY),
];
// Start each case from a known env-absent baseline.
foreach (array_keys($this->originalEnv) as $var) {
rwSetProcessEnv($var, null);
}
});
afterEach(function () {
foreach ($this->originalEnv as $var => $value) {
rwSetProcessEnv($var, $value === false ? null : $value);
}
foreach ($this->originalConfig as $key => $value) {
Config::set($key, $value);
}
rwResetEnvRepository();
foreach ([RW_ENVBOUND_KEY, RW_ENVCONFIG_KEY, RW_ADMINONLY_KEY, RW_PROTECTED_KEY, RW_UNLISTED_KEY] as $key) {
rwForget($key);
}
});
/*
|--------------------------------------------------------------------------
| get() — unlisted
|--------------------------------------------------------------------------
*/
test('get() unlisted returns config($key) and persists no row (4.1)', function () {
Config::set(RW_UNLISTED_KEY, 'passthrough-value');
expect(ConfigCacheService::get(RW_UNLISTED_KEY))->toBe('passthrough-value');
expect(ConfigCacheModel::where('k', RW_UNLISTED_KEY)->exists())->toBeFalse();
});
/*
|--------------------------------------------------------------------------
| get() — env-bound string key, env PRESENT (env authoritative over DB)
|--------------------------------------------------------------------------
*/
test('get() env-present returns config value, ignoring a differing DB row (4.2)', function () {
// s3.region is env-bound (ENVCONFIG). With the env var present + valid,
// config() wins over any DB row.
rwSetProcessEnv(RW_ENVBOUND_VAR, 'us-east-1');
Config::set(RW_ENVBOUND_KEY, 'us-east-1');
ConfigCacheService::putRaw(RW_ENVBOUND_KEY, 'eu-west-9');
rwForget(RW_ENVBOUND_KEY);
expect(ConfigCacheService::get(RW_ENVBOUND_KEY))->toBe('us-east-1');
});
test('get() env-present returns config value when no DB row exists (4.2)', function () {
rwSetProcessEnv(RW_ENVBOUND_VAR, 'us-east-1');
Config::set(RW_ENVBOUND_KEY, 'us-east-1');
expect(ConfigCacheService::get(RW_ENVBOUND_KEY))->toBe('us-east-1');
});
/*
|--------------------------------------------------------------------------
| get() — ENVCONFIG + env present + valid (env authoritative)
|--------------------------------------------------------------------------
*/
test('get() ENVCONFIG env-valid returns config value, ignoring a differing DB row (4.3)', function () {
rwSetProcessEnv(RW_ENVCONFIG_VAR, 'turnstile');
Config::set(RW_ENVCONFIG_KEY, 'turnstile'); // env resolved into config, passes in: rule
// Plant a DB row with a different value; env authoritative must win.
ConfigCacheService::putRaw(RW_ENVCONFIG_KEY, 'hcaptcha');
rwForget(RW_ENVCONFIG_KEY);
expect(ConfigCacheService::get(RW_ENVCONFIG_KEY))->toBe('turnstile');
});
test('get() ENVCONFIG env-valid returns config value when no DB row exists (4.3)', function () {
rwSetProcessEnv(RW_ENVCONFIG_VAR, 'cap');
Config::set(RW_ENVCONFIG_KEY, 'cap');
expect(ConfigCacheService::get(RW_ENVCONFIG_KEY))->toBe('cap');
});
/*
|--------------------------------------------------------------------------
| get() — ENVCONFIG + env absent (DB row wins, else config fallback)
|--------------------------------------------------------------------------
*/
test('get() ENVCONFIG env-absent returns the DB row when present (4.4)', function () {
rwSetProcessEnv(RW_ENVCONFIG_VAR, null);
Config::set(RW_ENVCONFIG_KEY, 'hcaptcha'); // config default
ConfigCacheService::putRaw(RW_ENVCONFIG_KEY, 'turnstile');
rwForget(RW_ENVCONFIG_KEY);
expect(ConfigCacheService::get(RW_ENVCONFIG_KEY))->toBe('turnstile');
});
test('get() ENVCONFIG env-absent falls back to config when no DB row (4.4)', function () {
rwSetProcessEnv(RW_ENVCONFIG_VAR, null);
Config::set(RW_ENVCONFIG_KEY, 'hcaptcha');
rwForget(RW_ENVCONFIG_KEY);
expect(ConfigCacheService::get(RW_ENVCONFIG_KEY))->toBe('hcaptcha');
});
/*
|--------------------------------------------------------------------------
| get() — ENVCONFIG + env invalid (treated as absent)
|--------------------------------------------------------------------------
*/
test('get() ENVCONFIG env-invalid is treated as absent: DB row wins (4.4)', function () {
rwSetProcessEnv(RW_ENVCONFIG_VAR, 'banana'); // fails in: rule → treated absent
Config::set(RW_ENVCONFIG_KEY, 'banana');
ConfigCacheService::putRaw(RW_ENVCONFIG_KEY, 'turnstile');
rwForget(RW_ENVCONFIG_KEY);
expect(ConfigCacheService::get(RW_ENVCONFIG_KEY))->toBe('turnstile');
});
test('get() ENVCONFIG env-invalid with no DB row falls back to config (4.4)', function () {
rwSetProcessEnv(RW_ENVCONFIG_VAR, 'banana');
Config::set(RW_ENVCONFIG_KEY, 'banana');
rwForget(RW_ENVCONFIG_KEY);
// No row: readFromDb seeds from config($key) and returns it.
expect(ConfigCacheService::get(RW_ENVCONFIG_KEY))->toBe('banana');
});
/*
|--------------------------------------------------------------------------
| get() — ADMINONLY (DB row wins, else config fallback)
|--------------------------------------------------------------------------
*/
test('get() ADMINONLY returns the DB row when present (4.5)', function () {
Config::set(RW_ADMINONLY_KEY, '/* default css */');
ConfigCacheService::putRaw(RW_ADMINONLY_KEY, '.admin { color: red; }');
rwForget(RW_ADMINONLY_KEY);
expect(ConfigCacheService::get(RW_ADMINONLY_KEY))->toBe('.admin { color: red; }');
});
test('get() ADMINONLY falls back to config when no DB row (4.5)', function () {
Config::set(RW_ADMINONLY_KEY, '/* default css */');
rwForget(RW_ADMINONLY_KEY);
expect(ConfigCacheService::get(RW_ADMINONLY_KEY))->toBe('/* default css */');
});
/*
|--------------------------------------------------------------------------
| put() — no-op branches (unlisted / env-present locked)
|--------------------------------------------------------------------------
*/
test('put() unlisted is a no-op: no row written (7.1)', function () {
Config::set(RW_UNLISTED_KEY, 'cfg');
ConfigCacheService::put(RW_UNLISTED_KEY, 'attempted');
expect(ConfigCacheModel::where('k', RW_UNLISTED_KEY)->exists())->toBeFalse();
});
test('put() env-present is a no-op: no row written (7.2)', function () {
// Env-bound key with the env var present is locked → put() is a no-op.
rwSetProcessEnv(RW_ENVBOUND_VAR, 'us-east-1');
Config::set(RW_ENVBOUND_KEY, 'us-east-1');
ConfigCacheService::put(RW_ENVBOUND_KEY, 'eu-west-9');
expect(ConfigCacheModel::where('k', RW_ENVBOUND_KEY)->exists())->toBeFalse();
rwForget(RW_ENVBOUND_KEY);
expect(ConfigCacheService::get(RW_ENVBOUND_KEY))->toBe('us-east-1');
});
test('put() ENVCONFIG env-valid is a no-op: no row written (7.3)', function () {
rwSetProcessEnv(RW_ENVCONFIG_VAR, 'turnstile');
Config::set(RW_ENVCONFIG_KEY, 'turnstile');
ConfigCacheService::put(RW_ENVCONFIG_KEY, 'hcaptcha');
expect(ConfigCacheModel::where('k', RW_ENVCONFIG_KEY)->exists())->toBeFalse();
rwForget(RW_ENVCONFIG_KEY);
expect(ConfigCacheService::get(RW_ENVCONFIG_KEY))->toBe('turnstile');
});
/*
|--------------------------------------------------------------------------
| put() — write branches (ENVCONFIG-env-absent / ADMINONLY)
|--------------------------------------------------------------------------
*/
test('put() ENVCONFIG env-absent writes the row (7.4)', function () {
rwSetProcessEnv(RW_ENVCONFIG_VAR, null);
Config::set(RW_ENVCONFIG_KEY, 'hcaptcha');
ConfigCacheService::put(RW_ENVCONFIG_KEY, 'turnstile');
$row = ConfigCacheModel::where('k', RW_ENVCONFIG_KEY)->first();
expect($row)->not->toBeNull();
expect($row->v)->toBe('turnstile');
rwForget(RW_ENVCONFIG_KEY);
expect(ConfigCacheService::get(RW_ENVCONFIG_KEY))->toBe('turnstile');
});
test('put() ADMINONLY writes the row (7.4)', function () {
Config::set(RW_ADMINONLY_KEY, '/* default css */');
ConfigCacheService::put(RW_ADMINONLY_KEY, '.x { color: blue; }');
$row = ConfigCacheModel::where('k', RW_ADMINONLY_KEY)->first();
expect($row)->not->toBeNull();
expect($row->v)->toBe('.x { color: blue; }');
rwForget(RW_ADMINONLY_KEY);
expect(ConfigCacheService::get(RW_ADMINONLY_KEY))->toBe('.x { color: blue; }');
});
/*
|--------------------------------------------------------------------------
| put() — protected key encryption at rest (4.6)
|--------------------------------------------------------------------------
*/
test('put() protected key stores value encrypted at rest; get() decrypts back (4.6)', function () {
// Ensure the env is absent so the write path runs (ENVCONFIG + secret).
rwSetProcessEnv(RW_PROTECTED_VAR, null);
Config::set(RW_PROTECTED_KEY, null);
$plaintext = 'super-secret-hcaptcha-value';
ConfigCacheService::put(RW_PROTECTED_KEY, $plaintext);
$row = ConfigCacheModel::where('k', RW_PROTECTED_KEY)->first();
expect($row)->not->toBeNull();
// Stored value must NOT be plaintext...
expect($row->v)->not->toBe($plaintext);
// ...and must decrypt back to the original plaintext.
expect(decrypt($row->v))->toBe($plaintext);
// get() returns the decrypted value.
rwForget(RW_PROTECTED_KEY);
expect(ConfigCacheService::get(RW_PROTECTED_KEY))->toBe($plaintext);
});
/*
|--------------------------------------------------------------------------
| get() — empty/null config value must NOT seed a DB row
|--------------------------------------------------------------------------
*/
test('get() protected key with empty config value seeds no row (no encrypt of "")', function () {
// ENVCONFIG + secret with env absent → read path runs. An unset secret
// resolves to '' in config; that must not be encrypted and written.
rwSetProcessEnv(RW_PROTECTED_VAR, null);
Config::set(RW_PROTECTED_KEY, '');
ConfigCacheModel::where('k', RW_PROTECTED_KEY)->delete();
rwForget(RW_PROTECTED_KEY);
expect(ConfigCacheService::get(RW_PROTECTED_KEY))->toBe('');
expect(ConfigCacheModel::where('k', RW_PROTECTED_KEY)->exists())->toBeFalse();
});
test('get() ADMINONLY with empty config value seeds no row', function () {
Config::set(RW_ADMINONLY_KEY, '');
ConfigCacheModel::where('k', RW_ADMINONLY_KEY)->delete();
rwForget(RW_ADMINONLY_KEY);
expect(ConfigCacheService::get(RW_ADMINONLY_KEY))->toBe('');
expect(ConfigCacheModel::where('k', RW_ADMINONLY_KEY)->exists())->toBeFalse();
});
test('get() ADMINONLY with null config value seeds no row', function () {
Config::set(RW_ADMINONLY_KEY, null);
ConfigCacheModel::where('k', RW_ADMINONLY_KEY)->delete();
rwForget(RW_ADMINONLY_KEY);
expect(ConfigCacheService::get(RW_ADMINONLY_KEY))->toBeNull();
expect(ConfigCacheModel::where('k', RW_ADMINONLY_KEY)->exists())->toBeFalse();
});
/*
|--------------------------------------------------------------------------
| get() — DB value is cast back to its declared type
|--------------------------------------------------------------------------
*/
test('get() boolean key returns a real bool, not the string "0", from a DB row', function () {
// autospam.nlp.enabled is ADMINONLY + rule boolean; config default is false.
$key = 'autospam.nlp.enabled';
ConfigCacheModel::where('k', $key)->delete();
$row = new ConfigCacheModel;
$row->k = $key;
$row->v = '0'; // how a stored false reads back from the text column
$row->save();
rwForget($key);
$val = ConfigCacheService::get($key);
expect($val)->toBeBool();
expect($val)->toBeFalse();
});
test('get() boolean key returns true for a stored "1"', function () {
$key = 'autospam.nlp.enabled';
ConfigCacheModel::where('k', $key)->delete();
$row = new ConfigCacheModel;
$row->k = $key;
$row->v = '1';
$row->save();
rwForget($key);
$val = ConfigCacheService::get($key);
expect($val)->toBeBool();
expect($val)->toBeTrue();
});
test('get() integer key returns a real int from a DB row', function () {
// instance.stats.total_local_posts is ADMINONLY + rule integer.
$key = 'instance.stats.total_local_posts';
ConfigCacheModel::where('k', $key)->delete();
$row = new ConfigCacheModel;
$row->k = $key;
$row->v = '42';
$row->save();
rwForget($key);
$val = ConfigCacheService::get($key);
expect($val)->toBeInt();
expect($val)->toBe(42);
ConfigCacheModel::where('k', $key)->delete();
rwForget($key);
});