From 97f277f424a88e3af6fdd7d5936fc447ae058ac7 Mon Sep 17 00:00:00 2001 From: abhishek Date: Wed, 30 Sep 2026 18:01:59 +0530 Subject: [PATCH] pos gap fix --- controllers/posController.go | 17 ++ main.go | 12 ++ middleware/posauth.go | 12 ++ middleware/posauthadoption.go | 249 ++++++++++++++++++++++++ middleware/posauthadoption_test.go | 297 +++++++++++++++++++++++++++++ routes/posroutes.go | 22 +++ 6 files changed, 609 insertions(+) create mode 100644 middleware/posauthadoption.go create mode 100644 middleware/posauthadoption_test.go diff --git a/controllers/posController.go b/controllers/posController.go index 1d7e04c..3d9a9ff 100644 --- a/controllers/posController.go +++ b/controllers/posController.go @@ -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(), + }) +} diff --git a/main.go b/main.go index 4c8ea17..82f28fd 100644 --- a/main.go +++ b/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. diff --git a/middleware/posauth.go b/middleware/posauth.go index 604a283..188ec0c 100644 --- a/middleware/posauth.go +++ b/middleware/posauth.go @@ -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() } diff --git a/middleware/posauthadoption.go b/middleware/posauthadoption.go new file mode 100644 index 0000000..ca23414 --- /dev/null +++ b/middleware/posauthadoption.go @@ -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, ", ")) + } + } +} diff --git a/middleware/posauthadoption_test.go b/middleware/posauthadoption_test.go new file mode 100644 index 0000000..793e088 --- /dev/null +++ b/middleware/posauthadoption_test.go @@ -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 +} diff --git a/routes/posroutes.go b/routes/posroutes.go index 69c3097..35962b2 100644 --- a/routes/posroutes.go +++ b/routes/posroutes.go @@ -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.