package middleware import ( "net/http/httptest" "strings" "testing" "time" "nearle/utils" "github.com/gofiber/fiber/v2" ) const webTestSecret = "a-test-signing-key-long-enough" // fakeLocations answers the tenant-owns-branch question without a database. // // `owned` is the branch the tenant genuinely has; anything else is refused, and // `fails` makes the lookup itself error so the unavailable path can be reached. type fakeLocations struct { tenant int owned int fails bool } func (f fakeLocations) LocationAllowed(tenantID, locationID int) (bool, error) { if f.fails { return false, errFakeLookup } return tenantID == f.tenant && locationID == f.owned, nil } type fakeErr struct{} func (fakeErr) Error() string { return "lookup unavailable" } var errFakeLookup = fakeErr{} // call runs one request through the middleware and reports the status. // // The handler behind it always succeeds, so any non-200 came from the guard. func call(t *testing.T, locations webLocationChecker, token, method, target, body string) int { t.Helper() app := fiber.New() app.Use("/live/api/v1/web", webAuthWith(locations)) app.All("/live/api/v1/web/*", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) }) req := httptest.NewRequest(method, target, strings.NewReader(body)) if body != "" { req.Header.Set("Content-Type", "application/json") } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := app.Test(req) if err != nil { t.Fatalf("calling: %v", err) } return resp.StatusCode } func tokenFor(t *testing.T, claims utils.WebClaims) string { t.Helper() token, _, err := utils.MintWebToken(claims, time.Now()) if err != nil { t.Fatalf("minting: %v", err) } return token } /* ── The hole this exists to close ─────────────────────────────────────── */ func TestASessionCannotNameAnotherTenant(t *testing.T) { // One number in a URL. Before this middleware it read another merchant's // orders, stock, staff and takings. t.Setenv("POS_TOKEN_SECRET", webTestSecret) session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147}) own := call(t, fakeLocations{}, session, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=1147", "") if own != fiber.StatusOK { t.Fatalf("a session was refused its own tenant: %d", own) } other := call(t, fakeLocations{}, session, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "") if other != fiber.StatusForbidden { t.Fatalf("tenant 916 was readable with a tenant 1147 session: %d", other) } } func TestAWriteCannotNameAnotherTenantInItsBody(t *testing.T) { // The half that would be easy to skip. Reads carry `tenantid` in the query; // the calls that CHANGE things post JSON, so a query-only check leaves every // write unguarded. t.Setenv("POS_TOKEN_SECRET", webTestSecret) session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147}) body := `{"tenantid":916,"productname":"Milk Bikis"}` got := call(t, fakeLocations{}, session, "POST", "/live/api/v1/web/products/create", body) if got != fiber.StatusForbidden { t.Fatalf("a write into tenant 916 was allowed: %d", got) } } func TestABatchCannotSmuggleAnotherTenantInAnArray(t *testing.T) { // `createdeliveries` posts an array. A probe that only understood objects // would wave through exactly the call that creates work in another // merchant's shop. t.Setenv("POS_TOKEN_SECRET", webTestSecret) session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147}) body := `[{"orderheaderid":1,"tenantid":916}]` got := call(t, fakeLocations{}, session, "POST", "/live/api/v1/web/deliveries/createdeliveries", body) if got != fiber.StatusForbidden { t.Fatalf("a batch naming tenant 916 was allowed: %d", got) } } func TestATenantSentAsAStringIsStillChecked(t *testing.T) { // Both spellings are on the wire. A probe that understood only numbers // returns 0 for `"916"`, which reads as "named no tenant" and passes. t.Setenv("POS_TOKEN_SECRET", webTestSecret) session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147}) got := call(t, fakeLocations{}, session, "POST", "/live/api/v1/web/products/create", `{"tenantid":"916"}`) if got != fiber.StatusForbidden { t.Fatalf("a string tenant id slipped past: %d", got) } } /* ── Scoping by branch alone ───────────────────────────────────────────── */ func TestABranchMustBelongToTheSessionsTenant(t *testing.T) { // A request can scope by branch and name no tenant at all, so pinning the // tenant is not sufficient on its own. t.Setenv("POS_TOKEN_SECRET", webTestSecret) session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172}) locations := fakeLocations{tenant: 1147, owned: 1173} mine := call(t, locations, session, "GET", "/live/api/v1/web/products/get?locationid=1173", "") if mine != fiber.StatusOK { t.Fatalf("a second branch of my own tenant was refused: %d", mine) } theirs := call(t, locations, session, "GET", "/live/api/v1/web/products/get?locationid=1185", "") if theirs != fiber.StatusForbidden { t.Fatalf("another tenant's branch was readable: %d", theirs) } } func TestTheSessionsOwnBranchNeedsNoLookup(t *testing.T) { // `fails: true` errors on any lookup, so reaching OK proves the home branch // short-circuits before asking. t.Setenv("POS_TOKEN_SECRET", webTestSecret) session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172}) got := call(t, fakeLocations{fails: true}, session, "GET", "/live/api/v1/web/products/get?locationid=1172", "") if got != fiber.StatusOK { t.Fatalf("the session's own branch was refused: %d", got) } } func TestAFailedBranchLookupIsNotAPass(t *testing.T) { // If the check cannot run, the answer is "cannot verify", never "allowed". t.Setenv("POS_TOKEN_SECRET", webTestSecret) session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172}) got := call(t, fakeLocations{fails: true}, session, "GET", "/live/api/v1/web/products/get?locationid=1185", "") if got != fiber.StatusServiceUnavailable { t.Fatalf("a broken lookup did not refuse: %d", got) } } /* ── Tokens ────────────────────────────────────────────────────────────── */ func TestABrokenTokenIsAlwaysRefused(t *testing.T) { // Refused whatever the flag says. Nothing sends a broken token by accident. t.Setenv("POS_TOKEN_SECRET", webTestSecret) t.Setenv("WEB_AUTH_REQUIRED", "false") got := call(t, fakeLocations{}, "w1.rubbish.signature", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=1147", "") if got != fiber.StatusUnauthorized { t.Fatalf("a forged token was not refused: %d", got) } } func TestATillsTokenIsNotAConsoleSessionHere(t *testing.T) { // A POS token is the same shape signed with the same key. If it verified // here its `Locationid` would land where `Tenantid` is read. t.Setenv("POS_TOKEN_SECRET", webTestSecret) pos, _, err := utils.MintPosToken(utils.PosClaims{Userid: 7, Tenantid: 916, Locationid: 1185, Roleid: 8}, time.Now()) if err != nil { t.Fatalf("minting a POS token: %v", err) } got := call(t, fakeLocations{}, pos, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=1147", "") if got != fiber.StatusUnauthorized { t.Fatalf("a cashier's token was accepted on the console: %d", got) } } /* ── The staged rollout ────────────────────────────────────────────────── */ func TestWithoutTheFlagAnUntokenedRequestStillWorks(t *testing.T) { // The console in production sends no token yet. Locking it out before // sign-in issues one would break a working product. t.Setenv("POS_TOKEN_SECRET", webTestSecret) t.Setenv("WEB_AUTH_REQUIRED", "false") got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "") if got != fiber.StatusOK { t.Fatalf("an untokened request was refused while enforcement is off: %d", got) } } func TestWithTheFlagAnUntokenedRequestIsRefused(t *testing.T) { t.Setenv("POS_TOKEN_SECRET", webTestSecret) t.Setenv("WEB_AUTH_REQUIRED", "true") got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "") if got != fiber.StatusUnauthorized { t.Fatalf("enforcement is on and an untokened request passed: %d", got) } } func TestSignInStillWorksWithEnforcementOn(t *testing.T) { // Guarding the login route with a session token means nobody can ever get // one. This is the test that catches a locked-out deployment. t.Setenv("POS_TOKEN_SECRET", webTestSecret) t.Setenv("WEB_AUTH_REQUIRED", "true") for _, path := range []string{ "/live/api/v1/web/users/applogin", "/live/api/v1/web/tenant/weblogin", } { if got := call(t, fakeLocations{}, "", "POST", path, `{"authname":"a@b.c"}`); got != fiber.StatusOK { t.Fatalf("%s was locked behind a session: %d", path, got) } } } /* ── The platform account ──────────────────────────────────────────────── */ func TestPlatformStaffMayNameAnyTenant(t *testing.T) { // Nearle's own staff work across tenants and the console's /nearle pages // depend on it. t.Setenv("POS_TOKEN_SECRET", webTestSecret) session := tokenFor(t, utils.WebClaims{Userid: 12, Superadmin: true, Roleid: 1}) got := call(t, fakeLocations{}, session, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "") if got != fiber.StatusOK { t.Fatalf("a platform session was refused tenant 916: %d", got) } } func TestNoTokenIsNotAPlatformAccount(t *testing.T) { // Tenant 0 is the session that reads everything, and Go's zero value is 0. // A handler reading claims off a request that carried none would hand an // anonymous caller exactly that session. app := fiber.New() var found bool app.Get("/probe", func(c *fiber.Ctx) error { _, found = WebClaimsFrom(c) return c.SendStatus(fiber.StatusOK) }) if _, err := app.Test(httptest.NewRequest("GET", "/probe", nil)); err != nil { t.Fatalf("probing: %v", err) } if found { t.Fatal("claims were reported present on a request that carried none") } } /* ── The default, after the rollout ────────────────────────────────────── */ func TestEnforcementIsOnByDefault(t *testing.T) { // It shipped defaulting to off so a live console could adopt tokens without // its users being locked out. That finished, and the default was measured // still open: a getorders with no credential returned a real merchant's // orders to anyone. t.Setenv("POS_TOKEN_SECRET", webTestSecret) t.Setenv("WEB_AUTH_REQUIRED", "") got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "") if got != fiber.StatusUnauthorized { t.Fatalf("an untokened request was served with no setting present: %d", got) } } func TestEnforcementCanBeTurnedOffWithoutADeploy(t *testing.T) { // The escape hatch. Flipping a default that can lock people out has to be // reversible by one person in one minute. t.Setenv("POS_TOKEN_SECRET", webTestSecret) t.Setenv("WEB_AUTH_REQUIRED", "false") got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "") if got != fiber.StatusOK { t.Fatalf("the escape hatch does not work: %d", got) } } func TestOnlyTheWordFalseOpensTheDoor(t *testing.T) { // A typo must fail closed. "no", "0" and "off" all look like they might // disable it, and a deployment that meant to disable it and did not is far // safer than one that meant to enable it and did not. t.Setenv("POS_TOKEN_SECRET", webTestSecret) for _, setting := range []string{"no", "0", "off", "FALSE ", "nope"} { t.Setenv("WEB_AUTH_REQUIRED", setting) got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "") if setting == "FALSE " && got != fiber.StatusOK { t.Fatalf("a trimmed, case-insensitive false was not honoured: %d", got) } if setting != "FALSE " && got != fiber.StatusUnauthorized { t.Fatalf("%q opened the door: %d", setting, got) } } } func TestSignInStillWorksWithTheNewDefault(t *testing.T) { // The test that catches a locked-out deployment. Guarding the login route // means nobody can ever obtain a token. t.Setenv("POS_TOKEN_SECRET", webTestSecret) t.Setenv("WEB_AUTH_REQUIRED", "") for _, path := range []string{ "/live/api/v1/web/users/applogin", "/live/api/v1/web/tenant/weblogin", } { if got := call(t, fakeLocations{}, "", "POST", path, `{"authname":"a@b.c"}`); got != fiber.StatusOK { t.Fatalf("%s was locked behind a session: %d", path, got) } } }