Files
MeshTender/internal/core/console_location_test.go
T

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")
}