Deploy MQTT test backend with private viewer and Gitea CI/CD
This commit is contained in:
@@ -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