Files
backend_fiesta/middleware/webauth_test.go
2026-09-24 11:38:46 +05:30

335 lines
13 KiB
Go

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)
}
}
}