Deploy MQTT test backend with private viewer and Gitea CI/CD
Test and deploy MQTT backend / test (push) Successful in 2m8s
Test and deploy MQTT backend / deploy (push) Successful in 18s

This commit is contained in:
2026-10-08 14:48:53 +03:30
commit 71ef622b4f
70 changed files with 7123 additions and 0 deletions
+218
View File
@@ -0,0 +1,218 @@
// Package backend observes contracts and correlates only this process's own commands.
package backend
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"robot-market/test-mqtt-5/internal/contracts"
"robot-market/test-mqtt-5/internal/events"
"robot-market/test-mqtt-5/internal/mqtt"
"github.com/eclipse/paho.golang/paho"
)
type Result struct {
Ack *contracts.Ack
Err error
}
type pending struct {
command contracts.Command
correlation []byte
started, expires time.Time
result chan Result
terminal bool
accepted bool
}
type Backend struct {
Client *mqtt.Client
mu sync.Mutex
pending map[string]*pending
receipts chan *paho.Publish
PublishReceipt func(context.Context, *paho.Publish) error
}
func New(client *mqtt.Client) *Backend {
return &Backend{Client: client, pending: make(map[string]*pending), receipts: make(chan *paho.Publish, client.Config.Limits.InboundQueue), PublishReceipt: client.PublishRaw}
}
func ID() string {
var data [16]byte
if _, err := rand.Read(data[:]); err != nil {
panic("system random source unavailable")
}
return hex.EncodeToString(data[:])
}
func Timestamp(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) }
func NewCommand(device, name, id string, lifetime time.Duration) contracts.Command {
now := time.Now().UTC()
return contracts.Command{SchemaVersion: 1, DeviceID: device, RequestID: id, Command: name, IssuedAt: Timestamp(now), ExpiresAt: Timestamp(now.Add(lifetime))}
}
func (b *Backend) register(cmd contracts.Command) (*pending, error) {
if err := cmd.Validate(b.Client.Config.Topics.DeviceID); err != nil {
return nil, err
}
if err := cmd.CheckDeadline(time.Now()); err != nil {
return nil, err
}
expires, _ := time.Parse(time.RFC3339Nano, cmd.ExpiresAt)
b.mu.Lock()
defer b.mu.Unlock()
if len(b.pending) >= b.Client.Config.Limits.PendingCommands {
return nil, errors.New("pending command limit reached")
}
if _, ok := b.pending[cmd.RequestID]; ok {
return nil, errors.New("request ID already pending")
}
p := &pending{command: cmd, correlation: []byte(cmd.RequestID), started: time.Now(), expires: expires, result: make(chan Result, 1)}
b.pending[cmd.RequestID] = p
return p, nil
}
func (b *Backend) Command(ctx context.Context, cmd contracts.Command) (*contracts.Ack, error) {
if !b.Client.Ready() {
return nil, mqtt.ErrNotReady
}
p, err := b.register(cmd)
if err != nil {
return nil, err
}
defer func() { b.mu.Lock(); delete(b.pending, cmd.RequestID); b.mu.Unlock() }()
packet := mqtt.Packet(mqtt.Topic(b.Client.Config, "commands"), 1, false, cmd)
packet.Properties.ResponseTopic = mqtt.Topic(b.Client.Config, "acks")
packet.Properties.CorrelationData = append([]byte(nil), p.correlation...)
expiry := uint32(max(1, int(time.Until(p.expires).Seconds()+0.999)))
packet.Properties.MessageExpiry = &expiry
// Register before publishing: accepted/completed can arrive before Publish returns.
publishCtx, stopPublish := context.WithDeadline(ctx, p.expires)
err = b.Client.Publish(publishCtx, packet)
stopPublish()
if err != nil {
// An authenticated correlated terminal ACK is execution evidence even if
// transport confirmation was lost. Never replace it with a generic timeout.
select {
case result := <-p.result:
return result.Ack, result.Err
default:
}
if !time.Now().Before(p.expires) {
b.Client.Record(events.CommandTimeout, events.Internal, packet, cmd.RequestID, "deadline elapsed during publish; execution unknown")
}
return nil, err
}
timer := time.NewTimer(time.Until(p.expires))
defer timer.Stop()
select {
case result := <-p.result:
return result.Ack, result.Err
case <-ctx.Done():
return nil, errors.New("command interrupted; execution unknown")
case <-timer.C:
b.Client.Record(events.CommandTimeout, events.Internal, packet, cmd.RequestID, "deadline elapsed; execution unknown")
return nil, errors.New("command deadline elapsed; execution unknown")
}
}
func (b *Backend) Disconnect() {
b.mu.Lock()
defer b.mu.Unlock()
for id, p := range b.pending {
if !p.terminal {
p.terminal = true
p.result <- Result{Err: errors.New("connection lost; execution unknown")}
b.Client.Record(events.Kind("command_unknown"), events.Internal, nil, id, "connection lost; no automatic retry")
}
}
}
func (b *Backend) Handle(packet *paho.Publish) {
if b.Client.Config.Topics.ObserveAll {
b.Client.Record(events.MessageReceived, events.In, packet, "", "raw observation; contract validation disabled")
if packet.Topic == b.Client.Config.Topics.Namespace+"/scenarios/results" {
var result events.ScenarioResult
if json.Unmarshal(packet.Payload, &result) == nil && result.Feature != "" && result.Environment != "" {
b.Client.Events.Add(events.Event{Kind: "scenario_result", Direction: events.In, Topic: packet.Topic, Scenario: &result, Outcome: "reported test evidence; not independently verified hardware"})
}
}
b.enqueueReceipt(packet)
return
}
device, kind := mqtt.Identity(b.Client.Config, packet.Topic)
msg, err := contracts.Decode(kind, packet.Payload, device)
if err != nil {
b.Client.Record(events.ValidationFailed, events.In, packet, "", "invalid contract: "+err.Error())
return
}
request := ""
switch v := msg.(type) {
case *contracts.Ack:
request = v.RequestID
case *contracts.Command:
request = v.RequestID
}
b.Client.Record(events.MessageReceived, events.In, packet, request, "")
ack, ok := msg.(*contracts.Ack)
if !ok {
return
}
b.mu.Lock()
defer b.mu.Unlock()
p, ok := b.pending[ack.RequestID]
if !ok || p.terminal || ack.DeviceID != p.command.DeviceID {
b.Client.Record(events.LateAck, events.In, packet, ack.RequestID, "no active own request; latency not measured")
return
}
if !time.Now().Before(p.expires) {
b.Client.Record(events.LateAck, events.In, packet, ack.RequestID, "ack arrived after deadline")
return
}
if packet.Retain || packet.Properties == nil || !bytes.Equal(packet.Properties.CorrelationData, p.correlation) || packet.Properties.ResponseTopic != "" {
b.Client.Record(events.ValidationFailed, events.In, packet, ack.RequestID, "invalid native ACK correlation or retained ACK")
return
}
if ack.Stage == "completed" && ((p.command.Command == "ping" && ack.Result.Pong == nil) || (p.command.Command == "get_version" && ack.Result.Version == "")) {
b.Client.Record(events.ValidationFailed, events.In, packet, ack.RequestID, "result does not match command")
return
}
if ack.Stage == "accepted" {
if !p.accepted {
p.accepted = true
b.Client.Record(events.CommandAccepted, events.In, packet, ack.RequestID, "")
}
return
}
p.terminal = true
elapsed := time.Since(p.started).Milliseconds()
b.Client.Events.Add(events.Event{Kind: events.CommandCompleted, Direction: events.Internal, RequestID: ack.RequestID, DeviceID: ack.DeviceID, Outcome: ack.Stage, LatencyMS: &elapsed})
if !*ack.OK {
p.result <- Result{Ack: ack, Err: errors.New("device rejected or failed command")}
} else {
p.result <- Result{Ack: ack}
}
}
func (b *Backend) Serve(ctx context.Context) error {
defer b.Disconnect()
workerCtx, cancelWorker := context.WithCancel(ctx)
done := make(chan struct{})
go func() { defer close(done); b.receiptWorker(workerCtx) }()
defer func() { cancelWorker(); <-done }()
for {
select {
case <-ctx.Done():
return nil
case err := <-b.Client.Fatal():
return err
case <-b.Client.Down:
b.Disconnect()
case packet := <-b.Client.Incoming:
b.Handle(packet)
}
}
}
func (b *Backend) Summary() string {
_, counts := b.Client.Events.Snapshot()
return fmt.Sprintf("received=%d; completed=%d; timeouts=%d; late_acks=%d", counts[events.MessageReceived], counts[events.CommandCompleted], counts[events.CommandTimeout], counts[events.LateAck])
}
+163
View File
@@ -0,0 +1,163 @@
package backend
import (
"context"
"io"
"log/slog"
"testing"
"time"
"github.com/eclipse/paho.golang/paho"
"robot-market/test-mqtt-5/internal/config"
"robot-market/test-mqtt-5/internal/contracts"
"robot-market/test-mqtt-5/internal/events"
"robot-market/test-mqtt-5/internal/mqtt"
)
func testBackend() *Backend {
c := config.Defaults()
return New(mqtt.New(c, events.NewStore(32, slog.New(slog.NewTextHandler(io.Discard, nil)))))
}
func TestNativeCorrelationAndResult(t *testing.T) {
for _, bad := range []string{"topic", "device", "correlation", "retained", "response", "result", "expired"} {
t.Run(bad, func(t *testing.T) {
b := testBackend()
cmd := NewCommand("sim-001", "ping", ID(), 15*time.Second)
p, err := b.register(cmd)
if err != nil {
t.Fatal(err)
}
yes := true
ack := contracts.Ack{Envelope: cmd.Envelope, RequestID: cmd.RequestID, Timestamp: Timestamp(time.Now()), Stage: "completed", OK: &yes, Result: &contracts.Result{Pong: &yes}}
if bad == "device" {
ack.DeviceID = "other"
}
if bad == "result" {
ack.Result = &contracts.Result{Version: "1.0.0"}
}
packet := mqtt.Packet(mqtt.Topic(b.Client.Config, "acks"), 1, false, ack)
packet.Properties.CorrelationData = []byte(cmd.RequestID)
switch bad {
case "topic":
packet.Topic = "robot-market/test/v1/other/acks"
case "correlation":
packet.Properties.CorrelationData = []byte("wrong")
case "retained":
packet.Retain = true
case "response":
packet.Properties.ResponseTopic = "unexpected"
case "expired":
p.expires = time.Now().Add(-time.Second)
}
b.Handle(packet)
select {
case <-p.result:
t.Fatal("invalid ACK completed request")
default:
}
})
}
b := testBackend()
cmd := NewCommand("sim-001", "ping", ID(), 15*time.Second)
p, _ := b.register(cmd)
yes := true
ack := contracts.Ack{Envelope: cmd.Envelope, RequestID: cmd.RequestID, Timestamp: Timestamp(time.Now()), Stage: "completed", OK: &yes, Result: &contracts.Result{Pong: &yes}}
packet := mqtt.Packet(mqtt.Topic(b.Client.Config, "acks"), 1, false, ack)
packet.Properties.CorrelationData = []byte(cmd.RequestID)
// Completion may precede accepted. A subsequent accepted/duplicate cannot regress it.
b.Handle(packet)
ack.Stage = "accepted"
ack.Result = nil
accepted := mqtt.Packet(packet.Topic, 1, false, ack)
accepted.Properties.CorrelationData = []byte(cmd.RequestID)
b.Handle(accepted)
b.Handle(packet)
select {
case result := <-p.result:
if result.Err != nil || result.Ack.Stage != "completed" {
t.Fatal("wrong completion")
}
default:
t.Fatal("valid completion ignored")
}
select {
case <-p.result:
t.Fatal("completion delivered twice")
default:
}
}
func TestPendingLimitsAndDisconnect(t *testing.T) {
b := testBackend()
b.Client.Config.Limits.PendingCommands = 1
cmd := NewCommand("sim-001", "ping", ID(), 15*time.Second)
p, _ := b.register(cmd)
if _, err := b.register(NewCommand("sim-001", "ping", ID(), 15*time.Second)); err == nil {
t.Fatal("capacity ignored")
}
b.Disconnect()
b.Disconnect()
result := <-p.result
if result.Err == nil {
t.Fatal("disconnect claimed success")
}
if _, err := b.Command(context.Background(), cmd); err == nil {
t.Fatal("disconnected command admitted")
}
}
func TestObserveDeviceAndInvalidPayloadDiagnostics(t *testing.T) {
b := testBackend()
b.Client.Config.Topics.ObserveDevices = "sim-001,esp32-lab-001"
b.Client.Config.Logging.RawPayloads = true
payload := []byte(`{"schema_version":1,"device_id":"esp32-lab-001","message_id":"real-device","boot_id":"boot-1","sequence":1,"timestamp":"2026-10-07T00:00:00Z","readings":{"uptime_s":12}}`)
b.Handle(&paho.Publish{Topic: "robot-market/test/v1/esp32-lab-001/telemetry", Payload: payload})
b.Handle(&paho.Publish{Topic: "robot-market/test/v1/esp32-lab-001/telemetry", Payload: []byte("hello from esp32")})
history, counts := b.Client.Events.Snapshot()
if counts[events.MessageReceived] != 1 || counts[events.ValidationFailed] != 1 {
t.Fatal("device receipt and contract failure must be distinct")
}
if history[0].DeviceID != "esp32-lab-001" || history[0].Payload.Text != string(payload) {
t.Fatal("ESP32 identity/payload was lost")
}
if history[1].Payload.Text != "hello from esp32" || history[1].Outcome == "" {
t.Fatal("invalid external payload needs diagnostic visibility")
}
b.Client.Config.Logging.RawPayloads = false
b.Handle(&paho.Publish{Topic: "robot-market/test/v1/esp32-lab-001/telemetry", Payload: []byte("private invalid input")})
history, _ = b.Client.Events.Snapshot()
if !history[2].Payload.Omitted || history[2].Payload.Text != "" {
t.Fatal("raw payload display must require explicit opt-in")
}
}
func TestOtherObservedDeviceCannotCompletePendingCommand(t *testing.T) {
b := testBackend()
b.Client.Config.Topics.ObserveDevices = "esp32-lab-001"
cmd := NewCommand("sim-001", "ping", ID(), 15*time.Second)
pending, _ := b.register(cmd)
yes := true
ack := contracts.Ack{Envelope: contracts.Envelope{SchemaVersion: 1, DeviceID: "esp32-lab-001"}, RequestID: cmd.RequestID, Timestamp: Timestamp(time.Now()), Stage: "completed", OK: &yes, Result: &contracts.Result{Pong: &yes}}
packet := mqtt.Packet("robot-market/test/v1/esp32-lab-001/acks", 1, false, ack)
packet.Properties.CorrelationData = []byte(cmd.RequestID)
b.Handle(packet)
select {
case <-pending.result:
t.Fatal("another device completed an own command")
default:
}
}
func TestRawObservationAcceptsArbitraryPayloadsAndTopics(t *testing.T) {
b := testBackend()
b.Client.Config.Topics.ObserveAll = true
for _, payload := range []string{"hello", `{"device_id":"different","timestamp":"{{$isoTimestamp}}"}`, "", "[1,2,3]"} {
b.Handle(&paho.Publish{Topic: "robot-market/test/v1/arbitrary/custom-topic", Payload: []byte(payload)})
}
history, counts := b.Client.Events.Snapshot()
if counts[events.MessageReceived] != 4 || counts[events.ValidationFailed] != 0 {
t.Fatal("raw observation incorrectly validated a payload")
}
if history[1].Payload.Text != `{"device_id":"different","timestamp":"{{$isoTimestamp}}"}` || history[1].DeviceID != "arbitrary" {
t.Fatal("raw observation lost payload or topic metadata")
}
}
+107
View File
@@ -0,0 +1,107 @@
package backend
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
"time"
"github.com/eclipse/paho.golang/paho"
"robot-market/test-mqtt-5/internal/events"
"robot-market/test-mqtt-5/internal/mqtt"
)
// ReceiptAck confirms observation only; it does not claim device execution,
// schema validity, durable storage, or delivery of this reply to the device.
type ReceiptAck struct {
Type string `json:"type"`
OK bool `json:"ok"`
SourceTopic string `json:"source_topic"`
DeviceID string `json:"device_id,omitempty"`
MessageID string `json:"message_id,omitempty"`
RequestID string `json:"request_id,omitempty"`
ReceivedAt string `json:"received_at"`
PayloadBytes int `json:"payload_bytes"`
PayloadSHA256 string `json:"payload_sha256"`
}
func (b *Backend) receipt(packet *paho.Publish) *paho.Publish {
if packet == nil || packet.Retain || !mqtt.InNamespace(b.Client.Config, packet.Topic) {
return nil
}
parts := strings.Split(packet.Topic, "/")
category := parts[len(parts)-1]
// Receipt messages and command replies must never form an ACK feedback loop.
if category == "acks" || category == "receipts" || category == "commands" {
return nil
}
var fields struct {
Type string `json:"type"`
MessageID string `json:"message_id"`
RequestID string `json:"request_id"`
}
// Extraction is optional: malformed JSON and missing fields still get a reply.
_ = json.Unmarshal(packet.Payload, &fields)
if fields.Type == "receipt_ack" {
return nil
}
device, _ := mqtt.Identity(b.Client.Config, packet.Topic)
topic := b.Client.Config.Topics.Namespace + "/acks"
if device != "" {
topic = b.Client.Config.Topics.Namespace + "/" + device + "/acks"
}
if packet.Properties != nil && packet.Properties.ResponseTopic != "" {
response := packet.Properties.ResponseTopic
if mqtt.InNamespace(b.Client.Config, response) && !strings.ContainsAny(response, "+#\x00") && len(response) <= 256 && response != packet.Topic {
topic = response
}
}
digest := sha256.Sum256(packet.Payload)
ack := ReceiptAck{Type: "receipt_ack", OK: true, SourceTopic: packet.Topic, DeviceID: device, ReceivedAt: Timestamp(time.Now()), PayloadBytes: len(packet.Payload), PayloadSHA256: hex.EncodeToString(digest[:])}
if len(fields.MessageID) <= 256 {
ack.MessageID = fields.MessageID
}
if len(fields.RequestID) <= 256 {
ack.RequestID = fields.RequestID
}
reply := mqtt.Packet(topic, 1, false, ack)
if packet.Properties != nil {
reply.Properties.CorrelationData = append([]byte(nil), packet.Properties.CorrelationData...)
}
return reply
}
func (b *Backend) enqueueReceipt(packet *paho.Publish) {
if !b.Client.Config.Receipts.Enabled {
return
}
reply := b.receipt(packet)
if reply == nil {
return
}
select {
case b.receipts <- reply:
default:
b.Client.Record(events.Kind("receipt_ack_dropped"), events.Internal, reply, "", "receipt queue full; no reply sent")
}
}
func (b *Backend) receiptWorker(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case reply := <-b.receipts:
if ctx.Err() != nil {
return
}
if err := b.PublishReceipt(ctx, reply); err != nil {
b.Client.Record(events.Kind("receipt_ack_failed"), events.Internal, reply, "", "reply not confirmed; no automatic retry")
} else {
b.Client.Record(events.Kind("receipt_ack_sent"), events.Out, reply, "", "backend receipt reply acknowledged by broker; device receipt not proven")
}
}
}
}
+108
View File
@@ -0,0 +1,108 @@
package backend
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/eclipse/paho.golang/paho"
"robot-market/test-mqtt-5/internal/events"
)
func TestReceiptRawPayloadRoutingAndCorrelation(t *testing.T) {
b := testBackend()
b.Client.Config.Topics.ObserveAll = true
for _, payload := range []string{"hello", "", "[1,2]", `{"device_id":"different","timestamp":"{{$isoTimestamp}}","message_id":"msg-1"}`} {
reply := b.receipt(&paho.Publish{Topic: "robot-market/test/v1/real-device/telemetry", Payload: []byte(payload)})
if reply == nil || reply.Topic != "robot-market/test/v1/real-device/acks" || reply.Retain || reply.QoS != 1 {
t.Fatal("missing/routed/retained reply")
}
var ack ReceiptAck
if json.Unmarshal(reply.Payload, &ack) != nil || !ack.OK || ack.Type != "receipt_ack" || ack.PayloadBytes != len(payload) || len(ack.PayloadSHA256) != 64 {
t.Fatal("invalid receipt")
}
}
p := &paho.Publish{Topic: "robot-market/test/v1/device/telemetry", Payload: []byte("x"), Properties: &paho.PublishProperties{ResponseTopic: "robot-market/test/v1/replies/custom", CorrelationData: []byte{0, 255, 1}}}
reply := b.receipt(p)
p.Properties.CorrelationData[0] = 99
if reply.Topic != "robot-market/test/v1/replies/custom" || reply.Properties.CorrelationData[0] != 0 || reply.Properties.ResponseTopic != "" {
t.Fatal("native response/correlation lost or aliased")
}
p.Properties.ResponseTopic = "outside/test"
if b.receipt(p).Topic != "robot-market/test/v1/device/acks" {
t.Fatal("unusable response topic should use default receipt topic")
}
}
func TestReceiptLoopAndRetainedReplayPrevention(t *testing.T) {
b := testBackend()
b.Client.Config.Topics.ObserveAll = true
for _, topic := range []string{"acks", "commands", "receipts"} {
if b.receipt(&paho.Publish{Topic: "robot-market/test/v1/device/" + topic, Payload: []byte("hello")}) != nil {
t.Fatal("ACK loop possible")
}
}
for _, p := range []*paho.Publish{
{Topic: "robot-market/test/v1/device/status", Retain: true, Payload: []byte("online")},
{Topic: "robot-market/test/v1/custom/topic", Payload: []byte(`{"type":"receipt_ack","ok":true}`)},
{Topic: "outside/device/topic", Payload: []byte("hello")},
} {
if b.receipt(p) != nil {
t.Fatal("reply created for replay, receipt or external topic")
}
}
}
func TestReceiptDuplicateDeliveryRepliesWithoutCommandExecution(t *testing.T) {
b := testBackend()
b.Client.Config.Topics.ObserveAll = true
b.Client.Config.Receipts.Enabled = true
p := &paho.Publish{Topic: "robot-market/test/v1/device/telemetry", Payload: []byte("same message")}
b.Handle(p)
b.Handle(p)
if len(b.receipts) != 2 || len(b.pending) != 0 {
t.Fatal("duplicate receipt handling must not execute commands")
}
}
func TestReceiptQueueFailureAndShutdown(t *testing.T) {
b := testBackend()
b.Client.Config.Topics.ObserveAll = true
b.Client.Config.Receipts.Enabled = true
b.receipts = make(chan *paho.Publish, 1)
p := &paho.Publish{Topic: "robot-market/test/v1/device/telemetry", Payload: []byte("hello")}
b.Handle(p)
b.Handle(p)
_, counts := b.Client.Events.Snapshot()
if counts[events.Kind("receipt_ack_dropped")] != 1 {
t.Fatal("queue overflow invisible")
}
b.PublishReceipt = func(context.Context, *paho.Publish) error { return errors.New("test disconnect") }
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { b.receiptWorker(ctx); close(done) }()
deadline := time.Now().Add(time.Second)
for {
_, counts = b.Client.Events.Snapshot()
if counts[events.Kind("receipt_ack_failed")] == 1 {
break
}
if time.Now().After(deadline) {
t.Fatal("failure not recorded")
}
time.Sleep(time.Millisecond)
}
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("worker shutdown blocked")
}
history, _ := b.Client.Events.Snapshot()
if !strings.Contains(history[len(history)-1].Outcome, "not confirmed") {
t.Fatal("failure claims success")
}
}
+215
View File
@@ -0,0 +1,215 @@
// Package cli shares safe flag parsing and role-specific MQTT startup.
package cli
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"time"
"robot-market/test-mqtt-5/internal/backend"
"robot-market/test-mqtt-5/internal/config"
"robot-market/test-mqtt-5/internal/events"
"robot-market/test-mqtt-5/internal/mqtt"
"robot-market/test-mqtt-5/internal/simulator"
"robot-market/test-mqtt-5/internal/web"
)
func Run(ctx context.Context, args []string, out, errOut io.Writer, sim bool) int {
name := "test-mqtt5"
if sim {
name = "device-sim"
}
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(io.Discard)
path := fs.String("config", "", "TOML file path (optional)")
device := fs.String("device-id", "", "Override topics.device_id")
level := fs.String("log-level", "", "Override logging.level")
credentials := fs.String("credentials", "", "Private deployment client JSON file (username/password only)")
broker := fs.String("broker", "", "Override mqtt.url")
ca := fs.String("ca", "", "Override tls.ca_file")
clientID := fs.String("client-id", "", "Override mqtt.client_id; use a distinct ID for each process")
plaintext := fs.Bool("allow-plaintext", false, "Explicitly permit mqtt:// test connections")
requestID := fs.String("request-id", "", "Command request ID; generated when omitted")
fs.Usage = func() {
if sim {
fmt.Fprintln(out, "Usage: device-sim [FLAGS] [check-config]\nRuns the harmless MQTT 5 simulator by default.")
} else {
fmt.Fprintln(out, "Usage: test-mqtt5 [FLAGS] check-config|observe|command ping|get_version\nFlags precede the subcommand.")
}
fs.SetOutput(out)
fs.PrintDefaults()
fs.SetOutput(io.Discard)
}
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
fmt.Fprintln(errOut, "invalid CLI arguments; use --help (values omitted)")
return 2
}
mode := "simulate"
command := ""
if !sim {
if fs.NArg() < 1 {
fmt.Fprintln(errOut, "expected check-config, observe or command; use --help")
return 2
}
mode = fs.Arg(0)
if mode == "command" {
if fs.NArg() != 2 || (fs.Arg(1) != "ping" && fs.Arg(1) != "get_version") {
fmt.Fprintln(errOut, "command requires ping or get_version")
return 2
}
command = fs.Arg(1)
} else if fs.NArg() != 1 || (mode != "observe" && mode != "check-config") {
fmt.Fprintln(errOut, "expected check-config, observe or command; use --help")
return 2
}
} else if fs.NArg() != 0 {
if fs.NArg() != 1 || fs.Arg(0) != "check-config" {
fmt.Fprintln(errOut, "simulator accepts only check-config or no subcommand")
return 2
}
mode = "check-config"
}
if ctx.Err() != nil {
fmt.Fprintln(errOut, "configuration check interrupted")
return 1
}
overrides := map[string]string{}
defaults := config.Defaults()
// Role defaults remain below TOML, environment and explicit flags.
if sim {
defaults.MQTT.ClientID = "rm5-lab-simulator-01"
defaults.MQTT.Username = "sim-001"
}
if mode == "command" {
defaults.MQTT.ClientID = "rm5-lab-command-" + backend.ID()[:12]
}
if *credentials != "" {
data, err := os.ReadFile(*credentials)
if err != nil || len(data) > 1<<20 {
fmt.Fprintln(errOut, "cannot read private client credentials")
return 1
}
var record struct {
Username string `json:"username"`
Password string `json:"password"`
}
if json.Unmarshal(data, &record) != nil || record.Username == "" || record.Password == "" {
fmt.Fprintln(errOut, "invalid private client credentials")
return 1
}
overrides["mqtt.username"] = record.Username
overrides["mqtt.password"] = record.Password
overrides["mqtt.password_file"] = ""
}
fs.Visit(func(f *flag.Flag) {
switch f.Name {
case "device-id":
overrides["topics.device_id"] = *device
case "log-level":
overrides["logging.level"] = *level
case "broker":
overrides["mqtt.url"] = *broker
case "ca":
overrides["tls.ca_file"] = *ca
case "client-id":
overrides["mqtt.client_id"] = *clientID
case "allow-plaintext":
overrides["mqtt.allow_plaintext"] = fmt.Sprint(*plaintext)
}
})
cfg, err := config.LoadWithDefaults(*path, overrides, defaults)
if err != nil {
fmt.Fprintln(errOut, "configuration invalid:", err)
return 1
}
if cfg.Web.Enabled && mode != "observe" {
fmt.Fprintln(errOut, "web.enabled requires observe mode")
return 1
}
if (cfg.Topics.ObserveDevices != "" || cfg.Topics.ObserveAll) && mode != "observe" && mode != "check-config" {
fmt.Fprintln(errOut, "topic observation settings require observe mode")
return 1
}
if cfg.Receipts.Enabled && (mode != "observe" && mode != "check-config" || !cfg.Topics.ObserveAll) {
fmt.Fprintln(errOut, "receipt replies require raw observe mode")
return 1
}
var logLevel slog.Level
_ = logLevel.UnmarshalText([]byte(cfg.Logging.Level))
options := &slog.HandlerOptions{Level: logLevel}
var handler slog.Handler = slog.NewTextHandler(out, options)
if cfg.Logging.Format == "json" {
handler = slog.NewJSONHandler(out, options)
}
log := slog.New(handler)
if mode == "check-config" {
log.Log(ctx, logLevel, "configuration valid", "event", "config_valid", "network_checked", false, "credentials_checked", false)
return 0
}
if !cfg.MQTT.CleanStart {
fmt.Fprintln(errOut, "runtime requires clean_start=true on initial process connection; durable client recovery is not implemented")
return 1
}
store := events.NewStore(cfg.Limits.EventHistory, log)
client := mqtt.New(cfg, store)
if sim {
err = simulator.New(client).Run(ctx)
} else {
b := backend.New(client)
var viewer *web.Viewer
if cfg.Web.Enabled {
viewer = web.New(cfg.Web, store, cfg.Deadlines.Shutdown)
if err = viewer.Start(); err != nil {
fmt.Fprintln(errOut, err)
return 1
}
}
if err = client.Start(ctx, []string{"telemetry", "status", "commands", "acks"}, nil); err == nil {
if mode == "observe" {
err = b.Serve(ctx)
} else {
workerCtx, cancel := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- b.Serve(workerCtx) }()
id := *requestID
if id == "" {
id = backend.ID()
}
_, err = b.Command(ctx, backend.NewCommand(cfg.Topics.DeviceID, command, id, cfg.Deadlines.Command))
cancel()
workerErr := <-done
if err == nil {
err = workerErr
}
}
closeErr := client.Close()
if err == nil {
err = closeErr
}
}
if viewer != nil {
shutdown, stop := context.WithTimeout(context.Background(), cfg.Deadlines.Shutdown)
viewErr := viewer.Close(shutdown)
stop()
if err == nil {
err = viewErr
}
}
log.Info("backend counters", "event", "counters", "summary", b.Summary())
}
if err != nil {
fmt.Fprintln(errOut, "operation failed:", err)
return 1
}
log.Info("shutdown complete", "event", "shutdown_complete", "time", time.Now().UTC())
return 0
}
+34
View File
@@ -0,0 +1,34 @@
package cli
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestPrivateHandoffAndSafeErrors(t *testing.T) {
root := t.TempDir()
credentials := filepath.Join(root, "client.json")
if err := os.WriteFile(credentials, []byte(`{"username":"sim-001","password":"PRIVATE_SENTINEL","host":"unused"}`), 0600); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{{"--credentials", credentials, "check-config"}, {"--credentials", credentials, "--device-id", "bad/+", "check-config"}} {
var out, errOut bytes.Buffer
Run(context.Background(), args, &out, &errOut, false)
if strings.Contains(out.String()+errOut.String(), "PRIVATE_SENTINEL") {
t.Fatal("private credentials leaked")
}
}
var out, errOut bytes.Buffer
if code := Run(context.Background(), []string{"--broker", "mqtt://localhost:1884", "check-config"}, &out, &errOut, false); code != 1 {
t.Fatal("plaintext not rejected")
}
out.Reset()
errOut.Reset()
if code := Run(context.Background(), []string{"--broker", "mqtt://localhost:1884", "--allow-plaintext", "check-config"}, &out, &errOut, false); code != 0 {
t.Fatal("explicit plaintext not accepted")
}
}
+417
View File
@@ -0,0 +1,417 @@
// Package config loads and validates configuration without opening network connections.
package config
import (
"fmt"
"net"
"net/url"
"os"
"reflect"
"regexp"
"strconv"
"strings"
"time"
"github.com/knadh/koanf/parsers/toml/v2"
"github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
)
const Prefix = "TEST_MQTT5_"
type Config struct {
MQTT MQTT `koanf:"mqtt"`
TLS TLS `koanf:"tls"`
Topics Topics `koanf:"topics"`
Deadlines Deadlines `koanf:"deadlines"`
Logging Logging `koanf:"logging"`
Limits Limits `koanf:"limits"`
Simulator Simulator `koanf:"simulator"`
Web Web `koanf:"web"`
Receipts Receipts `koanf:"receipts"`
}
type MQTT struct {
AllowPlaintext bool `koanf:"allow_plaintext"`
URL string `koanf:"url"`
ClientID string `koanf:"client_id"`
Username string `koanf:"username"`
Password string `koanf:"password"`
PasswordFile string `koanf:"password_file"`
QoS int `koanf:"qos"`
CleanStart bool `koanf:"clean_start"`
SessionExpiry time.Duration `koanf:"session_expiry"`
KeepAlive time.Duration `koanf:"keep_alive"`
ReconnectMin time.Duration `koanf:"reconnect_min"`
ReconnectMax time.Duration `koanf:"reconnect_max"`
}
type TLS struct {
CAFile string `koanf:"ca_file"`
ClientCert string `koanf:"client_cert"`
ClientKey string `koanf:"client_key"`
}
type Topics struct {
Namespace string `koanf:"namespace"`
DeviceID string `koanf:"device_id"`
ObserveDevices string `koanf:"observe_devices"`
ObserveAll bool `koanf:"observe_all"`
}
type Deadlines struct {
Connect time.Duration `koanf:"connect"`
Operation time.Duration `koanf:"operation"`
Command time.Duration `koanf:"command"`
Shutdown time.Duration `koanf:"shutdown"`
}
type Logging struct {
Level string `koanf:"level"`
Format string `koanf:"format"`
RawPayloads bool `koanf:"raw_payloads"`
}
type Limits struct {
PayloadBytes int `koanf:"payload_bytes"`
PreviewBytes int `koanf:"preview_bytes"`
PendingCommands int `koanf:"pending_commands"`
InboundQueue int `koanf:"inbound_queue"`
EventHistory int `koanf:"event_history"`
DedupEntries int `koanf:"dedup_entries"`
DedupRetention time.Duration `koanf:"dedup_retention"`
}
type Simulator struct {
TelemetryInterval time.Duration `koanf:"telemetry_interval"`
AckDelay time.Duration `koanf:"ack_delay"`
}
type Receipts struct {
Enabled bool `koanf:"enabled"`
}
type Web struct {
Enabled bool `koanf:"enabled"`
Address string `koanf:"address"`
MaxViewers int `koanf:"max_viewers"`
ViewerQueue int `koanf:"viewer_queue"`
}
func Defaults() Config {
return Config{
MQTT: MQTT{URL: "tls://localhost:8883", ClientID: "rm5-lab-observer-01", Username: "test-backend", QoS: 1, CleanStart: true, SessionExpiry: 5 * time.Minute, KeepAlive: 30 * time.Second, ReconnectMin: time.Second, ReconnectMax: 30 * time.Second},
Topics: Topics{Namespace: "robot-market/test/v1", DeviceID: "sim-001"},
Deadlines: Deadlines{Connect: 10 * time.Second, Operation: 5 * time.Second, Command: 15 * time.Second, Shutdown: 5 * time.Second},
Logging: Logging{Level: "info", Format: "text"},
Limits: Limits{PayloadBytes: 16384, PreviewBytes: 4096, PendingCommands: 128, InboundQueue: 256, EventHistory: 1000, DedupEntries: 1024, DedupRetention: 10 * time.Minute},
Simulator: Simulator{TelemetryInterval: 5 * time.Second},
Web: Web{Address: "127.0.0.1:8080", MaxViewers: 8, ViewerQueue: 64},
}
}
// Load uses only explicitly supplied overrides. Relative paths are relative to cwd.
// Errors deliberately exclude raw parser errors and values, which may contain secrets.
func Load(path string, overrides map[string]string) (Config, error) {
return LoadWithDefaults(path, overrides, Defaults())
}
// LoadWithDefaults applies role defaults below TOML, environment and explicit flags.
func LoadWithDefaults(path string, overrides map[string]string, cfg Config) (Config, error) {
fields := map[string]reflect.Value{}
collect(reflect.ValueOf(&cfg).Elem(), "", fields)
k := koanf.New(".")
for key, v := range fields {
value := v.Interface()
if v.Type() == reflect.TypeFor[time.Duration]() {
value = time.Duration(v.Int()).String()
}
if err := k.Set(key, value); err != nil {
return Config{}, fmt.Errorf("defaults: unable to set configuration")
}
}
if path != "" {
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
return Config{}, fmt.Errorf("config: file must exist and be a readable regular file")
}
if info.Size() > 1<<20 {
return Config{}, fmt.Errorf("config: file exceeds 1 MiB")
}
data, err := file.Provider(path).ReadBytes()
if err != nil {
return Config{}, fmt.Errorf("config: unable to read file")
}
if len(data) > 1<<20 {
return Config{}, fmt.Errorf("config: file exceeds 1 MiB")
}
values, err := toml.Parser().Unmarshal(data)
if err != nil {
return Config{}, fmt.Errorf("config: invalid TOML; check syntax (values omitted)")
}
if err = checkKeys(values, "", fields); err != nil {
return Config{}, err
}
if err = setValues(k, values, ""); err != nil {
return Config{}, fmt.Errorf("config: unable to merge TOML")
}
}
// Read flat keys to reject ambiguous names before unflattening.
values, err := env.Provider(Prefix, "", func(key string) string {
return strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(key, Prefix)), "__", ".")
}).Read()
if err != nil {
return Config{}, fmt.Errorf("environment: unable to read settings")
}
for key, value := range values {
if _, ok := fields[key]; !ok {
return Config{}, unknownKey(key)
}
if err = k.Set(key, value); err != nil {
return Config{}, fmt.Errorf("environment: unable to merge settings")
}
}
for key, value := range overrides {
if _, ok := fields[key]; !ok {
return Config{}, unknownKey(key)
}
if err = k.Set(key, value); err != nil {
return Config{}, fmt.Errorf("flags: unable to merge settings")
}
}
for key, field := range fields {
if err := assign(field, k.Get(key)); err != nil {
return Config{}, fmt.Errorf("%s: %s", key, err)
}
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func setValues(k *koanf.Koanf, values map[string]any, prefix string) error {
for key, value := range values {
if nested, ok := value.(map[string]any); ok {
if err := setValues(k, nested, prefix+key+"."); err != nil {
return err
}
} else if err := k.Set(prefix+key, value); err != nil {
return err
}
}
return nil
}
func collect(v reflect.Value, prefix string, out map[string]reflect.Value) {
for i := 0; i < v.NumField(); i++ {
key := prefix + v.Type().Field(i).Tag.Get("koanf")
f := v.Field(i)
if f.Kind() == reflect.Struct {
collect(f, key+".", out)
} else {
out[key] = f
}
}
}
func unknownKey(key string) error {
if !regexp.MustCompile(`^[a-z_][a-z0-9_.]{0,80}$`).MatchString(key) {
return fmt.Errorf("configuration: invalid or unknown key (name omitted)")
}
return fmt.Errorf("%s: unknown configuration key", key)
}
func checkKeys(values map[string]any, prefix string, fields map[string]reflect.Value) error {
for key, value := range values {
full := prefix + key
if nested, ok := value.(map[string]any); ok {
known := false
for field := range fields {
if strings.HasPrefix(field, full+".") {
known = true
break
}
}
if !known {
return unknownKey(full)
}
if err := checkKeys(nested, full+".", fields); err != nil {
return err
}
} else if _, ok := fields[full]; !ok {
return unknownKey(full)
}
}
return nil
}
func assign(field reflect.Value, value any) error {
if field.Type() == reflect.TypeFor[time.Duration]() {
s, ok := value.(string)
if !ok {
return fmt.Errorf("expected duration string with unit, e.g. 5s")
}
if !strings.ContainsAny(s, "nhµμmsu") {
return fmt.Errorf("expected duration string with unit, e.g. 5s")
}
d, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("invalid or overflowing duration")
}
field.SetInt(int64(d))
return nil
}
switch field.Kind() {
case reflect.String:
s, ok := value.(string)
if !ok {
return fmt.Errorf("expected string")
}
field.SetString(s)
case reflect.Bool:
b, ok := value.(bool)
if !ok {
s, isString := value.(string)
if !isString || (s != "true" && s != "false") {
return fmt.Errorf("expected true or false")
}
b = s == "true"
}
field.SetBool(b)
case reflect.Int:
var n int64
switch v := value.(type) {
case int:
n = int64(v)
case int64:
n = v
case string:
parsed, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return fmt.Errorf("expected integer within range")
}
n = parsed
default:
return fmt.Errorf("expected integer")
}
if field.OverflowInt(n) {
return fmt.Errorf("integer out of range")
}
field.SetInt(n)
default:
return fmt.Errorf("unsupported configuration type")
}
return nil
}
var identifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`)
func (c Config) Validate() error {
bad := func(key, why string) error { return fmt.Errorf("%s: %s", key, why) }
u, err := url.Parse(c.MQTT.URL)
if err != nil || (u.Scheme != "tls" && !(u.Scheme == "mqtt" && c.MQTT.AllowPlaintext)) || u.Hostname() == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
return bad("mqtt.url", "use tls://host:port; mqtt:// requires mqtt.allow_plaintext=true; omit credentials, path, query and fragment")
}
port, err := strconv.Atoi(u.Port())
if err != nil || port < 1 || port > 65535 {
return bad("mqtt.url", "explicit port must be 1..65535")
}
if !identifier.MatchString(c.MQTT.ClientID) {
return bad("mqtt.client_id", "use 1..64 letters, digits, underscores or hyphens, starting with a letter/digit")
}
if !identifier.MatchString(c.Topics.DeviceID) {
return bad("topics.device_id", "use 1..64 safe identifier characters")
}
if c.Topics.ObserveDevices != "" {
devices := strings.Split(c.Topics.ObserveDevices, ",")
if len(devices) > 16 {
return bad("topics.observe_devices", "maximum 16 device IDs")
}
for _, device := range devices {
if !identifier.MatchString(strings.TrimSpace(device)) {
return bad("topics.observe_devices", "use comma-separated safe device IDs")
}
}
}
if c.Topics.Namespace != "robot-market/test/v1" && !strings.HasPrefix(c.Topics.Namespace, "robot-market/test/v1/") {
return bad("topics.namespace", "must remain under robot-market/test/v1")
}
if len(c.Topics.Namespace) > 200 {
return bad("topics.namespace", "too long")
}
for _, part := range strings.Split(c.Topics.Namespace, "/") {
if !identifier.MatchString(part) {
return bad("topics.namespace", "segments must be nonempty safe identifiers")
}
}
if c.MQTT.QoS < 0 || c.MQTT.QoS > 1 {
return bad("mqtt.qos", "only 0 or 1 supported in this test environment")
}
if c.MQTT.Username == "" || len(c.MQTT.Username) > 128 {
return bad("mqtt.username", "required, maximum 128 bytes")
}
if c.MQTT.Password != "" && c.MQTT.PasswordFile != "" {
return bad("mqtt.password_file", "cannot be combined with mqtt.password")
}
if len(c.MQTT.Password) > 4096 {
return bad("mqtt.password", "maximum 4096 bytes")
}
if (c.TLS.ClientCert == "") != (c.TLS.ClientKey == "") {
return bad("tls.client_cert", "client_cert and client_key must be supplied together")
}
for key, path := range map[string]string{"tls.ca_file": c.TLS.CAFile, "tls.client_cert": c.TLS.ClientCert, "tls.client_key": c.TLS.ClientKey, "mqtt.password_file": c.MQTT.PasswordFile} {
if path != "" {
f, err := os.Open(path)
if err != nil {
return bad(key, "file must exist and be readable")
}
info, err := f.Stat()
f.Close()
if err != nil || !info.Mode().IsRegular() || info.Size() == 0 || info.Size() > 1<<20 {
return bad(key, "expected nonempty regular file up to 1 MiB")
}
}
}
if c.MQTT.SessionExpiry < 0 || c.MQTT.SessionExpiry%time.Second != 0 || c.MQTT.SessionExpiry/time.Second > 1<<32-1 {
return bad("mqtt.session_expiry", "must be whole seconds in 0..4294967295")
}
if c.MQTT.KeepAlive < time.Second || c.MQTT.KeepAlive%time.Second != 0 || c.MQTT.KeepAlive/time.Second > 65535 {
return bad("mqtt.keep_alive", "must be whole seconds in 1..65535")
}
for key, d := range map[string]time.Duration{"mqtt.reconnect_min": c.MQTT.ReconnectMin, "mqtt.reconnect_max": c.MQTT.ReconnectMax, "deadlines.connect": c.Deadlines.Connect, "deadlines.operation": c.Deadlines.Operation, "deadlines.shutdown": c.Deadlines.Shutdown, "simulator.telemetry_interval": c.Simulator.TelemetryInterval, "limits.dedup_retention": c.Limits.DedupRetention} {
if d <= 0 || d > 24*time.Hour {
return bad(key, "must be positive and no greater than 24h")
}
}
if c.MQTT.ReconnectMin > c.MQTT.ReconnectMax || c.MQTT.ReconnectMax > 30*time.Second {
return bad("mqtt.reconnect_max", "must be >= reconnect_min and <= 30s")
}
if c.Deadlines.Command < time.Second || c.Deadlines.Command > time.Minute || c.Deadlines.Command%time.Second != 0 {
return bad("deadlines.command", "must be whole seconds in 1s..1m")
}
if c.Limits.DedupRetention <= c.Deadlines.Command {
return bad("limits.dedup_retention", "must exceed command lifetime")
}
if c.Simulator.AckDelay < 0 || c.Simulator.AckDelay > time.Hour {
return bad("simulator.ack_delay", "must be in 0s..1h")
}
if c.Logging.Level != "debug" && c.Logging.Level != "info" && c.Logging.Level != "warn" && c.Logging.Level != "error" {
return bad("logging.level", "must be debug, info, warn or error")
}
if c.Logging.Format != "text" && c.Logging.Format != "json" {
return bad("logging.format", "must be text or json")
}
for key, n := range map[string]int{"limits.pending_commands": c.Limits.PendingCommands, "limits.inbound_queue": c.Limits.InboundQueue, "limits.event_history": c.Limits.EventHistory, "limits.dedup_entries": c.Limits.DedupEntries, "web.viewer_queue": c.Web.ViewerQueue} {
if n < 1 || n > 10000 {
return bad(key, "must be in 1..10000")
}
}
if c.Limits.PayloadBytes < 256 || c.Limits.PayloadBytes > 16384 {
return bad("limits.payload_bytes", "must be in 256..16384")
}
if c.Limits.PreviewBytes < 64 || c.Limits.PreviewBytes > 4096 || c.Limits.PreviewBytes > c.Limits.PayloadBytes {
return bad("limits.preview_bytes", "must be 64..4096 and no larger than payload_bytes")
}
if c.Web.ViewerQueue < 1 || c.Web.ViewerQueue > 256 {
return bad("web.viewer_queue", "must be in 1..256")
}
if c.Web.MaxViewers < 1 || c.Web.MaxViewers > 8 {
return bad("web.max_viewers", "must be 1..8")
}
host, portText, err := net.SplitHostPort(c.Web.Address)
p, portErr := strconv.Atoi(portText)
if err != nil || host != "127.0.0.1" || portErr != nil || p < 1 || p > 65535 {
return bad("web.address", "use 127.0.0.1 with a port in 1..65535")
}
return nil
}
+181
View File
@@ -0,0 +1,181 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func cleanEnv(t *testing.T) {
t.Helper()
for _, v := range os.Environ() {
key, _, _ := strings.Cut(v, "=")
if strings.HasPrefix(key, Prefix) {
t.Setenv(key, "")
os.Unsetenv(key)
}
}
}
func configFile(t *testing.T, text string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(p, []byte(text), 0600); err != nil {
t.Fatal(err)
}
return p
}
func TestPrecedence(t *testing.T) {
cleanEnv(t)
path := configFile(t, "[topics]\ndevice_id='file-device'\n[logging]\nlevel='debug'\n[mqtt]\nclean_start=false\n")
c, err := Load(path, nil)
if err != nil {
t.Fatal(err)
}
if c.Topics.DeviceID != "file-device" || c.Logging.Level != "debug" || c.MQTT.CleanStart {
t.Fatalf("TOML/default precedence failed")
}
t.Setenv(Prefix+"TOPICS__DEVICE_ID", "env-device")
t.Setenv(Prefix+"MQTT__SESSION_EXPIRY", "10m")
t.Setenv(Prefix+"MQTT__QOS", "0")
t.Setenv(Prefix+"MQTT__CLEAN_START", "true")
c, err = Load(path, nil)
if err != nil {
t.Fatal(err)
}
if c.Topics.DeviceID != "env-device" || c.MQTT.SessionExpiry != 10*time.Minute || c.MQTT.QoS != 0 || !c.MQTT.CleanStart {
t.Fatal("environment conversion failed")
}
c, err = Load(path, map[string]string{"topics.device_id": "cli-device"})
if err != nil {
t.Fatal(err)
}
if c.Topics.DeviceID != "cli-device" || c.Logging.Level != "debug" {
t.Fatal("explicit overrides failed")
}
}
func TestValidationAndSafeErrors(t *testing.T) {
cleanEnv(t)
cases := []struct{ name, body, key string }{
{"unknown", "[mqtt]\npasswrod='SUPER_SECRET'", "mqtt.passwrod"},
{"empty table", "[unexpected]", "unexpected"},
{"numeric duration", "[mqtt]\nkeep_alive=30", "mqtt.keep_alive"},
{"unitless duration", "[mqtt]\nkeep_alive='0'", "mqtt.keep_alive"},
{"overflow duration", "[mqtt]\nsession_expiry='999999999999999999999h'", "mqtt.session_expiry"},
{"fraction session", "[mqtt]\nsession_expiry='1.5s'", "mqtt.session_expiry"},
{"session max", "[mqtt]\nsession_expiry='4294967296s'", "mqtt.session_expiry"},
{"keepalive max", "[mqtt]\nkeep_alive='65536s'", "mqtt.keep_alive"},
{"negative", "[deadlines]\nconnect='-1s'", "deadlines.connect"},
{"qos", "[mqtt]\nqos=2", "mqtt.qos"},
{"fraction integer", "[mqtt]\nqos=0.5", "mqtt.qos"},
{"type mismatch", "[mqtt]\npassword=123", "mqtt.password"},
{"plain MQTT", "[mqtt]\nurl='tcp://localhost:1883'", "mqtt.url"},
{"userinfo", "[mqtt]\nurl='tls://user:SUPER_SECRET@localhost:8883'", "mqtt.url"},
{"port", "[mqtt]\nurl='tls://localhost:0'", "mqtt.url"},
{"wildcard", "[topics]\ndevice_id='sim/+'", "topics.device_id"},
{"production topic", "[topics]\nnamespace='device'", "topics.namespace"},
{"cert pair", "[tls]\nclient_key='SUPER_SECRET'", "tls.client_cert"},
{"secret source conflict", "[mqtt]\npassword='SUPER_SECRET'\npassword_file='secrets/password'", "mqtt.password_file"},
{"missing CA", "[tls]\nca_file='/missing/SUPER_SECRET'", "tls.ca_file"},
{"queue", "[limits]\ninbound_queue=0", "limits.inbound_queue"},
{"preview", "[limits]\npreview_bytes=20000", "limits.preview_bytes"},
{"retention", "[limits]\ndedup_retention='10s'", "limits.dedup_retention"},
{"command cap", "[deadlines]\ncommand='61s'", "deadlines.command"},
{"backoff", "[mqtt]\nreconnect_min='31s'", "mqtt.reconnect_max"},
{"public web", "[web]\naddress='0.0.0.0:8080'", "web.address"},
{"log level", "[logging]\nlevel='SUPER_SECRET'", "logging.level"},
{"syntax", "[mqtt]\npassword='SUPER_SECRET", "invalid TOML"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := Load(configFile(t, tc.body), nil)
if err == nil || !strings.Contains(err.Error(), tc.key) {
t.Fatalf("expected %s error, got %v", tc.key, err)
}
if strings.Contains(err.Error(), "SUPER_SECRET") {
t.Fatal("secret leaked")
}
})
}
}
func TestEmptyEnvAndUnknown(t *testing.T) {
cleanEnv(t)
path := configFile(t, "[mqtt]\npassword='file-secret'\n")
t.Setenv(Prefix+"MQTT__PASSWORD", "")
c, err := Load(path, nil)
if err != nil {
t.Fatal(err)
}
if c.MQTT.Password != "" {
t.Fatal("empty env must clear password")
}
t.Setenv(Prefix+"MQTT__CLEAN_START", "1")
if _, err = Load(path, nil); err == nil {
t.Fatal("numeric boolean accepted")
}
os.Unsetenv(Prefix + "MQTT__CLEAN_START")
t.Setenv(Prefix+"UNKNOWN", "secret")
if _, err = Load(path, nil); err == nil {
t.Fatal("unknown env accepted")
}
}
func TestFilesAndBounds(t *testing.T) {
cleanEnv(t)
if _, err := Load("/missing/config", nil); err == nil {
t.Fatal("missing config accepted")
}
c := Defaults()
c.MQTT.SessionExpiry = 4294967295 * time.Second
c.MQTT.KeepAlive = 65535 * time.Second
if err := c.Validate(); err != nil {
t.Fatal(err)
}
secret := configFile(t, "test-password")
c.MQTT.PasswordFile = secret
if err := c.Validate(); err != nil {
t.Fatal(err)
}
c.MQTT.PasswordFile = t.TempDir()
if err := c.Validate(); err == nil {
t.Fatal("directory accepted as secret")
}
}
func TestPlaintextRequiresOptIn(t *testing.T) {
cfg := Defaults()
cfg.MQTT.URL = "mqtt://localhost:1884"
if cfg.Validate() == nil {
t.Fatal("plaintext accepted without opt-in")
}
cfg.MQTT.AllowPlaintext = true
if err := cfg.Validate(); err != nil {
t.Fatal(err)
}
cfg.MQTT.URL = "mqtt://user:secret@localhost:1884"
if cfg.Validate() == nil {
t.Fatal("embedded credentials accepted")
}
cfg.MQTT.URL = "tls://localhost:8884"
cfg.MQTT.AllowPlaintext = false
if err := cfg.Validate(); err != nil {
t.Fatal(err)
}
}
func TestRoleDefaultsRemainBelowConfiguration(t *testing.T) {
defaults := Defaults()
defaults.MQTT.ClientID = "role-default"
file := filepath.Join(t.TempDir(), "role.toml")
if err := os.WriteFile(file, []byte("[mqtt]\nclient_id='file-client'\n"), 0600); err != nil {
t.Fatal(err)
}
cfg, err := LoadWithDefaults(file, nil, defaults)
if err != nil || cfg.MQTT.ClientID != "file-client" {
t.Fatal("role defaults overrode TOML")
}
cfg, err = LoadWithDefaults(file, map[string]string{"mqtt.client_id": "flag-client"}, defaults)
if err != nil || cfg.MQTT.ClientID != "flag-client" {
t.Fatal("explicit flags lost precedence")
}
}
+326
View File
@@ -0,0 +1,326 @@
// Package contracts defines the isolated test payloads, not the existing device protocol.
package contracts
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math"
"regexp"
"strings"
"time"
"unicode/utf8"
)
const MaxPayloadBytes = 16384
const MaxCommandLifetime = time.Minute
var safeID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`)
type Envelope struct {
SchemaVersion int `json:"schema_version"`
DeviceID string `json:"device_id"`
}
type Telemetry struct {
Envelope
MessageID string `json:"message_id"`
BootID string `json:"boot_id"`
Sequence *uint64 `json:"sequence"`
Timestamp string `json:"timestamp"`
Readings map[string]float64 `json:"readings"`
}
type Status struct {
Envelope
BootID string `json:"boot_id"`
State string `json:"state"`
Timestamp string `json:"timestamp,omitempty"`
WillConfiguredAt string `json:"will_configured_at,omitempty"`
Implementation string `json:"implementation,omitempty"`
ReportedProtocol string `json:"reported_protocol,omitempty"`
}
type Command struct {
Envelope
RequestID string `json:"request_id"`
IssuedAt string `json:"issued_at"`
ExpiresAt string `json:"expires_at"`
Command string `json:"command"`
}
type Result struct {
Pong *bool `json:"pong,omitempty"`
Version string `json:"version,omitempty"`
}
type AckError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type Ack struct {
Envelope
RequestID string `json:"request_id"`
Timestamp string `json:"timestamp"`
Stage string `json:"stage"`
OK *bool `json:"ok"`
Result *Result `json:"result,omitempty"`
Error *AckError `json:"error,omitempty"`
}
func (e Envelope) validate(device string) error {
if e.SchemaVersion != 1 {
return fmt.Errorf("schema_version: expected 1")
}
if !safeID.MatchString(e.DeviceID) || e.DeviceID != device {
return fmt.Errorf("device_id: invalid or mismatched topic identity")
}
return nil
}
func id(name, value string) error {
if !safeID.MatchString(value) {
return fmt.Errorf("%s: expected 1..64 safe identifier characters", name)
}
return nil
}
func timestamp(name, value string) (time.Time, error) {
t, err := time.Parse(time.RFC3339Nano, value)
if err != nil || !strings.HasSuffix(value, "Z") {
return time.Time{}, fmt.Errorf("%s: expected RFC3339 UTC timestamp ending Z", name)
}
return t, nil
}
func (m Telemetry) Validate(device string) error {
if err := m.Envelope.validate(device); err != nil {
return err
}
if err := id("message_id", m.MessageID); err != nil {
return err
}
if err := id("boot_id", m.BootID); err != nil {
return err
}
if _, err := timestamp("timestamp", m.Timestamp); err != nil {
return err
}
if m.Sequence == nil {
return fmt.Errorf("sequence: required")
}
if len(m.Readings) == 0 || len(m.Readings) > 16 {
return fmt.Errorf("readings: expected 1..16 numeric readings")
}
for key, value := range m.Readings {
if !safeID.MatchString(key) || math.IsNaN(value) || math.IsInf(value, 0) {
return fmt.Errorf("readings: invalid name or nonfinite value")
}
}
return nil
}
func (m Status) Validate(device string) error {
if err := m.Envelope.validate(device); err != nil {
return err
}
if err := id("boot_id", m.BootID); err != nil {
return err
}
if m.State != "online" && m.State != "offline" {
return fmt.Errorf("state: expected online or offline")
}
if m.Timestamp != "" && m.WillConfiguredAt != "" {
return fmt.Errorf("timestamp: cannot be combined with will_configured_at")
}
if m.WillConfiguredAt != "" {
if m.State != "offline" {
return fmt.Errorf("will_configured_at: only valid for offline Will")
}
if _, err := timestamp("will_configured_at", m.WillConfiguredAt); err != nil {
return err
}
} else {
if _, err := timestamp("timestamp", m.Timestamp); err != nil {
return err
}
}
if m.Implementation != "" {
if err := id("implementation", m.Implementation); err != nil {
return err
}
}
if m.ReportedProtocol != "" && m.ReportedProtocol != "5" && m.ReportedProtocol != "3.1.1" {
return fmt.Errorf("reported_protocol: expected 5 or 3.1.1")
}
return nil
}
func (m Command) Validate(device string) error {
if err := m.Envelope.validate(device); err != nil {
return err
}
if err := id("request_id", m.RequestID); err != nil {
return err
}
if m.Command != "ping" && m.Command != "get_version" {
return fmt.Errorf("command: only ping or get_version allowed")
}
issued, err := timestamp("issued_at", m.IssuedAt)
if err != nil {
return err
}
expires, err := timestamp("expires_at", m.ExpiresAt)
if err != nil {
return err
}
lifetime := expires.Sub(issued)
if lifetime < time.Second || lifetime > MaxCommandLifetime {
return fmt.Errorf("expires_at: lifetime must be 1s..1m after issued_at")
}
return nil
}
// CheckDeadline is deliberately separate from structural validation, for delayed/replayed messages.
func (m Command) CheckDeadline(now time.Time) error {
if err := m.Validate(m.DeviceID); err != nil {
return err
}
issued, _ := timestamp("issued_at", m.IssuedAt)
expires, _ := timestamp("expires_at", m.ExpiresAt)
if now.Before(issued) {
return fmt.Errorf("issued_at: command is from the future; check device clock")
}
if !now.Before(expires) {
return fmt.Errorf("expires_at: command expired")
}
return nil
}
func (m Ack) Validate(device string) error {
if err := m.Envelope.validate(device); err != nil {
return err
}
if err := id("request_id", m.RequestID); err != nil {
return err
}
if _, err := timestamp("timestamp", m.Timestamp); err != nil {
return err
}
if m.OK == nil {
return fmt.Errorf("ok: boolean is required")
}
switch m.Stage {
case "accepted":
if !*m.OK || m.Result != nil || m.Error != nil {
return fmt.Errorf("accepted: requires ok=true without result/error")
}
case "completed":
if !*m.OK || m.Error != nil || m.Result == nil {
return fmt.Errorf("completed: requires ok=true and result without error")
}
if (m.Result.Pong != nil) == (m.Result.Version != "") {
return fmt.Errorf("result: expected exactly one of pong or version")
}
if m.Result.Pong != nil && !*m.Result.Pong {
return fmt.Errorf("result.pong: expected true")
}
if m.Result.Version != "" && !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$`).MatchString(m.Result.Version) {
return fmt.Errorf("result.version: invalid or too long")
}
case "rejected", "failed":
if *m.OK || m.Error == nil || m.Result != nil {
return fmt.Errorf("rejected/failed: requires ok=false and error without result")
}
if err := id("error.code", m.Error.Code); err != nil {
return err
}
if m.Error.Message == "" || len(m.Error.Message) > 256 {
return fmt.Errorf("error.message: expected 1..256 bytes")
}
default:
return fmt.Errorf("stage: expected accepted, completed, rejected or failed")
}
return nil
}
// Decode rejects duplicate keys, unknown fields, invalid UTF-8 and trailing values.
// Errors contain no payload contents. The returned concrete type implements Validate.
func Decode(kind string, data []byte, device string) (any, error) {
if len(data) > MaxPayloadBytes || len(data) == 0 {
return nil, fmt.Errorf("payload: expected 1..16384 bytes")
}
if !utf8.Valid(data) {
return nil, fmt.Errorf("payload: invalid UTF-8")
}
if err := uniqueObject(data); err != nil {
return nil, err
}
var target interface{ Validate(string) error }
switch kind {
case "telemetry":
target = &Telemetry{}
case "status":
target = &Status{}
case "commands":
target = &Command{}
case "acks":
target = &Ack{}
default:
return nil, fmt.Errorf("topic: unsupported message category")
}
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(target); err != nil {
return nil, fmt.Errorf("payload: invalid JSON field or type")
}
if err := target.Validate(device); err != nil {
return nil, err
}
return target, nil
}
func uniqueObject(data []byte) error {
d := json.NewDecoder(bytes.NewReader(data))
d.UseNumber()
first, err := d.Token()
if err != nil || first != json.Delim('{') {
return fmt.Errorf("payload: expected JSON object")
}
if err = walkObject(d, 0); err != nil {
return fmt.Errorf("payload: malformed JSON, duplicate key or excessive nesting")
}
if _, err = d.Token(); err != io.EOF {
return fmt.Errorf("payload: trailing JSON data")
}
return nil
}
func walkObject(d *json.Decoder, depth int) error {
if depth > 8 {
return fmt.Errorf("depth")
}
seen := map[string]bool{}
for d.More() {
token, err := d.Token()
if err != nil {
return err
}
key, ok := token.(string)
// encoding/json matches struct fields case-insensitively. Reject aliases
// of an existing key as well as byte-identical duplicates.
canonical := strings.ToLower(key)
if !ok || seen[canonical] {
return fmt.Errorf("duplicate")
}
seen[canonical] = true
t, err := d.Token()
if err != nil {
return err
}
if t == nil {
return fmt.Errorf("null values unsupported")
}
if delim, ok := t.(json.Delim); ok {
if delim != '{' {
return fmt.Errorf("arrays unsupported")
}
if err = walkObject(d, depth+1); err != nil {
return err
}
}
}
end, err := d.Token()
if err != nil || end != json.Delim('}') {
return fmt.Errorf("object")
}
return nil
}
+94
View File
@@ -0,0 +1,94 @@
package contracts
import (
"strings"
"testing"
"time"
)
const commandJSON = `{"schema_version":1,"device_id":"sim-001","request_id":"req-001","issued_at":"2026-10-06T10:00:00Z","expires_at":"2026-10-06T10:00:15Z","command":"ping"}`
func TestDecodeAndDeadline(t *testing.T) {
got, err := Decode("commands", []byte(commandJSON), "sim-001")
if err != nil {
t.Fatal(err)
}
cmd := got.(*Command)
issued, _ := time.Parse(time.RFC3339, cmd.IssuedAt)
for _, tc := range []struct {
delta time.Duration
valid bool
}{{-time.Second, false}, {0, true}, {14 * time.Second, true}, {15 * time.Second, false}} {
err = cmd.CheckDeadline(issued.Add(tc.delta))
if (err == nil) != tc.valid {
t.Fatalf("deadline %s: %v", tc.delta, err)
}
}
}
func TestValidContracts(t *testing.T) {
cases := map[string]string{
"telemetry": `{"schema_version":1,"device_id":"sim-001","message_id":"evt-1","boot_id":"boot-1","sequence":0,"timestamp":"2026-10-06T10:00:00Z","readings":{"uptime_s":12}}`,
"status": `{"schema_version":1,"device_id":"sim-001","boot_id":"boot-1","state":"offline","will_configured_at":"2026-10-06T10:00:00Z"}`,
"acks": `{"schema_version":1,"device_id":"sim-001","request_id":"req-1","timestamp":"2026-10-06T10:00:00Z","stage":"completed","ok":true,"result":{"pong":true}}`,
}
for kind, payload := range cases {
t.Run(kind, func(t *testing.T) {
if _, err := Decode(kind, []byte(payload), "sim-001"); err != nil {
t.Fatal(err)
}
})
}
}
func TestRejectUnsafePayloads(t *testing.T) {
for _, payload := range []string{
`null`, `[]`, commandJSON + `{}`, strings.Repeat("x", MaxPayloadBytes+1),
strings.Replace(commandJSON, `"ping"`, `"vend"`, 1),
strings.Replace(commandJSON, `"request_id":"req-001",`, "", 1),
strings.Replace(commandJSON, `"schema_version":1`, `"schema_version":2`, 1),
strings.Replace(commandJSON, `"command":"ping"`, `"command":"ping","password":"SECRET"`, 1),
strings.Replace(commandJSON, `"command":"ping"`, `"command":"ping","command":"get_version"`, 1),
strings.Replace(commandJSON, `"command":"ping"`, `"command":"ping","COMMAND":"get_version"`, 1),
strings.Replace(commandJSON, `10:00:15Z`, `10:00:00Z`, 1),
strings.Replace(commandJSON, `10:00:15Z`, `10:02:00Z`, 1),
strings.Replace(commandJSON, `10:00:15Z`, `10:00:15+00:00`, 1),
string([]byte{255, 0, 1}),
} {
if _, err := Decode("commands", []byte(payload), "sim-001"); err == nil {
t.Fatal("invalid payload accepted")
} else if strings.Contains(err.Error(), "SECRET") {
t.Fatal("raw payload leaked")
}
}
if _, err := Decode("commands", []byte(commandJSON), "other-device"); err == nil {
t.Fatal("wrong device accepted")
}
}
func TestNullReadingRejected(t *testing.T) {
payload := `{"schema_version":1,"device_id":"sim-001","message_id":"evt-1","boot_id":"boot-1","sequence":0,"timestamp":"2026-10-06T10:00:00Z","readings":{"uptime_s":null}}`
if _, err := Decode("telemetry", []byte(payload), "sim-001"); err == nil {
t.Fatal("null must not become a numeric zero")
}
}
func TestAckStages(t *testing.T) {
base := `{"schema_version":1,"device_id":"sim-001","request_id":"req-1","timestamp":"2026-10-06T10:00:00Z",`
for _, tc := range []struct {
body string
valid bool
}{
{`"stage":"accepted","ok":true}`, true},
{`"stage":"accepted"}`, false},
{`"stage":"accepted","ok":false}`, false},
{`"stage":"sent","ok":true}`, false},
{`"stage":"completed","ok":true}`, false},
{`"stage":"completed","ok":true,"result":{"version":"v1.0.0"}}`, true},
{`"stage":"completed","ok":true,"result":{"pong":true,"version":"v1"}}`, false},
{`"stage":"failed","ok":false,"error":{"code":"expired","message":"too late"}}`, true},
{`"stage":"failed","ok":true,"error":{"code":"expired","message":"too late"}}`, false},
} {
_, err := Decode("acks", []byte(base+tc.body), "sim-001")
if (err == nil) != tc.valid {
t.Fatalf("stage case %s: %v", tc.body, err)
}
}
}
+138
View File
@@ -0,0 +1,138 @@
// Package events defines safe inspection records shared by logs and the future viewer.
package events
import (
"encoding/base64"
"encoding/json"
"time"
"unicode/utf8"
"robot-market/test-mqtt-5/internal/contracts"
)
type Direction string
const (
In Direction = "in"
Out Direction = "out"
Internal Direction = "internal"
)
type Kind string
const (
PublishAttempt Kind = "publish_attempt"
TransportAck Kind = "transport_ack"
MessageReceived Kind = "message_received"
ValidationFailed Kind = "validation_failed"
CommandAccepted Kind = "command_accepted"
CommandCompleted Kind = "command_completed"
CommandTimeout Kind = "command_timeout"
LateAck Kind = "late_ack"
DuplicateRequest Kind = "duplicate_request"
ConnectionUp Kind = "connection_up"
ConnectionDown Kind = "connection_down"
EventGap Kind = "event_gap"
)
type Properties struct {
ResponseTopic string `json:"response_topic,omitempty"`
CorrelationData []byte `json:"correlation_data,omitempty"`
MessageExpirySeconds *uint32 `json:"message_expiry_seconds,omitempty"`
}
// Event contains inspection data only. Configuration and connection credentials
// are never attached. Raw MQTT payloads require the explicit diagnostics opt-in.
type Event struct {
Scenario *ScenarioResult `json:"scenario,omitempty"`
ID uint64 `json:"id"`
ObservedAt time.Time `json:"observed_at"`
Kind Kind `json:"kind"`
Direction Direction `json:"direction"`
ClientID string `json:"client_id,omitempty"`
DeviceID string `json:"device_id,omitempty"`
Topic string `json:"topic,omitempty"`
QoS byte `json:"qos"`
Retain bool `json:"retain"`
Duplicate *bool `json:"duplicate,omitempty"`
Payload Preview `json:"payload"`
Properties Properties `json:"properties"`
RequestID string `json:"request_id,omitempty"`
Outcome string `json:"outcome,omitempty"`
LatencyMS *int64 `json:"latency_ms,omitempty"`
}
// ScenarioResult is reported test evidence, never inferred hardware compatibility.
type ScenarioResult struct {
Feature string `json:"feature"`
Environment string `json:"environment"`
Endpoint string `json:"endpoint"`
Version string `json:"version"`
Status string `json:"status"`
Observation string `json:"observation"`
Time time.Time `json:"time"`
}
type Preview struct {
Bytes int `json:"bytes"`
Text string `json:"text,omitempty"`
Omitted bool `json:"omitted"`
Truncated bool `json:"truncated"`
}
// SafePreview only renders validated contract fields. Unknown fields (including
// credentials) cause the entire payload to be omitted. Free-form error messages
// are always redacted. The original payload is never retained in this value.
func SafePreview(kind, device string, payload []byte, limit int) Preview {
p := Preview{Bytes: len(payload), Omitted: true}
if limit < 1 {
return p
}
if limit > 4096 {
limit = 4096
}
msg, err := contracts.Decode(kind, payload, device)
if err != nil {
return p
}
if ack, ok := msg.(*contracts.Ack); ok && ack.Error != nil {
ack.Error.Message = "[redacted]"
}
encoded, err := json.Marshal(msg)
if err != nil {
return p
}
if len(encoded) > limit {
encoded = encoded[:limit]
for len(encoded) > 0 && !utf8.Valid(encoded) {
encoded = encoded[:len(encoded)-1]
}
p.Truncated = true
}
p.Text = string(encoded)
p.Omitted = false
return p
}
// RawPreview is opt-in diagnostics for this isolated test harness. It displays
// received bytes without treating them as a valid contract or execution evidence.
func RawPreview(payload []byte, limit int) Preview {
p := Preview{Bytes: len(payload)}
if limit < 1 {
p.Omitted = true
return p
}
limit = min(limit, 4096)
text := string(payload)
if !utf8.Valid(payload) {
text = "[base64] " + base64.StdEncoding.EncodeToString(payload)
}
if len(text) > limit {
text = text[:limit]
for !utf8.ValidString(text) {
text = text[:len(text)-1]
}
p.Truncated = true
}
p.Text = text
return p
}
+55
View File
@@ -0,0 +1,55 @@
package events
import (
"encoding/json"
"strings"
"testing"
"unicode/utf8"
)
const ping = `{"schema_version":1,"device_id":"sim-001","request_id":"req-001","issued_at":"2026-10-06T10:00:00Z","expires_at":"2026-10-06T10:00:15Z","command":"ping"}`
func TestSafePreviews(t *testing.T) {
p := SafePreview("commands", "sim-001", []byte(ping), 4096)
if p.Omitted || !strings.Contains(p.Text, `"command":"ping"`) || p.Bytes != len(ping) {
t.Fatal("lost useful preview")
}
for _, raw := range []string{`{"password":"SECRET"}`, `{"x":"SECRET`, string([]byte{255, 0}), strings.Replace(ping, `"command":"ping"`, `"command":"ping","token":"SECRET"`, 1)} {
preview := SafePreview("commands", "sim-001", []byte(raw), 4096)
encoded, _ := json.Marshal(preview)
if !preview.Omitted || preview.Text != "" || strings.Contains(string(encoded), "SECRET") {
t.Fatal("unsafe payload exposed")
}
}
ack := `{"schema_version":1,"device_id":"sim-001","request_id":"req-001","timestamp":"2026-10-06T10:00:00Z","stage":"failed","ok":false,"error":{"code":"error","message":"password=SECRET"}}`
p = SafePreview("acks", "sim-001", []byte(ack), 4096)
if p.Omitted || strings.Contains(p.Text, "SECRET") || !strings.Contains(p.Text, "[redacted]") {
t.Fatal("free-form error not redacted")
}
}
func TestPreviewBounds(t *testing.T) {
for _, n := range []int{-1, 0, 1, 64, 100, 4096, 99999} {
p := SafePreview("commands", "sim-001", []byte(ping), n)
if n <= 0 {
if !p.Omitted {
t.Fatal("disabled preview not omitted")
}
continue
}
if len(p.Text) > n || !utf8.ValidString(p.Text) {
t.Fatal("invalid preview bounds")
}
if n < len(ping) && !p.Truncated {
t.Fatal("truncation not labeled")
}
}
}
func TestRawPreviewIsBounded(t *testing.T) {
for _, data := range [][]byte{[]byte("hello"), []byte("éééé"), {0xff, 0x00}} {
preview := RawPreview(data, 5)
if preview.Omitted || len(preview.Text) > 5 || preview.Bytes != len(data) {
t.Fatal("invalid bounded raw preview")
}
}
}
+144
View File
@@ -0,0 +1,144 @@
package events
import (
"log/slog"
"sync"
"time"
)
// Store retains bounded inspection events; the transport selects safe or opt-in raw previews.
type Notice struct {
Event *Event `json:"event,omitempty"`
Gap uint64 `json:"gap,omitempty"`
ResetID uint64 `json:"reset_id,omitempty"`
}
type Subscriber struct {
channel chan Notice
lost uint64
}
type Store struct {
mu sync.Mutex
history []Event
capacity int
next uint64
counts map[Kind]uint64
log *slog.Logger
nextSub uint64
subs map[uint64]*Subscriber
}
func NewStore(capacity int, log *slog.Logger) *Store {
return &Store{capacity: capacity, counts: make(map[Kind]uint64), log: log, subs: make(map[uint64]*Subscriber)}
}
func (s *Store) Add(e Event) {
s.mu.Lock()
s.next++
e.ID = s.next
e.ObservedAt = time.Now().UTC()
s.counts[e.Kind]++
if len(s.history) == s.capacity {
copy(s.history, s.history[1:])
s.history = s.history[:len(s.history)-1]
}
s.history = append(s.history, cloneEvent(e))
for _, sub := range s.subs {
if sub.lost > 0 {
select {
// Advance the cursor through the current event with the gap notice.
// Leaving the cursor before it would cause a queue-of-one viewer to
// receive a gap forever, since every notice would fill its queue.
case sub.channel <- Notice{Gap: sub.lost + 1, ResetID: e.ID}:
sub.lost = 0
continue
default:
sub.lost++
continue
}
}
copyEvent := cloneEvent(e)
select {
case sub.channel <- Notice{Event: &copyEvent}:
default:
sub.lost++
}
}
s.mu.Unlock()
s.log.Info(string(e.Kind), "event", e)
}
func (s *Store) Snapshot() ([]Event, map[Kind]uint64) {
s.mu.Lock()
defer s.mu.Unlock()
history := make([]Event, len(s.history))
for i, event := range s.history {
history[i] = cloneEvent(event)
}
counts := make(map[Kind]uint64, len(s.counts))
for k, v := range s.counts {
counts[k] = v
}
return history, counts
}
// Clone pointer fields so snapshot consumers cannot mutate retained history.
func cloneEvent(e Event) Event {
if e.Scenario != nil {
result := *e.Scenario
e.Scenario = &result
}
e.Properties.CorrelationData = append([]byte(nil), e.Properties.CorrelationData...)
if e.Properties.MessageExpirySeconds != nil {
n := *e.Properties.MessageExpirySeconds
e.Properties.MessageExpirySeconds = &n
}
if e.Duplicate != nil {
n := *e.Duplicate
e.Duplicate = &n
}
if e.LatencyMS != nil {
n := *e.LatencyMS
e.LatencyMS = &n
}
return e
}
// Subscribe atomically captures bounded replay and registers a bounded live queue.
// A cursor outside retained history creates an explicit reset to the oldest event.
func (s *Store) Subscribe(after uint64, queue, maxReplay int) (replay []Event, initialGap *Notice, stream <-chan Notice, unsubscribe func()) {
s.mu.Lock()
s.nextSub++
id := s.nextSub
sub := &Subscriber{channel: make(chan Notice, queue)}
s.subs[id] = sub
if len(s.history) > 0 {
oldest := s.history[0].ID
newest := s.history[len(s.history)-1].ID
if after > newest {
initialGap = &Notice{Gap: after - newest, ResetID: newest}
after = newest
} else if after < oldest-1 {
initialGap = &Notice{Gap: oldest - 1 - after, ResetID: oldest - 1}
after = oldest - 1
}
if maxReplay > 0 && newest-after > uint64(maxReplay) {
reset := newest - uint64(maxReplay)
missed := reset - after
if initialGap == nil {
initialGap = &Notice{Gap: missed, ResetID: reset}
} else {
initialGap.Gap += missed
initialGap.ResetID = reset
}
after = reset
}
for _, event := range s.history {
if event.ID > after {
replay = append(replay, cloneEvent(event))
}
}
} else if after > 0 {
initialGap = &Notice{Gap: after, ResetID: 0}
}
s.mu.Unlock()
var once sync.Once
return replay, initialGap, sub.channel, func() { once.Do(func() { s.mu.Lock(); delete(s.subs, id); close(sub.channel); s.mu.Unlock() }) }
}
+82
View File
@@ -0,0 +1,82 @@
package events
import (
"io"
"log/slog"
"sync"
"testing"
)
func TestStoreBoundsCopiesAndConcurrentCounters(t *testing.T) {
s := NewStore(4, slog.New(slog.NewTextHandler(io.Discard, nil)))
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for n := 0; n < 10; n++ {
s.Add(Event{Kind: MessageReceived, Properties: Properties{CorrelationData: []byte("id")}})
s.Snapshot()
}
}()
}
wg.Wait()
history, counts := s.Snapshot()
if len(history) != 4 || counts[MessageReceived] != 100 {
t.Fatal("history/counter bound incorrect")
}
for i := 1; i < len(history); i++ {
if history[i].ID <= history[i-1].ID {
t.Fatal("history IDs out of order")
}
}
history[0].Properties.CorrelationData[0] = 'X'
again, _ := s.Snapshot()
if string(again[0].Properties.CorrelationData) != "id" {
t.Fatal("snapshot mutated internal history")
}
}
func TestSubscribeReplayGapLiveFanoutAndCleanup(t *testing.T) {
s := NewStore(3, slog.New(slog.NewTextHandler(io.Discard, nil)))
for i := 0; i < 5; i++ {
s.Add(Event{Kind: MessageReceived})
}
replay, gap, stream, unsubscribe := s.Subscribe(0, 1, 1)
if gap == nil || gap.ResetID != 4 || gap.Gap != 4 || len(replay) != 1 || replay[0].ID != 5 {
t.Fatalf("bounded replay gap: %#v %#v", gap, replay)
}
s.Add(Event{Kind: MessageReceived})
if n := <-stream; n.Event == nil || n.Event.ID != 6 {
t.Fatal("live event fanout failed")
}
unsubscribe()
unsubscribe()
if len(s.subs) != 0 {
t.Fatal("subscriber cleanup failed")
}
_, gap, _, closeSub := s.Subscribe(999, 1, 1)
defer closeSub()
if gap == nil || gap.ResetID != 6 {
t.Fatal("future cursor did not request reset")
}
}
func TestSlowViewerGetsGapWithoutBlockingEvents(t *testing.T) {
s := NewStore(8, slog.New(slog.NewTextHandler(io.Discard, nil)))
s.Add(Event{Kind: MessageReceived})
_, _, stream, unsubscribe := s.Subscribe(1, 1, 8)
defer unsubscribe()
s.Add(Event{Kind: MessageReceived})
s.Add(Event{Kind: MessageReceived})
if n := <-stream; n.Event == nil || n.Event.ID != 2 {
t.Fatal("first bounded event missing")
}
s.Add(Event{Kind: MessageReceived})
if n := <-stream; n.Gap != 2 || n.ResetID != 4 {
t.Fatalf("missing explicit overflow gap: %#v", n)
}
s.Add(Event{Kind: MessageReceived})
if n := <-stream; n.Event == nil || n.Event.ID != 5 {
t.Fatalf("unexpected event after overflow gap: %#v", n)
}
}
+244
View File
@@ -0,0 +1,244 @@
package integration
import (
"context"
"encoding/json"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"robot-market/test-mqtt-5/internal/backend"
"robot-market/test-mqtt-5/internal/config"
"robot-market/test-mqtt-5/internal/contracts"
"robot-market/test-mqtt-5/internal/events"
"robot-market/test-mqtt-5/internal/mqtt"
"robot-market/test-mqtt-5/internal/simulator"
)
func remoteConfig(t *testing.T, role string, plaintext bool) config.Config {
t.Helper()
root := os.Getenv("MQTT5_INTEGRATION_CREDENTIAL_DIR")
if root == "" {
t.Skip("opt-in remote test: set MQTT5_INTEGRATION_CREDENTIAL_DIR")
}
data, err := os.ReadFile(filepath.Join(root, role+".json"))
if err != nil {
t.Fatal("cannot read private credential handoff")
}
var record struct {
Host string `json:"host"`
TLS int `json:"tls_port"`
Plain int `json:"plaintext_port"`
Username string `json:"username"`
Password string `json:"password"`
}
if json.Unmarshal(data, &record) != nil {
t.Fatal("invalid credential handoff")
}
cfg := config.Defaults()
cfg.MQTT.Username = record.Username
cfg.MQTT.Password = record.Password
cfg.MQTT.ClientID = "rm5-integration-" + backend.ID()[:16]
scheme, port := "tls", record.TLS
if plaintext {
scheme, port = "mqtt", record.Plain
cfg.MQTT.AllowPlaintext = true
}
cfg.MQTT.URL = scheme + "://" + record.Host + ":" + jsonNumber(port)
cfg.TLS.CAFile = filepath.Join(root, "ca.crt")
cfg.Simulator.TelemetryInterval = 200 * time.Millisecond
if err := cfg.Validate(); err != nil {
t.Fatal(err)
}
reportScenario(t, cfg)
return cfg
}
var registeredResults sync.Map
func reportScenario(t *testing.T, cfg config.Config) {
if _, loaded := registeredResults.LoadOrStore(t, true); loaded {
return
}
t.Cleanup(func() {
registeredResults.Delete(t)
status := "passed"
if t.Failed() {
status = "failed"
}
if t.Skipped() {
status = "blocked"
}
result := events.ScenarioResult{Feature: t.Name(), Environment: "independent Go integration clients and remote test broker; no ESP32 hardware", Endpoint: cfg.MQTT.URL, Version: "MQTT 5 / Mosquitto 2.1.2", Status: status, Observation: "See named Go test assertions and verbose output; application duplicates are separate from wire retransmission", Time: time.Now().UTC()}
data, _ := json.Marshal(result)
t.Log("scenario_result " + string(data))
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
cfg.MQTT.ClientID = "rm5-result-" + backend.ID()[:12]
cfg.MQTT.SessionExpiry = 0
cfg.Topics.ObserveAll = true
client := mqtt.New(cfg, store(cfg))
if err := client.Start(ctx, nil, nil); err != nil {
t.Error("scenario reporting connection failed")
return
}
defer client.Close()
if err := client.PublishRaw(ctx, mqtt.Packet(cfg.Topics.Namespace+"/scenarios/results", 1, false, result)); err != nil {
t.Error("scenario reporting publication failed")
}
})
}
func jsonNumber(n int) string { data, _ := json.Marshal(n); return string(data) }
func store(cfg config.Config) *events.Store {
return events.NewStore(cfg.Limits.EventHistory, slog.New(slog.NewTextHandler(io.Discard, nil)))
}
func wait(t *testing.T, f func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !f() {
if time.Now().After(deadline) {
t.Fatal("condition deadline exceeded")
}
time.Sleep(10 * time.Millisecond)
}
}
func TestRemoteLoop(t *testing.T) {
for _, plain := range []bool{false, true} {
name := "TLS"
if plain {
name = "Plaintext"
}
t.Run(name, func(t *testing.T) {
cfg := remoteConfig(t, "sim-001", plain)
simClient := mqtt.New(cfg, store(cfg))
sim := simulator.New(simClient)
ctx, cancel := context.WithCancel(context.Background())
simDone := make(chan error, 1)
go func() { simDone <- sim.Run(ctx) }()
defer func() {
start := time.Now()
cancel()
select {
case err := <-simDone:
if err != nil {
t.Error(err)
}
case <-time.After(6 * time.Second):
t.Error("simulator shutdown blocked")
}
if time.Since(start) > cfg.Deadlines.Shutdown {
t.Error("simulator exceeded configured shutdown bound")
}
}()
wait(t, simClient.Ready)
backendCfg := remoteConfig(t, "test-backend", plain)
backendClient := mqtt.New(backendCfg, store(backendCfg))
if err := backendClient.Start(ctx, []string{"telemetry", "status", "commands", "acks"}, nil); err != nil {
t.Fatal(err)
}
defer backendClient.Close()
b := backend.New(backendClient)
workerCtx, stop := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- b.Serve(workerCtx) }()
defer func() {
stop()
if err := <-done; err != nil {
t.Error(err)
}
}()
wait(t, func() bool {
history, _ := backendClient.Events.Snapshot()
seenTelemetry, seenStatus := false, false
for _, event := range history {
if event.Kind == events.MessageReceived && event.Topic == mqtt.Topic(backendCfg, "telemetry") {
seenTelemetry = true
}
if event.Kind == events.MessageReceived && event.Topic == mqtt.Topic(backendCfg, "status") && strings.Contains(event.Payload.Text, `"state":"online"`) {
seenStatus = true
}
}
return seenTelemetry && seenStatus
})
command := backend.NewCommand("sim-001", "ping", backend.ID(), 5*time.Second)
for n := 0; n < 2; n++ {
ack, err := b.Command(ctx, command)
if err != nil || ack == nil || ack.Result.Pong == nil || !*ack.Result.Pong {
t.Fatal("ping/replay did not complete")
}
}
_, counts := simClient.Events.Snapshot()
if counts[events.Kind("simulator_execution")] != 1 || counts[events.DuplicateRequest] < 1 {
t.Fatal("duplicate caused re-execution or replay missing")
}
conflict := command
conflict.Command = "get_version"
if _, err := b.Command(ctx, conflict); err == nil {
t.Fatal("conflicting ID was not rejected")
}
ack, err := b.Command(ctx, backend.NewCommand("sim-001", "get_version", backend.ID(), 5*time.Second))
if err != nil || ack.Result.Version != "test-sim-1.0.0" {
t.Fatal("version result failed")
}
t.Log("observer telemetry/status, native correlated ping/version, cached duplicate and conflict rejection: PASS")
})
}
}
func TestRemoteWrongCorrelationTimeoutAndDisconnect(t *testing.T) {
cfg := remoteConfig(t, "sim-001", false)
cfg.Simulator.AckDelay = 2 * time.Second
simClient := mqtt.New(cfg, store(cfg))
sim := simulator.New(simClient)
ctx, cancel := context.WithCancel(context.Background())
simDone := make(chan error, 1)
go func() { simDone <- sim.Run(ctx) }()
defer func() {
cancel()
select {
case <-simDone:
case <-time.After(6 * time.Second):
t.Error("sim shutdown blocked")
}
}()
wait(t, simClient.Ready)
bc := remoteConfig(t, "test-backend", false)
client := mqtt.New(bc, store(bc))
if err := client.Start(ctx, []string{"acks"}, nil); err != nil {
t.Fatal(err)
}
b := backend.New(client)
workerCtx, stop := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- b.Serve(workerCtx) }()
defer func() { stop(); <-done }()
command := backend.NewCommand("sim-001", "ping", backend.ID(), time.Second)
commandDone := make(chan error, 1)
go func() { _, err := b.Command(ctx, command); commandDone <- err }()
wait(t, func() bool { _, counts := client.Events.Snapshot(); return counts[events.PublishAttempt] >= 1 })
yes := true
badAck := contracts.Ack{Envelope: command.Envelope, RequestID: command.RequestID, Timestamp: backend.Timestamp(time.Now()), Stage: "completed", OK: &yes, Result: &contracts.Result{Pong: &yes}}
packet := mqtt.Packet(mqtt.Topic(cfg, "acks"), 1, false, badAck)
packet.Properties.CorrelationData = []byte("wrong-correlation")
if err := simClient.Publish(ctx, packet); err != nil {
t.Fatal("invalid-correlation test publication failed")
}
if err := <-commandDone; err == nil || !strings.Contains(err.Error(), "deadline") {
t.Fatal("invalid correlation completed request or truthful timeout missing")
}
wait(t, func() bool { _, counts := client.Events.Snapshot(); return counts[events.LateAck] > 0 })
command = backend.NewCommand("sim-001", "ping", backend.ID(), 5*time.Second)
go func() { _, err := b.Command(ctx, command); commandDone <- err }()
wait(t, func() bool { _, counts := client.Events.Snapshot(); return counts[events.PublishAttempt] >= 2 })
if err := client.Close(); err != nil {
t.Fatal(err)
}
if err := <-commandDone; err == nil {
t.Fatal("disconnect claimed execution success")
}
t.Log("wrong correlation ignored, missing valid completion timeout, late ACK classification and disconnect unknown outcome: PASS")
}
+172
View File
@@ -0,0 +1,172 @@
package integration
import (
"context"
"io"
"net"
"strings"
"sync"
"testing"
"time"
"robot-market/test-mqtt-5/internal/backend"
"robot-market/test-mqtt-5/internal/events"
"robot-market/test-mqtt-5/internal/mqtt"
"robot-market/test-mqtt-5/internal/simulator"
)
// A loopback byte relay drops only this test client's connection. No broker changes.
type relay struct {
listener net.Listener
target string
mu sync.Mutex
paused bool
connections map[net.Conn]bool
wg sync.WaitGroup
}
func newRelay(t *testing.T, target string) *relay {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
r := &relay{listener: listener, target: target, connections: make(map[net.Conn]bool)}
r.wg.Add(1)
go func() {
defer r.wg.Done()
for {
local, err := listener.Accept()
if err != nil {
return
}
r.mu.Lock()
if r.paused {
r.mu.Unlock()
local.Close()
continue
}
r.connections[local] = true
r.wg.Add(1)
r.mu.Unlock()
go r.forward(local)
}
}()
return r
}
func (r *relay) forward(local net.Conn) {
defer r.wg.Done()
defer local.Close()
defer func() { r.mu.Lock(); delete(r.connections, local); r.mu.Unlock() }()
remote, err := net.DialTimeout("tcp", r.target, 3*time.Second)
if err != nil {
return
}
defer remote.Close()
r.mu.Lock()
if r.paused {
r.mu.Unlock()
return
}
r.connections[remote] = true
r.mu.Unlock()
defer func() { r.mu.Lock(); delete(r.connections, remote); r.mu.Unlock() }()
done := make(chan struct{})
go func() { io.Copy(remote, local); remote.Close(); close(done) }()
io.Copy(local, remote)
local.Close()
<-done
}
func (r *relay) pause() {
r.mu.Lock()
defer r.mu.Unlock()
r.paused = true
for c := range r.connections {
c.Close()
}
}
func (r *relay) resume() { r.mu.Lock(); r.paused = false; r.mu.Unlock() }
func (r *relay) close() { r.listener.Close(); r.pause(); r.wg.Wait() }
func TestRemoteReconnectSessionAndUnknown(t *testing.T) {
for _, resumed := range []bool{true, false} {
name := "SessionResumed"
if !resumed {
name = "SessionLostSubscriptionsRestored"
}
t.Run(name, func(t *testing.T) { testReconnect(t, resumed) })
}
}
func TestRemoteSessionExpiry(t *testing.T) {
testReconnectExpiry(t, false, time.Second)
}
func testReconnect(t *testing.T, expectResumed bool) { testReconnectExpiry(t, expectResumed, 0) }
func testReconnectExpiry(t *testing.T, expectResumed bool, expiry time.Duration) {
cfg := remoteConfig(t, "test-backend", false)
if !expectResumed {
cfg.MQTT.SessionExpiry = expiry
}
target := strings.TrimPrefix(cfg.MQTT.URL, "tls://")
proxy := newRelay(t, target)
defer proxy.close()
// The generated server certificate explicitly includes 127.0.0.1 for health checks.
cfg.MQTT.URL = "tls://" + proxy.listener.Addr().String()
cfg.MQTT.ReconnectMin = 100 * time.Millisecond
cfg.MQTT.ReconnectMax = 300 * time.Millisecond
client := mqtt.New(cfg, store(cfg))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := client.Start(ctx, []string{"acks", "telemetry"}, nil); err != nil {
t.Fatal(err)
}
defer client.Close()
b := backend.New(client)
workerCtx, stop := context.WithCancel(ctx)
workerDone := make(chan error, 1)
go func() { workerDone <- b.Serve(workerCtx) }()
defer func() { stop(); <-workerDone }()
simCfg := remoteConfig(t, "sim-001", false)
simCfg.Simulator.AckDelay = 500 * time.Millisecond
simClient := mqtt.New(simCfg, store(simCfg))
sim := simulator.New(simClient)
simCtx, simStop := context.WithCancel(ctx)
simDone := make(chan error, 1)
go func() { simDone <- sim.Run(simCtx) }()
defer func() { simStop(); <-simDone }()
wait(t, simClient.Ready)
cmdDone := make(chan error, 1)
go func() {
_, err := b.Command(ctx, backend.NewCommand("sim-001", "ping", backend.ID(), 5*time.Second))
cmdDone <- err
}()
wait(t, func() bool { _, counts := client.Events.Snapshot(); return counts[events.CommandAccepted] > 0 })
proxy.pause()
wait(t, func() bool { return !client.Ready() })
select {
case err := <-cmdDone:
if err == nil || !strings.Contains(err.Error(), "unknown") {
t.Fatal("connection loss did not produce unknown outcome")
}
case <-time.After(3 * time.Second):
t.Fatal("pending request did not stop on connection loss")
}
if expiry > 0 {
time.Sleep(expiry + 500*time.Millisecond)
}
proxy.resume()
wait(t, client.Ready)
history, _ := client.Events.Snapshot()
resumed := false
for _, e := range history {
if e.Kind == events.ConnectionUp && strings.Contains(e.Outcome, "session_present=true") {
resumed = true
}
}
if resumed != expectResumed {
t.Fatal("unexpected broker session present result")
}
ack, err := b.Command(ctx, backend.NewCommand("sim-001", "get_version", backend.ID(), 5*time.Second))
if err != nil || ack == nil || ack.Result.Version == "" {
t.Fatal("command did not recover after reconnect")
}
t.Log("actual connection loss -> unknown, TLS reconnect, session-dependent subscription restoration and command recovery: PASS")
}
+117
View File
@@ -0,0 +1,117 @@
package integration
import (
"context"
"strings"
"testing"
"time"
"github.com/eclipse/paho.golang/paho"
"robot-market/test-mqtt-5/internal/backend"
"robot-market/test-mqtt-5/internal/contracts"
"robot-market/test-mqtt-5/internal/events"
"robot-market/test-mqtt-5/internal/mqtt"
)
func TestRemoteBrokerQueuedMessageExpiry(t *testing.T) {
deviceCfg := remoteConfig(t, "sim-001", false)
deviceCfg.MQTT.ClientID = "rm5-expiry-" + backend.ID()[:12]
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
device := mqtt.New(deviceCfg, store(deviceCfg))
if err := device.Start(ctx, []string{"commands"}, nil); err != nil {
t.Fatal(err)
}
if err := device.Close(); err != nil {
t.Fatal(err)
}
serverCfg := remoteConfig(t, "test-backend", false)
server := mqtt.New(serverCfg, store(serverCfg))
if err := server.Start(ctx, []string{"acks"}, nil); err != nil {
t.Fatal(err)
}
defer server.Close()
// Application deadline remains valid; the broker must expire this queued packet.
p := mqtt.Packet(mqtt.Topic(serverCfg, "commands"), 1, false, backend.NewCommand("sim-001", "ping", backend.ID(), 15*time.Second))
p.Properties.MessageExpiry = paho.Uint32(1)
if err := server.Publish(ctx, p); err != nil {
t.Fatal(err)
}
time.Sleep(2 * time.Second)
deviceCfg.MQTT.CleanStart = false
device = mqtt.New(deviceCfg, store(deviceCfg))
if err := device.Start(ctx, []string{"commands"}, nil); err != nil {
t.Fatal(err)
}
defer device.Close()
history, _ := device.Events.Snapshot()
resumed := false
for _, e := range history {
if e.Kind == events.ConnectionUp && strings.Contains(e.Outcome, "session_present=true") {
resumed = true
}
}
if !resumed {
t.Fatal("offline subscriber session was lost; expiry test inconclusive")
}
select {
case <-device.Incoming:
t.Fatal("broker delivered expired queued command")
case <-time.After(300 * time.Millisecond):
}
fresh := mqtt.Packet(mqtt.Topic(serverCfg, "commands"), 1, false, backend.NewCommand("sim-001", "ping", backend.ID(), 15*time.Second))
if err := server.Publish(ctx, fresh); err != nil {
t.Fatal(err)
}
select {
case got := <-device.Incoming:
if string(got.Payload) != string(fresh.Payload) {
t.Fatal("wrong queued packet")
}
case <-time.After(3 * time.Second):
t.Fatal("fresh control command missing")
}
t.Log("broker expiry distinct from valid application deadline, persistent offline queue, fresh control delivery: PASS")
}
func TestRemoteWillAfterAbruptDisconnect(t *testing.T) {
observerCfg := remoteConfig(t, "test-backend", false)
observer := mqtt.New(observerCfg, store(observerCfg))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := observer.Start(ctx, []string{"status"}, nil); err != nil {
t.Fatal(err)
}
defer observer.Close()
cfg := remoteConfig(t, "sim-001", false)
proxy := newRelay(t, strings.TrimPrefix(cfg.MQTT.URL, "tls://"))
defer proxy.close()
cfg.MQTT.URL = "tls://" + proxy.listener.Addr().String()
cfg.MQTT.ReconnectMin = 100 * time.Millisecond
cfg.MQTT.ReconnectMax = 200 * time.Millisecond
boot := "will-" + backend.ID()[:12]
status := contracts.Status{Envelope: contracts.Envelope{SchemaVersion: 1, DeviceID: "sim-001"}, BootID: boot, State: "offline", WillConfiguredAt: backend.Timestamp(time.Now())}
packet := mqtt.Packet(mqtt.Topic(cfg, "status"), 1, false, status)
client := mqtt.New(cfg, store(cfg))
if err := client.Start(ctx, []string{"commands"}, &paho.WillMessage{Topic: packet.Topic, Payload: packet.Payload, QoS: 1, Retain: false}); err != nil {
t.Fatal(err)
}
defer client.Close()
proxy.pause()
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
for {
select {
case p := <-observer.Incoming:
if strings.Contains(string(p.Payload), boot) {
if !strings.Contains(string(p.Payload), "will_configured_at") {
t.Fatal("Will invents disconnect timestamp")
}
t.Log("broker emits non-retained Will after actual abrupt TLS connection loss: PASS")
return
}
case <-deadline.C:
t.Fatal("Will not observed after abrupt connection loss")
}
}
}
+461
View File
@@ -0,0 +1,461 @@
// Package mqtt provides a bounded MQTT 5 transport without an application outbox.
package mqtt
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"math/rand/v2"
"net/url"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"robot-market/test-mqtt-5/internal/config"
"robot-market/test-mqtt-5/internal/contracts"
"robot-market/test-mqtt-5/internal/events"
"github.com/eclipse/paho.golang/autopaho"
"github.com/eclipse/paho.golang/paho"
)
var ErrNotReady = errors.New("MQTT subscriptions are not ready")
var ErrUnknown = errors.New("publication outcome unknown; no automatic command retry")
type Client struct {
Config config.Config
Events *events.Store
Incoming chan *paho.Publish
Down chan struct{}
cm *autopaho.ConnectionManager
cancel context.CancelFunc
ready atomic.Bool
dropped atomic.Uint64
invalid atomic.Uint64
stateMu sync.Mutex
generation uint64
state connection
changed chan struct{}
firstReady chan struct{}
firstOnce sync.Once
fatal chan error
workerDone chan struct{}
}
type connection struct {
cm *autopaho.ConnectionManager
ack *paho.Connack
generation uint64
}
func Topic(c config.Config, kind string) string {
return c.Topics.Namespace + "/" + c.Topics.DeviceID + "/" + kind
}
// ObservedDevices is an explicit list: broker ACLs can authorize each exact topic.
func ObservedDevices(c config.Config) []string {
devices := []string{c.Topics.DeviceID}
seen := map[string]bool{c.Topics.DeviceID: true}
if c.Topics.ObserveDevices != "" {
for _, device := range strings.Split(c.Topics.ObserveDevices, ",") {
device = strings.TrimSpace(device)
if !seen[device] {
devices = append(devices, device)
seen[device] = true
}
}
}
return devices
}
func InNamespace(c config.Config, topic string) bool {
return topic == c.Topics.Namespace || strings.HasPrefix(topic, c.Topics.Namespace+"/")
}
func Identity(c config.Config, topic string) (device, kind string) {
if c.Topics.ObserveAll && InNamespace(c, topic) {
parts := strings.Split(strings.TrimPrefix(topic, c.Topics.Namespace+"/"), "/")
if len(parts) >= 2 {
return parts[0], strings.Join(parts[1:], "/")
}
return "", ""
}
for _, device := range ObservedDevices(c) {
for _, kind := range []string{"telemetry", "status", "commands", "acks"} {
if topic == c.Topics.Namespace+"/"+device+"/"+kind {
return device, kind
}
}
}
return "", ""
}
func Kind(c config.Config, topic string) string {
_, kind := Identity(c, topic)
return kind
}
func Backoff(min, max time.Duration) func(int) time.Duration {
return func(attempt int) time.Duration {
upper := min
for i := 1; i < attempt && upper < max; i++ {
if upper > max/2 {
upper = max
} else {
upper *= 2
}
}
if upper > max {
upper = max
}
if upper <= min {
return min
}
return min + time.Duration(rand.Int64N(int64(upper-min)+1))
}
}
func TLSConfig(c config.Config) (*tls.Config, error) {
tc := &tls.Config{MinVersion: tls.VersionTLS12}
if c.TLS.CAFile != "" {
data, err := os.ReadFile(c.TLS.CAFile)
if err != nil {
return nil, errors.New("cannot read CA file")
}
roots, err := x509.SystemCertPool()
if err != nil {
roots = x509.NewCertPool()
}
if !roots.AppendCertsFromPEM(data) {
return nil, errors.New("CA file contains no valid certificates")
}
tc.RootCAs = roots
}
if c.TLS.ClientCert != "" {
pair, err := tls.LoadX509KeyPair(c.TLS.ClientCert, c.TLS.ClientKey)
if err != nil {
return nil, errors.New("invalid client certificate/key pair")
}
tc.Certificates = []tls.Certificate{pair}
}
return tc, nil
}
func New(c config.Config, store *events.Store) *Client {
return &Client{Config: c, Events: store, Incoming: make(chan *paho.Publish, c.Limits.InboundQueue), Down: make(chan struct{}, 1), changed: make(chan struct{}, 1), firstReady: make(chan struct{}), fatal: make(chan error, 1), workerDone: make(chan struct{})}
}
func (c *Client) Ready() bool { return c.ready.Load() }
func (c *Client) Fatal() <-chan error { return c.fatal }
func (c *Client) change(cm *autopaho.ConnectionManager, ack *paho.Connack) {
c.ready.Store(false)
c.stateMu.Lock()
c.generation++
c.state = connection{cm, ack, c.generation}
c.stateMu.Unlock()
select {
case c.changed <- struct{}{}:
default:
}
if ack == nil {
select {
case c.Down <- struct{}{}:
default:
}
}
}
func (c *Client) receive(r paho.PublishReceived) (bool, error) {
p := r.Packet
if p == nil {
return true, nil
}
admitted := Kind(c.Config, p.Topic) != ""
if c.Config.Topics.ObserveAll {
admitted = InNamespace(c.Config, p.Topic)
}
if !admitted || len(p.Payload) > c.Config.Limits.PayloadBytes || (!c.Config.Topics.ObserveAll && p.Properties != nil && (len(p.Properties.CorrelationData) > 64 || len(p.Properties.ResponseTopic) > 256)) {
c.invalid.Add(1)
return true, nil
}
cp := *p
cp.Payload = append([]byte(nil), p.Payload...)
if p.Properties != nil {
props := *p.Properties
props.CorrelationData = append([]byte(nil), props.CorrelationData...)
props.User = nil
if props.MessageExpiry != nil {
v := *props.MessageExpiry
props.MessageExpiry = &v
}
cp.Properties = &props
}
select {
case c.Incoming <- &cp:
default:
c.dropped.Add(1)
}
// Paho acknowledges receipt after callbacks. Receipt is not admission/execution.
return true, nil
}
func (c *Client) Start(ctx context.Context, kinds []string, will *paho.WillMessage) error {
if err := c.Config.Validate(); err != nil {
return err
}
tc, err := TLSConfig(c.Config)
if err != nil {
return err
}
password := []byte(c.Config.MQTT.Password)
if c.Config.MQTT.PasswordFile != "" {
password, err = os.ReadFile(c.Config.MQTT.PasswordFile)
if err != nil {
return errors.New("cannot read password file")
}
password = []byte(strings.TrimRight(string(password), "\r\n"))
}
u, _ := url.Parse(c.Config.MQTT.URL)
// Transport remains alive until explicit Close, so graceful offline publication can finish.
life, cancel := context.WithCancel(context.Background())
c.cancel = cancel
go c.supervise(life, kinds)
ac := autopaho.ClientConfig{
ServerUrls: []*url.URL{u}, TlsCfg: tc, KeepAlive: uint16(c.Config.MQTT.KeepAlive / time.Second),
CleanStartOnInitialConnection: c.Config.MQTT.CleanStart, SessionExpiryInterval: uint32(c.Config.MQTT.SessionExpiry / time.Second),
ReconnectBackoff: Backoff(c.Config.MQTT.ReconnectMin, c.Config.MQTT.ReconnectMax), ConnectTimeout: c.Config.Deadlines.Connect,
ConnectUsername: c.Config.MQTT.Username, ConnectPassword: password, WillMessage: will,
OnConnectionUp: func(cm *autopaho.ConnectionManager, a *paho.Connack) { c.change(cm, a) },
OnConnectionDown: func() bool { c.change(nil, nil); return true },
OnConnectError: func(error) { c.invalid.Add(1) },
ConnectPacketBuilder: func(p *paho.Connect, _ *url.URL) (*paho.Connect, error) {
if p.Properties == nil {
p.Properties = &paho.ConnectProperties{}
}
size := uint32(32768)
receive := uint16(min(20, c.Config.Limits.InboundQueue))
p.Properties.MaximumPacketSize = &size
p.Properties.ReceiveMaximum = &receive
return p, nil
},
ClientConfig: paho.ClientConfig{ClientID: c.Config.MQTT.ClientID, PacketTimeout: c.Config.Deadlines.Operation, OnPublishReceived: []func(paho.PublishReceived) (bool, error){c.receive}},
}
if will != nil {
ac.WillProperties = &paho.WillProperties{ContentType: "application/json", PayloadFormat: paho.Byte(1), WillDelayInterval: paho.Uint32(0)}
}
c.cm, err = autopaho.NewConnection(life, ac)
if err != nil {
cancel()
return errors.New("MQTT manager initialization failed")
}
wait, stop := context.WithTimeout(ctx, c.Config.Deadlines.Connect)
defer stop()
select {
case <-c.firstReady:
return nil
case err := <-c.fatal:
c.Close()
return err
case <-wait.Done():
c.Close()
return errors.New("MQTT readiness deadline exceeded or interrupted")
}
}
func (c *Client) supervise(ctx context.Context, kinds []string) {
defer close(c.workerDone)
subscribed := false
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if n := c.dropped.Swap(0); n > 0 {
c.Events.Add(events.Event{Kind: events.EventGap, Direction: events.Internal, Outcome: fmt.Sprintf("inbound_dropped=%d; MQTT receipt was not application admission", n)})
}
if n := c.invalid.Swap(0); n > 0 {
c.Events.Add(events.Event{Kind: events.ValidationFailed, Direction: events.Internal, Outcome: fmt.Sprintf("invalid_input_or_connection_attempts=%d", n)})
}
case <-c.changed:
c.stateMu.Lock()
state := c.state
c.stateMu.Unlock()
if state.ack == nil {
c.Events.Add(events.Event{Kind: events.ConnectionDown, Direction: events.Internal, Outcome: "disconnected; pending execution unknown"})
continue
}
c.Events.Add(events.Event{Kind: events.ConnectionUp, Direction: events.Internal, ClientID: c.Config.MQTT.ClientID, Outcome: fmt.Sprintf("session_present=%t; protocol=5", state.ack.SessionPresent)})
if state.ack.Properties != nil && state.ack.Properties.MaximumQoS != nil && *state.ack.Properties.MaximumQoS < 1 {
c.fail("broker does not support required QoS 1")
continue
}
if !state.ack.SessionPresent || !subscribed {
subs := make([]paho.SubscribeOptions, 0, len(kinds))
for _, device := range ObservedDevices(c.Config) {
for _, kind := range kinds {
subs = append(subs, paho.SubscribeOptions{Topic: c.Config.Topics.Namespace + "/" + device + "/" + kind, QoS: 1})
}
}
if c.Config.Topics.ObserveAll {
subs = []paho.SubscribeOptions{{Topic: c.Config.Topics.Namespace + "/#", QoS: 1}}
}
op, stop := context.WithTimeout(ctx, c.Config.Deadlines.Operation)
ack, err := state.cm.Subscribe(op, &paho.Subscribe{Subscriptions: subs})
stop()
c.stateMu.Lock()
current := c.generation == state.generation
c.stateMu.Unlock()
// A lost connection invalidates this SUBACK attempt. Let the
// newer connection restore readiness instead of failing it.
if !current {
continue
}
if err != nil || ack == nil || len(ack.Reasons) != len(subs) {
c.fail("subscription setup failed")
continue
}
valid := true
for _, reason := range ack.Reasons {
if reason >= 128 {
valid = false
}
}
if !valid {
c.fail("broker rejected required subscription")
continue
}
subscribed = true
}
c.stateMu.Lock()
current := c.generation == state.generation
if current {
c.ready.Store(true)
}
c.stateMu.Unlock()
if current {
c.Events.Add(events.Event{Kind: events.Kind("subscriptions_ready"), Direction: events.Internal, ClientID: c.Config.MQTT.ClientID, Outcome: c.SubscriptionSummary(kinds)})
c.firstOnce.Do(func() { close(c.firstReady) })
}
}
}
}
func (c *Client) SubscriptionSummary(kinds []string) string {
if c.Config.Topics.ObserveAll {
return "topic=" + c.Config.Topics.Namespace + "/#; raw observation; contract validation disabled"
}
return "devices=" + strings.Join(ObservedDevices(c.Config), ",") + "; categories=" + strings.Join(kinds, ",")
}
func (c *Client) fail(message string) {
c.ready.Store(false)
select {
case c.fatal <- errors.New(message):
default:
}
}
func (c *Client) Close() error {
ctx, stop := context.WithTimeout(context.Background(), c.Config.Deadlines.Shutdown)
defer stop()
return c.CloseContext(ctx)
}
// CloseContext shares the caller's complete shutdown budget, including offline publication.
func (c *Client) CloseContext(ctx context.Context) error {
if c.cancel == nil {
return nil
}
c.change(nil, nil)
var err error
if c.cm != nil {
err = c.cm.Disconnect(ctx)
}
c.cancel()
select {
case <-c.workerDone:
case <-ctx.Done():
return errors.New("MQTT shutdown deadline exceeded")
}
if err != nil {
return errors.New("MQTT shutdown failed")
}
return nil
}
func (c *Client) Record(kind events.Kind, direction events.Direction, p *paho.Publish, request, outcome string) {
e := events.Event{Kind: kind, Direction: direction, ClientID: c.Config.MQTT.ClientID, DeviceID: c.Config.Topics.DeviceID, RequestID: request, Outcome: outcome}
if p != nil {
e.Topic = p.Topic
e.QoS = p.QoS
e.Retain = p.Retain
duplicate := p.Duplicate()
e.Duplicate = &duplicate
device, category := Identity(c.Config, p.Topic)
e.DeviceID = device
e.Payload = events.SafePreview(category, device, p.Payload, c.Config.Limits.PreviewBytes)
if c.Config.Logging.RawPayloads || c.Config.Topics.ObserveAll {
e.Payload = events.RawPreview(p.Payload, c.Config.Limits.PreviewBytes)
}
// Only native values belonging to our exact test contract are visible.
if p.Properties != nil {
if p.Properties.ResponseTopic == Topic(c.Config, "acks") || (c.Config.Topics.ObserveAll && len(p.Properties.ResponseTopic) <= 256) {
e.Properties.ResponseTopic = p.Properties.ResponseTopic
}
if len(p.Properties.CorrelationData) <= 64 && (c.Config.Topics.ObserveAll || (request != "" && string(p.Properties.CorrelationData) == request)) {
e.Properties.CorrelationData = append([]byte(nil), p.Properties.CorrelationData...)
}
e.Properties.MessageExpirySeconds = p.Properties.MessageExpiry
}
}
c.Events.Add(e)
}
func (c *Client) Publish(ctx context.Context, p *paho.Publish) error {
kind := Kind(c.Config, p.Topic)
if kind == "" || len(p.Payload) > c.Config.Limits.PayloadBytes {
return errors.New("publication outside test contract")
}
msg, err := contracts.Decode(kind, p.Payload, c.Config.Topics.DeviceID)
if err != nil {
return err
}
request := ""
switch v := msg.(type) {
case *contracts.Command:
request = v.RequestID
case *contracts.Ack:
request = v.RequestID
}
return c.publishPacket(ctx, p, request)
}
// PublishRaw sends test receipt replies without requiring a device JSON contract.
func (c *Client) PublishRaw(ctx context.Context, p *paho.Publish) error {
if p == nil || !InNamespace(c.Config, p.Topic) || len(p.Payload) > c.Config.Limits.PayloadBytes {
return errors.New("receipt publication outside test namespace or size limit")
}
return c.publishPacket(ctx, p, "")
}
func (c *Client) publishPacket(ctx context.Context, p *paho.Publish, request string) error {
c.Record(events.PublishAttempt, events.Out, p, request, "")
if !c.Ready() {
return ErrNotReady
}
op, stop := context.WithTimeout(ctx, c.Config.Deadlines.Operation)
defer stop()
ack, err := c.cm.Publish(op, p)
if err != nil {
c.Record(events.Kind("transport_unknown"), events.Internal, p, request, "publication not confirmed; execution unknown")
return ErrUnknown
}
if ack != nil && ack.ReasonCode >= 128 {
c.Record(events.Kind("transport_rejected"), events.Internal, p, request, fmt.Sprintf("reason_code=%d", ack.ReasonCode))
return errors.New("broker rejected publication")
}
outcome := "QoS0 write completed; no broker acknowledgement"
if p.QoS == 1 {
if ack == nil {
return ErrUnknown
}
outcome = fmt.Sprintf("reason_code=%d; device execution not proven", ack.ReasonCode)
}
c.Record(events.TransportAck, events.Internal, p, request, outcome)
return nil
}
func Packet(topic string, qos byte, retained bool, value any) *paho.Publish {
data, _ := json.Marshal(value)
return &paho.Publish{Topic: topic, QoS: qos, Retain: retained, Payload: data, Properties: &paho.PublishProperties{ContentType: "application/json", PayloadFormat: paho.Byte(1)}}
}
+92
View File
@@ -0,0 +1,92 @@
package mqtt
import (
"io"
"log/slog"
"testing"
"time"
"github.com/eclipse/paho.golang/paho"
"robot-market/test-mqtt-5/internal/config"
"robot-market/test-mqtt-5/internal/events"
)
func TestCopyAndOverflow(t *testing.T) {
cfg := config.Defaults()
cfg.Limits.InboundQueue = 1
c := New(cfg, events.NewStore(4, slog.New(slog.NewTextHandler(io.Discard, nil))))
p := &paho.Publish{Topic: Topic(cfg, "acks"), Payload: []byte("payload"), Properties: &paho.PublishProperties{CorrelationData: []byte("id"), MessageExpiry: paho.Uint32(5)}}
c.receive(paho.PublishReceived{Packet: p})
p.Payload[0] = 'X'
p.Properties.CorrelationData[0] = 'X'
*p.Properties.MessageExpiry = 99
c.receive(paho.PublishReceived{Packet: p})
if c.dropped.Load() != 1 {
t.Fatal("overflow not counted")
}
got := <-c.Incoming
if string(got.Payload) != "payload" || string(got.Properties.CorrelationData) != "id" || *got.Properties.MessageExpiry != 5 {
t.Fatal("callback input was aliased")
}
p.Topic = "production/topic"
c.receive(paho.PublishReceived{Packet: p})
if c.invalid.Load() != 1 {
t.Fatal("foreign topic not rejected")
}
}
func TestBoundedBackoff(t *testing.T) {
f := Backoff(time.Second, 30*time.Second)
for attempt := 0; attempt < 100; attempt++ {
for n := 0; n < 10; n++ {
d := f(attempt)
if d < time.Second || d > 30*time.Second {
t.Fatal("backoff outside bounds")
}
}
}
}
func TestTLSNeverSkipsVerification(t *testing.T) {
cfg := config.Defaults()
tc, err := TLSConfig(cfg)
if err != nil || tc.InsecureSkipVerify || tc.MinVersion == 0 {
t.Fatal("unsafe TLS")
}
}
func TestObservedDeviceTopicIdentity(t *testing.T) {
cfg := config.Defaults()
cfg.Topics.ObserveDevices = "sim-001, esp32-lab-001,esp32-lab-001"
if len(ObservedDevices(cfg)) != 2 {
t.Fatal("duplicate subscriptions")
}
device, kind := Identity(cfg, "robot-market/test/v1/esp32-lab-001/telemetry")
if device != "esp32-lab-001" || kind != "telemetry" {
t.Fatal("device topic missing")
}
for _, topic := range []string{"robot-market/test/v1/other/telemetry", "production/esp32-lab-001/telemetry", "robot-market/test/v1/esp32-lab-001/unknown"} {
if _, kind := Identity(cfg, topic); kind != "" {
t.Fatal("unconfigured topic admitted")
}
}
}
func TestRawObservationAdmitsNamespaceWithoutDeviceOrCategoryRestriction(t *testing.T) {
cfg := config.Defaults()
cfg.Topics.ObserveAll = true
c := New(cfg, events.NewStore(8, slog.New(slog.NewTextHandler(io.Discard, nil))))
for _, topic := range []string{cfg.Topics.Namespace, cfg.Topics.Namespace + "/custom", cfg.Topics.Namespace + "/new-device/custom-topic"} {
c.receive(paho.PublishReceived{Packet: &paho.Publish{Topic: topic, Payload: []byte("not JSON")}})
select {
case got := <-c.Incoming:
if got.Topic != topic {
t.Fatal("topic changed")
}
default:
t.Fatal("raw topic filtered")
}
}
c.receive(paho.PublishReceived{Packet: &paho.Publish{Topic: cfg.Topics.Namespace + "-other/device/custom"}})
if c.invalid.Load() != 1 {
t.Fatal("namespace boundary lost")
}
}
+193
View File
@@ -0,0 +1,193 @@
// Package simulator executes only harmless ping/version commands in a bounded worker.
package simulator
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"time"
"github.com/eclipse/paho.golang/paho"
"robot-market/test-mqtt-5/internal/backend"
"robot-market/test-mqtt-5/internal/contracts"
"robot-market/test-mqtt-5/internal/events"
"robot-market/test-mqtt-5/internal/mqtt"
)
type entry struct {
fingerprint [32]byte
command contracts.Command
correlation []byte
ack contracts.Ack
due, forget time.Time
complete bool
}
type Simulator struct {
Client *mqtt.Client
BootID string
cache map[string]*entry
Executions uint64
sequence uint64
}
func New(client *mqtt.Client) *Simulator {
return &Simulator{Client: client, BootID: backend.ID(), cache: make(map[string]*entry)}
}
func (s *Simulator) envelope() contracts.Envelope {
return contracts.Envelope{SchemaVersion: 1, DeviceID: s.Client.Config.Topics.DeviceID}
}
func (s *Simulator) status(state string, will bool) contracts.Status {
status := contracts.Status{Envelope: s.envelope(), BootID: s.BootID, State: state, Implementation: "go-simulator", ReportedProtocol: "5"}
if will {
status.WillConfiguredAt = backend.Timestamp(time.Now())
} else {
status.Timestamp = backend.Timestamp(time.Now())
}
return status
}
func (s *Simulator) Will() *paho.WillMessage {
p := mqtt.Packet(mqtt.Topic(s.Client.Config, "status"), 1, true, s.status("offline", true))
return &paho.WillMessage{Topic: p.Topic, Payload: p.Payload, QoS: 1, Retain: true}
}
func (s *Simulator) ack(id, stage, code string) contracts.Ack {
ok := stage == "accepted" || stage == "completed"
ack := contracts.Ack{Envelope: s.envelope(), RequestID: id, Timestamp: backend.Timestamp(time.Now()), Stage: stage, OK: &ok}
if !ok {
ack.Error = &contracts.AckError{Code: code, Message: "test simulator rejected the request"}
}
return ack
}
func (s *Simulator) sendAck(ctx context.Context, ack contracts.Ack, correlation []byte) {
p := mqtt.Packet(mqtt.Topic(s.Client.Config, "acks"), 1, false, ack)
p.Properties.CorrelationData = append([]byte(nil), correlation...)
if err := s.Client.Publish(ctx, p); err != nil {
s.Client.Record(events.Kind("ack_not_confirmed"), events.Internal, p, ack.RequestID, "cached outcome; replay only on duplicate request")
}
}
func (s *Simulator) purge(now time.Time) {
for id, e := range s.cache {
if e.complete && !now.Before(e.forget) {
delete(s.cache, id)
}
}
}
func (s *Simulator) Handle(ctx context.Context, p *paho.Publish) {
if ctx.Err() != nil {
return
}
value, err := contracts.Decode("commands", p.Payload, s.Client.Config.Topics.DeviceID)
if err != nil || p.Topic != mqtt.Topic(s.Client.Config, "commands") {
s.Client.Record(events.ValidationFailed, events.In, p, "", "invalid command contract")
return
}
cmd := *value.(*contracts.Command)
s.Client.Record(events.MessageReceived, events.In, p, cmd.RequestID, "")
if p.Retain || p.QoS != 1 || p.Properties == nil || p.Properties.ResponseTopic != mqtt.Topic(s.Client.Config, "acks") || (len(p.Properties.CorrelationData) == 0 || len(p.Properties.CorrelationData) > 64) || p.Properties.MessageExpiry == nil || *p.Properties.MessageExpiry == 0 || *p.Properties.MessageExpiry > 60 {
s.Client.Record(events.ValidationFailed, events.In, p, cmd.RequestID, "requires non-retained QoS1 and exact native command properties")
return
}
now := time.Now()
s.purge(now)
data, _ := json.Marshal(cmd)
fingerprint := sha256.Sum256(append(data, p.Properties.CorrelationData...))
if previous, ok := s.cache[cmd.RequestID]; ok {
if previous.fingerprint != fingerprint {
s.sendAck(ctx, s.ack(cmd.RequestID, "rejected", "conflicting_request"), p.Properties.CorrelationData)
return
}
s.Client.Record(events.DuplicateRequest, events.In, p, cmd.RequestID, "cached ACK replay; no repeated execution")
s.sendAck(ctx, previous.ack, previous.correlation)
return
}
if err := cmd.CheckDeadline(now); err != nil {
s.sendAck(ctx, s.ack(cmd.RequestID, "rejected", "deadline_invalid"), p.Properties.CorrelationData)
return
}
if len(s.cache) >= s.Client.Config.Limits.DedupEntries {
s.sendAck(ctx, s.ack(cmd.RequestID, "rejected", "dedup_capacity"), p.Properties.CorrelationData)
return
}
accepted := s.ack(cmd.RequestID, "accepted", "")
s.cache[cmd.RequestID] = &entry{fingerprint: fingerprint, command: cmd, correlation: append([]byte(nil), p.Properties.CorrelationData...), ack: accepted, due: now.Add(s.Client.Config.Simulator.AckDelay), forget: now.Add(s.Client.Config.Limits.DedupRetention)}
s.Client.Record(events.CommandAccepted, events.Internal, p, cmd.RequestID, "admitted to bounded simulator worker")
s.sendAck(ctx, accepted, p.Properties.CorrelationData)
}
func (s *Simulator) CompleteDue(ctx context.Context, now time.Time) {
if ctx.Err() != nil {
return
}
s.purge(now)
for id, e := range s.cache {
if e.complete || now.Before(e.due) {
continue
}
if err := e.command.CheckDeadline(now); err != nil {
e.ack = s.ack(id, "failed", "deadline_elapsed")
} else {
s.Executions++
e.ack = s.ack(id, "completed", "")
if e.command.Command == "ping" {
yes := true
e.ack.Result = &contracts.Result{Pong: &yes}
} else {
e.ack.Result = &contracts.Result{Version: "test-sim-1.0.0"}
}
}
e.complete = true
e.forget = now.Add(s.Client.Config.Limits.DedupRetention)
s.Client.Events.Add(events.Event{Kind: events.Kind("simulator_execution"), Direction: events.Internal, DeviceID: s.Client.Config.Topics.DeviceID, RequestID: id, Outcome: e.ack.Stage})
s.sendAck(ctx, e.ack, e.correlation)
}
}
func (s *Simulator) Run(ctx context.Context) (runErr error) {
if err := s.Client.Start(ctx, []string{"commands"}, s.Will()); err != nil {
return err
}
defer func() {
shutdown, stop := context.WithTimeout(context.Background(), s.Client.Config.Deadlines.Shutdown)
defer stop()
if err := s.Client.Publish(shutdown, mqtt.Packet(mqtt.Topic(s.Client.Config, "status"), 1, true, s.status("offline", false))); err != nil {
s.Client.Record(events.Kind("offline_not_confirmed"), events.Internal, nil, "", "graceful offline status not confirmed")
}
if err := s.Client.CloseContext(shutdown); err != nil && runErr == nil {
runErr = err
}
}()
tick := time.NewTicker(50 * time.Millisecond)
defer tick.Stop()
telemetry := time.NewTicker(s.Client.Config.Simulator.TelemetryInterval)
defer telemetry.Stop()
online := false
for {
select {
case <-ctx.Done():
return nil
case err := <-s.Client.Fatal():
return err
case <-s.Client.Down:
online = false
case p := <-s.Client.Incoming:
s.Handle(ctx, p)
case now := <-tick.C:
if !s.Client.Ready() {
online = false
}
if !online && s.Client.Ready() {
if err := s.Client.Publish(ctx, mqtt.Packet(mqtt.Topic(s.Client.Config, "status"), 1, true, s.status("online", false))); err != nil {
return errors.New("online status not confirmed")
}
online = true
}
s.CompleteDue(ctx, now)
case <-telemetry.C:
if !s.Client.Ready() {
continue
}
s.sequence++
seq := s.sequence
message := contracts.Telemetry{Envelope: s.envelope(), MessageID: backend.ID(), BootID: s.BootID, Sequence: &seq, Timestamp: backend.Timestamp(time.Now()), Readings: map[string]float64{"temperature_c": 22.5, "executions": float64(s.Executions)}}
_ = s.Client.Publish(ctx, mqtt.Packet(mqtt.Topic(s.Client.Config, "telemetry"), byte(s.Client.Config.MQTT.QoS), false, message))
}
}
}
+103
View File
@@ -0,0 +1,103 @@
package simulator
import (
"context"
"io"
"log/slog"
"testing"
"time"
"github.com/eclipse/paho.golang/paho"
"robot-market/test-mqtt-5/internal/backend"
"robot-market/test-mqtt-5/internal/config"
"robot-market/test-mqtt-5/internal/events"
"robot-market/test-mqtt-5/internal/mqtt"
)
func testSimulator() *Simulator {
cfg := config.Defaults()
return New(mqtt.New(cfg, events.NewStore(64, slog.New(slog.NewTextHandler(io.Discard, nil)))))
}
func commandPacket(s *Simulator, cmdName, id string) *paho.Publish {
cmd := backend.NewCommand("sim-001", cmdName, id, 15*time.Second)
p := mqtt.Packet(mqtt.Topic(s.Client.Config, "commands"), 1, false, cmd)
p.Properties.ResponseTopic = mqtt.Topic(s.Client.Config, "acks")
p.Properties.CorrelationData = []byte(id)
p.Properties.MessageExpiry = paho.Uint32(15)
return p
}
func TestDuplicateAndConflict(t *testing.T) {
s := testSimulator()
ctx := context.Background()
id := backend.ID()
p := commandPacket(s, "ping", id)
s.Handle(ctx, p)
s.Handle(ctx, p)
if len(s.cache) != 1 {
t.Fatal("duplicate not bounded")
}
s.CompleteDue(ctx, time.Now())
s.Handle(ctx, p)
s.CompleteDue(ctx, time.Now())
if s.Executions != 1 || !s.cache[id].complete {
t.Fatal("duplicate executed again")
}
conflict := commandPacket(s, "get_version", id)
s.Handle(ctx, conflict)
s.CompleteDue(ctx, time.Now())
if s.Executions != 1 || s.cache[id].command.Command != "ping" {
t.Fatal("conflicting reuse executed")
}
}
func TestInvalidMetadataAndCapacity(t *testing.T) {
for _, bad := range []string{"retained", "topic", "correlation", "expiry", "qos"} {
t.Run(bad, func(t *testing.T) {
s := testSimulator()
p := commandPacket(s, "ping", backend.ID())
switch bad {
case "retained":
p.Retain = true
case "topic":
p.Properties.ResponseTopic = "other/acks"
case "correlation":
p.Properties.CorrelationData = nil
case "expiry":
p.Properties.MessageExpiry = nil
case "qos":
p.QoS = 0
}
s.Handle(context.Background(), p)
if len(s.cache) != 0 {
t.Fatal("invalid command admitted")
}
})
}
s := testSimulator()
s.Client.Config.Limits.DedupEntries = 1
s.Client.Config.Simulator.AckDelay = time.Second
s.Handle(context.Background(), commandPacket(s, "ping", backend.ID()))
s.Handle(context.Background(), commandPacket(s, "ping", backend.ID()))
if len(s.cache) != 1 {
t.Fatal("cache capacity ignored")
}
s.CompleteDue(context.Background(), time.Now().Add(time.Minute))
if s.Executions != 0 {
t.Fatal("expired command executed")
}
s.purge(time.Now().Add(time.Hour))
if len(s.cache) != 0 {
t.Fatal("completed cache not purged")
}
}
func TestCancellationStopsAdmissionAndExecution(t *testing.T) {
s := testSimulator()
s.Handle(context.Background(), commandPacket(s, "ping", backend.ID()))
ctx, cancel := context.WithCancel(context.Background())
cancel()
s.Handle(ctx, commandPacket(s, "ping", backend.ID()))
s.CompleteDue(ctx, time.Now().Add(time.Second))
if len(s.cache) != 1 || s.Executions != 0 {
t.Fatal("work admitted/executed after cancellation")
}
}
+14
View File
@@ -0,0 +1,14 @@
(()=>{
const q=(selector)=>document.querySelector(selector),limit=1000;
const timeline=q('#timeline'),notice=q('#notice'),streamLabel=q('#stream'),connection=q('#connection'),filters=q('#filters');
const items=[];let paused=false,overflow=0,lastId=0,source=null;
function filtered(event){const values=new FormData(filters);return (!values.get('direction')||String(event.direction||'').toLowerCase().includes(String(values.get('direction')).trim().toLowerCase()))&&(!values.get('topic')||String(event.topic||'').toLowerCase().includes(String(values.get('topic')).trim().toLowerCase()))&&(!values.get('device')||String(event.device_id||'').toLowerCase().includes(String(values.get('device')).trim().toLowerCase()))&&(!values.get('request')||String(event.request_id||'').toLowerCase().includes(String(values.get('request')).trim().toLowerCase()))}
function node(tag,className,text){const el=document.createElement(tag);if(className)el.className=className;if(text!==undefined)el.textContent=text;return el}
function renderEvent(e){const card=node('article','event '+e.direction);const head=node('div','event-head');head.append(node('span','kind',e.kind));head.append(node('time','time',new Date(e.observed_at).toLocaleTimeString()));const details=[e.direction,e.topic,e.device_id&&('device '+e.device_id),e.request_id&&('request '+e.request_id),'QoS '+e.qos,e.retain?'retained':'non-retained',e.outcome].filter(Boolean);head.append(node('span','meta',details.join(' · ')));card.append(head);const p=e.payload||{};if(p.text||p.omitted){card.append(node('pre','',p.omitted?('Payload omitted · '+p.bytes+' bytes'):p.text+(p.truncated?' … [preview truncated]':'')))}const props=e.properties||{};const values=[];if(props.response_topic)values.push('Response Topic '+props.response_topic);if(props.correlation_data)values.push('Correlation Data '+props.correlation_data);if(props.message_expiry_seconds!==undefined)values.push('Message Expiry '+props.message_expiry_seconds+'s');if(values.length)card.append(node('div','meta',values.join(' · ')));if(e.latency_ms!==undefined)card.append(node('div','meta','This process measured '+e.latency_ms+' ms'));return card}
function redraw(){timeline.replaceChildren();let shown=0;for(const e of items){if(!filtered(e))continue;timeline.append(renderEvent(e));shown++}if(!shown)timeline.append(node('div','empty',items.length?'No events match these filters.':'Waiting for events. Start the device simulator or ESP32 to see input and output.'));notice.textContent=overflow?'Event gap: '+overflow+' earlier event(s) are outside this page’s bounded view.':''}
function add(e){lastId=Math.max(lastId,e.id||0);if(paused){overflow++;return}items.push(e);if(items.length>limit)items.splice(0,items.length-limit);redraw()}
function compatibility(rows){const tbody=q('#compatibility');tbody.replaceChildren();for(const row of rows||[]){const tr=document.createElement('tr');tr.append(node('td','',row.feature));const td=node('td');td.append(node('span','status '+row.status,row.status));tr.append(td,node('td','',row.evidence));tbody.append(tr)}}
function stats(data){connection.textContent=data.connection||'starting';connection.className='badge '+(data.connection||'starting');const box=q('#stats');box.replaceChildren();const all=[['Retained events',data.events?.length||0],['Last event ID',data.last_event_id||0],['Received',data.counts?.message_received||0],['Contract errors',data.counts?.validation_failed||0],['Published',data.counts?.publish_attempt||0],['Transport ACK',data.counts?.transport_ack||0],['Receipt ACKs',data.counts?.receipt_ack_sent||0],['ACK failures',data.counts?.receipt_ack_failed||0],['ACK queue drops',data.counts?.receipt_ack_dropped||0],['Accepted',data.counts?.command_accepted||0],['Completed',data.counts?.command_completed||0],['Timeouts',data.counts?.command_timeout||0],['Event gaps',data.counts?.event_gap||0]];for(const [label,value]of all){const card=node('div','stat');card.append(node('b','',String(value)),node('span','',label));box.append(card)}compatibility(data.compatibility)}
async function connect(){streamLabel.textContent='connecting';try{const response=await fetch('/api/snapshot',{cache:'no-store'});if(!response.ok)throw Error();const data=await response.json();stats(data);if(!source){if(data.gap){lastId=Number(data.gap.reset_id)||lastId;overflow+=Number(data.gap.gap)||1}for(const event of data.events||[])add(event);source=new EventSource('/api/events?after='+encodeURIComponent(lastId));source.addEventListener('mqtt-event',e=>{try{add(JSON.parse(e.data).event)}catch{notice.textContent='Received an unreadable event.'}});source.addEventListener('gap',e=>{let value={};try{value=JSON.parse(e.data)}catch{}lastId=Number(value.reset_id)||lastId;overflow+=Number(value.gap)||1;streamLabel.textContent='resynced after event gap';redraw()});source.onopen=()=>{streamLabel.textContent='live'};source.onerror=()=>{streamLabel.textContent='reconnecting'}}}catch{connection.textContent='viewer unavailable';connection.className='badge disconnected';streamLabel.textContent='disconnected';setTimeout(connect,2000)}}
q('#pause').addEventListener('click',()=>{paused=!paused;q('#pause').textContent=paused?'Resume view':'Pause view';if(!paused){overflow=0;redraw()}});q('#clear').addEventListener('click',()=>{items.length=0;redraw()});filters.addEventListener('input',redraw);filters.addEventListener('reset',()=>setTimeout(redraw,0));connect();setInterval(async()=>{try{const r=await fetch('/api/snapshot',{cache:'no-store'});if(r.ok)stats(await r.json())}catch{}},3000)
})();
+1
View File
@@ -0,0 +1 @@
:root{color-scheme:dark;--bg:#10151c;--panel:#19232d;--edge:#2b3946;--text:#edf4f7;--muted:#9badb8;--blue:#77c8e9;--green:#66d6a2;--orange:#ffca77;--red:#ff9292;font:15px/1.5 ui-sans-serif,system-ui,sans-serif}*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 90% -10%,#244154 0,transparent 42%),var(--bg);color:var(--text)}header,main{width:min(1360px,calc(100% - 40px));margin:auto}header{padding:42px 0 28px;display:flex;align-items:center;justify-content:space-between;gap:18px}.eyebrow{letter-spacing:.16em;color:var(--blue);font-size:.75rem;font-weight:700;margin:0 0 8px}h1{font-size:clamp(2rem,5vw,3.4rem);line-height:1.05;margin:0}h2{margin:0 0 10px;font-size:1.25rem}.sub,.hint{color:var(--muted);margin:8px 0}.badge,.stream{border:1px solid var(--edge);padding:7px 12px;border-radius:999px;color:var(--muted);white-space:nowrap}.badge.connected{color:var(--green);border-color:#34785c}.badge.disconnected{color:var(--red);border-color:#76414b}.badge.starting{color:var(--orange)}main{padding-bottom:60px}.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(135px,1fr));gap:10px;margin-bottom:16px}.stat,.compat,.events{border:1px solid var(--edge);background:linear-gradient(145deg,#1b2731,#151d26);border-radius:14px}.stat{padding:14px}.stat b{display:block;font-size:1.55rem}.stat span{font-size:.82rem;color:var(--muted)}.compat,.events{padding:20px;margin-top:14px}.table-scroll{overflow:auto}table{width:100%;border-collapse:collapse;text-align:left}th,td{padding:11px;border-bottom:1px solid var(--edge);vertical-align:top}th{color:var(--muted);font-size:.8rem;font-weight:600}.status{display:inline-block;border:1px solid var(--edge);padding:2px 9px;border-radius:999px;color:var(--orange);font-size:.8rem}.status.passed{color:var(--green);border-color:#34785c}.status.failed{color:var(--red);border-color:#76414b}.status.blocked,.status.unsupported{color:var(--muted)}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}button,input{font:inherit;color:var(--text);background:#101820;border:1px solid var(--edge);border-radius:8px;padding:8px 10px}button{cursor:pointer}button:hover{border-color:var(--blue)}button:focus-visible,input:focus-visible{outline:2px solid var(--blue);outline-offset:2px}.stream{font-size:.8rem}.filters{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));align-items:end;gap:10px;margin:18px 0}.filters label{display:grid;gap:5px;color:var(--muted);font-size:.82rem}.notice{min-height:1.5em;color:var(--orange)}.timeline{display:grid;gap:9px}.event{border:1px solid var(--edge);border-left:3px solid var(--blue);border-radius:9px;background:#111921;padding:12px 14px;min-width:0}.event.out{border-left-color:#b79cff}.event.internal{border-left-color:var(--orange)}.event-head{display:flex;gap:9px;align-items:center;flex-wrap:wrap}.kind{font-weight:700}.time,.meta{color:var(--muted);font-size:.82rem}.event pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:160px;overflow:auto;margin:9px 0 0;padding:10px;background:#0b1117;border-radius:7px;color:#c9e5ef;font:12px/1.5 ui-monospace,monospace}.empty{border:1px dashed var(--edge);padding:28px;text-align:center;border-radius:10px;color:var(--muted)}@media(max-width:720px){header{align-items:flex-start;padding-top:26px;flex-direction:column}.section-heading{align-items:flex-start;flex-direction:column}.compat,.events{padding:14px}header,main{width:min(100% - 24px,1360px)}}
+320
View File
@@ -0,0 +1,320 @@
// Package web serves an optional loopback-only, read-only event inspector.
package web
import (
"context"
"embed"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"robot-market/test-mqtt-5/internal/config"
"robot-market/test-mqtt-5/internal/events"
)
const maxReplay = 500
//go:embed assets
var assets embed.FS
type Compatibility struct {
Feature string `json:"feature"`
Status string `json:"status"`
Evidence string `json:"evidence"`
}
type Snapshot struct {
Events []events.Event `json:"events"`
Counts map[events.Kind]uint64 `json:"counts"`
Gap *events.Notice `json:"gap,omitempty"`
Connection string `json:"connection"`
LastEventID uint64 `json:"last_event_id"`
Compatibility []Compatibility `json:"compatibility"`
}
type Viewer struct {
cfg config.Web
store *events.Store
shutdown time.Duration
handler http.Handler
server *http.Server
listener net.Listener
done chan error
slots chan struct{}
active atomic.Int32
once sync.Once
}
func New(cfg config.Web, store *events.Store, shutdown time.Duration) *Viewer {
v := &Viewer{cfg: cfg, store: store, shutdown: shutdown, done: make(chan error, 1), slots: make(chan struct{}, cfg.MaxViewers)}
mux := http.NewServeMux()
mux.HandleFunc("GET /", v.index)
sub, _ := fs.Sub(assets, "assets")
mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(sub))))
mux.HandleFunc("GET /api/snapshot", v.snapshot)
mux.HandleFunc("GET /api/events", v.stream)
v.handler = v.guard(mux)
v.server = &http.Server{Handler: v.handler, ReadHeaderTimeout: 3 * time.Second, IdleTimeout: 20 * time.Second, MaxHeaderBytes: 8 << 10}
return v
}
func (v *Viewer) Address() string {
if v.listener == nil {
return v.cfg.Address
}
return v.listener.Addr().String()
}
func (v *Viewer) Handler() http.Handler { return v.handler }
func (v *Viewer) Start() error {
listener, err := net.Listen("tcp", v.cfg.Address)
if err != nil {
return errors.New("viewer could not bind configured loopback address")
}
v.listener = listener
go func() {
err := v.server.Serve(listener)
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
v.done <- err
}()
return nil
}
func (v *Viewer) Close(ctx context.Context) error {
var result error
v.once.Do(func() {
if v.listener == nil {
return
}
result = v.server.Shutdown(ctx)
if result != nil {
_ = v.server.Close()
}
select {
case err := <-v.done:
if result == nil {
result = err
}
case <-ctx.Done():
if result == nil {
result = errors.New("viewer shutdown deadline exceeded")
}
}
})
return result
}
func (v *Viewer) guard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
if !allowedHost(r.Host) {
http.Error(w, "local viewer only", http.StatusForbidden)
return
}
if origin := r.Header.Get("Origin"); origin != "" {
parsed, err := url.Parse(origin)
if err != nil || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Scheme != "http" || !strings.EqualFold(parsed.Host, r.Host) {
http.Error(w, "same-origin viewer only", http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}
func allowedHost(hostport string) bool {
host, _, err := net.SplitHostPort(hostport)
if err != nil {
return false
}
return strings.EqualFold(host, "127.0.0.1") || strings.EqualFold(host, "localhost")
}
func (v *Viewer) index(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(indexHTML)
}
var indexHTML = []byte(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>MQTT test viewer</title><link rel="stylesheet" href="/assets/style.css"></head>
<body><header><div><p class="eyebrow">ROBOT MARKET · TEST LAB</p><h1>MQTT message viewer</h1><p class="sub">Live, read-only view of the Go observer event stream.</p></div><span id="connection" class="badge">starting</span></header>
<main><section class="stats" id="stats" aria-live="polite"></section><section class="compat"><h2>Compatibility evidence</h2><p class="hint">A message arriving does not establish the device’s MQTT version or native MQTT 5 feature support.</p><div class="table-scroll"><table><thead><tr><th>Feature</th><th>Status</th><th>Evidence</th></tr></thead><tbody id="compatibility"></tbody></table></div></section>
<section class="events"><div class="section-heading"><div><h2>Event timeline</h2><p class="hint">Incoming message previews. Pause affects the display, not MQTT processing.</p></div><div class="actions"><span id="stream" class="stream">connecting</span><button id="pause">Pause view</button><button id="clear">Clear view</button></div></div>
<form id="filters" class="filters"><label>Direction<input name="direction" placeholder="in / out"></label><label>Topic contains<input name="topic"></label><label>Device ID<input name="device"></label><label>Request ID<input name="request"></label><button type="reset">Clear filters</button></form>
<p id="notice" class="notice" aria-live="polite"></p><div id="timeline" class="timeline" aria-live="polite"></div></section></main><script src="/assets/app.js" defer></script></body></html>`)
func (v *Viewer) snapshot(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
eventsList, counts := v.store.Snapshot()
var gap *events.Notice
if len(eventsList) > maxReplay {
reset := eventsList[len(eventsList)-maxReplay].ID - 1
gap = &events.Notice{Gap: uint64(len(eventsList) - maxReplay), ResetID: reset}
eventsList = eventsList[len(eventsList)-maxReplay:]
} else if len(eventsList) > 0 && eventsList[0].ID > 1 {
gap = &events.Notice{Gap: eventsList[0].ID - 1, ResetID: eventsList[0].ID - 1}
}
snap := makeSnapshot(eventsList, counts)
snap.Gap = gap
if err := json.NewEncoder(w).Encode(snap); err != nil {
return
}
}
func makeSnapshot(list []events.Event, counts map[events.Kind]uint64) Snapshot {
connection := "starting"
var last uint64
for _, e := range list {
if e.ID > last {
last = e.ID
}
switch e.Kind {
case events.ConnectionUp:
connection = "connected"
case events.ConnectionDown:
connection = "disconnected"
}
}
compatible := []Compatibility{
{Feature: "Go backend MQTT 5 session", Status: "not_tested", Evidence: "No connection event recorded in the retained viewer history."},
{Feature: "Device JSON telemetry/status contract", Status: "not_tested", Evidence: "No device payload has been validated in this observer session."},
{Feature: "ESP32 native response/correlation round trip", Status: "not_tested", Evidence: "The viewer is read-only and cannot issue a correlated command. Payload arrival alone is not proof."},
}
for _, e := range list {
if e.Kind == "scenario_result" && e.Scenario != nil {
r := e.Scenario
compatible = append(compatible, Compatibility{Feature: r.Feature, Status: r.Status, Evidence: "Reported " + r.Environment + " · " + r.Endpoint + " · " + r.Version + " · " + r.Time.Format(time.RFC3339) + " · " + r.Observation})
}
if e.Kind == events.ConnectionUp && strings.Contains(e.Outcome, "protocol=5") {
compatible[0].Status = "passed"
compatible[0].Evidence = "Go MQTT 5 observer connection established (client " + safeEvidence(e.ClientID) + "). This says nothing about an ESP32 session."
}
if e.Kind == events.MessageReceived && !strings.Contains(e.Outcome, "contract validation disabled") && strings.Contains(e.Topic, "/telemetry") && e.Payload.Text != "" {
compatible[1].Status = "passed"
compatible[1].Evidence = "A telemetry JSON payload matched the test contract for device " + safeEvidence(e.DeviceID) + ". This does not identify the device implementation or prove ESP32 compatibility."
}
}
return Snapshot{Events: list, Counts: counts, Connection: connection, LastEventID: last, Compatibility: compatible}
}
func safeEvidence(s string) string {
if len(s) > 64 {
return s[:64]
}
return s
}
func (v *Viewer) stream(w http.ResponseWriter, r *http.Request) {
after, err := cursor(r)
if err != nil {
http.Error(w, "invalid event cursor", http.StatusBadRequest)
return
}
select {
case v.slots <- struct{}{}:
defer func() { <-v.slots }()
case <-r.Context().Done():
return
default:
http.Error(w, "viewer connection limit reached", http.StatusServiceUnavailable)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "stream unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()
v.active.Add(1)
defer v.active.Add(-1)
replay, gap, stream, unsubscribe := v.store.Subscribe(after, v.cfg.ViewerQueue, maxReplay)
defer unsubscribe()
if gap != nil {
if !writeSSE(w, flusher, "gap", gap.ResetID, *gap) {
return
}
}
for _, event := range replay {
if !writeSSE(w, flusher, "mqtt-event", event.ID, events.Notice{Event: &event}) {
return
}
}
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
return
case notice, open := <-stream:
if !open {
return
}
if notice.Gap > 0 {
if !writeSSE(w, flusher, "gap", notice.ResetID, notice) {
return
}
}
if notice.Event != nil {
if !writeSSE(w, flusher, "mqtt-event", notice.Event.ID, notice) {
return
}
}
case <-ticker.C:
controller := http.NewResponseController(w)
_ = controller.SetWriteDeadline(time.Now().Add(3 * time.Second))
if _, err := fmt.Fprint(w, ": keepalive\n\n"); err != nil {
return
}
flusher.Flush()
}
}
}
func cursor(r *http.Request) (uint64, error) {
value := r.Header.Get("Last-Event-ID")
if value == "" {
value = r.URL.Query().Get("after")
}
if value == "" {
return 0, nil
}
n, err := strconv.ParseUint(value, 10, 64)
if err != nil {
return 0, errors.New("bad event cursor")
}
return n, nil
}
func writeSSE(w http.ResponseWriter, f http.Flusher, name string, id uint64, value any) bool {
data, err := json.Marshal(value)
if err != nil {
return false
}
controller := http.NewResponseController(w)
if err = controller.SetWriteDeadline(time.Now().Add(3 * time.Second)); err != nil && !errors.Is(err, http.ErrNotSupported) {
return false
}
if id > 0 {
if _, err = fmt.Fprintf(w, "id: %d\n", id); err != nil {
return false
}
}
if _, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", name, data); err != nil {
return false
}
f.Flush()
return true
}
func (v *Viewer) ActiveViewers() int { return int(v.active.Load()) }
+164
View File
@@ -0,0 +1,164 @@
package web
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http/httptest"
"strings"
"testing"
"time"
"robot-market/test-mqtt-5/internal/config"
"robot-market/test-mqtt-5/internal/events"
)
func testViewer() *Viewer {
c := config.Defaults()
return New(c.Web, events.NewStore(8, slog.New(slog.NewTextHandler(io.Discard, nil))), time.Second)
}
func TestHostOriginAndSecurityHeaders(t *testing.T) {
v := testViewer()
cases := []struct {
name, host, origin string
want int
}{
{"local", "127.0.0.1:8080", "", 200},
{"same origin", "127.0.0.1:8080", "http://127.0.0.1:8080", 200},
{"foreign host", "evil.example", "", 403},
{"foreign origin", "127.0.0.1:8080", "http://evil.example", 403},
{"null origin", "127.0.0.1:8080", "null", 403},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest("GET", "http://127.0.0.1/", nil)
r.Host = tc.host
if tc.origin != "" {
r.Header.Set("Origin", tc.origin)
}
w := httptest.NewRecorder()
v.Handler().ServeHTTP(w, r)
if w.Code != tc.want {
t.Fatalf("got %d, want %d", w.Code, tc.want)
}
if w.Code == 200 {
for key, want := range map[string]string{"Content-Security-Policy": "default-src 'none'", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY"} {
if !strings.Contains(w.Header().Get(key), want) {
t.Fatalf("missing %s", key)
}
}
}
})
}
}
func TestAssetsUseTextOnlyRendering(t *testing.T) {
script, err := assets.ReadFile("assets/app.js")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(script), ".textContent") || strings.Contains(string(script), ".innerHTML") || strings.Contains(string(script), "insertAdjacentHTML") {
t.Fatal("viewer must render event data with text-only DOM sinks")
}
}
func TestSnapshotEscapesHostileValuesAndSeparatesCompatibility(t *testing.T) {
v := testViewer()
hostile := "<script>alert(1)</script>"
v.store.Add(events.Event{Kind: events.MessageReceived, Direction: events.In, Outcome: hostile, DeviceID: "sim-001", Topic: "robot-market/test/v1/sim-001/status"})
v.store.Add(events.Event{Kind: events.ConnectionUp, Direction: events.Internal, Outcome: "session_present=false; protocol=5", ClientID: "observer"})
r := httptest.NewRequest("GET", "http://127.0.0.1:8080/api/snapshot", nil)
r.Host = "127.0.0.1:8080"
w := httptest.NewRecorder()
v.Handler().ServeHTTP(w, r)
if w.Code != 200 || strings.Contains(w.Body.String(), hostile) || !strings.Contains(w.Body.String(), `\u003cscript\u003e`) {
t.Fatalf("hostile content not JSON-escaped: %s", w.Body.String())
}
var data Snapshot
if err := json.Unmarshal(w.Body.Bytes(), &data); err != nil {
t.Fatal(err)
}
if data.Connection != "connected" || data.Compatibility[0].Status != "passed" || data.Compatibility[1].Status != "not_tested" || data.Compatibility[2].Status != "not_tested" {
t.Fatal("connection/compatibility evidence conflated")
}
}
func TestSSEReplaysGapAndRejectsBadCursor(t *testing.T) {
v := testViewer()
for i := 0; i < 10; i++ {
v.store.Add(events.Event{Kind: events.MessageReceived})
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
r := httptest.NewRequest("GET", "http://127.0.0.1:8080/api/events?after=1", nil).WithContext(ctx)
r.Host = "127.0.0.1:8080"
w := httptest.NewRecorder()
done := make(chan struct{})
go func() { v.Handler().ServeHTTP(w, r); close(done) }()
time.Sleep(10 * time.Millisecond)
cancel()
<-done
body := w.Body.String()
if w.Code != 200 || !strings.Contains(body, "event: gap") || !strings.Contains(body, `"reset_id":2`) || !strings.Contains(body, "id: 3") {
t.Fatalf("replay gap missing: %s", body)
}
bad := httptest.NewRequest("GET", "http://127.0.0.1:8080/api/events?after=bad", nil)
bad.Host = "127.0.0.1:8080"
out := httptest.NewRecorder()
v.Handler().ServeHTTP(out, bad)
if out.Code != 400 {
t.Fatal("bad cursor accepted")
}
}
func TestSSEViewerLimitAndDisconnectCleanup(t *testing.T) {
c := config.Defaults()
c.Web.MaxViewers = 1
v := New(c.Web, events.NewStore(8, slog.New(slog.NewTextHandler(io.Discard, nil))), time.Second)
request, stop := context.WithCancel(context.Background())
defer stop()
req := httptest.NewRequest("GET", "http://127.0.0.1:8080/api/events", nil).WithContext(request)
req.Host = "127.0.0.1:8080"
response := httptest.NewRecorder()
done := make(chan struct{})
go func() { v.Handler().ServeHTTP(response, req); close(done) }()
deadline := time.Now().Add(time.Second)
for v.ActiveViewers() != 1 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
second := httptest.NewRecorder()
secondRequest := httptest.NewRequest("GET", "http://127.0.0.1:8080/api/events", nil)
secondRequest.Host = "127.0.0.1:8080"
v.Handler().ServeHTTP(second, secondRequest)
if second.Code != 503 {
t.Fatal("viewer limit not enforced")
}
stop()
<-done
deadline = time.Now().Add(time.Second)
for v.ActiveViewers() != 0 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if v.ActiveViewers() != 0 {
t.Fatal("viewer disconnect was not cleaned up")
}
}
func TestReportedScenariosDoNotClaimHardwareCompatibility(t *testing.T) {
r := &events.ScenarioResult{Feature: "session expiry", Environment: "Go clients", Endpoint: "tls://test:8884", Version: "MQTT 5", Status: "passed", Observation: "session absent after expiry", Time: time.Now().UTC()}
v := testViewer()
v.store.Add(events.Event{Kind: "scenario_result", Scenario: r})
r.Feature = "mutated"
list, counts := v.store.Snapshot()
snap := makeSnapshot(list, counts)
if len(snap.Compatibility) != 4 || snap.Compatibility[3].Feature != "session expiry" || snap.Compatibility[2].Status != "not_tested" || !strings.Contains(snap.Compatibility[3].Evidence, "Reported Go clients") {
t.Fatal("reported evidence or hardware distinction lost")
}
list[0].Scenario.Feature = "mutated snapshot"
again, _ := v.store.Snapshot()
if again[0].Scenario.Feature != "session expiry" {
t.Fatal("scenario aliases retained history")
}
}