b8af72764d
gates / gates (push) Successful in 11s
Four defects on the restore surface, all proven on demo-hp during the 2026-08-21 backup-truth drill, all still in shipped code. They share one acceptance idea: a restore surface must state what it actually did, and must refuse what it cannot do. VERSION NOTE. The task specifying this targeted v0.224.0 against baselinef8c9390. Both were consumed earlier the same day by R-330 (0.224.0) and R-331 (0.225.0). Drift re-confirmed against live Gitea before the first edit, operator authorised proceeding, every symbol the spec named re-verified present at the real baselinee5eee50. R-353 -- a restore that gave back nothing still said it worked. RestoreFromRecoveryUnit returned only error, so the surface printed "<app> visszaallitva (<snapshot>)." -- equally true of a run that returned an entire dataset and one that returned nothing. The count already existed and was discarded one line deep: restoreDockerVolumesFrom always returned it, the wrapper threw it away. Now (UnitRestoreResult, error), carrying replayed counts AND what the manifest LISTED, because zero-replayed has two causes that are opposite news. Three cases, three sentences, and EVERY one is a claim about the BACKUP, never about the app -- this path has no SafetyDump discriminator, and 07-backup-architecture 6.3 records that an absent dump says nothing about the app (R-361 destroyed canonical .sql files for four months). R-357 -- the destructive restore had no free-space gate. offbox_reconstitute.go contained ZERO references to offboxFree; all three existing gates guard non-destructive paths. The gate now sits before mapOffsiteRestorePaths, writeSafetyDump and StopStack, so a refusal costs nothing. Position IS the fix, which is why the test asserts StopStack was never called. No headroom multiplier (matches PlaceOffsiteRestore; the x1.1 elsewhere predicts a download). Fail closed on either probe <= 0 -- otherwise `free < need` with need==0 is FALSE and an unmeasurable scratch sails through: a gate present and inert. R-358 -- a failed download was offered as a good one. The gate answered "the directory exists and is non-empty", which is exactly what a part-way restic run leaves. Now a completion marker written 0600 atomically AFTER restic returns nil, with any stale one cleared BEFORE it starts; both orders pinned by an AST test because resticStep is not a seam. Both handlers refuse server-side: the wizard flags control a button, and a hidden button is not a guard. SCENARIO F ANSWERED, and worse than the question assumed: a unit-only scratch IS reachable through the real flow, by the most ordinary route. "Ellenorzo visszaallitas" (mode=unit, advertised non-destructive) writes the SAME directory -- offboxRestoreScratchDir ignores `full` and --include limits what restic extracts, never where -- so a customer who ran the SAFE restore was then offered the destructive one over a unit-only copy. Filed R-396; the marker closes it. R-360 -- the delete refused only while a BACKUP ran. IsRunning() is FALSE for the whole of a verification restore; the five sibling handlers all use restoreOpBlocked(). Its doc comment claimed it already did this, which is why nobody looked -- corrected in place. Red-proofs, each printing the pre-fix behaviour, in CHANGELOG and REPORT. The first R-357 red-proof exposed a hollow test OF MY OWN and it is recorded rather than quietly fixed: the fixture refused earlier at the placement stat pre-pass, so `stops == 0` passed against the pre-fix code. Fixture corrected, assertions reordered so a removed gate reports the outage rather than "no error returned". Green gate clean: 28 packages, rc 0. All 12 controller gates OK.
377 lines
16 KiB
Go
377 lines
16 KiB
Go
package backup
|
|
|
|
import (
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// captureFixtureUnit writes a real stack tree, runs the REAL CaptureRecoveryUnit over it, and returns
|
|
// the drive (namespace root) holding the resulting unit.
|
|
//
|
|
// Fixtures come from a CAPTURED unit rather than hand-written YAML deliberately: the capture side and
|
|
// the restore side must meet at real bytes on a real filesystem, so a change to the on-disk shape
|
|
// (header text, key ordering, the non-secret/portable split) cannot pass by having a test agree with
|
|
// itself. Everything from buildUnitAppYaml through readUnitEnv is production code here.
|
|
func captureFixtureUnit(t *testing.T, portable map[string]string) (drive string) {
|
|
t.Helper()
|
|
tmp := t.TempDir()
|
|
stackDir := filepath.Join(tmp, "stack")
|
|
drive = filepath.Join(tmp, "drive")
|
|
if err := os.MkdirAll(stackDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mustWrite(t, filepath.Join(stackDir, "docker-compose.yml"),
|
|
"services:\n app:\n image: example/app:1\n db:\n image: postgres:16\n")
|
|
mustWrite(t, filepath.Join(stackDir, ".felhom.yml"), "display_name: App\n")
|
|
mustWrite(t, filepath.Join(stackDir, "app.yaml"), "deployed: true\nenv:\n SUBDOMAIN: trips\n")
|
|
|
|
var names []string
|
|
for _, n := range []string{"DB_PASSWORD", "SECRET_KEY"} {
|
|
if _, ok := portable[n]; ok {
|
|
names = append(names, n)
|
|
}
|
|
}
|
|
info := RecoveryInfo{
|
|
StackDir: stackDir,
|
|
DisplayName: "App",
|
|
ImagePins: []string{"example/app:1"},
|
|
NonSecretEnv: map[string]string{"SUBDOMAIN": "trips"},
|
|
SecretEnvVars: []string{"DB_PASSWORD", "SECRET_KEY"},
|
|
DataKeyEnvVars: []string{"SECRET_KEY"},
|
|
PortableSecretEnvVars: names,
|
|
PortableSecrets: portable,
|
|
}
|
|
m := &Manager{
|
|
logger: log.New(io.Discard, "", 0),
|
|
systemDataPath: filepath.Join(tmp, "system"),
|
|
stackProvider: &fakeRecoveryProvider{info: info, hdd: drive},
|
|
version: "vtest",
|
|
}
|
|
if err := m.CaptureRecoveryUnit("app"); err != nil {
|
|
t.Fatalf("capture fixture: %v", err)
|
|
}
|
|
return drive
|
|
}
|
|
|
|
// TestRestoreFromRecoveryUnitWithGuestAbsent is D5's entire claim, as a test rather than a
|
|
// description: a Tier-1/2 restore SUCCEEDS when the guest's app.yaml is unavailable.
|
|
//
|
|
// SEAM (R-125): injection is at Manager.stackProvider only — i.e. the docker/compose operations and the
|
|
// guest's app.yaml decrypt. RecoverStackSecrets returning nil IS the guest being gone: it is exactly
|
|
// what the real adapter returns when the stack or its app.yaml cannot be read (main.go
|
|
// GetStack/LoadAppConfigDecrypted nil paths). Everything under test is production code: the unit was
|
|
// written by the real CaptureRecoveryUnit, read back by the real readUnitEnv, and reconciled by the
|
|
// real reconcileRestoreSecrets.
|
|
func TestRestoreFromRecoveryUnitWithGuestAbsent(t *testing.T) {
|
|
const (
|
|
dataKey = "deadbeefdeadbeef"
|
|
dbPw = "pw-from-the-drive"
|
|
)
|
|
drive := captureFixtureUnit(t, map[string]string{"DB_PASSWORD": dbPw, "SECRET_KEY": dataKey})
|
|
|
|
// The guest is GONE: no secrets recoverable from it at all.
|
|
fake := &fakeRecoveryProvider{hdd: drive, running: true, secrets: nil}
|
|
m := &Manager{logger: log.New(io.Discard, "", 0),
|
|
systemDataPath: filepath.Join(drive, "..", "sys"), stackProvider: fake}
|
|
|
|
if _, err := m.RestoreFromRecoveryUnit("app"); err != nil {
|
|
t.Fatalf("restore must succeed from the drive alone, got: %v", err)
|
|
}
|
|
if fake.gotEnv == nil {
|
|
t.Fatal("recreate was not called — the restore did not reach the redeploy")
|
|
}
|
|
// The consequence: the app is redeployed with the key that decrypts the data beside it.
|
|
if fake.gotEnv["SECRET_KEY"] != dataKey {
|
|
t.Errorf("data-encrypting key not recovered from the unit: %q", fake.gotEnv["SECRET_KEY"])
|
|
}
|
|
if fake.gotEnv["DB_PASSWORD"] != dbPw {
|
|
t.Errorf("DB password not recovered from the unit: %q", fake.gotEnv["DB_PASSWORD"])
|
|
}
|
|
if fake.gotEnv["SUBDOMAIN"] != "trips" {
|
|
t.Errorf("plain config lost: %v", fake.gotEnv)
|
|
}
|
|
}
|
|
|
|
// TestRestoreFromRecoveryUnitGuestAbsentStillFailsClosed proves D5 did not soften the gate: with the
|
|
// data key in NEITHER the unit nor the guest, the restore still REFUSES and mutates nothing.
|
|
func TestRestoreFromRecoveryUnitGuestAbsentStillFailsClosed(t *testing.T) {
|
|
// Unit carries only the DB password — the data key is absent from both sources.
|
|
drive := captureFixtureUnit(t, map[string]string{"DB_PASSWORD": "pw"})
|
|
fake := &fakeRecoveryProvider{hdd: drive, running: true, secrets: nil}
|
|
m := &Manager{logger: log.New(io.Discard, "", 0),
|
|
systemDataPath: filepath.Join(drive, "..", "sys"), stackProvider: fake}
|
|
|
|
_, err := m.RestoreFromRecoveryUnit("app")
|
|
if err == nil {
|
|
t.Fatal("expected fail-closed refusal when the data key is in neither source")
|
|
}
|
|
if !strings.Contains(err.Error(), "SECRET_KEY") {
|
|
t.Errorf("refusal should name the missing data key, got: %v", err)
|
|
}
|
|
if fake.gotEnv != nil {
|
|
t.Errorf("recreate must NOT be called on refusal, got %v", fake.gotEnv)
|
|
}
|
|
if fake.stopped {
|
|
t.Error("the live app must not be stopped when the restore refuses")
|
|
}
|
|
}
|
|
|
|
// TestRestoreFromRecoveryUnitOrchestration exercises the full in-process flow: read manifest →
|
|
// recover secrets → apply gate → recreate with the reconciled env. It proves (a) on success the
|
|
// recreate is called with non-secret env + recovered secrets merged, and (b) on a missing data-key the
|
|
// restore is REFUSED and recreate is never called.
|
|
func TestRestoreFromRecoveryUnitOrchestration(t *testing.T) {
|
|
newUnit := func(t *testing.T) (drive string) {
|
|
tmp := t.TempDir()
|
|
drive = filepath.Join(tmp, "drive")
|
|
// A schema-1 unit: no portable secrets, so the guest is the only source (pre-D5 behaviour).
|
|
mustWrite(t, filepath.Join(RecoveryUnitComposePath(drive, "app"), "app.yaml"),
|
|
"deployed: true\nenv:\n SUBDOMAIN: trips\n")
|
|
man := &RecoveryManifest{SchemaVersion: 1, AppName: "app", ControllerVer: "v",
|
|
SecretEnvVars: []string{"DB_PASSWORD", "SECRET_KEY"}, DataKeyEnvVars: []string{"SECRET_KEY"}}
|
|
if err := writeManifest(RecoveryUnitManifestPath(drive, "app"), man); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return drive
|
|
}
|
|
|
|
t.Run("success — recreate called with merged env", func(t *testing.T) {
|
|
drive := newUnit(t)
|
|
fake := &fakeRecoveryProvider{
|
|
hdd: drive,
|
|
running: true, // so the post-restore health wait returns promptly
|
|
secrets: map[string]string{"DB_PASSWORD": "pw", "SECRET_KEY": "deadbeef"},
|
|
}
|
|
m := &Manager{logger: log.New(io.Discard, "", 0), systemDataPath: filepath.Join(drive, "..", "sys"),
|
|
stackProvider: fake}
|
|
if _, err := m.RestoreFromRecoveryUnit("app"); err != nil {
|
|
t.Fatalf("restore: %v", err)
|
|
}
|
|
if fake.gotEnv == nil {
|
|
t.Fatal("recreate was not called")
|
|
}
|
|
if fake.gotEnv["SUBDOMAIN"] != "trips" || fake.gotEnv["DB_PASSWORD"] != "pw" || fake.gotEnv["SECRET_KEY"] != "deadbeef" {
|
|
t.Errorf("recreate got wrong env: %v", fake.gotEnv)
|
|
}
|
|
if !fake.stopped {
|
|
t.Errorf("app should be stopped before restore")
|
|
}
|
|
})
|
|
|
|
t.Run("schema-1 unit still restores from the guest — no regression", func(t *testing.T) {
|
|
// An old unit carries nothing; the guest must still be able to supply everything.
|
|
drive := newUnit(t)
|
|
fake := &fakeRecoveryProvider{hdd: drive, running: true,
|
|
secrets: map[string]string{"DB_PASSWORD": "pw", "SECRET_KEY": "deadbeef"}}
|
|
m := &Manager{logger: log.New(io.Discard, "", 0),
|
|
systemDataPath: filepath.Join(drive, "..", "sys"), stackProvider: fake}
|
|
if _, err := m.RestoreFromRecoveryUnit("app"); err != nil {
|
|
t.Fatalf("a pre-D5 unit must still restore: %v", err)
|
|
}
|
|
if fake.gotEnv["SECRET_KEY"] != "deadbeef" {
|
|
t.Errorf("guest fallback lost the data key: %v", fake.gotEnv)
|
|
}
|
|
})
|
|
|
|
t.Run("data-key unrecoverable — REFUSED, recreate not called", func(t *testing.T) {
|
|
drive := newUnit(t)
|
|
fake := &fakeRecoveryProvider{
|
|
hdd: drive,
|
|
secrets: map[string]string{"DB_PASSWORD": "pw"}, // SECRET_KEY (data_key) missing
|
|
}
|
|
m := &Manager{logger: log.New(io.Discard, "", 0), systemDataPath: filepath.Join(drive, "..", "sys"),
|
|
stackProvider: fake}
|
|
_, err := m.RestoreFromRecoveryUnit("app")
|
|
if err == nil {
|
|
t.Fatal("expected fail-closed refusal, got nil")
|
|
}
|
|
if fake.gotEnv != nil {
|
|
t.Errorf("recreate must NOT be called on refusal, got %v", fake.gotEnv)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestReconcileRestoreSecretsPrecedence pins the D5 precedence rule in BOTH directions. An undefined
|
|
// precedence between two sources of a decryption key is a data-loss bug waiting for its first
|
|
// disagreement, so this is not a style question.
|
|
//
|
|
// The UNIT wins: its secrets were captured in the same run as the dumps beside them, so the unit's
|
|
// value is the one that matches the data about to be restored. The guest's value is merely the most
|
|
// recent — and a rotated key does not decrypt data encrypted with the old one.
|
|
func TestReconcileRestoreSecretsPrecedence(t *testing.T) {
|
|
nonSecret := map[string]string{"SUBDOMAIN": "trips"}
|
|
names := []string{"DB_PASSWORD", "SECRET_KEY"}
|
|
dataKeys := []string{"SECRET_KEY"}
|
|
|
|
t.Run("both sources disagree — the UNIT wins", func(t *testing.T) {
|
|
unit := map[string]string{"DB_PASSWORD": "unit-pw", "SECRET_KEY": "unit-key"}
|
|
guest := map[string]string{"DB_PASSWORD": "guest-pw", "SECRET_KEY": "guest-key"}
|
|
full, missing, err := reconcileRestoreSecrets(nonSecret, unit, guest, names, dataKeys)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(missing) != 0 {
|
|
t.Errorf("missing: %v", missing)
|
|
}
|
|
if full["SECRET_KEY"] != "unit-key" {
|
|
t.Errorf("data key: got %q, want the UNIT's value (it matches the restored data)", full["SECRET_KEY"])
|
|
}
|
|
if full["DB_PASSWORD"] != "unit-pw" {
|
|
t.Errorf("DB password: got %q, want the UNIT's value (it matches the restored data dir hash)", full["DB_PASSWORD"])
|
|
}
|
|
})
|
|
|
|
t.Run("unit silent — the GUEST fills in", func(t *testing.T) {
|
|
// The withheld class (admin logins) is never in the unit, so this direction must work too.
|
|
guest := map[string]string{"DB_PASSWORD": "guest-pw", "SECRET_KEY": "guest-key"}
|
|
full, _, err := reconcileRestoreSecrets(nonSecret, nil, guest, names, dataKeys)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if full["SECRET_KEY"] != "guest-key" || full["DB_PASSWORD"] != "guest-pw" {
|
|
t.Errorf("guest fallback not applied: %v", full)
|
|
}
|
|
})
|
|
|
|
t.Run("unit present but EMPTY for a name — the guest fills in", func(t *testing.T) {
|
|
// An empty value is not a value; it must not shadow a good one from the guest.
|
|
unit := map[string]string{"SECRET_KEY": ""}
|
|
guest := map[string]string{"SECRET_KEY": "guest-key", "DB_PASSWORD": "guest-pw"}
|
|
full, _, err := reconcileRestoreSecrets(nonSecret, unit, guest, names, dataKeys)
|
|
if err != nil {
|
|
t.Fatalf("an empty unit value must fall through to the guest, got: %v", err)
|
|
}
|
|
if full["SECRET_KEY"] != "guest-key" {
|
|
t.Errorf("empty unit value shadowed the guest: %q", full["SECRET_KEY"])
|
|
}
|
|
})
|
|
|
|
t.Run("a portable secret never shadows plain config", func(t *testing.T) {
|
|
// GetStackRecoveryInfo keeps the two sets disjoint; if that ever breaks, the merge order in
|
|
// buildUnitAppYaml decides silently. Pin the intended outcome.
|
|
full, _, err := reconcileRestoreSecrets(map[string]string{"DB_PASSWORD": "should-not-win"},
|
|
map[string]string{"DB_PASSWORD": "unit-pw"}, nil, []string{"DB_PASSWORD"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if full["DB_PASSWORD"] != "unit-pw" {
|
|
t.Errorf("the secret source must win over a stray non-secret entry: %q", full["DB_PASSWORD"])
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestReconcileRestoreSecrets covers the safety-critical fail-closed gate + secret reconciliation.
|
|
func TestReconcileRestoreSecrets(t *testing.T) {
|
|
nonSecret := map[string]string{"SUBDOMAIN": "trips", "DOMAIN": "demo-felhom.eu"}
|
|
|
|
t.Run("all recovered, no data_key — full env, no error", func(t *testing.T) {
|
|
guest := map[string]string{"DB_PASSWORD": "pw", "SECRET_KEY": "deadbeef"}
|
|
full, missing, err := reconcileRestoreSecrets(nonSecret, nil, guest,
|
|
[]string{"DB_PASSWORD", "SECRET_KEY"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(missing) != 0 {
|
|
t.Errorf("missing: %v", missing)
|
|
}
|
|
// Non-secret + both secrets present, and recovered values used VERBATIM (regenerate nothing).
|
|
if full["SUBDOMAIN"] != "trips" || full["DB_PASSWORD"] != "pw" || full["SECRET_KEY"] != "deadbeef" {
|
|
t.Errorf("full env wrong: %v", full)
|
|
}
|
|
})
|
|
|
|
t.Run("data_key missing from BOTH sources — FAIL CLOSED (refuse)", func(t *testing.T) {
|
|
guest := map[string]string{"DB_PASSWORD": "pw"} // SECRET_KEY (a data_key) is gone
|
|
full, _, err := reconcileRestoreSecrets(nonSecret, nil, guest,
|
|
[]string{"DB_PASSWORD", "SECRET_KEY"}, []string{"SECRET_KEY"})
|
|
if err == nil {
|
|
t.Fatal("expected fail-closed error for missing data-encrypting key, got nil")
|
|
}
|
|
if full != nil {
|
|
t.Errorf("full env should be nil on refusal, got %v", full)
|
|
}
|
|
})
|
|
|
|
t.Run("data_key empty in both — FAIL CLOSED", func(t *testing.T) {
|
|
guest := map[string]string{"SECRET_KEY": ""} // present but empty == unrecoverable
|
|
_, _, err := reconcileRestoreSecrets(nonSecret, map[string]string{"SECRET_KEY": ""}, guest,
|
|
[]string{"SECRET_KEY"}, []string{"SECRET_KEY"})
|
|
if err == nil {
|
|
t.Fatal("empty data-key value must fail closed")
|
|
}
|
|
})
|
|
|
|
t.Run("data_key recovered from the UNIT — no refusal", func(t *testing.T) {
|
|
// The D5 case: the guest is gone but the unit carries the key, so the gate must NOT fire.
|
|
unit := map[string]string{"SECRET_KEY": "deadbeef", "DB_PASSWORD": "pw"}
|
|
full, missing, err := reconcileRestoreSecrets(nonSecret, unit, nil,
|
|
[]string{"DB_PASSWORD", "SECRET_KEY"}, []string{"SECRET_KEY"})
|
|
if err != nil {
|
|
t.Fatalf("the unit's data key must satisfy the gate: %v", err)
|
|
}
|
|
if len(missing) != 0 {
|
|
t.Errorf("nothing should be missing: %v", missing)
|
|
}
|
|
if full["SECRET_KEY"] != "deadbeef" {
|
|
t.Errorf("data key wrong: %v", full)
|
|
}
|
|
})
|
|
|
|
t.Run("resettable secret missing — proceed with warning", func(t *testing.T) {
|
|
guest := map[string]string{"SECRET_KEY": "deadbeef"} // data_key ok; DB_PASSWORD missing
|
|
full, missing, err := reconcileRestoreSecrets(nonSecret, nil, guest,
|
|
[]string{"DB_PASSWORD", "SECRET_KEY"}, []string{"SECRET_KEY"})
|
|
if err != nil {
|
|
t.Fatalf("a missing resettable secret must NOT fail closed: %v", err)
|
|
}
|
|
if len(missing) != 1 || missing[0] != "DB_PASSWORD" {
|
|
t.Errorf("missing should be [DB_PASSWORD], got %v", missing)
|
|
}
|
|
if full["SECRET_KEY"] != "deadbeef" {
|
|
t.Errorf("data-key should be preserved verbatim: %v", full)
|
|
}
|
|
if _, present := full["DB_PASSWORD"]; present {
|
|
t.Errorf("missing resettable secret should be absent, not regenerated")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestReadUnitEnvSplitsByManifest proves the split is driven by the manifest's portable names, and that
|
|
// a schema-1 unit (no names) degrades to "everything is plain config" rather than losing entries.
|
|
func TestReadUnitEnvSplitsByManifest(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "app.yaml")
|
|
mustWrite(t, path, "deployed: true\nenv:\n SUBDOMAIN: trips\n DB_PASSWORD: pw\n SECRET_KEY: key\n")
|
|
|
|
t.Run("named secrets land in unitSecrets, the rest in nonSecret", func(t *testing.T) {
|
|
nonSecret, unitSecrets := readUnitEnv(path, []string{"DB_PASSWORD", "SECRET_KEY"})
|
|
if nonSecret["SUBDOMAIN"] != "trips" || len(nonSecret) != 1 {
|
|
t.Errorf("nonSecret = %v, want only SUBDOMAIN", nonSecret)
|
|
}
|
|
if unitSecrets["DB_PASSWORD"] != "pw" || unitSecrets["SECRET_KEY"] != "key" || len(unitSecrets) != 2 {
|
|
t.Errorf("unitSecrets = %v, want the two named secrets", unitSecrets)
|
|
}
|
|
})
|
|
|
|
t.Run("schema-1 (no portable names) — everything is plain config, nothing lost", func(t *testing.T) {
|
|
nonSecret, unitSecrets := readUnitEnv(path, nil)
|
|
if len(unitSecrets) != 0 {
|
|
t.Errorf("a schema-1 unit carries no secrets, got %v", unitSecrets)
|
|
}
|
|
if len(nonSecret) != 3 {
|
|
t.Errorf("nonSecret should keep every entry, got %v", nonSecret)
|
|
}
|
|
})
|
|
|
|
t.Run("absent file — empty maps, no panic", func(t *testing.T) {
|
|
nonSecret, unitSecrets := readUnitEnv(filepath.Join(dir, "nope.yaml"), []string{"DB_PASSWORD"})
|
|
if len(nonSecret) != 0 || len(unitSecrets) != 0 {
|
|
t.Errorf("want empty maps, got %v / %v", nonSecret, unitSecrets)
|
|
}
|
|
})
|
|
}
|