From 361c9bf75fa62df0bc9f1da6c23422fdd6b583ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20N=C3=B8rgaard?= Date: Sat, 3 Oct 2026 13:13:22 +0200 Subject: [PATCH] Fix crashes and lost messages A review of the codebase found these bugs. Crashes: - The switcher's down key set pick to -1 with no matches, and the next refilter indexed matches[-1]. - Opening a space kept the old thread cursor, so an event during the load left it past the end and enter indexed out of range. - Image and GIF decoding allocated by the declared size. A small file could declare gigabytes. Sizes are now checked with DecodeConfig, and GIFs are cut to a bounded number of frames before decoding. Lost or wrong messages: - Messages that arrived while a space loaded were replaced by the load result. They are now merged in. - A live reply to a thread outside the loaded history showed without its root, and loading older history then dropped the full thread. The thread is now fetched when its first reply arrives. - The first message in a new space was dropped when it beat the membership event. The space is now fetched first. - A failed page of older history blocked further loads until restart. Untrusted content: - Sender names, attachment names and quote labels go through clean. The renderer turns OSC 8 sequences into links, so stripC1 on the frame doesn't cover them. - The login callback ignores requests without our state, such as /favicon.ico, instead of failing the login, and never blocks on a repeated redirect. Smaller fixes: - Selecting the first thread or message no longer scrolls its first line out of view. - "*a* *b*" bolds both words. - Deleting a message keeps the selection on the same thread or message. - Switching spaces drops a pending edit or quote. - A config.json with only a topic keeps the baked-in OAuth client. --- README.md | 2 +- actions.go | 2 +- auth.go | 25 ++++++++++------ chat.go | 16 ++++++---- format.go | 20 +++++++++---- format_test.go | 1 + image.go | 80 ++++++++++++++++++++++++++++++++++++++++++++++++-- image_test.go | 37 +++++++++++++++++++++++ main.go | 4 +++ nav.go | 25 ++++++++++++++-- ui.go | 69 ++++++++++++++++++++++++++++++++++--------- ui_test.go | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 320 insertions(+), 41 deletions(-) create mode 100644 ui_test.go diff --git a/README.md b/README.md index 2a899a0..f4c3665 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ Per-user topics in the shared project, with resources an admin creates: There is no setup with a baked-in build. The first run opens a browser for login, and the token is stored in the OS keychain. On Linux, the keychain is the Secret Service over D-Bus, such as GNOME Keyring or KeePassXC. Without one, mutter can't store the token. -To use a different OAuth client, for example during development, write it to `~/Library/Application Support/mutter/config.json` (macOS) or `~/.config/mutter/config.json` (Linux). The file takes precedence over the baked-in client: +To use a different OAuth client, for example during development, write it to `~/Library/Application Support/mutter/config.json` (macOS) or `~/.config/mutter/config.json` (Linux). The file takes precedence over the baked-in client. A file with only `topic` keeps the baked-in client: ```json {"client_id": "....apps.googleusercontent.com", "client_secret": "...", "topic": "projects//topics/mutter-events"} diff --git a/actions.go b/actions.go index 6bd2842..66c8056 100644 --- a/actions.go +++ b/actions.go @@ -311,5 +311,5 @@ func senderName(msg *chat.Message) string { if msg.Sender == nil { return "" } - return cmp.Or(msg.Sender.DisplayName, msg.Sender.Name) + return clean(cmp.Or(msg.Sender.DisplayName, msg.Sender.Name)) } diff --git a/auth.go b/auth.go index 2f72735..91e970c 100644 --- a/auth.go +++ b/auth.go @@ -146,16 +146,23 @@ func login(ctx context.Context, cfg *oauth2.Config) (*oauth2.Token, error) { ch := make(chan result, 1) srv := &http.Server{ReadHeaderTimeout: 10 * time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - switch { - case q.Get("state") != state: - http.Error(w, "state mismatch", http.StatusBadRequest) - ch <- result{err: errors.New("oauth state mismatch")} - case q.Get("error") != "": - http.Error(w, q.Get("error"), http.StatusBadRequest) - ch <- result{err: fmt.Errorf("oauth: %s", q.Get("error"))} - default: + // Only the redirect carries our state. Browsers also ask for + // /favicon.ico, and any local process can reach the port. + if r.URL.Path != "/" || q.Get("state") != state { + http.NotFound(w, r) + return + } + res := result{code: q.Get("code")} + if e := q.Get("error"); e != "" { + http.Error(w, e, http.StatusBadRequest) + res = result{err: fmt.Errorf("oauth: %s", e)} + } else { fmt.Fprintln(w, "mutter is logged in. You can close this tab.") - ch <- result{code: q.Get("code")} + } + // A repeated redirect finds the channel full and is dropped. + select { + case ch <- res: + default: } })} // Serve returns ErrServerClosed once the login finishes. diff --git a/chat.go b/chat.go index 8821d52..7a6fc22 100644 --- a/chat.go +++ b/chat.go @@ -252,11 +252,7 @@ func (c *client) threads(ctx context.Context, space string, n int64, pageToken s continue } g.Go(func() error { - var msgs []*chat.Message - err := c.svc.Spaces.Messages.List(space).Filter("thread.name = "+t.name).PageSize(1000).Pages(gctx, func(r *chat.ListMessagesResponse) error { - msgs = append(msgs, r.Messages...) - return nil - }) + msgs, err := c.threadMessages(gctx, space, t.name) if err != nil { return err } @@ -272,6 +268,16 @@ func (c *client) threads(ctx context.Context, space string, n int64, pageToken s return out, r.NextPageToken, nil } +// threadMessages fetches every message in thread, oldest first. +func (c *client) threadMessages(ctx context.Context, space, thread string) ([]*chat.Message, error) { + var msgs []*chat.Message + err := c.svc.Spaces.Messages.List(space).Filter("thread.name = "+thread).PageSize(1000).Pages(ctx, func(r *chat.ListMessagesResponse) error { + msgs = append(msgs, r.Messages...) + return nil + }) + return msgs, err +} + // groupThreads groups msgs, oldest first, by thread in order of first // appearance. func groupThreads(msgs []*chat.Message) []*thread { diff --git a/format.go b/format.go index 86625fc..bdecc73 100644 --- a/format.go +++ b/format.go @@ -79,12 +79,20 @@ func formatText(s string) string { return b.String() } +// formatInline repeats each marker until nothing changes, because a match +// consumes the boundary character after it, which the next match needs. func formatInline(s string) string { for _, in := range inline { - s = in.re.ReplaceAllStringFunc(s, func(m string) string { - sub := in.re.FindStringSubmatch(m) - return sub[1] + in.style.Render(sub[2]) + sub[3] - }) + for { + next := in.re.ReplaceAllStringFunc(s, func(m string) string { + sub := in.re.FindStringSubmatch(m) + return sub[1] + in.style.Render(sub[2]) + sub[3] + }) + if next == s { + break + } + s = next + } } return s } @@ -111,7 +119,7 @@ func messageBody(m *chat.Message, img func(ref string) string, num *int) string } for _, a := range m.Attachment { *num++ - label := dimStyle.Render(fmt.Sprintf("[%d · %s]", *num, cmp.Or(a.ContentName, a.ContentType))) + label := dimStyle.Render(fmt.Sprintf("[%d · %s]", *num, clean(cmp.Or(a.ContentName, a.ContentType)))) if s := img(imageRef(a)); s != "" { parts = append(parts, s) } @@ -138,7 +146,7 @@ const maxQuoteLines = 3 // lines so the reply stays the focus. func quote(q *chat.QuotedMessageMetadata) string { snap := q.QuotedMessageSnapshot - label := snap.Sender + label := clean(snap.Sender) if q.QuoteType == "FORWARD" { label = "Forwarded from " + label } diff --git a/format_test.go b/format_test.go index 3694876..7bd290e 100644 --- a/format_test.go +++ b/format_test.go @@ -17,6 +17,7 @@ func TestFormatText(t *testing.T) { {"2*3*4", "2*3*4"}, {"* not bold *", "* not bold *"}, {"(*x*)", "(" + b("x") + ")"}, + {"*a* *b*", b("a") + " " + b("b")}, {"`*raw*`", c("*raw*")}, {"```\nfn *x*\n```", c("fn *x*")}, } diff --git a/image.go b/image.go index b49bb8a..b67ff1b 100644 --- a/image.go +++ b/image.go @@ -30,6 +30,11 @@ const ( maxImageBytes = 20 << 20 maxImageCols = 60 maxGIFFrames = 150 + + // Decoded size limits. A still decodes to 4 bytes per pixel, a GIF frame + // to 1. + maxImagePixels = 1 << 24 + maxGIFPixels = 1 << 26 ) // Images use the kitty graphics protocol with Unicode placeholders. The @@ -151,13 +156,32 @@ func download(ref string, do func() (*http.Response, error), lay layout) imageMs if err != nil { return imageMsg{ref: ref, err: err} } - if g, err := gif.DecodeAll(bytes.NewReader(data)); err == nil { + // Decoding allocates by the declared size, which a small file can set + // to gigabytes, so it's checked first. + cfg, format, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + log.Printf("image %s: decode: %v", key, err) + return imageMsg{ref: ref, err: err} + } + pixels := cfg.Width * cfg.Height + if pixels == 0 || pixels > maxImagePixels { + return imageMsg{ref: ref, err: fmt.Errorf("image is %dx%d pixels", cfg.Width, cfg.Height)} + } + if format == "gif" { + // Every frame decodes to the full canvas at most, so capping the + // frames bounds the total. + frames := min(maxGIFFrames, maxGIFPixels/pixels) + g, err := gif.DecodeAll(bytes.NewReader(gifPrefix(data, frames))) + if err != nil { + log.Printf("image %s: decode: %v", key, err) + return imageMsg{ref: ref, err: err} + } msg := decodeGIF(g, lay) msg.ref = ref log.Printf("image %s: format=gif frames=%d cells=%dx%d err=%v", key, len(msg.pngs), msg.cols, msg.rows, msg.err) return msg } - src, format, err := image.Decode(bytes.NewReader(data)) + src, _, err := image.Decode(bytes.NewReader(data)) if err != nil { log.Printf("image %s: decode: %v", key, err) return imageMsg{ref: ref, err: err} @@ -172,6 +196,58 @@ func download(ref string, do func() (*http.Response, error), lay layout) imageMs return imageMsg{ref: ref, pngs: [][]byte{out}, cols: cols, rows: rows} } +// gifPrefix cuts a GIF after n frames by walking its blocks. A GIF it can't +// walk comes back truncated, so decoding fails instead of running unbounded. +func gifPrefix(data []byte, n int) []byte { + const header = 13 // signature and logical screen descriptor + if len(data) < header { + return data + } + i := header + if data[10]&0x80 != 0 { + i += 3 << (data[10]&7 + 1) // global color table + } + subBlocks := func() bool { + for i < len(data) { + size := int(data[i]) + i += 1 + size + if size == 0 { + return true + } + } + return false + } + for frames := 0; i < len(data); { + switch data[i] { + case 0x21: // extension: introducer, label, sub-blocks + i += 2 + if !subBlocks() { + return data[:0] + } + case 0x2c: // image descriptor + if frames == n { + return append(data[:i:i], 0x3b) + } + frames++ + if i+10 > len(data) { + return data[:0] + } + if flags := data[i+9]; flags&0x80 != 0 { + i += 3 << (flags&7 + 1) // local color table + } + i += 11 // descriptor and LZW minimum code size + if !subBlocks() { + return data[:0] + } + case 0x3b: // trailer + return data[:i+1] + default: + return data[:0] + } + } + return data // no trailer, but every frame was counted +} + // decodeGIF composites each frame onto a canvas following the frame's // disposal method, because GIF frames are often partial updates. func decodeGIF(g *gif.GIF, lay layout) imageMsg { diff --git a/image_test.go b/image_test.go index ea391a6..034844e 100644 --- a/image_test.go +++ b/image_test.go @@ -6,6 +6,8 @@ import ( "image/color" "image/gif" "image/png" + "io" + "net/http" "strings" "testing" "time" @@ -53,6 +55,41 @@ func TestFormatTextDropsControls(t *testing.T) { } } +func TestGIFPrefix(t *testing.T) { + pal := color.Palette{color.Black, color.White} + g := &gif.GIF{Delay: []int{0, 0, 0}} + for range 3 { + g.Image = append(g.Image, image.NewPaletted(image.Rect(0, 0, 2, 2), pal)) + } + var buf bytes.Buffer + if err := gif.EncodeAll(&buf, g); err != nil { + t.Fatal(err) + } + for _, n := range []int{1, 2, 3, 5} { + got, err := gif.DecodeAll(bytes.NewReader(gifPrefix(buf.Bytes(), n))) + if err != nil { + t.Fatalf("n=%d: %v", n, err) + } + if want := min(n, 3); len(got.Image) != want { + t.Errorf("n=%d: %d frames, want %d", n, len(got.Image), want) + } + } + if got := gifPrefix([]byte("GIF89a\x01\x00\x01\x00\x00\x00\x00junk"), 1); len(got) != 0 { + t.Errorf("unwalkable GIF kept %d bytes", len(got)) + } +} + +func TestDownloadRejectsHugeImage(t *testing.T) { + // A header declaring 65535x65535 pixels, which would decode to gigabytes. + data := []byte("GIF89a\xff\xff\xff\xff\x00\x00\x00;") + msg := download("ref", func() (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Body: io.NopCloser(bytes.NewReader(data))}, nil + }, layout{8, 16, 60, 20}) + if msg.err == nil { + t.Fatal("huge image accepted") + } +} + func TestEncodePNGFlattensPalettedFrame(t *testing.T) { // An offset paletted frame, as gif.Decode can return. src := image.NewPaletted(image.Rect(5, 5, 9, 7), color.Palette{color.Transparent, color.White}) diff --git a/main.go b/main.go index d71ebec..687c398 100644 --- a/main.go +++ b/main.go @@ -90,6 +90,10 @@ func loadConfig() (config, error) { if err := json.Unmarshal(b, &cfg); err != nil { return config{}, fmt.Errorf("%s: %w", path, err) } + if cfg.ClientID == "" && cfg.ClientSecret == "" { + // A file that only sets the topic keeps the baked-in client. + cfg.ClientID, cfg.ClientSecret = clientID, clientSecret + } if cfg.ClientID == "" || cfg.ClientSecret == "" { return config{}, fmt.Errorf("%s: client_id and client_secret are required", path) } diff --git a/nav.go b/nav.go index 100862c..6b3c746 100644 --- a/nav.go +++ b/nav.go @@ -21,7 +21,13 @@ type ( space space name string gone bool - open bool // open it once known, for /dm + open bool // open it once known, for /dm + msg *chat.Message // handle it once known, for a new space + } + // threadMsg carries every message of a thread, oldest first. + threadMsg struct { + name string + msgs []*chat.Message } sectionsMsg map[string]string ) @@ -77,13 +83,23 @@ func (m *model) addOlder(msg olderMsg) tea.Cmd { return m.imgs.fetch(m.ctx, m.c, roots) } -func (m model) fetchSpace(name string, open bool) tea.Cmd { +func (m model) fetchSpace(name string, open bool, msg *chat.Message) tea.Cmd { return func() tea.Msg { s, gone, err := m.c.getSpace(m.ctx, name) if err != nil { return errMsg(err) } - return spaceInfoMsg{space: s, name: name, gone: gone, open: open} + return spaceInfoMsg{space: s, name: name, gone: gone, open: open, msg: msg} + } +} + +func (m model) fetchThread(space, name string) tea.Cmd { + return func() tea.Msg { + msgs, err := m.c.threadMessages(m.ctx, space, name) + if err != nil { + return errMsg(err) + } + return threadMsg{name, msgs} } } @@ -115,6 +131,9 @@ func (m *model) applySpace(msg spaceInfoMsg) tea.Cmd { if msg.open { return m.open(i) } + if msg.msg != nil { + return m.incoming(msg.msg) + } return nil } diff --git a/ui.go b/ui.go index 57596fc..47570d3 100644 --- a/ui.go +++ b/ui.go @@ -320,10 +320,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case messagesMsg: if m.cur >= 0 && m.spaces[m.cur].name == msg.space { + live := m.threads m.threads = msg.threads m.cursor = len(m.threads) - 1 m.olderToken, m.openRead = msg.next, msg.lastRead m.status = "" + // Messages that arrived while the space loaded may postdate the + // fetch. + for _, t := range live { + for _, x := range t.msgs { + if _, _, ok := m.find(x.Name); !ok { + m.add(x) + } + } + } m.render() var roots []*chat.Message for _, t := range m.threads { @@ -372,7 +382,22 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case spaceChangedMsg: - return m, m.fetchSpace(msg.space, false) + return m, m.fetchSpace(msg.space, false, nil) + + case threadMsg: + if i := slices.IndexFunc(m.threads, func(t *thread) bool { return t.name == msg.name }); i >= 0 && len(msg.msgs) > 0 { + t := m.threads[i] + // Keep replies that arrived while the thread was fetched. + for _, x := range t.msgs { + if !slices.ContainsFunc(msg.msgs, func(y *chat.Message) bool { return y.Name == x.Name }) { + msg.msgs = append(msg.msgs, x) + } + } + t.msgs = msg.msgs + m.render() + return m, m.imgs.fetch(m.ctx, m.c, msg.msgs[:1]) + } + return m, nil case spaceInfoMsg: return m, m.applySpace(msg) @@ -388,6 +413,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case errMsg: log.Printf("error: %v", msg) + m.loadingOlder = false // a failed page would otherwise block older history m.status = errStyle.Render(msg.Error()) return m, nil @@ -442,8 +468,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.render() return m, nil case "enter": - if m.ta.Value() == "" && m.inThread == nil && len(m.threads) > 0 { - t := m.threads[m.cursor] + if t := m.target(); m.ta.Value() == "" && m.inThread == nil && t != nil { m.setThread(t) return m, m.imgs.fetch(m.ctx, m.c, t.msgs) } @@ -554,7 +579,7 @@ func (m model) updateSwitcher(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.pick = max(0, m.pick-1) return m, nil case "down", "ctrl+n": - m.pick = min(len(m.matches)-1, m.pick+1) + m.pick = max(0, min(len(m.matches)-1, m.pick+1)) return m, nil case "enter": m.switching = false @@ -613,7 +638,12 @@ func fuzzy(pattern, s string) bool { func (m *model) open(i int) tea.Cmd { m.cur = i m.threads = nil + m.cursor = -1 // add moves it onto the first thread to arrive m.newBelow = 0 + if m.editing != nil { + m.ta.Reset() + } + m.editing, m.quoting = nil, nil m.spaces[i].unread = false m.holdRead = false lastRead := m.spaces[i].lastRead @@ -688,14 +718,21 @@ func (m *model) spaceIndex(name string) int { func (m *model) incoming(msg *chat.Message) tea.Cmd { i := m.spaceIndex(spaceOf(msg.Name)) if i < 0 { - return nil + // The first message in a new space can beat the membership event. + return m.fetchSpace(spaceOf(msg.Name), false, msg) } m.spaces[i].lastActive = msg.CreateTime own := msg.Sender != nil && msg.Sender.Name == m.c.meID + name := threadName(msg) + known := slices.ContainsFunc(m.threads, func(t *thread) bool { return t.name == name }) m.add(msg) var cmds []tea.Cmd if i == m.cur { cmds = append(cmds, m.imgs.fetch(m.ctx, m.c, []*chat.Message{msg})) + if msg.ThreadReply && !known { + // A reply to a thread outside the loaded history needs its root. + cmds = append(cmds, m.fetchThread(spaceOf(msg.Name), name)) + } } if own { return tea.Batch(cmds...) @@ -822,8 +859,17 @@ func (m *model) remove(name string) { } t := m.threads[ti] t.msgs = slices.Delete(t.msgs, mi, mi+1) + if t == m.inThread { + if mi < m.msgCursor { + m.msgCursor-- // stay on the selected message + } + m.msgCursor = min(m.msgCursor, len(t.msgs)-1) + } if len(t.msgs) == 0 { m.threads = slices.Delete(m.threads, ti, ti+1) + if ti < m.cursor { + m.cursor-- // stay on the selected thread + } m.cursor = min(m.cursor, max(0, len(m.threads)-1)) if m.inThread == t { m.setThread(nil) @@ -884,9 +930,10 @@ func (m *model) setBlocks(blocks []string, sel int) { continue } blocks[i] = cursorStyle.Render(b) - top = lipgloss.Height(strings.Join(blocks[:i], "\n\n")) if i > 0 { - top++ // the blank separator line + // lipgloss.Height counts "" as one line, so the first block is + // left at 0. The +1 is the blank separator line. + top = lipgloss.Height(strings.Join(blocks[:i], "\n\n")) + 1 } bottom = top + lipgloss.Height(blocks[i]) } @@ -906,13 +953,7 @@ func newDot() string { return liveStyle.Render("● ") } // message renders msg's sender, time and body. func (m *model) message(msg *chat.Message, num *int) string { - name := "" - if msg.Sender != nil { - name = msg.Sender.DisplayName - if name == "" { - name = msg.Sender.Name - } - } + name := senderName(msg) body := lipgloss.NewStyle().Width(max(1, m.width-4)).Render(messageBody(msg, m.imgs.render, num)) return senderStyle.Render(name) + " " + dimStyle.Render(when(msg.CreateTime)) + "\n" + body } diff --git a/ui_test.go b/ui_test.go new file mode 100644 index 0000000..e831e1d --- /dev/null +++ b/ui_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "testing" + + tea "charm.land/bubbletea/v2" + "google.golang.org/api/chat/v1" +) + +func testModel() model { + m := newModel(context.Background(), &client{meID: "users/me"}, nil) + m.spaces = []space{{name: "spaces/A", title: "alpha"}, {name: "spaces/B", title: "beta"}} + m.cur = 0 + return m +} + +func msgIn(space, thread, id string) *chat.Message { + return &chat.Message{Name: space + "/messages/" + id, Thread: &chat.Thread{Name: space + "/threads/" + thread}, Sender: &chat.User{Name: "users/other"}} +} + +func TestSwitcherDownWithoutMatches(t *testing.T) { + m := testModel() + m.switching = true + m.filter.SetValue("zzz") + m.resetFilter() + next, _ := m.updateSwitcher(tea.KeyPressMsg{Code: tea.KeyDown}) + m = next.(model) + m.refilter() // panicked on pick -1 + if m.pick != 0 { + t.Errorf("pick = %d, want 0", m.pick) + } +} + +func TestSetBlocksFirstBlockAtTop(t *testing.T) { + m := testModel() + m.vp.SetWidth(20) + m.vp.SetHeight(3) + blocks := func() []string { return []string{"a\nb", "c\nd", "e\nf", "g\nh"} } + m.setBlocks(blocks(), 3) + m.setBlocks(blocks(), 0) + if m.vp.YOffset() != 0 { + t.Errorf("offset = %d, want 0", m.vp.YOffset()) + } +} + +func TestEventWhileLoading(t *testing.T) { + m := testModel() + m.cursor = 5 + m.open(1) + early, late := msgIn("spaces/B", "t1", "1"), msgIn("spaces/B", "t2", "2") + m.add(early) + m.add(late) + if m.cursor != 1 { + t.Fatalf("cursor = %d, want 1", m.cursor) + } + // The load saw only the early message. + next, _ := m.Update(messagesMsg{space: "spaces/B", threads: groupThreads([]*chat.Message{early})}) + m = next.(model) + if len(m.threads) != 2 || m.threads[1].msgs[0] != late { + t.Fatalf("got %d threads, the late message was dropped", len(m.threads)) + } +} + +func TestRemoveKeepsSelection(t *testing.T) { + m := testModel() + m.threads = groupThreads([]*chat.Message{msgIn("spaces/A", "t1", "1"), msgIn("spaces/A", "t2", "2"), msgIn("spaces/A", "t3", "3")}) + m.cursor = 2 + m.remove("spaces/A/messages/1") + if m.cursor != 1 || m.threads[m.cursor].name != "spaces/A/threads/t3" { + t.Errorf("cursor = %d, want 1 on t3", m.cursor) + } +} + +func TestIncomingUnknownSpaceFetchesIt(t *testing.T) { + m := testModel() + if m.incoming(msgIn("spaces/NEW", "t", "1")) == nil { + t.Error("message in an unknown space was dropped") + } +}