e71063b16e
The durable JetStream stream of a persisted (ModeMatrix) room was created only
by the Go client's first publish/subscribe. A client that speaks only core NATS
(the browser client uniweb, which has no JetStream) therefore never created it,
so its messages were captured nowhere and lost on reload. Move stream ownership
to the control plane and expose the backlog over plain HTTP.
- handleCreateRoom ensures the room's stream (idempotent CreateOrUpdateStream)
BEFORE writing the room row, so the subject is captured from the first message
whoever publishes it. Done before the store write so a stream failure leaves no
orphan room. Skipped when no JetStream is wired (room still works, no history).
- New member-only GET /rooms/{id}/history?limit=N (default 200, hard cap 1000):
reads the stream server-side via the modern jetstream API (Stream.Info +
GetMsg by sequence, no consumer) and returns the last N frames oldest->newest
as {"messages":[<base64-std of the marshaled frame>]}. The server never
decrypts — it relays the E2E ciphertext bytes the stream already holds.
Existence is checked first (404), then membership (403); enforce rejects an
unsigned request with 401 before the handler runs.
- Lazy backfill: the history endpoint ensures the stream of a pre-existing
persisted room, so it starts capturing from now on. Messages sent before the
stream existed were never captured and are unrecoverable.
- The stream config (streamConfigForRoom) mirrors pkg/client/persist.go
byte-for-byte plus Replicas (matched to the control-plane KV replication). It
is copied rather than imported because pkg/client imports pkg/membership and
the reverse would be an import cycle; the source of truth is documented in a
comment.
- Server gains SetJetStream(js, replicas) to wire the privileged JetStream
context and the room-stream replication factor.
Tests (history_test.go): golden (3 frames round-trip in order, decodable),
core-NATS capture (the central fix), handleCreateRoom creates the stream, limit,
empty room ([] not null), 401 unsigned, 403 non-member, 404 missing room.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
401 lines
14 KiB
Go
401 lines
14 KiB
Go
package membership
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
cs "fn-registry/functions/cybersecurity"
|
|
|
|
"github.com/enmanuel/unibus/pkg/blobstore"
|
|
"github.com/enmanuel/unibus/pkg/embeddednats"
|
|
"github.com/enmanuel/unibus/pkg/frame"
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
)
|
|
|
|
// historyHarness is an enforce-mode control plane wired to a real embedded NATS
|
|
// JetStream, so the history path exercises the production code: the server ensures
|
|
// and reads actual durable streams. alice is a seeded admin (and any room's owner),
|
|
// bob is a registered user added as a room member, and carol is a registered user
|
|
// that is NOT a member of the test room (to exercise the 403 path).
|
|
type historyHarness struct {
|
|
ts *httptest.Server
|
|
store Store
|
|
js jetstream.JetStream
|
|
nc *nats.Conn
|
|
alice cs.Identity // admin + room owner
|
|
bob cs.Identity // room member
|
|
carol cs.Identity // registered, non-member
|
|
}
|
|
|
|
func newHistoryHarness(t *testing.T) *historyHarness {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
|
|
StoreDir: filepath.Join(dir, "jetstream"),
|
|
Host: "127.0.0.1",
|
|
Port: kvFreePort(t),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("embedded nats: %v", err)
|
|
}
|
|
nc, err := nats.Connect(ns.ClientURL())
|
|
if err != nil {
|
|
ns.Shutdown()
|
|
t.Fatalf("nats connect: %v", err)
|
|
}
|
|
js, err := jetstream.New(nc)
|
|
if err != nil {
|
|
nc.Close()
|
|
ns.Shutdown()
|
|
t.Fatalf("jetstream: %v", err)
|
|
}
|
|
store, err := Open(filepath.Join(dir, "unibus.db"))
|
|
if err != nil {
|
|
nc.Close()
|
|
ns.Shutdown()
|
|
t.Fatalf("open store: %v", err)
|
|
}
|
|
blobs, err := blobstore.New(filepath.Join(dir, "blobs"))
|
|
if err != nil {
|
|
store.Close()
|
|
nc.Close()
|
|
ns.Shutdown()
|
|
t.Fatalf("open blobs: %v", err)
|
|
}
|
|
mustID := func(name string) cs.Identity {
|
|
id, err := cs.GenerateIdentity()
|
|
if err != nil {
|
|
t.Fatalf("identity %s: %v", name, err)
|
|
}
|
|
return id
|
|
}
|
|
alice, bob, carol := mustID("alice"), mustID("bob"), mustID("carol")
|
|
if err := store.AddUser(hex.EncodeToString(alice.SignPub), "alice", RoleAdmin); err != nil {
|
|
t.Fatalf("seed admin: %v", err)
|
|
}
|
|
for _, u := range []struct {
|
|
id cs.Identity
|
|
handle string
|
|
}{{bob, "bob"}, {carol, "carol"}} {
|
|
if err := store.AddUser(hex.EncodeToString(u.id.SignPub), u.handle, RoleMember); err != nil {
|
|
t.Fatalf("register %s: %v", u.handle, err)
|
|
}
|
|
}
|
|
|
|
srv := NewServer(store, blobs, AuthEnforce)
|
|
srv.SetJetStream(js, 1)
|
|
ts := httptest.NewServer(srv)
|
|
t.Cleanup(func() {
|
|
ts.Close()
|
|
store.Close()
|
|
nc.Close()
|
|
ns.Shutdown()
|
|
ns.WaitForShutdown()
|
|
})
|
|
return &historyHarness{ts: ts, store: store, js: js, nc: nc, alice: alice, bob: bob, carol: carol}
|
|
}
|
|
|
|
// seedPersistRoom creates a persisted (Matrix-policy) room directly in the store
|
|
// with alice as owner and bob as a member, returning its id and subject. It does
|
|
// NOT create the stream — that is left to the code under test (handleCreateRoom or
|
|
// the lazy ensure in the history endpoint), which is exactly what we want to verify.
|
|
func (h *historyHarness) seedPersistRoom(t *testing.T) (roomID, subject string) {
|
|
t.Helper()
|
|
roomID = newULID()
|
|
subject = "unibus.room." + roomID
|
|
aliceEp := frame.EndpointID(h.alice.SignPub)
|
|
info := RoomInfo{RoomID: roomID, Subject: subject, OwnerEndpoint: aliceEp, Encrypt: true, Persist: true}
|
|
if err := h.store.CreateRoom(info, h.alice.SignPub, h.alice.KexPub, []byte("alice-sealed")); err != nil {
|
|
t.Fatalf("seed room: %v", err)
|
|
}
|
|
bobEp := frame.EndpointID(h.bob.SignPub)
|
|
bobM := Member{Endpoint: bobEp, Role: RoleMember, SignPub: h.bob.SignPub, KexPub: h.bob.KexPub}
|
|
if err := h.store.AddMember(roomID, bobM, 0, []byte("bob-sealed")); err != nil {
|
|
t.Fatalf("add member bob: %v", err)
|
|
}
|
|
return roomID, subject
|
|
}
|
|
|
|
// makeFrame builds a marshaled PUB frame whose payload identifies it, so a test can
|
|
// assert exact bytes and ordering after a round trip through the stream + endpoint.
|
|
func makeFrame(t *testing.T, subject, sender string, i int) []byte {
|
|
t.Helper()
|
|
f := frame.Frame{
|
|
Type: frame.PUB,
|
|
Subject: subject,
|
|
Sender: sender,
|
|
MsgID: fmt.Sprintf("msg-%02d", i),
|
|
Payload: []byte(fmt.Sprintf("ciphertext-%02d", i)),
|
|
}
|
|
b, err := f.Marshal()
|
|
if err != nil {
|
|
t.Fatalf("marshal frame %d: %v", i, err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// getHistory signs a GET /rooms/{id}/history request as id and returns the status,
|
|
// the raw body, and the decoded envelope. query is the raw query string (e.g.
|
|
// "limit=2") or "". The signed path includes the query because the server verifies
|
|
// the signature over r.URL.RequestURI(), which carries it.
|
|
func (h *historyHarness) getHistory(t *testing.T, id cs.Identity, roomID, query string, n int) (int, string, historyResp) {
|
|
t.Helper()
|
|
path := "/rooms/" + roomID + "/history"
|
|
if query != "" {
|
|
path += "?" + query
|
|
}
|
|
req := signedReq(t, h.ts.URL, "GET", path, nil, id, time.Now().Unix(), nonceN(n))
|
|
code, body := do(t, req)
|
|
var out historyResp
|
|
if code == 200 {
|
|
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
|
t.Fatalf("decode history: %v (%s)", err, body)
|
|
}
|
|
}
|
|
return code, body, out
|
|
}
|
|
|
|
// TestCreateRoomEnsuresStream verifies handleCreateRoom creates the durable stream
|
|
// for a persisted room before responding, so capture starts at room creation.
|
|
func TestCreateRoomEnsuresStream(t *testing.T) {
|
|
h := newHistoryHarness(t)
|
|
aliceEp := frame.EndpointID(h.alice.SignPub)
|
|
reqBody := createRoomReq{
|
|
Subject: "unibus.room.created",
|
|
Policy: policyJSON{Encrypt: true, Persist: true},
|
|
Owner: endpointJSON{Endpoint: aliceEp, SignPub: h.alice.SignPub, KexPub: h.alice.KexPub},
|
|
SealedKeySelf: []byte("alice-sealed"),
|
|
}
|
|
body, _ := json.Marshal(reqBody)
|
|
req := signedReq(t, h.ts.URL, "POST", "/rooms", body, h.alice, time.Now().Unix(), nonceN(1))
|
|
code, respBody := do(t, req)
|
|
if code != 201 {
|
|
t.Fatalf("create room: want 201, got %d (%s)", code, respBody)
|
|
}
|
|
var cr createRoomResp
|
|
if err := json.Unmarshal([]byte(respBody), &cr); err != nil {
|
|
t.Fatalf("decode create resp: %v (%s)", err, respBody)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
if _, err := h.js.Stream(ctx, roomStreamName(cr.RoomID)); err != nil {
|
|
t.Fatalf("stream for created persist room should exist: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestRoomHistoryGolden is the golden path: three frames published to a persisted
|
|
// room's stream come back from the endpoint base64-encoded, in chronological order,
|
|
// and decode to the exact frames that were published.
|
|
func TestRoomHistoryGolden(t *testing.T) {
|
|
h := newHistoryHarness(t)
|
|
roomID, subject := h.seedPersistRoom(t)
|
|
bobEp := frame.EndpointID(h.bob.SignPub)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := ensureRoomStream(ctx, h.js, roomID, subject, 1); err != nil {
|
|
t.Fatalf("ensure stream: %v", err)
|
|
}
|
|
want := make([][]byte, 3)
|
|
for i := 0; i < 3; i++ {
|
|
want[i] = makeFrame(t, subject, bobEp, i)
|
|
// js.Publish waits for the stream ack, so the message is durably stored before
|
|
// the next iteration — no sleeps, deterministic ordering.
|
|
if _, err := h.js.Publish(ctx, subject, want[i]); err != nil {
|
|
t.Fatalf("publish %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
code, raw, hr := h.getHistory(t, h.bob, roomID, "", 10)
|
|
if code != 200 {
|
|
t.Fatalf("history: want 200, got %d (%s)", code, raw)
|
|
}
|
|
if len(hr.Messages) != 3 {
|
|
t.Fatalf("want 3 messages, got %d (%s)", len(hr.Messages), raw)
|
|
}
|
|
for i, m := range hr.Messages {
|
|
decoded, err := base64.StdEncoding.DecodeString(m)
|
|
if err != nil {
|
|
t.Fatalf("message %d not valid base64: %v", i, err)
|
|
}
|
|
if string(decoded) != string(want[i]) {
|
|
t.Fatalf("message %d bytes mismatch (order or content)", i)
|
|
}
|
|
f, err := frame.Unmarshal(decoded)
|
|
if err != nil {
|
|
t.Fatalf("message %d does not decode to a frame: %v", i, err)
|
|
}
|
|
if f.MsgID != fmt.Sprintf("msg-%02d", i) {
|
|
t.Fatalf("message %d: want MsgID msg-%02d, got %q", i, i, f.MsgID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRoomHistoryCapturesCoreNATSPublish proves the central fix: a message
|
|
// published over PLAIN core NATS (as the JetStream-less browser client uniweb does)
|
|
// is captured by the server-owned stream and served by the endpoint. Without the
|
|
// server ensuring the stream, this message would be captured nowhere.
|
|
func TestRoomHistoryCapturesCoreNATSPublish(t *testing.T) {
|
|
h := newHistoryHarness(t)
|
|
roomID, subject := h.seedPersistRoom(t)
|
|
bobEp := frame.EndpointID(h.bob.SignPub)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := ensureRoomStream(ctx, h.js, roomID, subject, 1); err != nil {
|
|
t.Fatalf("ensure stream: %v", err)
|
|
}
|
|
sent := makeFrame(t, subject, bobEp, 7)
|
|
if err := h.nc.Publish(subject, sent); err != nil {
|
|
t.Fatalf("core publish: %v", err)
|
|
}
|
|
if err := h.nc.Flush(); err != nil {
|
|
t.Fatalf("flush: %v", err)
|
|
}
|
|
// Core NATS publish has no stream ack; poll the stream until the message lands.
|
|
h.waitMsgs(t, roomID, 1)
|
|
|
|
code, raw, hr := h.getHistory(t, h.bob, roomID, "", 11)
|
|
if code != 200 {
|
|
t.Fatalf("history: want 200, got %d (%s)", code, raw)
|
|
}
|
|
if len(hr.Messages) != 1 {
|
|
t.Fatalf("want 1 captured message, got %d (%s)", len(hr.Messages), raw)
|
|
}
|
|
decoded, err := base64.StdEncoding.DecodeString(hr.Messages[0])
|
|
if err != nil || string(decoded) != string(sent) {
|
|
t.Fatalf("captured core-NATS message round-trip mismatch (err=%v)", err)
|
|
}
|
|
}
|
|
|
|
// TestRoomHistoryLimit verifies ?limit caps the response to the most recent N
|
|
// messages, oldest→newest within the window.
|
|
func TestRoomHistoryLimit(t *testing.T) {
|
|
h := newHistoryHarness(t)
|
|
roomID, subject := h.seedPersistRoom(t)
|
|
bobEp := frame.EndpointID(h.bob.SignPub)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := ensureRoomStream(ctx, h.js, roomID, subject, 1); err != nil {
|
|
t.Fatalf("ensure stream: %v", err)
|
|
}
|
|
for i := 0; i < 5; i++ {
|
|
if _, err := h.js.Publish(ctx, subject, makeFrame(t, subject, bobEp, i)); err != nil {
|
|
t.Fatalf("publish %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
code, raw, hr := h.getHistory(t, h.bob, roomID, "limit=2", 12)
|
|
if code != 200 {
|
|
t.Fatalf("history: want 200, got %d (%s)", code, raw)
|
|
}
|
|
if len(hr.Messages) != 2 {
|
|
t.Fatalf("limit=2 over 5 messages: want 2, got %d", len(hr.Messages))
|
|
}
|
|
// The window is the last two messages (indices 3 and 4), in order.
|
|
for off, m := range hr.Messages {
|
|
decoded, _ := base64.StdEncoding.DecodeString(m)
|
|
f, err := frame.Unmarshal(decoded)
|
|
if err != nil {
|
|
t.Fatalf("limited message %d does not decode: %v", off, err)
|
|
}
|
|
want := fmt.Sprintf("msg-%02d", off+3)
|
|
if f.MsgID != want {
|
|
t.Fatalf("limited message %d: want MsgID %s, got %q", off, want, f.MsgID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRoomHistoryEmptyRoom verifies a persisted room with no messages returns an
|
|
// empty (non-null) array, lazily ensuring the stream on the way.
|
|
func TestRoomHistoryEmptyRoom(t *testing.T) {
|
|
h := newHistoryHarness(t)
|
|
roomID, _ := h.seedPersistRoom(t)
|
|
|
|
code, raw, hr := h.getHistory(t, h.bob, roomID, "", 13)
|
|
if code != 200 {
|
|
t.Fatalf("history: want 200, got %d (%s)", code, raw)
|
|
}
|
|
if hr.Messages == nil {
|
|
t.Fatalf("empty room must return [] not null (%s)", raw)
|
|
}
|
|
if len(hr.Messages) != 0 {
|
|
t.Fatalf("empty room: want 0 messages, got %d", len(hr.Messages))
|
|
}
|
|
// The lazy ensure should have created the stream even though no message exists.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
if _, err := h.js.Stream(ctx, roomStreamName(roomID)); err != nil {
|
|
t.Fatalf("lazy ensure should have created the stream: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestRoomHistoryUnauthenticated verifies an unsigned request is rejected with 401
|
|
// under enforce, before the handler runs.
|
|
func TestRoomHistoryUnauthenticated(t *testing.T) {
|
|
h := newHistoryHarness(t)
|
|
roomID, _ := h.seedPersistRoom(t)
|
|
// No signing headers: plain GET against the enforce-mode control plane.
|
|
req, err := http.NewRequest("GET", h.ts.URL+"/rooms/"+roomID+"/history", nil)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
code, body := do(t, req)
|
|
if code != 401 {
|
|
t.Fatalf("unauthenticated history: want 401, got %d (%s)", code, body)
|
|
}
|
|
}
|
|
|
|
// TestRoomHistoryNonMember verifies a registered user who is NOT a member of the
|
|
// room is rejected with 403.
|
|
func TestRoomHistoryNonMember(t *testing.T) {
|
|
h := newHistoryHarness(t)
|
|
roomID, _ := h.seedPersistRoom(t)
|
|
code, body, _ := h.getHistory(t, h.carol, roomID, "", 14)
|
|
if code != 403 {
|
|
t.Fatalf("non-member history: want 403, got %d (%s)", code, body)
|
|
}
|
|
}
|
|
|
|
// TestRoomHistoryRoomNotFound verifies a request for a non-existent room is a 404,
|
|
// distinct from the 403 a non-member of an existing room gets.
|
|
func TestRoomHistoryRoomNotFound(t *testing.T) {
|
|
h := newHistoryHarness(t)
|
|
code, body, _ := h.getHistory(t, h.alice, newULID(), "", 15)
|
|
if code != 404 {
|
|
t.Fatalf("missing room history: want 404, got %d (%s)", code, body)
|
|
}
|
|
}
|
|
|
|
// waitMsgs polls the room's stream until it holds at least want messages or a short
|
|
// deadline elapses, so a core-NATS publish (which carries no stream ack) is observed
|
|
// deterministically without a fixed sleep.
|
|
func (h *historyHarness) waitMsgs(t *testing.T, roomID string, want uint64) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
st, err := h.js.Stream(ctx, roomStreamName(roomID))
|
|
if err == nil {
|
|
si, ierr := st.Info(ctx)
|
|
if ierr == nil && si.State.Msgs >= want {
|
|
cancel()
|
|
return
|
|
}
|
|
}
|
|
cancel()
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
t.Fatalf("stream for room %s never reached %d message(s)", roomID, want)
|
|
}
|