c48f95fe06
appbackup/userdata.go: EnsureUserdataDir (MkdirAll + explicit setgid Chmod 2775 +
chown gid 1000), UserdataSkeleton, EnsureUserdataSkeleton; linux chown/StatGID +
non-linux stubs. stackEnv injects USERDATA_PATH=<HDD_PATH>/userdata. Skeleton
pre-created on register + FileBrowser sync; deploy belt (composeExecCustomEnv on
'up') pre-creates every ${USERDATA_PATH} bind source. FileBrowser mounts userdata
(was appdata) — uid 1000 can now write into 2775 setgid. #8: migrate merge walk +
copyFile preserve source setgid+group so the convention survives MigrateAll.
Non-hollow tests incl. Linux setgid assertions + migration-preserve companion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
102 lines
4.1 KiB
Go
102 lines
4.1 KiB
Go
package stacks
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Regression tests for the deploy-lifecycle slice (audit/2026-06-13):
|
|
// - H10: SaveAppConfig must FAIL CLOSED on an encryption error (never persist
|
|
// a sensitive value in plaintext).
|
|
// - CTRL-T2-1: the durable record must read as NOT deployed until a deploy
|
|
// actually completes, so a crash during the image-pull window leaves a
|
|
// redeployable stack rather than a ghost-deployed one.
|
|
|
|
// TestSaveAppConfigFailsClosedOnEncryptError — H10. Originated as a failing
|
|
// reconcile test; now a permanent guard. A bad-length encKey makes crypto.Encrypt
|
|
// fail; SaveAppConfig must return an error and write NO plaintext secret.
|
|
func TestSaveAppConfigFailsClosedOnEncryptError(t *testing.T) {
|
|
dir := t.TempDir()
|
|
const secret = "supersecret-pw-do-not-leak"
|
|
cfg := &AppConfig{Deployed: true, Env: map[string]string{"DB_PASSWORD": secret}}
|
|
|
|
badKey := []byte("short") // non-nil, invalid AES key length → crypto.Encrypt errors
|
|
|
|
err := SaveAppConfig(dir, cfg, badKey, []string{"DB_PASSWORD"})
|
|
if err == nil {
|
|
t.Fatalf("H10: SaveAppConfig returned nil on an encrypt failure — expected a fail-closed error")
|
|
}
|
|
|
|
// No app.yaml should have been written; even if one was, it must not contain
|
|
// the plaintext secret.
|
|
if data, readErr := os.ReadFile(filepath.Join(dir, "app.yaml")); readErr == nil {
|
|
if strings.Contains(string(data), secret) {
|
|
t.Fatalf("H10: app.yaml contains the secret in plaintext after an encrypt failure:\n%s", data)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSaveAppConfigEncryptsWithGoodKey — companion: a valid key encrypts the
|
|
// sensitive var (it must not appear in plaintext) and the save succeeds.
|
|
func TestSaveAppConfigEncryptsWithGoodKey(t *testing.T) {
|
|
dir := t.TempDir()
|
|
const secret = "supersecret-pw"
|
|
key := make([]byte, 32) // valid AES-256 key
|
|
cfg := &AppConfig{Deployed: true, Env: map[string]string{"DB_PASSWORD": secret}}
|
|
|
|
if err := SaveAppConfig(dir, cfg, key, []string{"DB_PASSWORD"}); err != nil {
|
|
t.Fatalf("SaveAppConfig with a valid key failed: %v", err)
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(dir, "app.yaml"))
|
|
if err != nil {
|
|
t.Fatalf("reading app.yaml: %v", err)
|
|
}
|
|
if strings.Contains(string(data), secret) {
|
|
t.Fatalf("sensitive value was written in plaintext despite a valid key:\n%s", data)
|
|
}
|
|
}
|
|
|
|
// TestTransitionalDeployStateReadsNotDeployed — CTRL-T2-1. DeployStack now
|
|
// writes the env with Deployed:false before launching compose, and flips it to
|
|
// Deployed:true only after `up -d` succeeds. This locks the durable-state
|
|
// semantics ScanStacks relies on: a stack persisted in the transitional state
|
|
// (the on-disk state left by a crash mid-pull) MUST read as not-deployed, so the
|
|
// customer can redeploy. The full crash-window is integration-level (needs a
|
|
// real compose); see the audit's manual repro.
|
|
func TestTransitionalDeployStateReadsNotDeployed(t *testing.T) {
|
|
key := make([]byte, 32)
|
|
sensitive := []string{"DB_PASSWORD"}
|
|
|
|
// Transitional state DeployStack writes BEFORE compose succeeds.
|
|
transitional := t.TempDir()
|
|
if err := SaveAppConfig(transitional, &AppConfig{
|
|
Deployed: false, DeployedAt: "2026-06-13T00:00:00Z",
|
|
Env: map[string]string{"DB_PASSWORD": "x"},
|
|
}, key, sensitive); err != nil {
|
|
t.Fatalf("saving transitional config: %v", err)
|
|
}
|
|
got := LoadAppConfig(transitional)
|
|
if got == nil {
|
|
t.Fatal("LoadAppConfig returned nil for the transitional state")
|
|
}
|
|
// This is exactly the expression ScanStacks uses: deployed := cfg != nil && cfg.Deployed
|
|
if got.Deployed {
|
|
t.Fatalf("CTRL-T2-1: a deploy that did not complete reads as Deployed=true — ghost-deployed; " +
|
|
"DeployStack must persist Deployed:false until compose succeeds")
|
|
}
|
|
|
|
// Completed state (what runComposeDeploy writes on success) reads deployed.
|
|
completed := t.TempDir()
|
|
if err := SaveAppConfig(completed, &AppConfig{
|
|
Deployed: true, DeployedAt: "2026-06-13T00:00:00Z",
|
|
Env: map[string]string{"DB_PASSWORD": "x"},
|
|
}, key, sensitive); err != nil {
|
|
t.Fatalf("saving completed config: %v", err)
|
|
}
|
|
if c := LoadAppConfig(completed); c == nil || !c.Deployed {
|
|
t.Fatalf("CTRL-T2-1: a completed deploy must read as Deployed=true, got %+v", c)
|
|
}
|
|
}
|