package station_test import ( "context" "errors" "io" "net" "os" "path/filepath" "strconv" "strings" "testing" "time" "gitea.neitzel.de/konrad/funknetz/station" ) func TestOpenConfigRereadsTheFile(t *testing.T) { path := writeConfigFile(t, fullConfig("127.0.0.1", 5308, "DL1ABC", "key", 20, "/tmp/funknetz.log")) st, err := station.New(station.Options{ConfigPath: path}) if err != nil { t.Fatal(err) } t.Cleanup(st.Close) edited := fullConfig("127.0.0.1", 5308, "DL1ABD", "key", 21, "/tmp/funknetz.log") if err := os.WriteFile(path, []byte(edited), 0o600); err != nil { t.Fatal(err) } st.OpenConfig() cfg := st.Config() if !cfg.Open || cfg.Error != "" || cfg.Form.Callsign != "DL1ABD" || cfg.Form.TalkGroup != 21 { t.Fatalf("Config = %+v, want the hand-edited callsign and talk group", cfg) } if st.State().Callsign != "DL1ABC" || st.State().TalkGroup != 20 { t.Fatalf("State = %+v, want the session to keep the loaded config", st.State()) } } func TestFailedRereadKeepsTheSession(t *testing.T) { in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) if err := os.WriteFile(in.path, []byte("{"), 0o600); err != nil { t.Fatal(err) } in.st.OpenConfig() cfg := in.st.Config() if !cfg.Open || !strings.Contains(cfg.Error, "the file could not be read") { t.Fatalf("Config = %+v, want an open window and a read error", cfg) } got := in.st.State() if got.Connection != "Connected" || got.Callsign != "DL1ABC" || got.TalkGroup != 20 { t.Fatalf("State = %+v, want the live session kept", got) } if err := connStillOpen(in.refl.conn()); err != nil { t.Fatal(err) } } func TestCancelAfterLoadLeavesFileAndSession(t *testing.T) { in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) before, err := os.ReadFile(in.path) if err != nil { t.Fatal(err) } in.st.OpenConfig() outside := []byte(fullConfig("127.0.0.1", in.refl.port, "DL1ABD", "other", 21, "/tmp/funknetz.log")) if err := os.WriteFile(in.path, outside, 0o600); err != nil { t.Fatal(err) } if in.st.Cancel() { t.Fatal("Cancel() quit after a loaded config") } if in.st.State().ConfigOpen || in.st.State().Connection != "Connected" || in.st.State().Callsign != "DL1ABC" { t.Fatalf("State = %+v, want the window closed and the session kept", in.st.State()) } got, err := os.ReadFile(in.path) if err != nil { t.Fatal(err) } if string(got) != string(outside) { t.Fatalf("file = %s, want the outside edit left in place", got) } if string(before) == string(got) { t.Fatal("file did not change, so the test did not prove Cancel left it alone") } if err := connStillOpen(in.refl.conn()); err != nil { t.Fatal(err) } } func TestSaveRewritesAndReconnects(t *testing.T) { in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) if err := os.Chmod(in.path, 0o640); err != nil { t.Fatal(err) } in.st.OpenConfig() form := in.st.Config().Form form.TalkGroup = 21 if err := in.st.Save(form); err != nil { t.Fatal(err) } if in.st.Config().Open { t.Fatal("Config window stayed open") } info, err := os.Stat(in.path) if err != nil { t.Fatal(err) } if got, want := info.Mode().Perm(), os.FileMode(0o640); got != want { t.Fatalf("mode = %o, want %o", got, want) } body, err := os.ReadFile(in.path) if err != nil { t.Fatal(err) } if !strings.Contains(string(body), `"talkGroup":21`) || !strings.HasSuffix(string(body), "\n") { t.Fatalf("file = %s, want talk group 21 and a trailing newline", body) } assertKeyOrder(t, body) in.refl.accept(t) declineTo2(t, in.refl) challenge := make([]byte, 20) for i := range challenge { challenge[i] = byte(i + 1) } in.refl.writeFrame(t, 10, appendBytes(nil, challenge)) typ, _ := in.refl.readFrame(t) if typ != 11 { t.Fatalf("auth type %d, want 11", typ) } in.refl.writeFrame(t, 12, nil) in.refl.writeFrame(t, 100, serverInfo(7, []string{"db0abc"}, []string{"OPUS"})) typ, _ = in.refl.readFrame(t) if typ != 111 { t.Fatalf("type %d, want software identity", typ) } typ, tgBody := in.refl.readFrame(t) if typ != 106 { t.Fatalf("type %d, want select talk group", typ) } gotTG, _ := takeU32(t, tgBody) if gotTG != 21 { t.Fatalf("talk group %d, want 21", gotTG) } ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) defer cancel() state, err := in.st.Wait(ctx, func(s station.State) bool { return s.Connection == "Connected" && s.TalkGroup == 21 }) if err != nil { t.Fatal(err) } if state.Callsign != "DL1ABC" { t.Fatalf("Callsign = %q, want DL1ABC", state.Callsign) } } func TestInvalidSaveLeavesTheSession(t *testing.T) { in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) before, err := os.ReadFile(in.path) if err != nil { t.Fatal(err) } in.st.OpenConfig() form := in.st.Config().Form form.Host = "" err = in.st.Save(form) if err == nil || err.Error() != "host is empty" { t.Fatalf("Save() error = %v, want host is empty", err) } got, err := os.ReadFile(in.path) if err != nil { t.Fatal(err) } if string(got) != string(before) { t.Fatalf("file = %s, want it unchanged", got) } state := in.st.State() if !state.ConfigOpen || state.Connection != "Connected" || state.Callsign != "DL1ABC" || state.Detail != "host is empty" { t.Fatalf("State = %+v, want the window open, the session up, and the host error", state) } if err := connStillOpen(in.refl.conn()); err != nil { t.Fatal(err) } } func TestPushToTalkIsOffWhileConfigIsOpen(t *testing.T) { in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) in.st.OpenConfig() in.st.HoldButton() if in.st.State().Transmitting || in.st.State().Connection != "Connected" { t.Fatalf("State = %+v, want listening with push-to-talk off", in.st.State()) } if err := connStillOpen(in.refl.conn()); err != nil { t.Fatal(err) } } func TestConfigStaysClosedWhilePushToTalkIsHeld(t *testing.T) { in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) in.st.HoldButton() if !in.st.State().Transmitting { t.Fatal("Transmitting = false, want the button held") } in.st.OpenConfig() state := in.st.State() if state.ConfigOpen || state.Detail != "Release push-to-talk" || !state.Transmitting { t.Fatalf("State = %+v, want no Config window and a push-to-talk notice", state) } in.st.ReleaseButton() if in.st.State().Detail == "Release push-to-talk" || in.st.State().Transmitting { t.Fatalf("State = %+v, want the notice cleared after release", in.st.State()) } } func TestCertificateProblemStaysOnTheStation(t *testing.T) { path := filepath.Join(t.TempDir(), "funknetz.json") body := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"key\",\"talkGroup\":20,\"certificateDir\":\"\",\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n") if err := os.WriteFile(path, body, 0o600); err != nil { t.Fatal(err) } st, err := station.New(station.Options{ConfigPath: path}) if err != nil { t.Fatal(err) } t.Cleanup(st.Close) got := st.State() if got.ConfigOpen || got.NeedsConfig || got.Detail != "certificateDir is empty" || got.Connection != "Disconnected" { t.Fatalf("State = %+v, want the station window with the certificate detail", got) } if st.Cancel() { t.Fatal("Cancel() quit after a certificate-directory problem") } } func TestOpenConfigDoesNotRewriteAHandEdit(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "funknetz.json") body := []byte(fullConfig("127.0.0.1", 5308, "DL1ABC", "key", 20, filepath.Join(dir, "funknetz.log"))) if err := os.WriteFile(path, body, 0o600); err != nil { t.Fatal(err) } st, err := station.New(station.Options{ConfigPath: path}) if err != nil { t.Fatal(err) } t.Cleanup(st.Close) edited := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"key\",\"talkGroup\":20,\"enableLogging\":false,\"logFile\":\"" + filepath.Join(dir, "funknetz.log") + "\"}\n") if err := os.WriteFile(path, edited, 0o600); err != nil { t.Fatal(err) } st.OpenConfig() if st.Cancel() { t.Fatal("Cancel() quit") } got, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if string(got) != string(edited) { t.Fatalf("file = %s, want the hand-edit left unchanged", got) } if st.State().Callsign != "DL1ABC" || st.State().ConfigOpen { t.Fatalf("State = %+v, want the loaded session and a closed Config window", st.State()) } } func TestSaveEmptyCertificateDirDoesNotConnect(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer ln.Close() port := ln.Addr().(*net.TCPAddr).Port path := filepath.Join(t.TempDir(), "funknetz.json") st, err := station.New(station.Options{ConfigPath: path}) if err != nil { t.Fatal(err) } t.Cleanup(st.Close) ctx, cancel := context.WithCancel(t.Context()) defer cancel() go func() { // Run's error is observed through State, which is the station seam. _ = st.Run(ctx) }() err = st.Save(station.ConfigForm{ Host: "127.0.0.1", Port: port, Callsign: "DL1ABC", AuthKey: "key", TalkGroup: 20, CertificateDir: "", LogFile: "/tmp/funknetz.log", }) if err != nil { t.Fatal(err) } got := st.State() if got.ConfigOpen || got.Detail != "certificateDir is empty" || got.Connection != "Disconnected" { t.Fatalf("State = %+v, want the window closed and the certificate detail", got) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } if info.Mode().Perm() != 0o600 { t.Fatalf("mode = %o, want 600", info.Mode().Perm()) } ln.(*net.TCPListener).SetDeadline(time.Now().Add(200 * time.Millisecond)) if conn, err := ln.Accept(); err == nil { conn.Close() t.Fatal("opened a socket") } } func TestLoggingOnlySaveKeepsTheSession(t *testing.T) { in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) logPath := filepath.Join(t.TempDir(), "station.log") in.st.OpenConfig() form := in.st.Config().Form form.EnableLogging = true form.LogFile = logPath if err := in.st.Save(form); err != nil { t.Fatal(err) } if in.st.Config().Open { t.Fatal("Config window stayed open") } state := in.st.State() if state.Connection != "Connected" || state.TalkGroup != 20 || state.Callsign != "DL1ABC" { t.Fatalf("State = %+v, want the session kept", state) } if _, err := os.Stat(logPath); err != nil { t.Fatalf("log file: %v", err) } if err := connStillOpen(in.refl.conn()); err != nil { t.Fatal(err) } } func TestLoggingSaveDoesNotClearAFatalStop(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer ln.Close() port := ln.Addr().(*net.TCPAddr).Port dir := t.TempDir() path := writeConfigFile(t, fullConfig("127.0.0.1", port, "DL1ABC", "key", 20, filepath.Join(dir, "funknetz.log"))) st, err := station.New(station.Options{ConfigPath: path}) if err != nil { t.Fatal(err) } t.Cleanup(st.Close) st.Fail("could not open the audio device") ctx, cancel := context.WithCancel(t.Context()) defer cancel() go func() { // Run's error is observed through State, which is the station seam. _ = st.Run(ctx) }() st.OpenConfig() form := st.Config().Form form.EnableLogging = true form.LogFile = filepath.Join(dir, "station.log") if err := st.Save(form); err != nil { t.Fatal(err) } if st.State().Detail != "could not open the audio device" || st.State().Connection != "Disconnected" { t.Fatalf("State = %+v, want the fatal stop kept", st.State()) } ln.(*net.TCPListener).SetDeadline(time.Now().Add(200 * time.Millisecond)) if conn, err := ln.Accept(); err == nil { conn.Close() t.Fatal("opened a socket") } } func TestUnchangedSaveDoesNotReconnect(t *testing.T) { in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) in.st.OpenConfig() form := in.st.Config().Form if err := in.st.Save(form); err != nil { t.Fatal(err) } if in.st.Config().Open || in.st.State().Connection != "Connected" { t.Fatalf("State = %+v, Config open = %v, want the window closed and the session kept", in.st.State(), in.st.Config().Open) } if err := connStillOpen(in.refl.conn()); err != nil { t.Fatal(err) } } func TestSaveWriteFailureKeepsTheSession(t *testing.T) { in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) before, err := os.ReadFile(in.path) if err != nil { t.Fatal(err) } dir := filepath.Dir(in.path) if err := os.Chmod(dir, 0o555); err != nil { t.Fatal(err) } t.Cleanup(func() { // Restore the directory so the test temp dir can be removed. _ = os.Chmod(dir, 0o755) }) in.st.OpenConfig() form := in.st.Config().Form form.TalkGroup = 21 err = in.st.Save(form) if err == nil || !strings.Contains(err.Error(), "the file could not be written") { t.Fatalf("Save() error = %v, want a write failure", err) } state := in.st.State() if !state.ConfigOpen || state.Connection != "Connected" || state.TalkGroup != 20 || !strings.Contains(state.Detail, "the file could not be written") { t.Fatalf("State = %+v, want the window open, the session kept, and the write error", state) } if err := os.Chmod(dir, 0o755); err != nil { t.Fatal(err) } got, err := os.ReadFile(in.path) if err != nil { t.Fatal(err) } if string(got) != string(before) { t.Fatalf("file = %s, want it unchanged", got) } if err := connStillOpen(in.refl.conn()); err != nil { t.Fatal(err) } } func TestEachConnectionFieldReconnects(t *testing.T) { tests := []struct { name string edit func(*station.ConfigForm, int) }{ {name: "host", edit: func(f *station.ConfigForm, _ int) { f.Host = "localhost" }}, {name: "callsign", edit: func(f *station.ConfigForm, _ int) { f.Callsign = "DL1ABD" }}, {name: "authKey", edit: func(f *station.ConfigForm, _ int) { f.AuthKey = "other-key" }}, {name: "certificateDir", edit: func(f *station.ConfigForm, _ int) { f.CertificateDir = "./radios" }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) in.st.OpenConfig() form := in.st.Config().Form tt.edit(&form, in.refl.port) if err := in.st.Save(form); err != nil { t.Fatal(err) } if in.st.Config().Open { t.Fatal("Config window stayed open") } old := in.refl.conn() in.refl.accept(t) if err := connStillOpen(old); err == nil { t.Fatal("old session stayed open") } }) } t.Run("port", func(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer ln.Close() in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) in.st.OpenConfig() form := in.st.Config().Form form.Port = ln.Addr().(*net.TCPAddr).Port if err := in.st.Save(form); err != nil { t.Fatal(err) } old := in.refl.conn() ln.(*net.TCPListener).SetDeadline(time.Now().Add(2 * time.Second)) conn, err := ln.Accept() if err != nil { t.Fatal(err) } conn.Close() if err := connStillOpen(old); err == nil { t.Fatal("old session stayed open") } }) } func fullConfig(host string, port int, callsign, authKey string, talkGroup int, logFile string) string { return "{\"host\":\"" + host + "\",\"port\":" + strconv.Itoa(port) + ",\"callsign\":\"" + callsign + "\",\"authKey\":\"" + authKey + "\",\"talkGroup\":" + strconv.Itoa(talkGroup) + ",\"certificateDir\":\"./funknetz\",\"enableLogging\":false,\"logFile\":\"" + logFile + "\"}\n" } func connStillOpen(conn net.Conn) error { if err := conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond)); err != nil { return err } var hdr [4]byte _, err := io.ReadFull(conn, hdr[:]) if err == nil { return nil } if ne, ok := errors.AsType[net.Error](err); ok && ne.Timeout() { return nil } return err }