2f5b372a80
A secured bus freezes per-subject permissions at connect time, so a peer that creates or joins a room after connecting cannot pub/sub on it until it reconnects (RefreshSession). No client called it, so under enforce+ACL the demos failed closed — pushing the operator to disable the ACL (a security regression at the operator's discretion). Wire the membership-change contract into every client: - cmd/worker: RefreshSession after CreateRoom, before publishing. - cmd/chat (simple): RefreshSession after CreateRoom+Join, before Subscribe. - cmd/chat (encrypted demo): A refreshes after CreateRoom; B refreshes after the invite+join, both before pub/sub. - local_files/bridge (gateway): RefreshSession after CreateRoom+Join, before Subscribe. - mobile: new Session.RefreshSession wrapper + the contract documented for callers. Contract (documented on the wrappers): after ANY membership change, call RefreshSession BEFORE pub/sub on the new room (it drops active subs, so it must precede Subscribe). On an unsecured/dev bus it is a harmless reconnect. Test: - TestClientCreateRoomRefreshPublishFlow: end-to-end under enforce+ACL, a peer creates a room, refreshes, invites a second peer who joins+refreshes+subscribes, and the publish is received — no manual intervention, the ACL stays on. CGO_ENABLED=0 go build/vet/test green; govulncheck 0 reachable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
89 lines
3.0 KiB
Go
89 lines
3.0 KiB
Go
package membership_test
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/enmanuel/unibus/pkg/blobstore"
|
|
"github.com/enmanuel/unibus/pkg/client"
|
|
"github.com/enmanuel/unibus/pkg/frame"
|
|
"github.com/enmanuel/unibus/pkg/membership"
|
|
"github.com/enmanuel/unibus/pkg/room"
|
|
)
|
|
|
|
// TestClientCreateRoomRefreshPublishFlow is the issue 0006e DoD: under enforce+ACL
|
|
// a peer creates a room AFTER connecting, and pub/sub works without manual
|
|
// intervention because the client follows the membership-change contract
|
|
// (CreateRoom -> RefreshSession -> Subscribe/Publish), exactly as cmd/chat and
|
|
// cmd/worker now do. This is the end-to-end flow through the client API, proving
|
|
// the ACL is usable under enforce rather than something an operator must disable.
|
|
func TestClientCreateRoomRefreshPublishFlow(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := membership.Open(filepath.Join(dir, "unibus.db"))
|
|
if err != nil {
|
|
t.Fatalf("store: %v", err)
|
|
}
|
|
t.Cleanup(func() { store.Close() })
|
|
|
|
alice, bob := mustID(t), mustID(t)
|
|
mustAddUser(t, store, alice, "alice")
|
|
mustAddUser(t, store, bob, "bob")
|
|
|
|
srv := startACLNats(t, store) // data plane: enforce + per-subject ACL
|
|
blobs, _ := blobstore.New(filepath.Join(dir, "blobs"))
|
|
ctrl := newCtrl(t, store, blobs)
|
|
|
|
aliceC, err := client.NewWithOptions(srv.ClientURL(), ctrl, alice, client.Options{UseNkey: true})
|
|
if err != nil {
|
|
t.Fatalf("connect alice: %v", err)
|
|
}
|
|
defer aliceC.Close()
|
|
bobC, err := client.NewWithOptions(srv.ClientURL(), ctrl, bob, client.Options{UseNkey: true})
|
|
if err != nil {
|
|
t.Fatalf("connect bob: %v", err)
|
|
}
|
|
defer bobC.Close()
|
|
|
|
// alice creates a room AFTER connecting: the subject was not in her ACL at
|
|
// connect time, so she must refresh to publish on it (the worker contract).
|
|
roomID, err := aliceC.CreateRoom("room.flow.x", room.ModeNATS)
|
|
if err != nil {
|
|
t.Fatalf("alice create room: %v", err)
|
|
}
|
|
if err := aliceC.RefreshSession(); err != nil {
|
|
t.Fatalf("alice refresh: %v", err)
|
|
}
|
|
|
|
// alice invites bob; bob joins then refreshes to gain the subject (the chat
|
|
// subscriber contract), and only then subscribes.
|
|
if err := aliceC.Invite(roomID, bobC.Endpoint()); err != nil {
|
|
t.Fatalf("alice invite bob: %v", err)
|
|
}
|
|
if err := bobC.Join(roomID); err != nil {
|
|
t.Fatalf("bob join: %v", err)
|
|
}
|
|
if err := bobC.RefreshSession(); err != nil {
|
|
t.Fatalf("bob refresh: %v", err)
|
|
}
|
|
got := make(chan string, 4)
|
|
sub, err := bobC.Subscribe(roomID, func(_ frame.Frame, plaintext []byte) { got <- string(plaintext) })
|
|
if err != nil {
|
|
t.Fatalf("bob subscribe after refresh: %v", err)
|
|
}
|
|
defer sub.Unsubscribe()
|
|
time.Sleep(200 * time.Millisecond) // let the subscription settle
|
|
|
|
if err := aliceC.Publish(roomID, []byte("hello-under-acl")); err != nil {
|
|
t.Fatalf("alice publish after refresh: %v", err)
|
|
}
|
|
select {
|
|
case msg := <-got:
|
|
if msg != "hello-under-acl" {
|
|
t.Fatalf("bob got %q", msg)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatalf("bob did not receive the message: the create->refresh->subscribe flow is broken under enforce+ACL")
|
|
}
|
|
}
|