335 lines
13 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|