package station_test import ( "context" "fmt" "net" "os" "path/filepath" "strings" "testing" "time" jsonv2 "encoding/json/v2" "gitea.neitzel.de/konrad/funknetz/station" ) func TestMissingConfigIsCreatedAndRejected(t *testing.T) { path := filepath.Join(t.TempDir(), ".funknetz.json") _, err := station.New(station.Options{ConfigPath: path}) if err == nil { t.Fatal("New() error = nil, want a rejection after creating the file") } if !strings.Contains(err.Error(), "created") { t.Fatalf("New() error = %q, want it to say the file was created", err.Error()) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } if got, want := info.Mode().Perm(), os.FileMode(0o600); got != want { t.Fatalf("mode = %o, want %o", got, want) } body, err := os.ReadFile(path) if err != nil { t.Fatal(err) } var got map[string]any if err := jsonv2.Unmarshal(body, &got); err != nil { t.Fatalf("created file is not JSON: %v\n%s", err, body) } if got["host"] != "" || got["callsign"] != "" || got["authKey"] != "" { t.Fatalf("string fields = %#v, want empty host, callsign, and authKey", got) } if got["port"] != float64(0) || got["talkGroup"] != float64(0) { t.Fatalf("numeric fields = %#v, want port and talkGroup at zero", got) } home, err := os.UserHomeDir() if err != nil { t.Fatal(err) } if got["enableLogging"] != false { t.Fatalf("enableLogging = %#v, want false", got["enableLogging"]) } if got["logFile"] != filepath.Join(home, "funknetz.log") { t.Fatalf("logFile = %#v, want funknetz.log in the home directory", got["logFile"]) } if got["certificateDir"] != "./funknetz" { t.Fatalf("certificateDir = %#v, want ./funknetz", got["certificateDir"]) } assertKeyOrder(t, body) } func TestExistingConfigIsNotRewritten(t *testing.T) { path := filepath.Join(t.TempDir(), "funknetz.json") original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"certificateDir\":\"/var/funknetz\",\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n") if err := os.WriteFile(path, original, 0o640); err != nil { t.Fatal(err) } if _, err := station.New(station.Options{ConfigPath: path}); err != nil { t.Fatal(err) } got, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if string(got) != string(original) { t.Fatalf("file = %q, want %q", got, original) } info, err := os.Stat(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) } } func TestInvalidConfigNamesTheField(t *testing.T) { tests := []struct { name string host string port int callsign string authKey string talkGroup int body string want string }{ {name: "empty host", host: "", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "host is empty"}, {name: "blank host", host: " ", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "host is empty"}, {name: "host type", body: `{"host":1,"port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":1}`, want: "host has the wrong type"}, {name: "empty callsign", host: "127.0.0.1", port: 5308, callsign: "", authKey: "key", talkGroup: 1, want: "callsign is empty"}, {name: "callsign type", body: `{"host":"127.0.0.1","port":5308,"callsign":1,"authKey":"key","talkGroup":1}`, want: "callsign has the wrong type"}, {name: "empty auth key", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: "", talkGroup: 1, want: "authKey is empty"}, {name: "whitespace auth key", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: " \t", talkGroup: 1, want: "authKey is empty"}, {name: "auth key type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":1,"talkGroup":1}`, want: "authKey has the wrong type"}, {name: "port zero", host: "127.0.0.1", port: 0, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "port is invalid"}, {name: "port negative", host: "127.0.0.1", port: -1, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "port is invalid"}, {name: "port too high", host: "127.0.0.1", port: 65536, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "port is invalid"}, {name: "port type", body: `{"host":"127.0.0.1","port":"5308","callsign":"DL1ABC","authKey":"key","talkGroup":1}`, want: "port has the wrong type"}, {name: "port fraction", body: `{"host":"127.0.0.1","port":5308.5,"callsign":"DL1ABC","authKey":"key","talkGroup":1}`, want: "port is invalid"}, {name: "talk group zero", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: 0, want: "talkGroup is invalid"}, {name: "talk group negative", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: -3, want: "talkGroup is invalid"}, {name: "talk group type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":"20"}`, want: "talkGroup has the wrong type"}, {name: "bad callsign", host: "127.0.0.1", port: 5308, callsign: "HELLO", authKey: "key", talkGroup: 1, want: "callsign is invalid"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { body := tt.body if body == "" { body = configJSON(t, tt.host, tt.port, tt.callsign, tt.authKey, tt.talkGroup) } path := filepath.Join(t.TempDir(), "funknetz.json") if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) } before, err := os.ReadFile(path) if err != nil { t.Fatal(err) } _, err = station.New(station.Options{ConfigPath: path}) if err == nil { t.Fatal("New() error = nil, want a field rejection") } if err.Error() != tt.want { t.Fatalf("New() error = %q, want %q", err.Error(), tt.want) } after, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if strings.Contains(tt.want, "wrong type") { if string(after) != string(before) { t.Fatalf("file = %q, want it left untouched", after) } return } var got map[string]any if err := jsonv2.Unmarshal(after, &got); err != nil { t.Fatalf("rewritten file is not JSON: %v\n%s", err, after) } if got["enableLogging"] != false { t.Fatalf("enableLogging = %#v, want false", got["enableLogging"]) } if _, ok := got["logFile"].(string); !ok { t.Fatalf("logFile = %#v, want a string", got["logFile"]) } }) } } func TestBrokenConfigSaysTheFileCouldNotBeRead(t *testing.T) { path := filepath.Join(t.TempDir(), "funknetz.json") if err := os.WriteFile(path, []byte("{"), 0o600); err != nil { t.Fatal(err) } _, err := station.New(station.Options{ConfigPath: path}) if err == nil { t.Fatal("New() error = nil, want a read failure") } if !strings.Contains(err.Error(), "the file could not be read") { t.Fatalf("New() error = %q, want it to say the file could not be read", err.Error()) } if strings.Contains(err.Error(), "callsign") { t.Fatalf("New() error = %q, want no field name", err.Error()) } } func TestCallsignIsTrimmedAndUppercased(t *testing.T) { path := filepath.Join(t.TempDir(), "funknetz.json") body := configJSON(t, " 127.0.0.1 ", 5308, " dl1abc-r ", "a b", 20) if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) } st, err := station.New(station.Options{ConfigPath: path}) if err != nil { t.Fatal(err) } got := st.State() if got.Callsign != "DL1ABC-R" { t.Fatalf("Callsign = %q, want DL1ABC-R", got.Callsign) } if got.TalkGroup != 20 { t.Fatalf("TalkGroup = %d, want 20", got.TalkGroup) } if got.Connection != "Disconnected" || got.Detail != "Idle" || got.Talker != "" || got.Transmitting { t.Fatalf("State = %+v, want Disconnected, Idle, no talker", got) } } func TestMissingLogKeysAreRewritten(t *testing.T) { path := filepath.Join(t.TempDir(), "funknetz.json") original := []byte("{\"note\":true,\"host\":\" 127.0.0.1 \",\"port\":5308,\"callsign\":\" dl1abc \",\"authKey\":\"a b\",\"talkGroup\":20}\n") if err := os.WriteFile(path, original, 0o640); err != nil { t.Fatal(err) } st, err := station.New(station.Options{ConfigPath: path}) if err != nil { t.Fatal(err) } t.Cleanup(st.Close) if st.State().Callsign != "DL1ABC" { t.Fatalf("Callsign = %q", st.State().Callsign) } info, err := os.Stat(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(path) if err != nil { t.Fatal(err) } var got map[string]any if err := jsonv2.Unmarshal(body, &got); err != nil { t.Fatal(err) } if _, ok := got["note"]; ok { t.Fatalf("note = %#v, want it dropped", got["note"]) } if got["callsign"] != " dl1abc " || got["authKey"] != "a b" || got["host"] != " 127.0.0.1 " { t.Fatalf("kept fields = %#v", got) } if got["enableLogging"] != false { t.Fatalf("enableLogging = %#v", got["enableLogging"]) } home, err := os.UserHomeDir() if err != nil { t.Fatal(err) } if got["logFile"] != filepath.Join(home, "funknetz.log") { t.Fatalf("logFile = %#v", got["logFile"]) } if got["certificateDir"] != "./funknetz" { t.Fatalf("certificateDir = %#v, want ./funknetz", got["certificateDir"]) } assertKeyOrder(t, body) if st.State().Detail != "Idle" { t.Fatalf("Detail = %q, want Idle", st.State().Detail) } } func TestLogSettingsDoNotStopTheStation(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Run("missing log file uses home", func(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,"enableLogging":true}`) 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, err := os.ReadFile(path) if err != nil { t.Fatal(err) } var fields map[string]any if err := jsonv2.Unmarshal(got, &fields); err != nil { t.Fatal(err) } wantPath := filepath.Join(home, "funknetz.log") if fields["logFile"] != wantPath || fields["enableLogging"] != true { t.Fatalf("fields = %#v", fields) } if _, err := os.Stat(wantPath); err != nil { t.Fatal(err) } }) t.Run("logging off ignores a bad path", func(t *testing.T) { path := filepath.Join(t.TempDir(), "funknetz.json") logPath := filepath.Join(t.TempDir(), "funknetz.log") body := []byte(`{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":false,"logFile":1}`) if err := os.WriteFile(path, body, 0o600); err != nil { t.Fatal(err) } before, err := os.ReadFile(path) if err != nil { t.Fatal(err) } st, err := station.New(station.Options{ConfigPath: path}) if err != nil { t.Fatal(err) } t.Cleanup(st.Close) if st.State().Detail != "Idle" { t.Fatalf("Detail = %q, want Idle", st.State().Detail) } after, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if string(after) != string(before) { t.Fatalf("file = %s, want it left untouched", after) } if _, err := os.Stat(logPath); !os.IsNotExist(err) { t.Fatalf("log file err = %v, want not exist", err) } }) cases := []struct { name string body string detail string same bool }{ {name: "relative", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"certificateDir":"./funknetz","enableLogging":true,"logFile":"funknetz.log"}`, detail: "Cannot write funknetz.log", same: true}, {name: "empty", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"certificateDir":"./funknetz","enableLogging":true,"logFile":" "}`, detail: "Cannot write logFile", same: true}, {name: "tilde", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"certificateDir":"./funknetz","enableLogging":true,"logFile":"~/funknetz.log"}`, detail: "Cannot write ~/funknetz.log", same: true}, {name: "enable type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes","logFile":"/tmp/funknetz.log"}`, detail: "enableLogging has the wrong type", same: true}, {name: "file type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":true,"logFile":1}`, detail: "logFile has the wrong type", same: true}, {name: "both types", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes","logFile":1}`, detail: "enableLogging has the wrong type", same: true}, {name: "missing enable and bad file", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"logFile":1}`, detail: "Idle", same: true}, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { path := filepath.Join(t.TempDir(), "funknetz.json") if err := os.WriteFile(path, []byte(tt.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) if st.State().Detail != tt.detail { t.Fatalf("Detail = %q, want %q", st.State().Detail, tt.detail) } if tt.same { after, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if string(after) != tt.body { t.Fatalf("file = %s, want it left untouched", after) } } }) } t.Run("bad host still quits", func(t *testing.T) { path := filepath.Join(t.TempDir(), "funknetz.json") body := []byte(`{"host":"","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes"}`) if err := os.WriteFile(path, body, 0o600); err != nil { t.Fatal(err) } _, err := station.New(station.Options{ConfigPath: path}) if err == nil || err.Error() != "host is empty" { t.Fatalf("New() error = %v, want host is empty", err) } after, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if string(after) != string(body) { t.Fatalf("file = %s, want it left untouched", after) } }) } func assertKeyOrder(t *testing.T, body []byte) { t.Helper() keys := []string{`"host"`, `"port"`, `"callsign"`, `"authKey"`, `"talkGroup"`, `"certificateDir"`, `"enableLogging"`, `"logFile"`} last := -1 for _, key := range keys { i := strings.Index(string(body), key) if i < 0 || i < last { t.Fatalf("key %s out of order in %s", key, body) } last = i } } func TestMissingCertificateDirIsWrittenAfterTalkGroup(t *testing.T) { path := filepath.Join(t.TempDir(), "funknetz.json") original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n") if err := os.WriteFile(path, original, 0o640); err != nil { t.Fatal(err) } if _, err := station.New(station.Options{ConfigPath: path}); err != nil { t.Fatal(err) } info, err := os.Stat(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(path) if err != nil { t.Fatal(err) } var got map[string]any if err := jsonv2.Unmarshal(body, &got); err != nil { t.Fatal(err) } if got["authKey"] != "a b" || got["callsign"] != "DL1ABC" || got["certificateDir"] != "./funknetz" { t.Fatalf("fields = %#v", got) } if strings.Contains(string(body), t.TempDir()) { t.Fatalf("file = %s, want the stored directory to stay relative", body) } assertKeyOrder(t, body) } func TestCertificateDirStopsBeforeASocket(t *testing.T) { tests := []struct { name string dir string want string }{ {name: "wrong type", dir: "1", want: "certificateDir has the wrong type"}, {name: "empty", dir: `""`, want: "certificateDir is empty"}, {name: "whitespace", dir: `" "`, want: "certificateDir is empty"}, } for _, tt := range tests { t.Run(tt.name, func(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 body := fmt.Sprintf(`{"host":"127.0.0.1","port":%d,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"certificateDir":%s,"enableLogging":false,"logFile":"/tmp/funknetz.log"}`, port, tt.dir) path := filepath.Join(t.TempDir(), "funknetz.json") if err := os.WriteFile(path, []byte(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.Detail != tt.want || got.Connection != "Disconnected" || got.Callsign != "DL1ABC" { t.Fatalf("State = %+v, want detail %q", got, tt.want) } after, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if string(after) != body { t.Fatalf("file = %s, want it left untouched", after) } ctx, cancel := context.WithCancel(t.Context()) defer cancel() go func() { _ = st.Run(ctx) }() 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 configJSON(t testing.TB, host string, port int, callsign, authKey string, talkGroup int) string { t.Helper() body, err := jsonv2.Marshal(map[string]any{ "host": host, "port": port, "callsign": callsign, "authKey": authKey, "talkGroup": talkGroup, }) if err != nil { t.Fatal(err) } return string(body) } func configWithLog(t testing.TB, host string, port int, callsign, authKey string, talkGroup int, logPath string) string { t.Helper() body, err := jsonv2.Marshal(map[string]any{ "host": host, "port": port, "callsign": callsign, "authKey": authKey, "talkGroup": talkGroup, "enableLogging": true, "logFile": logPath, }) if err != nil { t.Fatal(err) } return string(body) }