Opening a shop is an API call; the broker learns of it in the same request
The last step of onboarding that needed a shell: provision site printed a broker password and a person typed it into Mosquitto's passwd file on the host - mounted read-only in the container, so the first attempt failed silently and the password was re-rolled. No tenant could open a second branch without us. The server now drives Mosquitto's dynamic-security plugin over its own broker login: POST /api/sites (owner) writes the row and the sealed password, registers the login and a per-site role with literal topics (the 2.0 plugin does not substitute %u - measured), and removes the row again if the broker refuses, so a shop cannot exist in the database and not on the broker. provision site goes through the same path. The head-office Shops screen gets 'Open a new shop'. broker-init converts the existing passwd file into the plugin's store with every hash intact - PBKDF2-SHA512 both sides - so the cutover re-claims no shop PC. Rehearsed locally: old logins keep working, isolation holds, the health probe works, and a PC claiming a shop opened through the API connects as that shop. run-local.sh now brings the broker up the same way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
110
server/internal/api/sites_test.go
Normal file
110
server/internal/api/sites_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeBroker records what the server asked the broker to do.
|
||||
type fakeBroker struct {
|
||||
ensured map[string]string
|
||||
deleted []string
|
||||
fail error
|
||||
}
|
||||
|
||||
func (b *fakeBroker) EnsureSite(_ context.Context, user, pass string) error {
|
||||
if b.fail != nil {
|
||||
return b.fail
|
||||
}
|
||||
if b.ensured == nil {
|
||||
b.ensured = map[string]string{}
|
||||
}
|
||||
b.ensured[user] = pass
|
||||
return nil
|
||||
}
|
||||
func (b *fakeBroker) DeleteSite(_ context.Context, user string) error {
|
||||
b.deleted = append(b.deleted, user)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ownerSession(t *testing.T, s *Server, fs *fakeStore) Session {
|
||||
t.Helper()
|
||||
fs.addUser("owner@acme.com", "correct horse battery", UserRecord{
|
||||
ID: "u-owner", ClientID: "client-acme", Role: "owner", Active: true,
|
||||
})
|
||||
return login(t, s, "owner@acme.com", "correct horse battery")
|
||||
}
|
||||
|
||||
func TestAnOwnerOpensAShopAndTheBrokerLearnsOfIt(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
b := &fakeBroker{}
|
||||
s.Broker = b
|
||||
sess := ownerSession(t, s, fs)
|
||||
|
||||
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Acme Bengaluru!"})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out["slug"] != "acme-bengaluru" {
|
||||
t.Errorf("slug not derived from the name: %v", out["slug"])
|
||||
}
|
||||
if _, leaked := out["password"]; leaked {
|
||||
t.Fatal("the broker password was serialised")
|
||||
}
|
||||
if b.ensured["acme.acme-bengaluru"] == "" {
|
||||
t.Fatalf("broker was not told about the shop: %+v", b.ensured)
|
||||
}
|
||||
if len(fs.sites) != 1 {
|
||||
t.Fatalf("expected one site, have %d", len(fs.sites))
|
||||
}
|
||||
}
|
||||
|
||||
func TestABrokerFailureLeavesNoHalfMadeShop(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Broker = &fakeBroker{fail: errors.New("no answer on the control topic")}
|
||||
sess := ownerSession(t, s, fs)
|
||||
|
||||
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Ghost"})
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(fs.sites) != 0 {
|
||||
t.Fatalf("a shop the broker never accepted was kept: %+v", fs.sites)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAManagerCannotOpenAShop(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Broker = &fakeBroker{}
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Nope"})
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("manager opened a shop: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADuplicateShortNameIsAConflict(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Broker = &fakeBroker{}
|
||||
seedSite(fs)
|
||||
sess := ownerSession(t, s, fs)
|
||||
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Again", "slug": "chennai"})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoBrokerConfiguredSaysSo(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
sess := ownerSession(t, s, fs)
|
||||
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Shop"})
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user