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