pos gap fix
This commit is contained in:
@@ -1015,3 +1015,20 @@ func (ctl *PosController) WebUpdateStaffShift(c *fiber.Ctx) error {
|
|||||||
"message": "Shift updated", "details": shift,
|
"message": "Shift updated", "details": shift,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthAdoption reports how much of the till fleet is carrying a session token.
|
||||||
|
//
|
||||||
|
// The answer to "is it safe to set POS_AUTH_REQUIRED=true yet". Every outlet it
|
||||||
|
// lists is a till that would stop being able to ring a bill the moment
|
||||||
|
// enforcement goes on.
|
||||||
|
//
|
||||||
|
// Behind the web session guard on purpose: that list is also a map of which
|
||||||
|
// shops are reachable without a credential today.
|
||||||
|
func (ctl *PosController) AuthAdoption(c *fiber.Ctx) error {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"status": true,
|
||||||
|
"message": "Success",
|
||||||
|
"details": middleware.PosAdoptionReport(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
12
main.go
12
main.go
@@ -1,12 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"nearle/config"
|
"nearle/config"
|
||||||
"nearle/db"
|
"nearle/db"
|
||||||
"nearle/facade"
|
"nearle/facade"
|
||||||
"nearle/messaging"
|
"nearle/messaging"
|
||||||
|
"nearle/middleware"
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
"nearle/repositories"
|
"nearle/repositories"
|
||||||
"nearle/routes"
|
"nearle/routes"
|
||||||
@@ -510,6 +512,16 @@ func main() {
|
|||||||
repositories.SetCatalogueNotifier(posMqtt)
|
repositories.SetCatalogueNotifier(posMqtt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// How much of the till fleet is carrying a session token, in the log every
|
||||||
|
// half hour.
|
||||||
|
//
|
||||||
|
// `POS_AUTH_REQUIRED` is off, and the only thing between here and switching
|
||||||
|
// it on is that number — nothing was recording it, so an untokened till was
|
||||||
|
// waved through in silence and the risk of flipping the flag could only be
|
||||||
|
// measured by flipping it. The same figures are on
|
||||||
|
// `GET /v1/web/pos/authadoption`, behind the session guard.
|
||||||
|
go middleware.LogPosAdoption(context.Background())
|
||||||
|
|
||||||
// Start server on APP_PORT (1122 locally, 1009 in production — see the
|
// Start server on APP_PORT (1122 locally, 1009 in production — see the
|
||||||
// env files). Running a second copy beside something else is a one-line
|
// env files). Running a second copy beside something else is a one-line
|
||||||
// change there rather than here.
|
// change there rather than here.
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ func PosAuth(pos services.PosService) fiber.Handler {
|
|||||||
token := bearerToken(c)
|
token := bearerToken(c)
|
||||||
|
|
||||||
if token == "" {
|
if token == "" {
|
||||||
|
// Counted before anything else happens to it. This is the number
|
||||||
|
// that decides when POS_AUTH_REQUIRED can be switched on, and
|
||||||
|
// nothing else in the system was recording it — the request was
|
||||||
|
// simply waved through in silence. See posauthadoption.go.
|
||||||
|
recordPosUntokened(requestedLocation(c), c.Path())
|
||||||
|
|
||||||
if posAuthRequired() {
|
if posAuthRequired() {
|
||||||
return posUnauthorized(c, "a session token is required; sign in at /pos/login")
|
return posUnauthorized(c, "a session token is required; sign in at /pos/login")
|
||||||
}
|
}
|
||||||
@@ -96,6 +102,12 @@ func PosAuth(pos services.PosService) fiber.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A till that has adopted the new sign-in. Counted only once the token
|
||||||
|
// has verified AND the outlet check has passed, so the figure means
|
||||||
|
// "requests this guard would still serve with enforcement on" rather
|
||||||
|
// than "requests that carried something token-shaped".
|
||||||
|
recordPosToken()
|
||||||
|
|
||||||
c.Locals(PosLocalsKey, claims)
|
c.Locals(PosLocalsKey, claims)
|
||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
|
|||||||
249
middleware/posauthadoption.go
Normal file
249
middleware/posauthadoption.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
How much of the till fleet is carrying a session token.
|
||||||
|
|
||||||
|
── Why this exists ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
`POS_AUTH_REQUIRED` is off, and the only thing standing between here and
|
||||||
|
switching it on is a number nobody has: how many terminals still call the POS
|
||||||
|
routes with no token. Flipping the flag blind is the one action on this surface
|
||||||
|
that can stop a shop trading mid-queue — a cashier at a counter unable to ring a
|
||||||
|
bill is not a reversible inconvenience.
|
||||||
|
|
||||||
|
So this counts, and names the outlets that are still untokened, so the flag gets
|
||||||
|
flipped on evidence rather than on hope.
|
||||||
|
|
||||||
|
── What it deliberately is not ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
Not persisted. It lives in memory and resets on restart, which is honest about
|
||||||
|
what it measures: adoption since this process started, not all time. A restart
|
||||||
|
mid-observation means starting the week again, and that is a smaller cost than a
|
||||||
|
migration and a table for a number that stops mattering the day the flag is on.
|
||||||
|
|
||||||
|
Not a rate limiter and not a gate. It records; it never refuses. Nothing in
|
||||||
|
here can change whether a request is served.
|
||||||
|
|
||||||
|
Capped. `store_id` comes off the wire, so an untokened caller could otherwise
|
||||||
|
name ten thousand outlets and grow this without bound. Past the cap new outlets
|
||||||
|
are counted in the totals and not listed individually, which keeps the answer
|
||||||
|
useful without making it a way to spend the server's memory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// posAdoptionCap is how many distinct untokened outlets are named individually.
|
||||||
|
// The real fleet is dozens; anything beyond this is noise or somebody probing.
|
||||||
|
const posAdoptionCap = 200
|
||||||
|
|
||||||
|
type posOutletSeen struct {
|
||||||
|
Locationid int
|
||||||
|
Requests int64
|
||||||
|
FirstSeen time.Time
|
||||||
|
LastSeen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var posAdoption = struct {
|
||||||
|
sync.Mutex
|
||||||
|
since time.Time
|
||||||
|
tokened int64
|
||||||
|
untokened int64
|
||||||
|
// Untokened requests by the outlet they named, and by the route they hit.
|
||||||
|
outlets map[int]*posOutletSeen
|
||||||
|
paths map[string]int64
|
||||||
|
// True once the cap was reached, so the report can say it is partial
|
||||||
|
// rather than quietly under-reporting.
|
||||||
|
truncated bool
|
||||||
|
}{
|
||||||
|
since: time.Now(),
|
||||||
|
outlets: map[int]*posOutletSeen{},
|
||||||
|
paths: map[string]int64{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordPosToken notes one request that arrived with a usable token.
|
||||||
|
func recordPosToken() {
|
||||||
|
posAdoption.Lock()
|
||||||
|
posAdoption.tokened++
|
||||||
|
posAdoption.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordPosUntokened notes one request that arrived with none, and where it
|
||||||
|
// claimed to be. `locationid` is 0 when the route named no outlet.
|
||||||
|
func recordPosUntokened(locationid int, path string) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
posAdoption.Lock()
|
||||||
|
defer posAdoption.Unlock()
|
||||||
|
|
||||||
|
posAdoption.untokened++
|
||||||
|
posAdoption.paths[path]++
|
||||||
|
|
||||||
|
if locationid <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if seen, ok := posAdoption.outlets[locationid]; ok {
|
||||||
|
seen.Requests++
|
||||||
|
seen.LastSeen = now
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(posAdoption.outlets) >= posAdoptionCap {
|
||||||
|
posAdoption.truncated = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
posAdoption.outlets[locationid] = &posOutletSeen{
|
||||||
|
Locationid: locationid, Requests: 1, FirstSeen: now, LastSeen: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosAdoptionOutlet is one outlet still calling without a token.
|
||||||
|
type PosAdoptionOutlet struct {
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
|
Requests int64 `json:"requests"`
|
||||||
|
FirstSeen string `json:"firstseen"`
|
||||||
|
LastSeen string `json:"lastseen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosAdoptionPath is one route, and how often it was reached untokened.
|
||||||
|
type PosAdoptionPath struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Requests int64 `json:"requests"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosAdoption is the answer to "is it safe to switch enforcement on yet".
|
||||||
|
type PosAdoption struct {
|
||||||
|
// Whether an untokened request is currently refused.
|
||||||
|
Enforced bool `json:"enforced"`
|
||||||
|
// When counting started — process start, not all time.
|
||||||
|
Since string `json:"since"`
|
||||||
|
// Requests seen on the POS surface since then.
|
||||||
|
Tokened int64 `json:"tokened"`
|
||||||
|
Untokened int64 `json:"untokened"`
|
||||||
|
// 0–100. 100 means every request in this window carried a token, which is
|
||||||
|
// the condition for flipping the flag.
|
||||||
|
AdoptedPercent float64 `json:"adoptedpercent"`
|
||||||
|
// The outlets still calling without one, busiest first. These are the tills
|
||||||
|
// that would stop working the moment enforcement is switched on.
|
||||||
|
Outlets []PosAdoptionOutlet `json:"outlets"`
|
||||||
|
// Which routes they are reaching, busiest first.
|
||||||
|
Paths []PosAdoptionPath `json:"paths"`
|
||||||
|
// True when more outlets were seen than are listed — see posAdoptionCap.
|
||||||
|
Truncated bool `json:"truncated"`
|
||||||
|
// Plain-language reading of the above, for whoever has to make the call.
|
||||||
|
Verdict string `json:"verdict"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosAdoptionReport is the snapshot, safe to call at any time.
|
||||||
|
func PosAdoptionReport() PosAdoption {
|
||||||
|
posAdoption.Lock()
|
||||||
|
defer posAdoption.Unlock()
|
||||||
|
|
||||||
|
report := PosAdoption{
|
||||||
|
Enforced: posAuthRequired(),
|
||||||
|
Since: posAdoption.since.Format(time.RFC3339),
|
||||||
|
Tokened: posAdoption.tokened,
|
||||||
|
Untokened: posAdoption.untokened,
|
||||||
|
Truncated: posAdoption.truncated,
|
||||||
|
Outlets: make([]PosAdoptionOutlet, 0, len(posAdoption.outlets)),
|
||||||
|
Paths: make([]PosAdoptionPath, 0, len(posAdoption.paths)),
|
||||||
|
}
|
||||||
|
|
||||||
|
total := posAdoption.tokened + posAdoption.untokened
|
||||||
|
if total > 0 {
|
||||||
|
report.AdoptedPercent = float64(posAdoption.tokened) * 100 / float64(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, seen := range posAdoption.outlets {
|
||||||
|
report.Outlets = append(report.Outlets, PosAdoptionOutlet{
|
||||||
|
Locationid: seen.Locationid,
|
||||||
|
Requests: seen.Requests,
|
||||||
|
FirstSeen: seen.FirstSeen.Format(time.RFC3339),
|
||||||
|
LastSeen: seen.LastSeen.Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Busiest first: the outlet ringing the most bills is the one that hurts
|
||||||
|
// most if enforcement switches on before it has adopted.
|
||||||
|
sort.Slice(report.Outlets, func(i, j int) bool {
|
||||||
|
return report.Outlets[i].Requests > report.Outlets[j].Requests
|
||||||
|
})
|
||||||
|
|
||||||
|
for path, count := range posAdoption.paths {
|
||||||
|
report.Paths = append(report.Paths, PosAdoptionPath{Path: path, Requests: count})
|
||||||
|
}
|
||||||
|
sort.Slice(report.Paths, func(i, j int) bool {
|
||||||
|
return report.Paths[i].Requests > report.Paths[j].Requests
|
||||||
|
})
|
||||||
|
|
||||||
|
report.Verdict = posAdoptionVerdict(report)
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
// posAdoptionVerdict says what the numbers mean, because the number on its own
|
||||||
|
// invites the wrong reading in both directions: a clean window that is only an
|
||||||
|
// hour long proves nothing, and one stubborn outlet is not a reason to leave
|
||||||
|
// the whole surface open.
|
||||||
|
func posAdoptionVerdict(r PosAdoption) string {
|
||||||
|
switch {
|
||||||
|
case r.Enforced:
|
||||||
|
return "Enforcement is already on: an untokened request is refused."
|
||||||
|
case r.Tokened+r.Untokened == 0:
|
||||||
|
return "No POS traffic seen since this process started, so there is nothing to conclude yet."
|
||||||
|
case r.Untokened == 0:
|
||||||
|
return "Every POS request in this window carried a token. Watch for a few trading days — a quiet window is not the same as an adopted fleet — then set POS_AUTH_REQUIRED=true."
|
||||||
|
case len(r.Outlets) == 0:
|
||||||
|
return "Untokened requests are arriving but none names an outlet, so they cannot be traced to a till. Check the paths below before switching enforcement on."
|
||||||
|
default:
|
||||||
|
return "Terminals are still calling without a token. The outlets listed below would stop being able to trade the moment POS_AUTH_REQUIRED=true is set. Update those tills first."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// posAdoptionLogEvery is how often the summary reaches the log.
|
||||||
|
//
|
||||||
|
// Long, because this is a slow-moving fact — a fleet adopts over days, not
|
||||||
|
// minutes — and a log line nobody needs every minute is a log line people learn
|
||||||
|
// to scroll past.
|
||||||
|
const posAdoptionLogEvery = 30 * time.Minute
|
||||||
|
|
||||||
|
// LogPosAdoption prints the summary on a timer until ctx is done.
|
||||||
|
//
|
||||||
|
// In the log as well as on the endpoint because the two get used by different
|
||||||
|
// people at different moments: somebody already reading Dokploy's log because a
|
||||||
|
// till is misbehaving should not have to know an endpoint exists.
|
||||||
|
//
|
||||||
|
// Outlet ids only, never names or counts of takings — a log is the one place
|
||||||
|
// this data ends up somewhere nobody chose to put it.
|
||||||
|
func LogPosAdoption(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(posAdoptionLogEvery)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
report := PosAdoptionReport()
|
||||||
|
if report.Tokened+report.Untokened == 0 {
|
||||||
|
continue // nothing happened; saying so every half hour is noise
|
||||||
|
}
|
||||||
|
if report.Untokened == 0 {
|
||||||
|
log.Printf("pos auth: %.0f%% of %d requests carried a token; no untokened terminals seen since %s",
|
||||||
|
report.AdoptedPercent, report.Tokened+report.Untokened, report.Since)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outlets := make([]string, 0, len(report.Outlets))
|
||||||
|
for _, o := range report.Outlets {
|
||||||
|
outlets = append(outlets, strconv.Itoa(o.Locationid))
|
||||||
|
}
|
||||||
|
log.Printf("pos auth: %.0f%% of %d requests carried a token; %d untokened, from outlet(s) %s — these would stop trading if POS_AUTH_REQUIRED were set",
|
||||||
|
report.AdoptedPercent, report.Tokened+report.Untokened,
|
||||||
|
report.Untokened, strings.Join(outlets, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
297
middleware/posauthadoption_test.go
Normal file
297
middleware/posauthadoption_test.go
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nearle/services"
|
||||||
|
"nearle/utils"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Counting the till fleet's adoption of the session token.
|
||||||
|
|
||||||
|
This exists to answer one question — is it safe to set POS_AUTH_REQUIRED=true —
|
||||||
|
and the cost of answering it wrong is a cashier at a counter who cannot ring a
|
||||||
|
bill. So these are mostly about the figure being honest: not flattering, not
|
||||||
|
alarmist, and never able to change whether a request is served.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// resetAdoption puts the counters back, since they are process-wide.
|
||||||
|
func resetAdoption(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
posAdoption.Lock()
|
||||||
|
posAdoption.since = time.Now()
|
||||||
|
posAdoption.tokened = 0
|
||||||
|
posAdoption.untokened = 0
|
||||||
|
posAdoption.outlets = map[int]*posOutletSeen{}
|
||||||
|
posAdoption.paths = map[string]int64{}
|
||||||
|
posAdoption.truncated = false
|
||||||
|
posAdoption.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUntokenedTillIsNamedByItsOutlet(t *testing.T) {
|
||||||
|
// The whole point. Without this list, switching enforcement on is a guess
|
||||||
|
// about which shops stop trading.
|
||||||
|
resetAdoption(t)
|
||||||
|
|
||||||
|
recordPosUntokened(1185, "/live/api/v1/pos/orders")
|
||||||
|
recordPosUntokened(1185, "/live/api/v1/pos/orders")
|
||||||
|
recordPosUntokened(1170, "/live/api/v1/pos/catalogue")
|
||||||
|
recordPosToken()
|
||||||
|
|
||||||
|
report := PosAdoptionReport()
|
||||||
|
if report.Untokened != 3 || report.Tokened != 1 {
|
||||||
|
t.Fatalf("counts wrong: %+v", report)
|
||||||
|
}
|
||||||
|
if len(report.Outlets) != 2 {
|
||||||
|
t.Fatalf("outlets: %+v", report.Outlets)
|
||||||
|
}
|
||||||
|
// Busiest first — the outlet ringing the most bills is the one that hurts
|
||||||
|
// most if enforcement goes on before it has adopted.
|
||||||
|
if report.Outlets[0].Locationid != 1185 || report.Outlets[0].Requests != 2 {
|
||||||
|
t.Errorf("not ordered by traffic: %+v", report.Outlets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheAdoptedPercentageIsOfEverything(t *testing.T) {
|
||||||
|
resetAdoption(t)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
recordPosToken()
|
||||||
|
}
|
||||||
|
recordPosUntokened(1185, "/pos/orders")
|
||||||
|
|
||||||
|
if got := PosAdoptionReport().AdoptedPercent; got != 75 {
|
||||||
|
t.Fatalf("adopted = %v%%, want 75", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoTrafficIsNotAHundredPercent(t *testing.T) {
|
||||||
|
// A fleet nobody has used is not a fleet that has adopted. Reporting 100%
|
||||||
|
// here is the single most dangerous rounding this file could do — it would
|
||||||
|
// green-light the flag on an empty window.
|
||||||
|
resetAdoption(t)
|
||||||
|
|
||||||
|
report := PosAdoptionReport()
|
||||||
|
if report.AdoptedPercent != 0 {
|
||||||
|
t.Fatalf("empty window reported as %v%%", report.AdoptedPercent)
|
||||||
|
}
|
||||||
|
if !strings.Contains(report.Verdict, "nothing to conclude") {
|
||||||
|
t.Errorf("verdict oversells an empty window: %q", report.Verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACleanWindowStillSaysToKeepWatching(t *testing.T) {
|
||||||
|
// Zero untokened requests in one hour is not an adopted fleet — a shop that
|
||||||
|
// is shut has no traffic either. The verdict has to say so, because the
|
||||||
|
// number on its own reads as permission.
|
||||||
|
resetAdoption(t)
|
||||||
|
recordPosToken()
|
||||||
|
|
||||||
|
verdict := PosAdoptionReport().Verdict
|
||||||
|
if !strings.Contains(verdict, "trading days") {
|
||||||
|
t.Errorf("a one-request window was treated as proof: %q", verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUntokenedFleetIsNotDescribedAsReady(t *testing.T) {
|
||||||
|
resetAdoption(t)
|
||||||
|
recordPosToken()
|
||||||
|
recordPosUntokened(1185, "/pos/orders")
|
||||||
|
|
||||||
|
verdict := PosAdoptionReport().Verdict
|
||||||
|
if !strings.Contains(verdict, "stop being able to trade") {
|
||||||
|
t.Errorf("the consequence is not stated: %q", verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARequestThatNamesNoOutletIsStillCounted(t *testing.T) {
|
||||||
|
// `/pos/staff` deliberately takes no location parameter. Such a request is
|
||||||
|
// still an untokened till, and dropping it would understate the problem.
|
||||||
|
resetAdoption(t)
|
||||||
|
recordPosUntokened(0, "/live/api/v1/pos/staff")
|
||||||
|
|
||||||
|
report := PosAdoptionReport()
|
||||||
|
if report.Untokened != 1 {
|
||||||
|
t.Fatalf("not counted: %+v", report)
|
||||||
|
}
|
||||||
|
if len(report.Outlets) != 0 {
|
||||||
|
t.Errorf("invented an outlet for a request that named none: %+v", report.Outlets)
|
||||||
|
}
|
||||||
|
if len(report.Paths) != 1 || report.Paths[0].Path != "/live/api/v1/pos/staff" {
|
||||||
|
t.Errorf("the route was lost: %+v", report.Paths)
|
||||||
|
}
|
||||||
|
if !strings.Contains(report.Verdict, "cannot be traced") {
|
||||||
|
t.Errorf("verdict does not explain the blind spot: %q", report.Verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutletsCannotGrowWithoutBound(t *testing.T) {
|
||||||
|
// `store_id` comes off the wire. Without a cap an untokened caller could
|
||||||
|
// name ten thousand outlets and spend the server's memory doing it.
|
||||||
|
resetAdoption(t)
|
||||||
|
for i := 1; i <= posAdoptionCap+50; i++ {
|
||||||
|
recordPosUntokened(i, "/pos/orders")
|
||||||
|
}
|
||||||
|
|
||||||
|
report := PosAdoptionReport()
|
||||||
|
if len(report.Outlets) > posAdoptionCap {
|
||||||
|
t.Fatalf("listed %d outlets, cap is %d", len(report.Outlets), posAdoptionCap)
|
||||||
|
}
|
||||||
|
if report.Untokened != int64(posAdoptionCap+50) {
|
||||||
|
// The total must stay true even when the list is trimmed.
|
||||||
|
t.Errorf("total under-reported: %d", report.Untokened)
|
||||||
|
}
|
||||||
|
if !report.Truncated {
|
||||||
|
t.Error("a trimmed list was presented as complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstAndLastSeenAreBothKept(t *testing.T) {
|
||||||
|
// "This till stopped calling untokened three days ago" and "it did so a
|
||||||
|
// minute ago" are different facts, and only one of them means it has been
|
||||||
|
// updated. So the first sighting must stick and the last must move.
|
||||||
|
//
|
||||||
|
// The clock is wound back rather than slept through: the report formats to
|
||||||
|
// RFC3339, which is second-precision, and a test that waits a second to
|
||||||
|
// prove an assignment is a second every run forever.
|
||||||
|
resetAdoption(t)
|
||||||
|
recordPosUntokened(1185, "/pos/orders")
|
||||||
|
|
||||||
|
posAdoption.Lock()
|
||||||
|
seen := posAdoption.outlets[1185]
|
||||||
|
seen.FirstSeen = seen.FirstSeen.Add(-48 * time.Hour)
|
||||||
|
seen.LastSeen = seen.LastSeen.Add(-48 * time.Hour)
|
||||||
|
posAdoption.Unlock()
|
||||||
|
|
||||||
|
recordPosUntokened(1185, "/pos/orders")
|
||||||
|
|
||||||
|
out := PosAdoptionReport().Outlets[0]
|
||||||
|
if out.FirstSeen == "" || out.LastSeen == "" {
|
||||||
|
t.Fatalf("timestamps missing: %+v", out)
|
||||||
|
}
|
||||||
|
if out.Requests != 2 {
|
||||||
|
t.Errorf("requests = %d, want 2", out.Requests)
|
||||||
|
}
|
||||||
|
if out.FirstSeen == out.LastSeen {
|
||||||
|
t.Errorf("last seen never moved: %+v", out)
|
||||||
|
}
|
||||||
|
if out.FirstSeen > out.LastSeen {
|
||||||
|
// RFC3339 sorts lexically, so this comparison is meaningful.
|
||||||
|
t.Errorf("first seen is after last seen: %+v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheReportSaysWhetherEnforcementIsAlreadyOn(t *testing.T) {
|
||||||
|
resetAdoption(t)
|
||||||
|
t.Setenv("POS_AUTH_REQUIRED", "true")
|
||||||
|
|
||||||
|
report := PosAdoptionReport()
|
||||||
|
if !report.Enforced {
|
||||||
|
t.Fatal("enforcement is on and the report says otherwise")
|
||||||
|
}
|
||||||
|
if !strings.Contains(report.Verdict, "already on") {
|
||||||
|
t.Errorf("verdict ignores that the work is done: %q", report.Verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The guard still behaves exactly as it did ───────────────────────────── */
|
||||||
|
|
||||||
|
func TestCountingNeverChangesWhetherARequestIsServed(t *testing.T) {
|
||||||
|
// This whole file is instrumentation. If it can refuse a request, or let
|
||||||
|
// one through that should have been refused, it has become the thing it was
|
||||||
|
// built to make safe.
|
||||||
|
//
|
||||||
|
// Both sides of the flag, against the real middleware.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", posTestSecret)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
required string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"off: an untokened till still trades", "", 200},
|
||||||
|
{"on: an untokened till is refused", "true", 401},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
resetAdoption(t)
|
||||||
|
t.Setenv("POS_AUTH_REQUIRED", tc.required)
|
||||||
|
|
||||||
|
got := callPos(t, "GET", "/live/api/v1/pos/catalogue?store_id=1185", "")
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("status %d, want %d", got, tc.want)
|
||||||
|
}
|
||||||
|
// Counted either way: the figure is about what the fleet is doing,
|
||||||
|
// not about what the flag currently allows.
|
||||||
|
if report := PosAdoptionReport(); report.Untokened != 1 {
|
||||||
|
t.Errorf("untokened = %d, want 1", report.Untokened)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlyARequestThatWouldSurviveEnforcementCountsAsAdopted(t *testing.T) {
|
||||||
|
// A token that verifies but names somebody else's outlet is refused, and
|
||||||
|
// must NOT be counted as adopted — otherwise a misconfigured till inflates
|
||||||
|
// the very number used to decide the flag is safe to set.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", posTestSecret)
|
||||||
|
t.Setenv("POS_AUTH_REQUIRED", "")
|
||||||
|
resetAdoption(t)
|
||||||
|
|
||||||
|
token := posTokenFor(t, 1147, 1185)
|
||||||
|
if got := callPos(t, "GET", "/live/api/v1/pos/catalogue?store_id=9999", token); got != 403 {
|
||||||
|
t.Fatalf("a token was allowed to name another tenant's outlet: %d", got)
|
||||||
|
}
|
||||||
|
if report := PosAdoptionReport(); report.Tokened != 0 {
|
||||||
|
t.Errorf("a refused request was counted as adopted: %+v", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Harness ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const posTestSecret = "a-pos-signing-secret-of-ample-length"
|
||||||
|
|
||||||
|
// posLocations answers the tenant-owns-outlet question without a database.
|
||||||
|
// Only LocationAllowed is real; anything else the guard touched would panic,
|
||||||
|
// which is the signal wanted.
|
||||||
|
type posLocations struct {
|
||||||
|
services.PosService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (posLocations) LocationAllowed(tenantID, locationID int) (bool, error) {
|
||||||
|
// Tenant 1147 owns 1185 and nothing else, which is all these tests need.
|
||||||
|
return tenantID == 1147 && locationID == 1185, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func posTokenFor(t *testing.T, tenantID, locationID int) string {
|
||||||
|
t.Helper()
|
||||||
|
token, _, err := utils.MintPosToken(utils.PosClaims{
|
||||||
|
Tenantid: tenantID, Locationid: locationID, Configid: 1,
|
||||||
|
}, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting a terminal session: %v", err)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func callPos(t *testing.T, method, target, token string) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Use("/live/api/v1/pos", PosAuth(posLocations{}))
|
||||||
|
app.All("/live/api/v1/pos/*", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) })
|
||||||
|
|
||||||
|
req := httptest.NewRequest(method, target, nil)
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("calling: %v", err)
|
||||||
|
}
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
@@ -77,6 +77,28 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
|
|||||||
registerPosStaffConsoleRoutes(api, f)
|
registerPosStaffConsoleRoutes(api, f)
|
||||||
registerPosReadConsoleRoutes(api, f)
|
registerPosReadConsoleRoutes(api, f)
|
||||||
registerLiveRoutes(api, f)
|
registerLiveRoutes(api, f)
|
||||||
|
registerPosAdoptionRoute(api, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// How much of the till fleet has adopted the session token.
|
||||||
|
//
|
||||||
|
// The number that decides when `POS_AUTH_REQUIRED` can be switched on. Nothing
|
||||||
|
// was recording it — an untokened request was waved through in silence — so the
|
||||||
|
// only way to judge the risk of flipping the flag was to flip it and watch.
|
||||||
|
//
|
||||||
|
// ── Why it is on /v1/web and only /v1/web ───────────────────────────────────
|
||||||
|
//
|
||||||
|
// It names the outlets still calling without a token, which is a list of the
|
||||||
|
// shops that would stop trading if enforcement went on today. That is exactly
|
||||||
|
// the list an attacker would want, so it sits behind `middleware.WebAuth` and
|
||||||
|
// NOT on the unauthenticated health endpoint, where the rest of "is this
|
||||||
|
// deployment wired up" lives.
|
||||||
|
//
|
||||||
|
// Registered on its own rather than inside registerPosReadConsoleRoutes,
|
||||||
|
// because that function deliberately mirrors every route onto `/v1/mob/pos`
|
||||||
|
// as well — which has no guard at all.
|
||||||
|
func registerPosAdoptionRoute(api fiber.Router, f *facade.Facade) {
|
||||||
|
api.Group("/v1/web/pos").Get("/authadoption", f.PosController.AuthAdoption)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The same counter-sales reads, for callers that are not a terminal.
|
// The same counter-sales reads, for callers that are not a terminal.
|
||||||
|
|||||||
Reference in New Issue
Block a user