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
PHTML

<?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);
});