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,
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"nearle/config"
|
||||
"nearle/db"
|
||||
"nearle/facade"
|
||||
"nearle/messaging"
|
||||
"nearle/middleware"
|
||||
"nearle/models"
|
||||
"nearle/repositories"
|
||||
"nearle/routes"
|
||||
@@ -510,6 +512,16 @@ func main() {
|
||||
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
|
||||
// env files). Running a second copy beside something else is a one-line
|
||||
// change there rather than here.
|
||||
|
||||
@@ -55,6 +55,12 @@ func PosAuth(pos services.PosService) fiber.Handler {
|
||||
token := bearerToken(c)
|
||||
|
||||
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() {
|
||||
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)
|
||||
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)
|
||||
registerPosReadConsoleRoutes(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.
|
||||
|
||||
Reference in New Issue
Block a user