From 9290748761b9c91c9e379ff2774fefa58bd1b533 Mon Sep 17 00:00:00 2001 From: Ophestra Date: Sat, 11 Oct 2025 00:54:04 +0900 Subject: [PATCH] internal/app/spaccount: check behaviour This begins the effort of fully covering internal/app. Signed-off-by: Ophestra --- internal/app/dispatcher_test.go | 264 ++++++++++++++++++++++++++++++++ internal/app/spaccount_test.go | 90 +++++++++++ 2 files changed, 354 insertions(+) create mode 100644 internal/app/spaccount_test.go diff --git a/internal/app/dispatcher_test.go b/internal/app/dispatcher_test.go index 23a5e52..1f5a9f6 100644 --- a/internal/app/dispatcher_test.go +++ b/internal/app/dispatcher_test.go @@ -1,13 +1,277 @@ package app import ( + "bytes" + "log" "os" "os/exec" + "reflect" + "slices" + "testing" + "time" + "hakurei.app/container" "hakurei.app/container/check" + "hakurei.app/container/stub" + "hakurei.app/hst" + "hakurei.app/internal/app/state" "hakurei.app/message" + "hakurei.app/system" ) +// call initialises a [stub.Call]. +// This keeps composites analysis happy without making the test cases too bloated. +func call(name string, args stub.ExpectArgs, ret any, err error) stub.Call { + return stub.Call{Name: name, Args: args, Ret: ret, Err: err} +} + +// checkExpectUid is the uid value used by checkOpBehaviour to initialise [system.I]. +const checkExpectUid = 0xcafebabe + +// checkExpectInstanceId is the [state.ID] value used by checkOpBehaviour to initialise outcomeState. +var checkExpectInstanceId = *(*state.ID)(bytes.Repeat([]byte{0xaa}, len(state.ID{}))) + +type opBehaviourTestCase struct { + name string + newOp func() outcomeOp + newConfig func() *hst.Config + + pStateSys func(state *outcomeStateSys) + toSystem []stub.Call + wantOpSys outcomeOp + wantSys *system.I + extraCheckSys func(t *testing.T, state *outcomeStateSys) + wantErrSystem error + + pStateContainer func(state *outcomeStateParams) + toContainer []stub.Call + wantOpContainer outcomeOp + wantParams *container.Params + extraCheckParams func(t *testing.T, state *outcomeStateParams) + wantErrContainer error +} + +func checkOpBehaviour(t *testing.T, testCases []opBehaviourTestCase) { + t.Helper() + + wantNewState := []stub.Call{ + // newOutcomeState + call("getpid", stub.ExpectArgs{}, 0xdead, nil), + call("isVerbose", stub.ExpectArgs{}, true, nil), + call("mustHsuPath", stub.ExpectArgs{}, m(container.Nonexistent), nil), + call("cmdOutput", stub.ExpectArgs{container.Nonexistent, os.Stderr, []string{}, "/"}, []byte("0"), nil), + call("tempdir", stub.ExpectArgs{}, container.Nonexistent+"/tmp", nil), + call("lookupEnv", stub.ExpectArgs{"XDG_RUNTIME_DIR"}, container.Nonexistent+"/xdg_runtime_dir", nil), + call("getuid", stub.ExpectArgs{}, 1000, nil), + call("getgid", stub.ExpectArgs{}, 100, nil), + + // populateLocal + call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{ + m(container.Nonexistent + "/tmp/hakurei.0"), + m(container.Nonexistent + "/xdg_runtime_dir/hakurei"), + }}, nil, nil), + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Helper() + + wantCallsFull := slices.Concat(wantNewState, tc.toSystem, []stub.Call{{Name: stub.CallSeparator}}) + if tc.wantErrSystem == nil { + wantCallsFull = append(wantCallsFull, slices.Concat(wantNewState, tc.toContainer)...) + } + + wantConfig := tc.newConfig() + k := &kstub{panicDispatcher{}, stub.New(t, + func(s *stub.Stub[syscallDispatcher]) syscallDispatcher { return &kstub{panicDispatcher{}, s} }, + stub.Expect{Calls: wantCallsFull}, + )} + defer stub.HandleExit(t) + + { + config := tc.newConfig() + s := newOutcomeState(k, k, &checkExpectInstanceId, config, &Hsu{k: k}) + if err := s.populateLocal(k, k); err != nil { + t.Fatalf("populateLocal: error = %v", err) + } + stateSys := s.newSys(config, system.New(panicMsgContext{}, panicMsgContext{}, checkExpectUid)) + if tc.pStateSys != nil { + tc.pStateSys(stateSys) + } + op := tc.newOp() + + if err := op.toSystem(stateSys); !reflect.DeepEqual(err, tc.wantErrSystem) { + t.Errorf("toSystem: error = %v, want %v", err, tc.wantErrSystem) + } + k.Expects(stub.CallSeparator) + if !reflect.DeepEqual(config, wantConfig) { + t.Errorf("toSystem clobbered config: %#v, want %#v", config, wantConfig) + } + + if tc.wantErrSystem != nil { + goto out + } + + if !stateSys.sys.Equal(tc.wantSys) { + t.Errorf("toSystem: %#v, want %#v", stateSys.sys, tc.wantSys) + } + if tc.extraCheckSys != nil { + tc.extraCheckSys(t, stateSys) + } + if !reflect.DeepEqual(op, tc.wantOpSys) { + t.Errorf("toSystem: op = %#v, want %#v", op, tc.wantOpSys) + } + } + + { + config := tc.newConfig() + s := newOutcomeState(k, k, &checkExpectInstanceId, config, &Hsu{k: k}) + stateParams := s.newParams() + if err := s.populateLocal(k, k); err != nil { + t.Fatalf("populateLocal: error = %v", err) + } + if tc.pStateContainer != nil { + tc.pStateContainer(stateParams) + } + op := tc.newOp() + + if err := op.toContainer(stateParams); !reflect.DeepEqual(err, tc.wantErrContainer) { + t.Errorf("toContainer: error = %v, want %v", err, tc.wantErrContainer) + } + + if tc.wantErrContainer != nil { + goto out + } + + if !reflect.DeepEqual(stateParams.params, tc.wantParams) { + t.Errorf("toContainer: %#v, want %#v", stateParams.params, tc.wantParams) + } + if tc.extraCheckParams != nil { + tc.extraCheckParams(t, stateParams) + } + if !reflect.DeepEqual(op, tc.wantOpContainer) { + t.Errorf("toContainer: op = %#v, want %#v", op, tc.wantOpContainer) + } + } + + out: + k.VisitIncomplete(func(s *stub.Stub[syscallDispatcher]) { + count := k.Pos() - 1 // separator + if count-len(wantNewState) < len(tc.toSystem) { + t.Errorf("toSystem: %d calls, want %d", count-len(wantNewState), len(tc.toSystem)) + } else { + t.Errorf("toContainer: %d calls, want %d", count-len(tc.toSystem)-2*len(wantNewState), len(tc.toContainer)) + } + }) + }) + } +} + +type kstub struct { + panicDispatcher + *stub.Stub[syscallDispatcher] +} + +func (k *kstub) getpid() int { k.Helper(); return k.Expects("getpid").Ret.(int) } +func (k *kstub) getuid() int { k.Helper(); return k.Expects("getuid").Ret.(int) } +func (k *kstub) getgid() int { k.Helper(); return k.Expects("getgid").Ret.(int) } +func (k *kstub) lookupEnv(key string) (string, bool) { + k.Helper() + expect := k.Expects("lookupEnv") + if expect.Error( + stub.CheckArg(k.Stub, "key", key, 0)) != nil { + k.FailNow() + } + if expect.Ret == nil { + return "\x00", false + } + return expect.Ret.(string), true +} +func (k *kstub) tempdir() string { k.Helper(); return k.Expects("tempdir").Ret.(string) } + +func (k *kstub) cmdOutput(cmd *exec.Cmd) ([]byte, error) { + k.Helper() + expect := k.Expects("cmdOutput") + return expect.Ret.([]byte), expect.Error( + stub.CheckArg(k.Stub, "cmd.Path", cmd.Path, 0), + stub.CheckArgReflect(k.Stub, "cmd.Stderr", cmd.Stderr, 1), + stub.CheckArgReflect(k.Stub, "cmd.Env", cmd.Env, 2), + stub.CheckArg(k.Stub, "cmd.Dir", cmd.Dir, 3)) +} + +func (k *kstub) mustHsuPath() *check.Absolute { + k.Helper() + return k.Expects("mustHsuPath").Ret.(*check.Absolute) +} + +func (k *kstub) GetLogger() *log.Logger { panic("unreachable") } + +func (k *kstub) IsVerbose() bool { k.Helper(); return k.Expects("isVerbose").Ret.(bool) } +func (k *kstub) SwapVerbose(verbose bool) bool { + k.Helper() + expect := k.Expects("swapVerbose") + if expect.Error( + stub.CheckArg(k.Stub, "verbose", verbose, 0)) != nil { + k.FailNow() + } + return expect.Ret.(bool) +} + +// ignoreValue marks a value to be ignored by the test suite. +type ignoreValue struct{} + +func (k *kstub) Verbose(v ...any) { + k.Helper() + expect := k.Expects("verbose") + + // translate ignores in v + if want, ok := expect.Args[0].([]any); ok && len(v) == len(want) { + for i, a := range want { + if _, ok = a.(ignoreValue); ok { + v[i] = ignoreValue{} + } + } + } + + if expect.Error( + stub.CheckArgReflect(k.Stub, "v", v, 0)) != nil { + k.FailNow() + } +} + +func (k *kstub) Verbosef(format string, v ...any) { + k.Helper() + if k.Expects("verbosef").Error( + stub.CheckArg(k.Stub, "format", format, 0), + stub.CheckArgReflect(k.Stub, "v", v, 1)) != nil { + k.FailNow() + } +} + +func (k *kstub) Suspend() bool { k.Helper(); return k.Expects("suspend").Ret.(bool) } +func (k *kstub) Resume() bool { k.Helper(); return k.Expects("resume").Ret.(bool) } +func (k *kstub) BeforeExit() { k.Helper(); k.Expects("beforeExit") } + +// panicMsgContext implements [message.Msg] and [context.Context] with methods wrapping panic. +// This should be assigned to test cases to be checked against. +type panicMsgContext struct{} + +func (panicMsgContext) GetLogger() *log.Logger { panic("unreachable") } +func (panicMsgContext) IsVerbose() bool { panic("unreachable") } +func (panicMsgContext) SwapVerbose(bool) bool { panic("unreachable") } +func (panicMsgContext) Verbose(...any) { panic("unreachable") } +func (panicMsgContext) Verbosef(string, ...any) { panic("unreachable") } +func (panicMsgContext) Suspend() bool { panic("unreachable") } +func (panicMsgContext) Resume() bool { panic("unreachable") } +func (panicMsgContext) BeforeExit() { panic("unreachable") } + +func (panicMsgContext) Deadline() (time.Time, bool) { panic("unreachable") } +func (panicMsgContext) Done() <-chan struct{} { panic("unreachable") } +func (panicMsgContext) Err() error { panic("unreachable") } +func (panicMsgContext) Value(any) any { panic("unreachable") } + +// panicDispatcher implements syscallDispatcher with methods wrapping panic. +// This type is meant to be embedded in partial syscallDispatcher implementations. type panicDispatcher struct{} func (panicDispatcher) new(func(k syscallDispatcher)) { panic("unreachable") } diff --git a/internal/app/spaccount_test.go b/internal/app/spaccount_test.go new file mode 100644 index 0000000..8d96b4b --- /dev/null +++ b/internal/app/spaccount_test.go @@ -0,0 +1,90 @@ +package app + +import ( + "maps" + "os" + "syscall" + "testing" + + "hakurei.app/container" + "hakurei.app/container/stub" + "hakurei.app/hst" + "hakurei.app/message" + "hakurei.app/system" +) + +func TestSpAccountOp(t *testing.T) { + config := hst.Template() + + checkOpBehaviour(t, []opBehaviourTestCase{ + {"invalid state", func() outcomeOp { return spAccountOp{} }, func() *hst.Config { + c := hst.Template() + c.Container.Shell = nil + return c + }, nil, []stub.Call{ + // this op performs basic validation and does not make calls during toSystem + }, spAccountOp{}, system.New(t.Context(), message.NewMsg(nil), checkExpectUid), nil, syscall.ENOTRECOVERABLE, nil, nil, nil, nil, nil, nil}, + + {"invalid user name", func() outcomeOp { return spAccountOp{} }, func() *hst.Config { + c := hst.Template() + c.Container.Username = "9" + return c + }, nil, []stub.Call{ + // this op performs basic validation and does not make calls during toSystem + }, spAccountOp{}, nil, nil, &hst.AppError{ + Step: "finalise", + Err: os.ErrInvalid, + Msg: `invalid user name "9"`, + }, nil, nil, nil, nil, nil, nil}, + + {"success fallback username", func() outcomeOp { return spAccountOp{} }, func() *hst.Config { + c := hst.Template() + c.Container.Username = "" + return c + }, nil, []stub.Call{ + // this op performs basic validation and does not make calls during toSystem + }, spAccountOp{}, system.New(t.Context(), message.NewMsg(nil), checkExpectUid), nil, nil, func(state *outcomeStateParams) { + state.params.Ops = new(container.Ops) + }, []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, spAccountOp{}, &container.Params{ + Dir: config.Container.Home, + Ops: new(container.Ops). + Place(m("/etc/passwd"), []byte("chronos:x:1000:100:Hakurei:/data/data/org.chromium.Chromium:/run/current-system/sw/bin/zsh\n")). + Place(m("/etc/group"), []byte("hakurei:x:100:\n")), + }, func(t *testing.T, state *outcomeStateParams) { + wantEnv := map[string]string{ + "HOME": config.Container.Home.String(), + "USER": config.Container.Username, + "SHELL": config.Container.Shell.String(), + } + maps.Copy(wantEnv, config.Container.Env) + if !maps.Equal(state.env, wantEnv) { + t.Errorf("toContainer: env = %#v, want %#v", state.env, wantEnv) + } + }, nil}, + + {"success", func() outcomeOp { return spAccountOp{} }, hst.Template, nil, []stub.Call{ + // this op performs basic validation and does not make calls during toSystem + }, spAccountOp{}, system.New(t.Context(), message.NewMsg(nil), checkExpectUid), nil, nil, func(state *outcomeStateParams) { + state.params.Ops = new(container.Ops) + }, []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, spAccountOp{}, &container.Params{ + Dir: config.Container.Home, + Ops: new(container.Ops). + Place(m("/etc/passwd"), []byte("chronos:x:1000:100:Hakurei:/data/data/org.chromium.Chromium:/run/current-system/sw/bin/zsh\n")). + Place(m("/etc/group"), []byte("hakurei:x:100:\n")), + }, func(t *testing.T, state *outcomeStateParams) { + wantEnv := map[string]string{ + "HOME": config.Container.Home.String(), + "USER": config.Container.Username, + "SHELL": config.Container.Shell.String(), + } + maps.Copy(wantEnv, config.Container.Env) + if !maps.Equal(state.env, wantEnv) { + t.Errorf("toContainer: env = %#v, want %#v", state.env, wantEnv) + } + }, nil}, + }) +}