mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-23 09:32:26 +00:00
## Show each channel message's region scope Each message in the Channels view now shows the region scope it was sent with, as a small chip in the meta line: the region name (for example `#be`), `unknown scope`, or nothing. This is the channel-message part of #1852 by @dborup, extracted as a focused change. #1852 was closed unmerged because it had grown to the whole fork diff. The implementation follows dborup's commitsc686ae3f,350bf7eeanda94d57edon dborup/CoreScope, adapted to current master. dborup is co-author on the commit. ### What changed - `cmd/server/db.go:2099,2195`: `GetChannelMessages` selects `t.scope_name` when the column exists and returns it as `scope_name`. - `cmd/server/store.go:5654`: the in-memory `GetChannelMessages` returns `scope_name`, so the field does not depend on which path serves the endpoint. - `cmd/server/store.go:2966,3244`: both WebSocket broadcast builders carry `scope_name`, so a live message shows its region immediately. - `public/channels.js:342,2278`: `messageScopeChipHtml` renders the chip with the existing `.sa-chip-declared` / `.sa-chip-unmatched` styles from `scope-audit.css`. The name goes through `escapeHtml`. No new CSS. - `public/channels.js:678,695,1435,1487`: the decrypt path and the WebSocket path keep `scope_name` on the message. ### Differences from #1852 - The field is `scope_name`, the name `/api/packets` already uses. - No `routeType` field. `transmissions.scope_name` already tells the states apart: NULL means no transport code, an empty string means a transport code that no configured region key matched. The frontend uses `??`, not `||`, so the empty string is kept. - A chip instead of `scope: <name>` text. The area label from later #1852 commits is not included. ### Perf One extra column per observation row in the page query (at most `limit` transmissions), and one extra map entry per broadcast observation. No new queries, loops or API calls. ### Tests - `cmd/server/channel_message_scope_name_test.go`: the three states through the DB query, the store, `/api/channels/{hash}/messages` over both paths, a schema without the column, and both broadcast builders. 5 of its 6 tests fail without the change; the sixth guards the missing-column case and passes either way. - `test-issue-1851-channel-message-scope.js`: the REST, WebSocket and client-side decrypt paths, escaping, and the name / unknown / none render. 4/4 fail without the change. Registered in `test-all.sh` and the unit step of `deploy.yml`. - Mutation checks: returning `nil` for `scope_name` in the DB path fails the DB and endpoint tests; `||` instead of `??` in the WebSocket path fails the WebSocket test. - `go test ./...` in `cmd/server`: ok. gofmt and go vet clean. ### Browser validation On a staging instance with live traffic (build `e84d2da6`), in Chrome: - `/api/channels/{hash}/messages` carries the `scope_name` key on every message in the 19 channels whose results I read. `#hamradio`, latest 50: 34 named, 1 empty string, 15 NULL. - Opening `#hamradio` renders 104 chips: `#nl` 53, `#be` 32, `#de` 11, `#eu` 6, `#bx` 1 and `unknown scope` 1, and no chip on unscoped messages. Chip text `rgb(26, 26, 46)` on `rgb(238, 242, 255)` in the light theme. ### Not verified - Dark theme not checked. - The real-decrypt branch of `decryptCandidates` has no test and was not exercised in the browser; the already-decrypted branch is tested. - Messages already in the client decrypt cache show no chip until they are decrypted again. - `go test -race` and the Playwright E2E suite were not run locally. Fixes #1851 ## Review follow-up (commit `50346589`) An independent review found no correctness or XSS problem and confirmed DB, store and WebSocket agree on the value. Changed: - **Real decrypt branch tested.** A new test runs the real AES+HMAC decrypt branch in `decryptCandidates` with one packet per scope state; deleting `scope_name` there now fails 2 of 7 tests. - **Tooltip wording.** The unknown-scope tooltip now says the scope "could not be matched to a single region on this instance" (`public/channels.js:338-347`). The ingestor stores an empty name both when no key matches and when several match without exactly one operator-configured key (`cmd/ingestor/region_keys.go:364-393`), so "matches none of the configured keys" was wrong for the second case. - **Old decrypt cache.** Decrypted messages cached before this change had no `scope_name` key and stayed chipless as long as the candidate count did not change. A cached message missing the key now forces one full decrypt; a cache that has it still takes the delta path. A test covers each case. - **Docs.** `docs/api-spec.md` documents `scope_name` on the channel messages response, with the null / empty string / name semantics. Corrections to the description: - **Test counts.** With the `db.go` and `store.go` changes reverted, 4 of the 5 top-level Go tests fail (6 of 7 counting subtests); only the missing-column test passes. - **Broadcast payload.** `scope_name` is added to `pkt`, which is copied into `broadcastMap` and also nested as `packet` (`store.go` ~2974-2980, ~3252-3257), so the key appears twice per observation: 36 bytes for `null`, 48 bytes for `"#belgium"`. - **Side effect on the Packets page.** The live table reads `m.data.packet` (`packets.js` ~1316-1318), so flat rows and expanded group children now show Scope for live packets. In grouped mode a new group copies a fixed field list without `scope_name` (~1384-1395) and shows the empty placeholder until reload. Before this PR every live row showed that placeholder, so this is not a regression. The three copies of the three-state scope rendering (`app.js`, `packets.js`, `channels.js`) are left as they are. --------- Co-authored-by: dborup <3627142+dborup@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
7 files changed
+646
-5
No files matched your search
@@ -0,0 +1,260 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// #1851: the Channels view shows the region scope a message was sent under.
|
||||
// transmissions.scope_name encodes three states (see StoreTx.ScopeName):
|
||||
// SQL NULL = not transport-scoped, "" = transport-scoped but no configured
|
||||
// region key matched, "#name" = matched region. /api/channels/{hash}/messages
|
||||
// is served by the DB query when a DB is attached and by the in-memory store
|
||||
// otherwise, and live messages arrive over the WebSocket broadcast, so all
|
||||
// three paths must carry the same three states.
|
||||
|
||||
const (
|
||||
chScopeHashUnscoped = "cccccccccccccc01"
|
||||
chScopeHashUnknown = "cccccccccccccc02"
|
||||
chScopeHashMatched = "cccccccccccccc03"
|
||||
)
|
||||
|
||||
var chScopeWant = map[string]interface{}{
|
||||
chScopeHashUnscoped: nil,
|
||||
chScopeHashUnknown: "",
|
||||
chScopeHashMatched: "#belgium",
|
||||
}
|
||||
|
||||
// setupChannelScopeDB seeds one channel with three messages, one per scope
|
||||
// state, each heard by one observation.
|
||||
func setupChannelScopeDB(t *testing.T) *DB {
|
||||
t.Helper()
|
||||
db := setupTestDB(t)
|
||||
if _, err := db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil {
|
||||
t.Fatalf("add scope_name column: %v", err)
|
||||
}
|
||||
db.hasScopeName = true
|
||||
if _, err := db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'BRU')`); err != nil {
|
||||
t.Fatalf("insert observer: %v", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
rows := []struct {
|
||||
hash string
|
||||
routeType int
|
||||
scope interface{}
|
||||
text string
|
||||
}{
|
||||
{chScopeHashUnscoped, 1, nil, "Alice: plain flood"},
|
||||
{chScopeHashUnknown, 0, "", "Bob: unknown region"},
|
||||
{chScopeHashMatched, 0, "#belgium", "Carol: matched region"},
|
||||
}
|
||||
for i, r := range rows {
|
||||
ts := now.Add(time.Duration(i-3) * time.Minute)
|
||||
res, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash, scope_name)
|
||||
VALUES ('AABB', ?, ?, ?, 5, ?, '#scopetest', ?)`,
|
||||
r.hash, ts.Format(time.RFC3339), r.routeType,
|
||||
`{"type":"CHAN","channel":"#scopetest","text":"`+r.text+`"}`, r.scope)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tx %s: %v", r.hash, err)
|
||||
}
|
||||
txID, _ := res.LastInsertId()
|
||||
if _, err := db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (?, 1, 9.5, -90, '[]', ?)`, txID, ts.Unix()); err != nil {
|
||||
t.Fatalf("insert obs %s: %v", r.hash, err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func assertChannelMessageScopes(t *testing.T, messages []map[string]interface{}) {
|
||||
t.Helper()
|
||||
if len(messages) != len(chScopeWant) {
|
||||
t.Fatalf("expected %d messages, got %d", len(chScopeWant), len(messages))
|
||||
}
|
||||
for _, m := range messages {
|
||||
h, _ := m["packetHash"].(string)
|
||||
want, known := chScopeWant[h]
|
||||
if !known {
|
||||
t.Errorf("unexpected packetHash %q", h)
|
||||
continue
|
||||
}
|
||||
got, present := m["scope_name"]
|
||||
if !present {
|
||||
t.Errorf("%s: scope_name key missing", h)
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("%s: scope_name = %#v, want %#v", h, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBGetChannelMessagesCarriesScopeName(t *testing.T) {
|
||||
db := setupChannelScopeDB(t)
|
||||
defer db.Close()
|
||||
messages, _, err := db.GetChannelMessages("#scopetest", 100, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertChannelMessageScopes(t, messages)
|
||||
}
|
||||
|
||||
func TestStoreGetChannelMessagesCarriesScopeName(t *testing.T) {
|
||||
db := setupChannelScopeDB(t)
|
||||
defer db.Close()
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load: %v", err)
|
||||
}
|
||||
messages, _ := store.GetChannelMessages("#scopetest", 100, 0)
|
||||
for i := range messages {
|
||||
// The store keeps the value as a *string; normalise through JSON so
|
||||
// the comparison is on what the browser receives.
|
||||
b, err := json.Marshal(messages[i])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var decoded map[string]interface{}
|
||||
if err := json.Unmarshal(b, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
messages[i] = decoded
|
||||
}
|
||||
assertChannelMessageScopes(t, messages)
|
||||
}
|
||||
|
||||
// A database the ingestor has not migrated yet has no scope_name column. The
|
||||
// DB query must not select it (the query would fail) and every message reports
|
||||
// null, the same as a non-transport-scoped message.
|
||||
func TestDBGetChannelMessagesWithoutScopeNameColumn(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
messages, _, err := db.GetChannelMessages("#test", 100, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChannelMessages on a schema without scope_name: %v", err)
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
t.Fatal("expected messages for #test")
|
||||
}
|
||||
for _, m := range messages {
|
||||
if got := m["scope_name"]; got != nil {
|
||||
t.Errorf("scope_name = %#v, want nil without the column", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelMessagesEndpointExposesScopeName(t *testing.T) {
|
||||
db := setupChannelScopeDB(t)
|
||||
defer db.Close()
|
||||
for _, withDB := range []bool{true, false} {
|
||||
name := "db"
|
||||
if !withDB {
|
||||
name = "store"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load: %v", err)
|
||||
}
|
||||
srv := NewServer(db, &Config{Port: 3000}, NewHub())
|
||||
srv.store = store
|
||||
if !withDB {
|
||||
srv.db = nil
|
||||
}
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/channels/%23scopetest/messages?limit=50", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Messages []map[string]interface{} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
assertChannelMessageScopes(t, body.Messages)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Live messages in the Channels view are built from the WebSocket broadcast,
|
||||
// not from the REST response, so both broadcast builders must carry scope_name
|
||||
// or a new message shows no region until the next REST refresh replaces it.
|
||||
func TestBroadcastMapsCarryScopeName(t *testing.T) {
|
||||
db := setupChannelScopeDB(t)
|
||||
defer db.Close()
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load: %v", err)
|
||||
}
|
||||
|
||||
assertBroadcast := func(t *testing.T, maps []map[string]interface{}, hash string, want interface{}) {
|
||||
t.Helper()
|
||||
found := false
|
||||
for _, bm := range maps {
|
||||
if bm["hash"] != hash {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
b, err := json.Marshal(bm)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var decoded map[string]interface{}
|
||||
if err := json.Unmarshal(b, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if got, ok := decoded["scope_name"]; !ok || got != want {
|
||||
t.Errorf("top-level scope_name = %#v (present=%v), want %#v", got, ok, want)
|
||||
}
|
||||
pkt, _ := decoded["packet"].(map[string]interface{})
|
||||
if got, ok := pkt["scope_name"]; !ok || got != want {
|
||||
t.Errorf("packet.scope_name = %#v (present=%v), want %#v", got, ok, want)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no broadcast map for %s", hash)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("IngestNewFromDB", func(t *testing.T) {
|
||||
txMax := store.MaxTransmissionID()
|
||||
now := time.Now().UTC()
|
||||
res, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash, scope_name)
|
||||
VALUES ('AABB', 'cccccccccccccc04', ?, 0, 5, '{"type":"CHAN","channel":"#scopetest","text":"Dave: live"}', '#scopetest', '#belgium')`,
|
||||
now.Format(time.RFC3339))
|
||||
if err != nil {
|
||||
t.Fatalf("insert tx: %v", err)
|
||||
}
|
||||
txID, _ := res.LastInsertId()
|
||||
if _, err := db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (?, 1, 7.0, -95, '[]', ?)`, txID, now.Unix()); err != nil {
|
||||
t.Fatalf("insert obs: %v", err)
|
||||
}
|
||||
maps, _ := store.IngestNewFromDB(txMax, 100)
|
||||
assertBroadcast(t, maps, "cccccccccccccc04", "#belgium")
|
||||
})
|
||||
|
||||
t.Run("IngestNewObservations", func(t *testing.T) {
|
||||
obsMax := db.GetMaxObservationID()
|
||||
var txID int
|
||||
if err := db.conn.QueryRow(`SELECT id FROM transmissions WHERE hash = ?`, chScopeHashUnknown).Scan(&txID); err != nil {
|
||||
t.Fatalf("lookup tx: %v", err)
|
||||
}
|
||||
if _, err := db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (?, 1, 3.0, -110, '["aa"]', ?)`, txID, time.Now().Unix()); err != nil {
|
||||
t.Fatalf("insert obs: %v", err)
|
||||
}
|
||||
maps := store.IngestNewObservations(obsMax, 100)
|
||||
assertBroadcast(t, maps, chScopeHashUnknown, "")
|
||||
})
|
||||
}
|
||||
+15
-3
@@ -2094,10 +2094,16 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
|
||||
idPlaceholders[i] = "?"
|
||||
obsArgs[i] = id
|
||||
}
|
||||
// #1851: scope_name lives on the transmission row, so appending it as the
|
||||
// last selected column is safe for both query shapes.
|
||||
scopeNameCol := ""
|
||||
if db.hasScopeName {
|
||||
scopeNameCol = ", t.scope_name"
|
||||
}
|
||||
var obsSQL string
|
||||
if db.isV3 {
|
||||
obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen,
|
||||
obs.id, obs.name, o.snr, o.path_json, o.timestamp
|
||||
obs.id, obs.name, o.snr, o.path_json, o.timestamp` + scopeNameCol + `
|
||||
FROM observations o
|
||||
JOIN transmissions t ON t.id = o.transmission_id
|
||||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
|
||||
@@ -2105,7 +2111,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
|
||||
ORDER BY o.id ASC`
|
||||
} else {
|
||||
obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen,
|
||||
o.observer_id, o.observer_name, o.snr, o.path_json, o.timestamp
|
||||
o.observer_id, o.observer_name, o.snr, o.path_json, o.timestamp` + scopeNameCol + `
|
||||
FROM observations o
|
||||
JOIN transmissions t ON t.id = o.transmission_id
|
||||
WHERE t.id IN (` + strings.Join(idPlaceholders, ",") + `)
|
||||
@@ -2130,7 +2136,12 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
|
||||
var pktHash, dj, fs, obsID, obsName, pathJSON sql.NullString
|
||||
var snr sql.NullFloat64
|
||||
var obsTs sql.NullInt64
|
||||
rows.Scan(&pktID, &txID, &pktHash, &dj, &fs, &obsID, &obsName, &snr, &pathJSON, &obsTs)
|
||||
var scopeName sql.NullString
|
||||
scanArgs := []interface{}{&pktID, &txID, &pktHash, &dj, &fs, &obsID, &obsName, &snr, &pathJSON, &obsTs}
|
||||
if db.hasScopeName {
|
||||
scanArgs = append(scanArgs, &scopeName)
|
||||
}
|
||||
rows.Scan(scanArgs...)
|
||||
if !dj.Valid {
|
||||
continue
|
||||
}
|
||||
@@ -2181,6 +2192,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
|
||||
"observers": []string{},
|
||||
"hops": hops,
|
||||
"snr": nullFloat(snr),
|
||||
"scope_name": nullStr(scopeName),
|
||||
},
|
||||
Repeats: 1,
|
||||
}
|
||||
|
||||
@@ -2968,6 +2968,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
"path_json": strOrNil(obs.PathJSON),
|
||||
"direction": strOrNil(obs.Direction),
|
||||
"observation_count": tx.ObservationCount,
|
||||
"scope_name": strPtrOrNil(tx.ScopeName),
|
||||
}
|
||||
// Use decode-window resolved path for broadcast (never from struct)
|
||||
if broadcastRP != nil {
|
||||
@@ -3245,6 +3246,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
||||
"path_json": strOrNil(obs.PathJSON),
|
||||
"direction": strOrNil(obs.Direction),
|
||||
"observation_count": tx.ObservationCount,
|
||||
"scope_name": strPtrOrNil(tx.ScopeName),
|
||||
}
|
||||
// Use decode-window resolved path for broadcast
|
||||
if obsRPMap != nil {
|
||||
@@ -5665,6 +5667,7 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int,
|
||||
"observers": observers,
|
||||
"hops": hops,
|
||||
"snr": snrVal,
|
||||
"scope_name": strPtrOrNil(tx.ScopeName),
|
||||
},
|
||||
Repeats: 1,
|
||||
Observers: observers,
|
||||
|
||||
+12
-1
@@ -1169,13 +1169,24 @@ Messages for a specific channel.
|
||||
"repeats": number, // dedup count
|
||||
"observers": [string], // observer names
|
||||
"hops": number,
|
||||
"snr": number | null
|
||||
"snr": number | null,
|
||||
"scope_name": string | null // region scope, see below
|
||||
}
|
||||
],
|
||||
"total": number // total deduplicated messages
|
||||
}
|
||||
```
|
||||
|
||||
`scope_name` is the transmission's region scope (`transmissions.scope_name`), the same field `/api/packets` returns:
|
||||
|
||||
| Value | Meaning |
|
||||
|-------|---------|
|
||||
| `null` | No transport code: the message was not region-scoped. Also `null` when the database has no `scope_name` column yet (ingestor migration not run). |
|
||||
| `""` | Transport-scoped, but the ingestor could not match it to a single region: no region key matched, or several matched with no single operator-configured key among them. |
|
||||
| `"#name"` | The matched region name. |
|
||||
|
||||
The same field is on the WebSocket `packet` broadcast, both top-level and inside `packet`.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/analytics/rf
|
||||
|
||||
+25
-1
@@ -335,6 +335,20 @@
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// #1851: region scope from the packet's transport code (scope_name).
|
||||
// null = no transport code, so no chip; '' = a transport code the ingestor
|
||||
// could not match to a single region (no key matched, or several did with no
|
||||
// operator-configured winner); otherwise the matched region name. Chip
|
||||
// classes are the Scope Audit's (scope-audit.css), so a scope looks the same
|
||||
// everywhere.
|
||||
function messageScopeChipHtml(scopeName) {
|
||||
if (scopeName == null) return '';
|
||||
if (scopeName === '') {
|
||||
return '<span class="sa-chip sa-chip-unmatched ch-msg-scope" title="Sent with a region scope that could not be matched to a single region on this instance">unknown scope</span>';
|
||||
}
|
||||
return '<span class="sa-chip sa-chip-declared ch-msg-scope" title="Region scope this message was sent with">' + escapeHtml(scopeName) + '</span>';
|
||||
}
|
||||
|
||||
function truncate(str, len) {
|
||||
if (!str) return '';
|
||||
return str.length > len ? str.slice(0, len) + '…' : str;
|
||||
@@ -579,7 +593,10 @@
|
||||
|
||||
// M5: Cache invalidation — if total candidate count changed, re-decrypt everything
|
||||
var totalCandidates = candidates.length;
|
||||
var needFullDecrypt = (totalCandidates !== cachedCount) || opts.forceFullDecrypt;
|
||||
// #1851: a cache written before messages carried scope_name would keep
|
||||
// those messages chipless on the delta path, so decrypt them again.
|
||||
var cacheLacksScope = cachedMsgs.some(function (m) { return !('scope_name' in m); });
|
||||
var needFullDecrypt = (totalCandidates !== cachedCount) || opts.forceFullDecrypt || cacheLacksScope;
|
||||
|
||||
// M5: Delta fetch — only decrypt packets newer than lastTs
|
||||
if (!needFullDecrypt && cachedMsgs.length > 0 && lastTs) {
|
||||
@@ -663,6 +680,7 @@
|
||||
packetHash: c.packet.hash, packetId: c.packet.id,
|
||||
hops: d.path_len || 0, snr: c.packet.snr || null,
|
||||
observers: c.packet.observer_name ? [c.packet.observer_name] : [],
|
||||
scope_name: c.packet.scope_name ?? null,
|
||||
repeats: 1
|
||||
});
|
||||
continue;
|
||||
@@ -679,6 +697,7 @@
|
||||
packetHash: c.packet.hash, packetId: c.packet.id,
|
||||
hops: 0, snr: c.packet.snr || null,
|
||||
observers: c.packet.observer_name ? [c.packet.observer_name] : [],
|
||||
scope_name: c.packet.scope_name ?? null,
|
||||
repeats: 1
|
||||
});
|
||||
} else {
|
||||
@@ -1417,6 +1436,8 @@
|
||||
var pktId = m.data?.id || null;
|
||||
var snr = m.data?.snr ?? m.data?.packet?.snr ?? payload.SNR ?? null;
|
||||
var observer = m.data?.packet?.observer_name || m.data?.observer || null;
|
||||
// ?? not ||: '' (transport-scoped, region unmatched) must survive.
|
||||
var scopeName = m.data?.scope_name ?? m.data?.packet?.scope_name ?? null;
|
||||
|
||||
// Update channel list entry — only once per unique packet hash
|
||||
var isFirstObservation = pktHash && !seenHashes.has(pktHash + ':' + channelKey);
|
||||
@@ -1468,6 +1489,7 @@
|
||||
observers: observer ? [observer] : [],
|
||||
hops: payload.path_len || 0,
|
||||
snr: snr,
|
||||
scope_name: scopeName,
|
||||
// #1498: mark as WS-pushed so a later REST replacement
|
||||
// (selectChannel / refreshMessages) can merge instead of
|
||||
// stomp. Without this flag the REST response wipes any
|
||||
@@ -2258,6 +2280,8 @@
|
||||
if (msg.observers?.length > 1) meta.push(`${msg.observers.length} observers`);
|
||||
if (msg.hops > 0) meta.push(`${msg.hops} hops`);
|
||||
if (msg.snr !== null && msg.snr !== undefined) meta.push(`SNR ${msg.snr}`);
|
||||
const scopeChip = messageScopeChipHtml(msg.scope_name);
|
||||
if (scopeChip) meta.push(scopeChip);
|
||||
|
||||
const safeId = btoa(encodeURIComponent(sender));
|
||||
// #1367: emit BOTH the new chat-app class names (.ch-message /
|
||||
|
||||
@@ -125,6 +125,7 @@ node test-issue-1836-crossnav-case-normalization.js
|
||||
node test-issue-1843-node-qr-quiet-zone.js
|
||||
node test-issue-1846-observers-width.js
|
||||
node test-issue-1849-trace-hashbytes.js
|
||||
node test-issue-1851-channel-message-scope.js
|
||||
node test-issue-1868-control-decode.js
|
||||
node test-issue-1890-og-url.js
|
||||
node test-issue-1956-release-routing.js
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
/* #1851: each channel message shows the region scope it was sent with.
|
||||
*
|
||||
* scope_name has three states (transmissions.scope_name): null = the packet
|
||||
* carried no transport code, '' = it carried one the ingestor could not match
|
||||
* to a single region, '#name' = matched region. A message reaches the Channels view by
|
||||
* three routes (REST /api/channels/{hash}/messages, the WebSocket broadcast,
|
||||
* and client-side decryption of /api/packets rows), so each route must keep
|
||||
* the field, and the render must keep the '' state apart from null.
|
||||
*
|
||||
* Loads the real public/channels.js in a vm sandbox and reads the HTML it
|
||||
* writes into #chMessages.
|
||||
*/
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
const { createCipheriv, createHmac, createHash } = require('crypto');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
const pending = [];
|
||||
function test(name, fn) {
|
||||
const done = (err) => {
|
||||
if (err) { failed++; console.log(` ❌ ${name}: ${err.message}`); }
|
||||
else { passed++; console.log(` ✅ ${name}`); }
|
||||
};
|
||||
try {
|
||||
const out = fn();
|
||||
if (out && typeof out.then === 'function') { pending.push(out.then(() => done(), done)); return; }
|
||||
done();
|
||||
} catch (e) { done(e); }
|
||||
}
|
||||
|
||||
function makeChannelsSandbox(apiImpl) {
|
||||
const dom = {};
|
||||
function makeEl(id) {
|
||||
if (dom[id]) return dom[id];
|
||||
dom[id] = {
|
||||
id, innerHTML: '', textContent: '', value: '',
|
||||
scrollTop: 0, scrollHeight: 100, clientHeight: 80,
|
||||
style: {}, dataset: {},
|
||||
classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
|
||||
addEventListener() {}, removeEventListener() {},
|
||||
querySelector() { return null; }, querySelectorAll() { return []; },
|
||||
getBoundingClientRect() { return { left: 0, bottom: 0, width: 0 }; },
|
||||
setAttribute() {}, removeAttribute() {}, focus() {},
|
||||
};
|
||||
return dom[id];
|
||||
}
|
||||
const headerText = { textContent: '' };
|
||||
makeEl('chHeader').querySelector = (sel) => (sel === '.ch-header-text' ? headerText : null);
|
||||
['chMessages', 'chList', 'chScrollBtn', 'chAriaLive', 'chBackBtn', 'chRegionFilter'].forEach(makeEl);
|
||||
const layout = { classList: { add() {}, remove() {}, contains() { return false; } } };
|
||||
const appEl = {
|
||||
innerHTML: '',
|
||||
querySelector(sel) { return sel === '.ch-layout' ? layout : makeEl(sel); },
|
||||
addEventListener() {},
|
||||
};
|
||||
const storage = {};
|
||||
const ctx = {
|
||||
window: { addEventListener() {}, dispatchEvent() {} },
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
createElement: () => ({ id: '', textContent: '', innerHTML: '' }),
|
||||
head: { appendChild() {} },
|
||||
body: { appendChild() {}, removeChild() {}, contains() { return false; } },
|
||||
documentElement: { getAttribute: () => null, setAttribute() {} },
|
||||
getElementById: makeEl,
|
||||
querySelector: (sel) => (sel === '.ch-layout' ? layout : null),
|
||||
querySelectorAll: () => [],
|
||||
addEventListener() {}, removeEventListener() {},
|
||||
},
|
||||
console, Date, Math, Array, Object, String, Number, JSON, RegExp, Error, TypeError,
|
||||
Map, Set, Promise, URLSearchParams, Infinity, parseInt, parseFloat, isNaN, isFinite,
|
||||
encodeURIComponent, decodeURIComponent,
|
||||
setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {},
|
||||
performance: { now: () => Date.now() },
|
||||
localStorage: {
|
||||
getItem: (k) => (k in storage ? storage[k] : null),
|
||||
setItem: (k, v) => { storage[k] = String(v); },
|
||||
removeItem: (k) => { delete storage[k]; },
|
||||
},
|
||||
location: { hash: '' },
|
||||
getHashParams: () => new URLSearchParams(''),
|
||||
history: { replaceState() {} },
|
||||
matchMedia: () => ({ matches: false }),
|
||||
MutationObserver: function () { this.observe = () => {}; this.disconnect = () => {}; },
|
||||
RegionFilter: { init() {}, onChange() { return () => {}; }, offChange() {}, getRegionParam() { return ''; } },
|
||||
debouncedOnWS: (fn) => fn, onWS() {}, offWS() {},
|
||||
api: apiImpl,
|
||||
CLIENT_TTL: { observers: 120000, channels: 15000, channelMessages: 10000, nodeDetail: 10000 },
|
||||
ROLE_EMOJI: {}, ROLE_LABELS: {},
|
||||
timeAgo: () => '1m ago',
|
||||
registerPage: (name, handlers) => { ctx._pageHandlers = handlers; },
|
||||
btoa: (s) => Buffer.from(String(s), 'utf8').toString('base64'),
|
||||
atob: (s) => Buffer.from(String(s), 'base64').toString('utf8'),
|
||||
crypto: { subtle: require('crypto').webcrypto.subtle },
|
||||
TextEncoder, TextDecoder, Uint8Array,
|
||||
};
|
||||
ctx.window.matchMedia = ctx.matchMedia;
|
||||
vm.createContext(ctx);
|
||||
for (const file of ['public/vendor/aes-ecb.js', 'public/channel-decrypt.js', 'public/channels.js']) {
|
||||
vm.runInContext(fs.readFileSync(file, 'utf8'), ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
}
|
||||
ctx._pageHandlers.init(appEl);
|
||||
return { ctx, dom };
|
||||
}
|
||||
|
||||
function listApi(extra) {
|
||||
return (path) => {
|
||||
if (path.indexOf('/observers') === 0) return Promise.resolve({ observers: [] });
|
||||
if (path.indexOf('/channels') === 0 && path.indexOf('/messages') === -1) {
|
||||
return Promise.resolve({ channels: [{ hash: 'general', name: 'general', messageCount: 4, lastActivity: null }] });
|
||||
}
|
||||
return extra(path);
|
||||
};
|
||||
}
|
||||
|
||||
// Splits the rendered message list into one HTML chunk per message so each
|
||||
// assertion is about a specific message, not about the page as a whole.
|
||||
function messageChunks(html) {
|
||||
return html.split('<div class="ch-msg ch-message">').slice(1);
|
||||
}
|
||||
|
||||
// A GRP_TXT packet ChannelDecrypt can really verify and decrypt: AES-128-ECB
|
||||
// under SHA-256(channelName)[:16], 2-byte HMAC-SHA256 MAC over the ciphertext
|
||||
// with key + 16 zero bytes. Same construction as buildEncryptedGrpTxt in
|
||||
// test-channel-live-decrypt.js, which runs on require and so cannot be imported.
|
||||
function encryptedGrpTxt(channelName, sender, message) {
|
||||
const key = createHash('sha256').update(channelName).digest().slice(0, 16);
|
||||
const channelHash = createHash('sha256').update(key).digest()[0];
|
||||
const text = `${sender}: ${message}`;
|
||||
const pt = Buffer.alloc(Math.ceil((5 + Buffer.byteLength(text, 'utf8') + 1) / 16) * 16);
|
||||
pt.writeUInt32LE(1788256800, 0);
|
||||
pt.write(text, 5, 'utf8');
|
||||
const cipher = createCipheriv('aes-128-ecb', key, null);
|
||||
cipher.setAutoPadding(false);
|
||||
const ct = Buffer.concat([cipher.update(pt), cipher.final()]);
|
||||
const mac = createHmac('sha256', Buffer.concat([key, Buffer.alloc(16)])).update(ct).digest().slice(0, 2);
|
||||
return {
|
||||
keyHex: key.toString('hex'),
|
||||
channelHash,
|
||||
decodedJson: JSON.stringify({ type: 'GRP_TXT', channelHash, mac: mac.toString('hex'), encryptedData: ct.toString('hex') }),
|
||||
};
|
||||
}
|
||||
|
||||
// Three encrypted packets on one channel, one per scope_name state.
|
||||
function encryptedChannelPackets(channelName) {
|
||||
const rows = [
|
||||
{ sender: 'Alice', text: 'secret matched', scope: '#belgium' },
|
||||
{ sender: 'Bob', text: 'secret unmatched', scope: '' },
|
||||
{ sender: 'Carol', text: 'secret plain', scope: null },
|
||||
];
|
||||
let fx;
|
||||
const packets = rows.map((r, i) => {
|
||||
fx = encryptedGrpTxt(channelName, r.sender, r.text);
|
||||
return { id: 10 + i, hash: 'e' + i, first_seen: `2026-09-01T10:0${i}:00Z`, scope_name: r.scope, decoded_json: fx.decodedJson };
|
||||
});
|
||||
return { packets, keyHex: fx.keyHex, channelHash: fx.channelHash, lastTs: packets[packets.length - 1].first_seen };
|
||||
}
|
||||
|
||||
function packetsApi(packets) {
|
||||
return listApi((path) => Promise.resolve(path.indexOf('/packets?') === 0 ? { packets } : {}));
|
||||
}
|
||||
|
||||
const SCOPE_CHIP =/<span class="sa-chip sa-chip-declared ch-msg-scope"[^>]*>([^<]*)<\/span>/;
|
||||
const UNKNOWN_CHIP = /<span class="sa-chip sa-chip-unmatched ch-msg-scope"[^>]*>unknown scope<\/span>/;
|
||||
|
||||
console.log('\n=== #1851: channel message region scope ===');
|
||||
|
||||
test('REST messages render a scope chip per state: name, unknown, none', async () => {
|
||||
const { ctx, dom } = makeChannelsSandbox(listApi((path) => {
|
||||
if (path.indexOf('/channels/general/messages') === 0) {
|
||||
return Promise.resolve({ messages: [
|
||||
{ sender: 'Alice', text: 'matched', timestamp: '2026-09-01T10:00:00Z', packetHash: 'h1', scope_name: '#belgium' },
|
||||
{ sender: 'Bob', text: 'unmatched', timestamp: '2026-09-01T10:01:00Z', packetHash: 'h2', scope_name: '' },
|
||||
{ sender: 'Carol', text: 'plain flood', timestamp: '2026-09-01T10:02:00Z', packetHash: 'h3', scope_name: null },
|
||||
{ sender: 'Dave', text: 'older server', timestamp: '2026-09-01T10:03:00Z', packetHash: 'h4' },
|
||||
] });
|
||||
}
|
||||
return Promise.resolve({});
|
||||
}));
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
await ctx.window._channelsSelectChannelForTest('general');
|
||||
const chunks = messageChunks(dom.chMessages.innerHTML);
|
||||
assert.strictEqual(chunks.length, 4, 'expected 4 rendered messages');
|
||||
|
||||
const named = chunks[0].match(SCOPE_CHIP);
|
||||
assert.ok(named, 'matched-region message must render a scope chip');
|
||||
assert.strictEqual(named[1], '#belgium');
|
||||
assert.ok(!UNKNOWN_CHIP.test(chunks[0]), 'matched-region message must not say unknown');
|
||||
|
||||
assert.ok(UNKNOWN_CHIP.test(chunks[1]), "scope_name '' must render the unknown-scope chip");
|
||||
assert.ok(!SCOPE_CHIP.test(chunks[1]), "scope_name '' must not render a named chip");
|
||||
// '' is stored both when no region key matched and when several matched
|
||||
// with no operator-configured winner (cmd/ingestor/region_keys.go match),
|
||||
// so the tooltip must not claim that no key matched.
|
||||
const title = (chunks[1].match(/ch-msg-scope" title="([^"]*)"/) || [])[1];
|
||||
assert.strictEqual(title, 'Sent with a region scope that could not be matched to a single region on this instance');
|
||||
|
||||
assert.ok(!/ch-msg-scope/.test(chunks[2]), 'scope_name null must render no chip');
|
||||
assert.ok(!/ch-msg-scope/.test(chunks[3]), 'a message without scope_name must render no chip');
|
||||
});
|
||||
|
||||
test('scope name is escaped before it reaches innerHTML', async () => {
|
||||
const { ctx, dom } = makeChannelsSandbox(listApi((path) => {
|
||||
if (path.indexOf('/channels/general/messages') === 0) {
|
||||
return Promise.resolve({ messages: [
|
||||
{ sender: 'Eve', text: 'x', timestamp: '2026-09-01T10:00:00Z', packetHash: 'h1', scope_name: '<img src=x onerror=alert(1)>' },
|
||||
] });
|
||||
}
|
||||
return Promise.resolve({});
|
||||
}));
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
await ctx.window._channelsSelectChannelForTest('general');
|
||||
const html = dom.chMessages.innerHTML;
|
||||
assert.ok(!html.includes('<img'), 'raw markup from scope_name must not be rendered');
|
||||
assert.ok(html.includes('<img src=x onerror=alert(1)>'), 'scope_name must be HTML-escaped');
|
||||
});
|
||||
|
||||
test('WebSocket-appended message keeps scope_name, including the empty state', () => {
|
||||
const { ctx, dom } = makeChannelsSandbox(listApi(() => Promise.resolve({ messages: [] })));
|
||||
ctx.window._channelsSetStateForTest({
|
||||
selectedHash: 'general',
|
||||
channels: [{ hash: 'general', name: 'general', messageCount: 0, lastActivityMs: 0 }],
|
||||
messages: [],
|
||||
});
|
||||
const packet = (hash, text, scope) => ({
|
||||
type: 'packet',
|
||||
data: {
|
||||
hash, scope_name: scope,
|
||||
decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: { channel: 'general', text } },
|
||||
packet: { hash, observer_name: 'Obs', scope_name: scope },
|
||||
},
|
||||
});
|
||||
ctx.window._channelsProcessWSBatchForTest([
|
||||
packet('w1', 'Alice: live matched', '#belgium'),
|
||||
packet('w2', 'Bob: live unmatched', ''),
|
||||
packet('w3', 'Carol: live plain', null),
|
||||
], null);
|
||||
const state = ctx.window._channelsGetStateForTest();
|
||||
assert.deepStrictEqual(Array.from(state.messages, (m) => m.scope_name), ['#belgium', '', null]);
|
||||
const chunks = messageChunks(dom.chMessages.innerHTML);
|
||||
assert.strictEqual(chunks.length, 3, 'expected 3 rendered messages');
|
||||
assert.strictEqual((chunks[0].match(SCOPE_CHIP) || [])[1], '#belgium');
|
||||
assert.ok(UNKNOWN_CHIP.test(chunks[1]), 'live message with scope_name "" must render the unknown chip');
|
||||
assert.ok(!/ch-msg-scope/.test(chunks[2]), 'live message with scope_name null must render no chip');
|
||||
});
|
||||
|
||||
test('client-side decrypted channel keeps scope_name from the /api/packets row', async () => {
|
||||
const { ctx, dom } = makeChannelsSandbox(listApi((path) => {
|
||||
if (path.indexOf('/packets?') === 0) {
|
||||
return Promise.resolve({ packets: [
|
||||
{ id: 1, hash: 'd1', first_seen: '2026-09-01T10:00:00Z', scope_name: '#belgium',
|
||||
decoded_json: JSON.stringify({ type: 'CHAN', channel: '#priv', sender: 'Alice', text: 'Alice: decrypted' }) },
|
||||
{ id: 2, hash: 'd2', first_seen: '2026-09-01T10:01:00Z', scope_name: '',
|
||||
decoded_json: JSON.stringify({ type: 'CHAN', channel: '#priv', sender: 'Bob', text: 'Bob: decrypted' }) },
|
||||
] });
|
||||
}
|
||||
return Promise.resolve({});
|
||||
}));
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
await ctx.window._channelsSelectChannelForTest('user:#priv', {
|
||||
userKey: '00112233445566778899aabbccddeeff', channelHashByte: 1, channelName: '#priv',
|
||||
});
|
||||
const state = ctx.window._channelsGetStateForTest();
|
||||
assert.deepStrictEqual(Array.from(state.messages, (m) => m.scope_name), ['#belgium', '']);
|
||||
const chunks = messageChunks(dom.chMessages.innerHTML);
|
||||
assert.strictEqual((chunks[0].match(SCOPE_CHIP) || [])[1], '#belgium');
|
||||
assert.ok(UNKNOWN_CHIP.test(chunks[1]), 'decrypted message with scope_name "" must render the unknown chip');
|
||||
});
|
||||
|
||||
test('packets decrypted with the stored key keep scope_name from the /api/packets row', async () => {
|
||||
const fx = encryptedChannelPackets('#secret');
|
||||
const { ctx, dom } = makeChannelsSandbox(packetsApi(fx.packets));
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
await ctx.window._channelsSelectChannelForTest('user:#secret', {
|
||||
userKey: fx.keyHex, channelHashByte: fx.channelHash, channelName: '#secret',
|
||||
});
|
||||
const state = ctx.window._channelsGetStateForTest();
|
||||
// Sender and text only exist after a MAC check and AES decrypt, so these
|
||||
// messages came through the encrypted branch, not the already-decrypted one.
|
||||
assert.deepStrictEqual(Array.from(state.messages, (m) => m.sender + ': ' + m.text),
|
||||
['Alice: secret matched', 'Bob: secret unmatched', 'Carol: secret plain']);
|
||||
assert.deepStrictEqual(Array.from(state.messages, (m) => m.scope_name), ['#belgium', '', null]);
|
||||
const chunks = messageChunks(dom.chMessages.innerHTML);
|
||||
assert.strictEqual(chunks.length, 3, 'expected 3 rendered messages');
|
||||
assert.strictEqual((chunks[0].match(SCOPE_CHIP) || [])[1], '#belgium');
|
||||
assert.ok(UNKNOWN_CHIP.test(chunks[1]), 'decrypted message with scope_name "" must render the unknown chip');
|
||||
assert.ok(!/ch-msg-scope/.test(chunks[2]), 'decrypted message with scope_name null must render no chip');
|
||||
});
|
||||
|
||||
test('a decrypt cache written before scope_name existed is decrypted again', async () => {
|
||||
const fx = encryptedChannelPackets('#secret');
|
||||
const { ctx } = makeChannelsSandbox(packetsApi(fx.packets));
|
||||
// Same candidate count and last timestamp as the API returns, so without the
|
||||
// missing key the delta path would find nothing new and serve this cache.
|
||||
ctx.ChannelDecrypt.setCache('#secret', fx.packets.map((p, i) => ({
|
||||
sender: 'Old', text: 'cached ' + i, timestamp: p.first_seen, packetHash: p.hash, packetId: p.id,
|
||||
hops: 0, snr: null, observers: [], repeats: 1,
|
||||
})), fx.lastTs, fx.packets.length);
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
await ctx.window._channelsSelectChannelForTest('user:#secret', {
|
||||
userKey: fx.keyHex, channelHashByte: fx.channelHash, channelName: '#secret',
|
||||
});
|
||||
const state = ctx.window._channelsGetStateForTest();
|
||||
assert.deepStrictEqual(Array.from(state.messages, (m) => m.scope_name), ['#belgium', '', null]);
|
||||
assert.deepStrictEqual(Array.from(ctx.ChannelDecrypt.getCache('#secret').messages, (m) => m.scope_name),
|
||||
['#belgium', '', null], 'the rewritten cache must carry scope_name');
|
||||
});
|
||||
|
||||
test('a decrypt cache that already carries scope_name is still served by the delta path', async () => {
|
||||
const fx = encryptedChannelPackets('#secret');
|
||||
const { ctx } = makeChannelsSandbox(packetsApi(fx.packets));
|
||||
ctx.ChannelDecrypt.setCache('#secret', fx.packets.map((p, i) => ({
|
||||
sender: 'Old', text: 'cached ' + i, timestamp: p.first_seen, packetHash: p.hash, packetId: p.id,
|
||||
hops: 0, snr: null, observers: [], scope_name: null, repeats: 1,
|
||||
})), fx.lastTs, fx.packets.length);
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
await ctx.window._channelsSelectChannelForTest('user:#secret', {
|
||||
userKey: fx.keyHex, channelHashByte: fx.channelHash, channelName: '#secret',
|
||||
});
|
||||
const state = ctx.window._channelsGetStateForTest();
|
||||
assert.deepStrictEqual(Array.from(state.messages, (m) => m.text), ['cached 0', 'cached 1', 'cached 2']);
|
||||
});
|
||||
|
||||
Promise.all(pending).then(() => {
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
});
|
||||
Reference in new issue
Block a user