Files
backend_fiesta/utils/webtoken_test.go
abhishek c516c224e5 Authenticate the console's /web surface
The /web endpoints have never had authentication. The console keeps its
login record in per-tab sessionStorage and sends no Authorization header,
so every endpoint under /v1/web read `tenantid` off the query string and
believed it — one number in a URL reached another merchant's orders,
stock, staff and takings. `createposuser` under /v1/web/tenants minted
till credentials on the strength of an unauthenticated request, which the
route file already flagged in as many words.

Closed the same way posauth.go closed it for the terminals, in the same
order: the caller holds a token this server signed, and the tenant they
name is the tenant inside that token.

- utils/webtoken.go   same HMAC construction as the POS token, 12h TTL,
                      a `w1.` prefix so the two kinds cannot verify as
                      each other
- middleware/webauth.go  verifies the token, pins the tenant, and checks
                      a named branch belongs to it; reads the tenant from
                      the query, the body, and inside a JSON array, since
                      createdeliveries posts one
- login now issues the token; the console sends it as Bearer

Platform access rides on issuperadmin and nothing else. Not the role —
app_roles calls roleid 1 "Super admin" and tenant onboarding wrote 1 for
every shop owner, so a role test would promote every merchant on the
platform. Not a zero tenant either, or a user row with the field unset
becomes the one session that reads everything. Both near-misses have
tests.

WEB_AUTH_REQUIRED defaults to off. The console in production does not
send a token yet, and enforcing before it does would lock every merchant
out of a working product. A token that IS sent is always verified, and
one naming the wrong tenant is always refused; the flag only decides what
happens to a request carrying none. This should be a short-lived state.

Still trusting the caller: partnerid, customerid and appuserid, which
some list endpoints also scope on. Noted in the middleware header.

25 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 11:22:20 +05:30

217 lines
7.2 KiB
Go

package utils
import (
"strings"
"testing"
"time"
)
func TestAConsoleSessionSurvivesTheRoundTrip(t *testing.T) {
withSecret(t, testSecret)
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
token, expires, err := MintWebToken(WebClaims{
Userid: 904, Tenantid: 1147, Locationid: 1172, Roleid: 3, Configid: 2,
}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
claims, err := ParseWebToken(token, now.Add(time.Hour))
if err != nil {
t.Fatalf("parsing a token we just issued: %v", err)
}
if claims.Tenantid != 1147 || claims.Userid != 904 {
t.Fatalf("the identity did not survive: user %d tenant %d", claims.Userid, claims.Tenantid)
}
if claims.Locationid != 1172 || claims.Roleid != 3 {
t.Fatalf("branch or role lost: location %d role %d", claims.Locationid, claims.Roleid)
}
if !expires.After(now) {
t.Fatalf("expiry is not in the future: %v", expires)
}
}
/* ── The two token kinds must not be interchangeable ────────────────────── */
func TestATillsTokenIsNotAConsoleSession(t *testing.T) {
// The whole reason `webTokenPrefix` exists. Both tokens are the same shape
// signed with the same key, so without the prefix a POS token verifies as a
// web one — and its `Locationid` would land where `Tenantid` is read, which
// is the field every permission decision is made on.
withSecret(t, testSecret)
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
pos, _, err := MintPosToken(PosClaims{Userid: 7, Tenantid: 916, Locationid: 1185, Roleid: 8}, now)
if err != nil {
t.Fatalf("minting a POS token: %v", err)
}
if _, err := ParseWebToken(pos, now); err == nil {
t.Fatal("a cashier's token was accepted as a console session")
}
}
func TestAConsoleSessionIsNotATillsToken(t *testing.T) {
withSecret(t, testSecret)
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
web, _, err := MintWebToken(WebClaims{Userid: 904, Tenantid: 1147}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
if _, err := ParsePosToken(web, now); err == nil {
t.Fatal("a console session was accepted at the till")
}
}
/* ── Forgery and tampering ─────────────────────────────────────────────── */
func TestATamperedTenantDoesNotVerify(t *testing.T) {
// The attack this is all for: take a valid session, change the tenant, read
// somebody else's shop.
withSecret(t, testSecret)
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
token, _, err := MintWebToken(WebClaims{Userid: 904, Tenantid: 1147}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
body, signature, _ := strings.Cut(strings.TrimPrefix(token, webTokenPrefix), ".")
forged := webTokenPrefix + body[:len(body)-1] + "X" + "." + signature
if _, err := ParseWebToken(forged, now); err == nil {
t.Fatal("an edited payload verified")
}
}
func TestATokenSignedWithAnotherKeyIsRefused(t *testing.T) {
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
withSecret(t, "a-completely-different-signing-key")
token, _, err := MintWebToken(WebClaims{Userid: 904, Tenantid: 1147}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
withSecret(t, testSecret)
if _, err := ParseWebToken(token, now); err == nil {
t.Fatal("a token signed with someone else's key verified")
}
}
func TestNoSigningKeyMeansNoSessions(t *testing.T) {
t.Setenv("POS_TOKEN_SECRET", "")
t.Setenv("JWT_SECRET_KEY", "")
if _, _, err := MintWebToken(WebClaims{Userid: 1, Tenantid: 1}, time.Now()); err == nil {
t.Fatal("a session was issued with no signing key")
}
if WebTokenConfigured() {
t.Fatal("reported configured with no signing key")
}
}
/* ── Expiry ────────────────────────────────────────────────────────────── */
func TestASessionExpires(t *testing.T) {
withSecret(t, testSecret)
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
token, _, err := MintWebToken(WebClaims{Userid: 904, Tenantid: 1147}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
if _, err := ParseWebToken(token, now.Add(WebTokenTTL-time.Minute)); err != nil {
t.Fatalf("refused inside its life: %v", err)
}
if _, err := ParseWebToken(token, now.Add(WebTokenTTL+time.Minute)); err == nil {
t.Fatal("a tab left open overnight still authorised")
}
}
/* ── The platform account ──────────────────────────────────────────────── */
func TestPlatformAccessComesFromSuperadminAndNothingElse(t *testing.T) {
withSecret(t, testSecret)
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
token, _, err := MintWebToken(WebClaims{Userid: 12, Superadmin: true, Roleid: 1}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
claims, err := ParseWebToken(token, now)
if err != nil {
t.Fatalf("a staff session was refused: %v", err)
}
if !claims.IsPlatformAccount() {
t.Fatal("issuperadmin did not grant platform access")
}
}
func TestRoleid1IsAMerchantNotAPlatformOperator(t *testing.T) {
// `app_roles` calls roleid 1 "Super admin", and tenant onboarding wrote 1
// for every shop owner. A role test here would hand cross-tenant access to
// every merchant on the system — the console already had to fix this once.
claims := WebClaims{Userid: 904, Tenantid: 1147, Roleid: 1}
if claims.IsPlatformAccount() {
t.Fatal("roleid 1 claimed platform access")
}
}
func TestAMissingTenantIsNotAPlatformAccount(t *testing.T) {
// The other near-miss: a user row whose tenant was never filled in must not
// become the one session that reads everything. Go's zero value is 0, so
// this is exactly what a forgotten field looks like.
withSecret(t, testSecret)
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
claims := WebClaims{Userid: 904, Tenantid: 0}
if claims.IsPlatformAccount() {
t.Fatal("a missing tenant claimed platform access")
}
token, _, err := MintWebToken(claims, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
if _, err := ParseWebToken(token, now); err == nil {
t.Fatal("a session naming no tenant and claiming no staff status verified")
}
}
func TestATokenNamingNobodyIsRefused(t *testing.T) {
// A token that authorises nothing must not be mistaken for one that
// authorises everything.
withSecret(t, testSecret)
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
token, _, err := MintWebToken(WebClaims{Userid: 0, Tenantid: 1147}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
if _, err := ParseWebToken(token, now); err == nil {
t.Fatal("a token naming no user verified")
}
}
/* ── Shape ─────────────────────────────────────────────────────────────── */
func TestMalformedTokensAreRefusedWithoutPanicking(t *testing.T) {
withSecret(t, testSecret)
now := time.Now()
for _, token := range []string{
"", " ", "w1.", "w1..", "w1.onlyonepart",
"w1.!!!not-base64!!!.sig", "no-prefix.payload.sig",
} {
if _, err := ParseWebToken(token, now); err == nil {
t.Fatalf("accepted a malformed token: %q", token)
}
}
}