Deploy MQTT test backend with private viewer and Gitea CI/CD
This commit is contained in:
@@ -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])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: ©Event}:
|
||||
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() }) }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})();
|
||||
@@ -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)}}
|
||||
@@ -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()) }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user