298 lines
9.6 KiB
Go
298 lines
9.6 KiB
Go
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
|
|
}
|