Files
fenrir/internal/app/remote_persistence_test.go

221 lines
10 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"os"
"path/filepath"
"reflect"
"strings"
"syscall"
"testing"
"time"
"git.stormux.org/storm/fenrir/internal/command"
"git.stormux.org/storm/fenrir/internal/config"
"git.stormux.org/storm/fenrir/internal/control"
"git.stormux.org/storm/fenrir/internal/input"
"git.stormux.org/storm/fenrir/internal/screen"
)
func persistence_application(t *testing.T, terminalMode bool) (*application, *fakeVoice) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
t.Cleanup(cancel)
base := config.Defaults()
base.Presentation.Speech.Rate = 24
base.Screen.IgnoredVTs = []int{2}
path := filepath.Join(t.TempDir(), "settings.toml")
if err := config.Save(path, base); err != nil {
t.Fatal(err)
}
voice := &fakeVoice{}
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true, Rows: 1, Cols: 5, Cells: []rune("alpha")}}
keys := &fakeKeys{poll: func(bool, func(input.Decision)) error { return nil }}
a, err := new_application(ctx, source, keys, voice, RunOptions{Settings: base, ConfigPath: path, TerminalMode: terminalMode, Sounds: &fakeSounds{}})
if err != nil {
t.Fatal(err)
}
t.Cleanup(a.ui.close)
if err := a.read_screen(); err != nil {
t.Fatal(err)
}
return a, voice
}
func persistence_request(a *application, text string) chan string {
reply := make(chan string, 1)
a.remote_request(control.Request{UID: os.Geteuid(), Text: "SETTING " + text, Reply: reply})
return reply
}
func persistence_reply(t *testing.T, reply chan string, wantError bool) {
t.Helper()
select {
case text := <-reply:
if (text != "") != wantError {
t.Fatal("unexpected persistence response", text, wantError)
}
case <-time.After(time.Second):
t.Fatal("persistence reply missing")
}
}
func finish_persistence(t *testing.T, a *application, wantError bool) {
t.Helper()
select {
case err := <-a.saver.results:
if (err != nil) != wantError {
t.Fatal("unexpected save result", err)
}
a.settings_save_result(err)
case <-time.After(time.Second):
t.Fatal("save worker stalled")
}
}
func TestRemoteResetRestoresLoadedValuesAndPreservesSessionState(t *testing.T) {
for _, terminalMode := range []bool{false, true} {
a, _ := persistence_application(t, terminalMode)
base := a.settings.resetBase
windowReply := make(chan string, 1)
a.remote_request(control.Request{UID: os.Geteuid(), Text: "COMMAND WINDOW 0 0 4 0", Reply: windowReply})
persistence_reply(t, windowReply, false)
regionRevision := a.engine.Region_revision()
a.settings.saveDraft.Keyboard.Layout = "laptop"
for _, id := range []command.ID{command.Mute, command.AutoRead, command.ToggleAttributes} {
a.handle_input(input.Decision{Command: id})
}
a.handle_input(input.Decision{Command: command.NextQuickValue})
persistence_reply(t, persistence_request(a, "SET speech#rate=0.2;sound#volume=0.3;screen#ignore_screen=7,8;general#punctuation_level=all;keyboard#word_echo=True"), false)
persistence_reply(t, persistence_request(a, "RESET"), false)
if !a.muted || a.engine.Automatic_reading() || a.settings.live.Presentation.Speech.Rate != base.Presentation.Speech.Rate || !reflect.DeepEqual(a.settings.live.Screen, base.Screen) || a.settings.live.Keyboard.WordEcho || a.settings.live.Presentation.Sound != base.Presentation.Sound || a.settings.live.Reading.PunctuationLevel != base.Reading.PunctuationLevel || a.settings.saveDraft.Keyboard.Layout != "laptop" {
t.Fatal("reset lost session overrides, loaded values or next-launch choices", a.settings.live, a.settings.saveDraft)
}
if got := a.engine.Command(command.ToggleAttributes).Text; got != "announcement of attributes disabled" {
t.Fatal("unrelated remote update/reset lost the session attribute toggle", got)
}
if !a.settings.saveDraft.Presentation.Speech.Enabled || !a.settings.saveDraft.Reading.Automatic || a.quick.changed || len(a.settings.remoteFields) != 0 || a.engine.Region_revision() != regionRevision {
t.Fatal("reset persisted unrelated session state or retained overrides")
}
// A successful menu save becomes the next reset baseline too.
menuSaved := a.settings.saveDraft
menuSaved.Presentation.Speech.Rate = 44
a.apply_saved_settings(menuSaved, false)
persistence_reply(t, persistence_request(a, "SET speech#rate=0.1;speech#enabled=False;reading#automatic=False;sound#enabled=False;sound#progress_monitoring=False;reading#cursor_echo=False;keyboard#char_echo_mode=2;keyboard#echo_on_capslock=True"), false)
persistence_reply(t, persistence_request(a, "RESET"), false)
if a.muted || !a.engine.Automatic_reading() || a.settings.live.Presentation != menuSaved.Presentation || a.settings.live.Keyboard.CharacterEcho != menuSaved.Keyboard.CharacterEcho || a.settings.live.Keyboard.EchoOnCapsLock != menuSaved.Keyboard.EchoOnCapsLock || a.settings.live.Reading.CursorEcho != menuSaved.Reading.CursorEcho {
t.Fatal("reset did not restore explicitly overridden booleans or menu baseline", a.settings.live)
}
}
}
func TestRemoteSaveAsRepliesAfterWriteAndRetainsNewerOverrides(t *testing.T) {
for _, terminalMode := range []bool{false, true} {
a, _ := persistence_application(t, terminalMode)
defaultPath := a.options.ConfigPath
exportPath := filepath.Join(t.TempDir(), "export with spaces.toml")
for _, id := range []command.ID{command.Mute, command.AutoRead} {
a.handle_input(input.Decision{Command: id})
}
persistence_reply(t, persistence_request(a, "SET speech#rate=0.2;sound#volume=0.3"), false)
reply := persistence_request(a, "SAVEAS "+exportPath)
select {
case response := <-reply:
t.Fatal("save acknowledged before result processing", response)
default:
}
persistence_reply(t, persistence_request(a, "RESET"), true)
persistence_reply(t, persistence_request(a, "SAVE"), true)
// Keep input and later SET requests responsive while disk work runs.
a.handle_input(input.Decision{Command: command.CurrentLine})
persistence_reply(t, persistence_request(a, "SET speech#rate=0.8;keyboard#word_echo=True"), false)
finish_persistence(t, a, false)
persistence_reply(t, reply, false)
saved, _, err := config.Load(exportPath, nil)
if err != nil || saved.Presentation.Speech.Rate != -60 || saved.Presentation.Sound.Volume != 0.3 || !saved.Presentation.Speech.Enabled || !saved.Reading.Automatic || saved.Keyboard.WordEcho {
t.Fatal("save did not retain its admitted snapshot", saved, err)
}
info, err := os.Stat(exportPath)
if err != nil || info.Mode().Perm() != 0600 {
t.Fatal("export permissions", info, err)
}
original, _, err := config.Load(defaultPath, nil)
if err != nil || original.Presentation.Speech.Rate != 24 || a.options.ConfigPath != defaultPath {
t.Fatal("SAVEAS changed the default destination", original, err)
}
persistence_reply(t, persistence_request(a, "RESET"), false)
if a.settings.live.Presentation.Speech.Rate != -60 || a.settings.live.Keyboard.WordEcho || !a.muted || a.engine.Automatic_reading() {
t.Fatal("save completion lost newer changes or reset session state", a.settings.live)
}
persistence_reply(t, persistence_request(a, "SET speech#rate=0.9"), false)
reply = persistence_request(a, "SAVE")
// The existing normal quit waits for the admitted write and reply.
a.handle_input(input.Decision{Command: command.Quit})
if err := a.loop(nil); err != nil || a.ctx.Err() != nil {
t.Fatal("save/quit failed", err, a.ctx.Err())
}
persistence_reply(t, reply, false)
current, _, err := config.Load(defaultPath, nil)
if err != nil || current.Presentation.Speech.Rate != 80 {
t.Fatal("SAVE did not use original configured path", current, err)
}
unchanged, _, err := config.Load(exportPath, nil)
if err != nil || !reflect.DeepEqual(unchanged, saved) {
t.Fatal("later SAVE overwrote export", unchanged, err)
}
}
}
func TestRemotePersistenceFailureAndAdmissionPreserveState(t *testing.T) {
a, _ := persistence_application(t, true)
base := a.settings.resetBase
persistence_reply(t, persistence_request(a, "SET speech#pitch=0.2;speech#volume=0.6;speech#auto_read_incoming=False"), false)
for _, text := range []string{"SAVEAS", "SAVE unexpected", "RESET unexpected", "SAVEAS bad\x00path", "SAVEAS bad\npath"} {
persistence_reply(t, persistence_request(a, text), true)
if a.saver.busy {
t.Fatal("malformed operation started a worker", text)
}
}
a.ui.active = true
persistence_reply(t, persistence_request(a, "RESET"), true)
persistence_reply(t, persistence_request(a, "SAVE"), true)
a.ui.active = false
a.settings.live.Remote.EnableSettingsRemote = false
persistence_reply(t, persistence_request(a, "RESET"), true)
persistence_reply(t, persistence_request(a, "SAVEAS "+filepath.Join(t.TempDir(), "denied.toml")), true)
a.settings.live.Remote.EnableSettingsRemote = true
denied := make(chan string, 1)
a.remote_request(control.Request{UID: os.Geteuid() + 1, Text: "SETTING RESET", Reply: denied})
persistence_reply(t, denied, true)
if response, deferred := a.remote_settings_request("SAVEAS ignored.toml", control.Request{UID: os.Geteuid() + 1}); response != "SAVEAS requires the reader owner or root" || deferred || a.saver.busy {
t.Fatal("SAVEAS granted arbitrary privileged writes", response, deferred)
}
reply := persistence_request(a, "SAVEAS "+t.TempDir()) // A directory cannot be a TOML file.
finish_persistence(t, a, true)
select {
case response := <-reply:
if !strings.HasPrefix(response, "Could not save settings:") {
t.Fatal(response)
}
default:
t.Fatal("failed write did not return an error")
}
if !reflect.DeepEqual(a.settings.resetBase, base) || len(a.settings.remoteFields) == 0 {
t.Fatal("failed save advanced the reset baseline")
}
pipePath := filepath.Join(t.TempDir(), "blocked-settings")
if err := syscall.Mkfifo(pipePath, 0600); err != nil {
t.Fatal(err)
}
reply = persistence_request(a, "SAVEAS "+pipePath)
finish_persistence(t, a, true)
persistence_reply(t, reply, true)
persistence_reply(t, persistence_request(a, "RESET"), false)
if a.settings.live.Presentation.Speech != base.Presentation.Speech || !a.engine.Automatic_reading() {
t.Fatal("failed-save overrides were not reset", a.settings.live)
}
}