package core import ( "context" "crypto/rand" "encoding/binary" "encoding/json" "math" "net/http" "net/http/cookiejar" "net/http/httptest" "strings" "testing" "time" "github.com/coder/websocket" meshcore "github.com/meshcore-go/meshcore-go" "github.com/meshcore-go/meshcore-go/hardware" "github.com/jleight/meshtender/internal/auth" "github.com/jleight/meshtender/internal/identity" "github.com/jleight/meshtender/internal/store" ) func TestParseLocationFloat(t *testing.T) { t.Parallel() cases := map[string]struct { want float64 ok bool }{ "> 37.7749": {37.7749, true}, "> -122.4194": {-122.4194, true}, "0": {0, true}, "> n/a": {0, false}, "": {0, false}, } for in, exp := range cases { got, ok := parseLocationFloat(in) if ok != exp.ok || (ok && got != exp.want) { t.Errorf("parseLocationFloat(%q) = (%v,%v), want (%v,%v)", in, got, ok, exp.want, exp.ok) } } } // TestConsoleFetchesLocation drives the console's "Fetch location" (getloc) // request and verifies the repeater's lat/lon are fetched (get lat / get lon) // and stored — including the stale-reply guard in fetchAndStoreLocation. func TestConsoleFetchesLocation(t *testing.T) { t.Parallel() st, ctx := coreStore(t) masterKey := testMasterKey idSvc, _ := identity.LoadOrCreate(ctx, st, masterKey) authSvc, _ := auth.New(st, st.Pool(), testAuthConfig()) srv, _ := NewServer(st, authSvc, idSvc, testConfig()) ts := httptest.NewServer(srv.Handler()) defer ts.Close() jar, _ := cookiejar.New(nil) user := seedSession(t, ts, st, ctx, jar, "alice") repeater, _ := meshcore.GenerateLocalIdentity(rand.Reader) rep, err := st.CreateRepeater(ctx, &store.Repeater{ OwnerID: user.ID, Name: "Geo", PublicKeyHex: repeater.String(), RadioFreqHz: 869525000, RadioBwHz: 250000, RadioSF: 11, RadioCR: 5, }) if err != nil { t.Fatalf("create repeater: %v", err) } wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/repeaters/" + rep.PublicID + "/console/ws" hdr := http.Header{} if cs := jar.Cookies(mustURL(t, ts.URL)); len(cs) > 0 { var parts []string for _, c := range cs { parts = append(parts, c.Name+"="+c.Value) } hdr.Set("Cookie", strings.Join(parts, "; ")) } dctx, dcancel := context.WithTimeout(ctx, 5*time.Second) defer dcancel() ws, _, err := websocket.Dial(dctx, wsURL, &websocket.DialOptions{HTTPHeader: hdr}) if err != nil { t.Fatalf("ws dial: %v", err) } defer ws.Close(websocket.StatusNormalClosure, "") rw, rwcancel := context.WithTimeout(ctx, 15*time.Second) defer rwcancel() serverID := idSvc.Local().Identity shared, _ := repeater.SharedSecret(serverID) _ = ws.Write(rw, websocket.MessageText, []byte(`{"type":"ready"}`)) // The console fetches location only on request (unlike the old confirm flow's // eager fetch). Queue the getloc; the console processes it after login. _ = ws.Write(rw, websocket.MessageText, []byte(`{"type":"getloc"}`)) // Reply to login (PATH) then to get lat / get lon (TXT_MSG). replyText := func(text string) []byte { plain := meshcore.BuildTextPlaintext(time.Unix(1_700_003_000, 0), 1<<2, []byte(text)) tm, _ := meshcore.NewTextMessage(repeater, serverID, plain, shared) payload, _ := tm.ToBytes() pkt := &meshcore.Packet{Header: meshcore.MakeHeader(meshcore.RouteTypeFlood, meshcore.PayloadTypeTxtMsg, 0), Payload: payload} raw, _ := pkt.ToBytes() return hardware.EncodeDataFrame(raw) } stored := func() bool { got, err := st.GetRepeaterForUser(ctx, user.ID, rep.ID) if err != nil || got.Latitude == nil || got.Longitude == nil { return false } if math.Abs(*got.Latitude-37.7749) > 1e-6 || math.Abs(*got.Longitude-(-122.4194)) > 1e-6 { t.Fatalf("stored location = %v,%v want 37.7749,-122.4194", *got.Latitude, *got.Longitude) } return true } var buf []byte loggedIn := false for { typ, data, err := ws.Read(rw) if err != nil { break // socket closed; check the DB below } if typ == websocket.MessageText { // Unlike the old confirm flow, the console stays open after fetching // location (it's an interactive session), so it never closes the socket // on its own. It signals success with a "location" status — stop then. var m struct{ State, Message string } if json.Unmarshal(data, &m) == nil && m.State == "location" { break } continue } buf = append(buf, data...) frames, rest, _ := hardware.ExtractFrames(buf) buf = rest for _, f := range frames { if f.Command != hardware.KISS_CMD_DATA { continue } pkt, err := meshcore.PacketFromBytes(f.Data) if err != nil { continue } switch pkt.PayloadType() { case meshcore.PayloadTypeAnonReq: if loggedIn { continue } loggedIn = true // PATH login reply granting admin. resp := make([]byte, 13) binary.LittleEndian.PutUint32(resp[:4], 1_700_003_000) resp[6] = 1 // admin resp[7] = 3 plain := append([]byte{0x00, meshcore.PayloadTypeResponse}, resp...) // [path_len=0][type][response] enc, _ := meshcore.EncryptThenMAC(shared, plain) p := &meshcore.Path{Destination: serverID.Hash()[0], Source: repeater.Hash()[0], MAC: [2]byte{enc[0], enc[1]}, EncryptedPayload: enc[2:]} payload, _ := p.ToBytes() lp := &meshcore.Packet{Header: meshcore.MakeHeader(meshcore.RouteTypeFlood, meshcore.PayloadTypePath, 0), Payload: payload} raw, _ := lp.ToBytes() _ = ws.Write(rw, websocket.MessageBinary, hardware.EncodeDataFrame(raw)) case meshcore.PayloadTypeTxtMsg: tm, err := meshcore.TextMessageFromBytes(pkt.Payload) if err != nil { continue } cmd := strings.TrimRight(string(tm.Decrypt(shared)[5:]), "\x00") switch { case strings.HasPrefix(cmd, "get lat"): _ = ws.Write(rw, websocket.MessageBinary, replyText("> 37.7749")) case strings.HasPrefix(cmd, "get lon"): _ = ws.Write(rw, websocket.MessageBinary, replyText("> -122.4194")) } } } if stored() { return // success } } // Socket closed; poll briefly for the stored location. for i := 0; i < 40; i++ { if stored() { return } time.Sleep(25 * time.Millisecond) } t.Fatal("location was not stored") }