package version import ( "encoding/json" "io" "os" "path/filepath" "runtime" "testing" "time" "uuid" "gitgud.io/mike/mpv-manager/pkg/config" "github.com/stretchr/testify/require" ) // Authentication and legacy rejection require no executable fixture. Build a // valid signed journal directly so these trust checks also run on Windows. func prepareJournalAuthenticationFixture(t *testing.T) (*PreparedSelfUpdate, string) { t.Helper() home := t.TempDir() for _, key := range []string{"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME"} { t.Setenv(key, home) } _, err := config.Reload() require.NoError(t, err) root, err := filepath.EvalSymlinks(t.TempDir()) require.NoError(t, err) primary := filepath.Join(root, "manager") require.NoError(t, os.WriteFile(primary, []byte("original executable evidence"), 0755)) id := uuid.NewV7().String() directory := updateTransactionDir(primary, id) require.NoError(t, os.Mkdir(directory, 0700)) staged := stagedTargetPath(primary, id) require.NoError(t, os.WriteFile(staged, []byte("staged"), 0755)) journal := &UpdateJournal{ SchemaVersion: updateJournalSchema, ID: id, State: UpdateTransactionPrepared, LockPath: updateTransactionLockPath(), HelperPath: filepath.Join(directory, helperFileName()), HealthPath: filepath.Join(directory, updateHealthName), PayloadPath: filepath.Join(directory, updatePayloadFileName()), ManifestKeyID: "test-release-key", ExpectedSize: 6, ExpectedHash: blake3HashOf([]byte("staged")), Expected: BinaryIdentity{Product: ProductID, Component: ComponentID, Version: "9.9.9", GOOS: runtime.GOOS, GOARCH: runtime.GOARCH}, Targets: []UpdateTarget{{Role: "primary", Path: primary, StagedPath: staged, BackupPath: backupTargetPath(primary, id)}}, } path := filepath.Join(directory, updateJournalName) require.NoError(t, writeUpdateJournal(path, journal)) return &PreparedSelfUpdate{JournalPath: path, TransactionID: id}, primary } func TestUpdateJournalRejectsTamperingBeforeRecovery(t *testing.T) { prepared, primary := prepareJournalAuthenticationFixture(t) data, err := os.ReadFile(prepared.JournalPath) require.NoError(t, err) var journal UpdateJournal require.NoError(t, json.Unmarshal(data, &journal)) journal.State = UpdateTransactionApplying journal.Targets[0].Applied = true marker := filepath.Join(filepath.Dir(primary), "untrusted-backup-executed") require.NoError(t, os.WriteFile(journal.Targets[0].BackupPath, []byte("#!/bin/sh\n: > '"+marker+"'\n"), 0755)) for _, authentication := range []string{journal.Authentication, ""} { journal.Authentication = authentication forged, err := json.Marshal(journal) require.NoError(t, err) require.NoError(t, os.WriteFile(prepared.JournalPath, forged, 0600)) err = recoverTransactionsLocked(primary) require.Error(t, err) require.NoFileExists(t, marker) require.FileExists(t, primary) require.FileExists(t, journal.Targets[0].BackupPath) } } func TestUpdateJournalVerificationDoesNotCreateMissingKey(t *testing.T) { prepared, primary := prepareJournalAuthenticationFixture(t) key, err := updateJournalAuthenticationKey(false) require.NoError(t, err) require.Len(t, key, 32) path := filepath.Join(config.GetConfigDir(), updateJournalKeyName) require.NoError(t, os.Remove(path)) _, err = loadUpdateJournal(prepared.JournalPath) require.Error(t, err) require.NoFileExists(t, path, "verification must never replace missing authentication state") require.Error(t, recoverTransactionsLocked(primary)) require.FileExists(t, prepared.JournalPath) require.FileExists(t, stagedTargetPath(primary, prepared.TransactionID)) } func TestJournalAuthenticationKeyIsPrivateAndStable(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) t.Setenv("APPDATA", home) t.Setenv("XDG_CONFIG_HOME", home) first, err := updateJournalAuthenticationKey(true) require.NoError(t, err) require.Len(t, first, 32) second, err := updateJournalAuthenticationKey(true) require.NoError(t, err) require.Equal(t, first, second, "preparing another update must not invalidate existing journals") info, err := os.Stat(filepath.Join(config.GetConfigDir(), updateJournalKeyName)) require.NoError(t, err) require.NoError(t, validateJournalKeyPermissions(filepath.Join(config.GetConfigDir(), updateJournalKeyName), info)) } func TestLegacyJournalIsPreservedWithoutExecution(t *testing.T) { prepared, primary := prepareJournalAuthenticationFixture(t) data, err := os.ReadFile(prepared.JournalPath) require.NoError(t, err) var journal UpdateJournal require.NoError(t, json.Unmarshal(data, &journal)) journal.SchemaVersion = 1 journal.Authentication = "" journal.State = UpdateTransactionApplying journal.Targets[0].Applied = true marker := filepath.Join(filepath.Dir(primary), "legacy-backup-executed") require.NoError(t, os.WriteFile(journal.Targets[0].BackupPath, []byte("#!/bin/sh\n: > '"+marker+"'\n"), 0755)) data, err = json.Marshal(journal) require.NoError(t, err) require.NoError(t, os.WriteFile(prepared.JournalPath, data, 0600)) require.ErrorContains(t, recoverTransactionsLocked(primary), "manual recovery") require.NoFileExists(t, marker) require.FileExists(t, primary) require.FileExists(t, journal.Targets[0].BackupPath) preserved, err := os.ReadFile(prepared.JournalPath) require.NoError(t, err) require.Equal(t, data, preserved) } func TestRollbackDoesNotExecuteBackupWithoutOriginalEvidence(t *testing.T) { prepared, primary := prepareJournalAuthenticationFixture(t) journal, err := loadUpdateJournal(prepared.JournalPath) require.NoError(t, err) target := &journal.Targets[0] target.OriginalSize = 0 target.OriginalHash = "" target.OriginalIdentity = BinaryIdentity{} marker := filepath.Join(filepath.Dir(primary), "unevidenced-backup-executed") require.NoError(t, os.WriteFile(target.BackupPath, []byte("#!/bin/sh\n: > '"+marker+"'\n"), 0755)) require.ErrorContains(t, rollbackUpdateTargets(journal), "original backup evidence is missing") require.NoFileExists(t, marker) require.FileExists(t, primary) require.FileExists(t, target.BackupPath) } func TestUpdateJournalReaderAllowsAtomicPublication(t *testing.T) { prepared, _ := prepareJournalAuthenticationFixture(t) reader, err := openUpdateJournal(prepared.JournalPath) require.NoError(t, err) defer reader.Close() original, err := os.ReadFile(prepared.JournalPath) require.NoError(t, err) journal, err := loadUpdateJournal(prepared.JournalPath) require.NoError(t, err) journal.HelperPID = 1234 publishedResult := make(chan error, 1) go func() { publishedResult <- writeUpdateJournal(prepared.JournalPath, journal) }() // Windows replacement may wait for a reader to close. The original remains // readable throughout; publication must succeed once the short read ends. time.Sleep(80 * time.Millisecond) retained, err := io.ReadAll(reader) require.NoError(t, err) require.Equal(t, original, retained) require.NoError(t, reader.Close()) require.NoError(t, <-publishedResult, "helper polling must not abort parent publication") published, err := loadUpdateJournal(prepared.JournalPath) require.NoError(t, err) require.Equal(t, 1234, published.HelperPID) }