mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-01 17:38:15 +00:00
238 lines
7.4 KiB
Go
238 lines
7.4 KiB
Go
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/MeshTender/MeshTender/internal/auth"
|
|
"github.com/MeshTender/MeshTender/internal/identity"
|
|
"github.com/MeshTender/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. 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
|
|
}
|
|
|
|
// The fetch transmits two real commands, so the repeater's owner must be able to
|
|
// see them in the command log with the reply the device gave — a location read is
|
|
// not exempt from the audit trail just because a button triggered it.
|
|
assertAudited := func() {
|
|
t.Helper()
|
|
rows, err := st.Pool().Query(ctx,
|
|
`SELECT command_text, response_text, user_id FROM command_log
|
|
WHERE repeater_id = $1 ORDER BY id`, rep.ID)
|
|
if err != nil {
|
|
t.Fatalf("read command log: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
type entry struct {
|
|
text string
|
|
reply *string
|
|
userID *int64
|
|
}
|
|
var got []entry
|
|
for rows.Next() {
|
|
var e entry
|
|
if err := rows.Scan(&e.text, &e.reply, &e.userID); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
got = append(got, e)
|
|
}
|
|
wantReplies := map[string]string{"get lat": "> 37.7749", "get lon": "> -122.4194"}
|
|
if len(got) != 2 {
|
|
t.Fatalf("command log has %d rows, want 2 (get lat, get lon): %+v", len(got), got)
|
|
}
|
|
for i, want := range []string{"get lat", "get lon"} {
|
|
if got[i].text != want {
|
|
t.Errorf("log row %d text = %q, want %q", i, got[i].text, want)
|
|
}
|
|
if got[i].userID == nil || *got[i].userID != user.ID {
|
|
t.Errorf("log row %d is not attributed to the user who asked (%d)", i, user.ID)
|
|
}
|
|
if got[i].reply == nil || *got[i].reply != wantReplies[want] {
|
|
t.Errorf("log row %d reply = %v, want %q", i, got[i].reply, wantReplies[want])
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
// The console is an interactive session: it stays open after fetching the
|
|
// location and 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() {
|
|
assertAudited()
|
|
return // success
|
|
}
|
|
}
|
|
|
|
// Socket closed; poll briefly for the stored location.
|
|
for i := 0; i < 40; i++ {
|
|
if stored() {
|
|
assertAudited()
|
|
return
|
|
}
|
|
time.Sleep(25 * time.Millisecond)
|
|
}
|
|
t.Fatal("location was not stored")
|
|
}
|