diff --git a/internal/link/enrol_reply_probe_test.go b/internal/link/enrol_reply_probe_test.go new file mode 100644 index 0000000..7f17d09 --- /dev/null +++ b/internal/link/enrol_reply_probe_test.go @@ -0,0 +1,81 @@ +package link + +import ( + "os" + "testing" + "time" + + "github.com/nats-io/nats.go" +) + +// **Verified 2026-09-27**: the caller asked for a reply to `_INBOX.LCr3M83q…` and the consumer +// saw `$JS.ACK.PROBE.probe_consumer.1.1.1…`. The design was right, and enrolment's payload-borne +// reply subject is necessary rather than defensive. +// +// Design 25 §2 asserts that a reply address is **eaten** when a message crosses a stream: core +// request/reply puts the requester's inbox in the message's Reply field, but a JetStream consumer +// has already claimed that field for its own ack address by the time a handler sees it. The whole +// enrolment design rests on it — the reply subject travels in the payload instead — so it is +// checked rather than believed. +// +// docker run -d --rm --name t -p 14222:4222 nats:2.10-alpine -js +// MESH_TEST_NATS=nats://127.0.0.1:14222 go test ./internal/link/ -run TestAReplyAddress +func TestAReplyAddressDoesNotSurviveAStream(t *testing.T) { + url := os.Getenv("MESH_TEST_NATS") + if url == "" { + t.Skip("MESH_TEST_NATS unset") + } + conn, err := nats.Connect(url) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + js, err := conn.JetStream() + if err != nil { + t.Fatal(err) + } + if _, err := js.AddStream(&nats.StreamConfig{ + Name: "PROBE", Subjects: []string{"probe.>"}, Retention: nats.WorkQueuePolicy, + }); err != nil && err != nats.ErrStreamNameAlreadyInUse { + t.Fatal(err) + } + defer js.DeleteStream("PROBE") + + seen := make(chan *nats.Msg, 1) + sub, err := js.Subscribe("probe.enrol", func(m *nats.Msg) { seen <- m }, + nats.Durable("probe_consumer"), nats.ManualAck()) + if err != nil { + t.Fatal(err) + } + defer sub.Unsubscribe() + + // A caller doing what core request/reply does: publish with its own inbox as the reply. + inbox := nats.NewInbox() + if err := conn.PublishMsg(&nats.Msg{Subject: "probe.enrol", Reply: inbox, Data: []byte("{}")}); err != nil { + t.Fatal(err) + } + + select { + case m := <-seen: + t.Logf("the caller asked for a reply to %s", inbox) + t.Logf("the consumer sees a Reply field of %s", m.Reply) + if m.Reply == inbox { + t.Fatalf("the reply address SURVIVED the stream. Design 25 §2 says it does not, and "+ + "builds enrolment around carrying the reply subject in the payload to work "+ + "around it. If this holds generally, that work is unnecessary and the design "+ + "should say so.") + } + if m.Reply == "" { + t.Fatal("the Reply field is empty rather than claimed; the design says it carries " + + "the consumer's ack address, which is a different fact") + } + // Answering it would send the enrolling node's credentials to an ack subject. + if len(m.Reply) < 7 || m.Reply[:7] != "$JS.ACK" { + t.Errorf("the Reply field is %q, which is neither the caller's inbox nor an ack "+ + "address — the design's reasoning assumes one of the two", m.Reply) + } + m.Ack() + case <-time.After(5 * time.Second): + t.Fatal("nothing was delivered") + } +}