Files
backend_fiesta/repositories/posUserRepository_test.go
Suriya 6a62dbb9f3 Hold web-created staff to the same rules the till applies
Two paths write `app_users`: the console's `tenants/createstaff`, and the
terminal's `/pos/users`. Only one of them checked anything.

`createstaff` wrote whatever it was handed. A cashier could be created there
with PIN "0451" — which a bigint column stores as 451 — and would then type four
digits at the counter and be refused for ever, with nothing on either screen to
explain it. Or with 1234, which live data already has on eleven accounts. Or
with a PIN somebody at the same outlet already had, which attributes a bill to
whichever row is read first. Or with no way to sign in at all.

None of that surfaced where it was caused. It surfaced at a counter, days later,
as "the new person cannot log in".

So the rules move into `ValidateStaffUser`, and both paths use it: a name, a
role that is actually a role, a PIN the schema can hold and nobody guesses
first, and at least one way to sign in. The duplicate-PIN check runs too, when
the row names an outlet.

The handler also stops answering 500 with a body claiming 409. Every one of
these is something the person filling in the form can fix, so it is a 400
carrying the reason.

`GetStaffs` now returns `rolename` alongside `roleid`, so a console can show
"Supervisor" without mapping ids itself — `app_roles` has six rows for four
back-office roles and most accounts carry an id absent from it, so any mapping
written client-side would be wrong.

This is what makes the two role systems one. A supervisor or cashier created
from the web behaves at the till exactly like one created at the till, because
there is now a single definition of what those are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:37:28 +05:30

193 lines
6.3 KiB
Go

package repositories
import (
"testing"
"nearle/models"
)
// A PIN has to survive a round trip through a `bigint` column, and has to be
// hard enough to guess to be worth having. These cover both, because the schema
// makes the first one non-obvious.
func TestAPinMustSurviveTheColumnItIsStoredIn(t *testing.T) {
// `app_users.pin` is a bigint. "0451" stored there comes back as 451, so a
// cashier would type four digits and be refused for ever. Live data already
// holds one such account.
if _, err := validatePosPin("0451"); err == nil {
t.Fatal("a PIN starting with zero was accepted; it cannot round-trip through a bigint")
}
value, err := validatePosPin("4821")
if err != nil {
t.Fatalf("a good PIN was refused: %v", err)
}
if value != 4821 {
t.Fatalf("PIN parsed to %d, want 4821", value)
}
}
func TestAPinIsExactlyFourDigits(t *testing.T) {
for _, pin := range []string{"123", "12345", "abcd", "12a4", " 12 "} {
if _, err := validatePosPin(pin); err == nil {
t.Errorf("PIN %q was accepted", pin)
}
}
}
// The first thing anyone tries. Live data has 1234 on eleven accounts and 1111
// on nine, which is exactly the outcome this prevents repeating.
func TestAnObviousPinIsRefused(t *testing.T) {
for _, pin := range []string{"1234", "1111", "2345", "4321", "9999", "2222"} {
if _, err := validatePosPin(pin); err == nil {
t.Errorf("PIN %q was accepted despite being one of the first guessed", pin)
}
}
}
// An empty PIN is not an error — somebody may be given a password instead. The
// caller decides whether having neither is a problem.
func TestAnAbsentPinIsNotAnError(t *testing.T) {
value, err := validatePosPin("")
if err != nil {
t.Fatalf("an absent PIN was treated as invalid: %v", err)
}
if value != 0 {
t.Fatalf("an absent PIN parsed to %d, want 0", value)
}
}
// A stored PIN the schema cannot represent as four digits comes back empty
// rather than short, because a three-digit PIN on screen is one a cashier
// cannot type — and they would have no way to describe the fault.
func TestAnUnrepresentablePinIsNotShown(t *testing.T) {
if got := posPinString(451); got != "" {
t.Fatalf("a three-digit PIN rendered as %q, want empty", got)
}
if got := posPinString(0); got != "" {
t.Fatalf("an unset PIN rendered as %q, want empty", got)
}
if got := posPinString(4821); got != "4821" {
t.Fatalf("PIN rendered as %q, want 4821", got)
}
}
func TestANameIsSplitAcrossTheTwoColumnsThisSchemaHas(t *testing.T) {
cases := []struct {
in string
first, last string
}{
{"Asha", "Asha", ""},
{"Asha Kumar", "Asha", "Kumar"},
{"Ragul Kannan Selvam", "Ragul", "Kannan Selvam"},
{" Divya R ", "Divya", "R"},
{"", "", ""},
}
for _, tc := range cases {
first, last := splitName(tc.in)
if first != tc.first || last != tc.last {
t.Errorf("splitName(%q) = (%q, %q), want (%q, %q)",
tc.in, first, last, tc.first, tc.last)
}
}
}
// Only a role that can actually be checked should grant anything. Zero is the
// one that matters: it is not a role, it is what an account carries when nobody
// set one, and live data has riders and shop accounts sharing it.
func TestOnlyRealRolesCanManageStaff(t *testing.T) {
if models.PosRoleCanManageStaff(0) {
t.Error("roleid 0 was allowed to manage staff; it is unset, not a role")
}
if models.PosRoleCanManageStaff(models.PosRoleCashier) {
t.Error("a cashier was allowed to manage staff, so could promote themselves")
}
if !models.PosRoleCanManageStaff(models.PosRoleSupervisor) {
t.Error("a supervisor was refused staff management, which is their whole purpose")
}
// Somebody who already administers the shop from a browser is not made less
// privileged by standing at the counter.
for _, role := range []int{1, 2, 3, 4, 5, 6} {
if !models.PosRoleCanManageStaff(role) {
t.Errorf("back-office role %d was refused staff management", role)
}
}
}
func TestARoleIsReadFromItsNameNotItsNumber(t *testing.T) {
if got := models.PosRoleFromName("supervisor"); got != models.PosRoleSupervisor {
t.Errorf("supervisor = %d, want %d", got, models.PosRoleSupervisor)
}
if got := models.PosRoleFromName(" Cashier "); got != models.PosRoleCashier {
t.Errorf("cashier = %d, want %d", got, models.PosRoleCashier)
}
// Anything unrecognised is zero, and every caller treats zero as a refusal
// rather than as a default — an unknown role must never become a supervisor.
for _, name := range []string{"", "admin", "manager", "owner", "7"} {
if got := models.PosRoleFromName(name); got != 0 {
t.Errorf("PosRoleFromName(%q) = %d, want 0", name, got)
}
}
}
// The web console writes `app_users` too, through `tenants/createstaff`, and
// that path had no validation at all. These cover the shared rule set, so a
// person created from a browser is subject to the same constraints as one
// created at a till — two paths writing one table is how they drift.
func TestStaffFromTheWebConsoleObeysTheTillsRules(t *testing.T) {
cases := []struct {
name string
user models.User
ok bool
}{
{
name: "a usable cashier",
user: models.User{Firstname: "Asha", Roleid: models.PosRoleCashier, Pin: 7391},
ok: true,
},
{
name: "a password instead of a PIN is fine",
user: models.User{Firstname: "Asha", Roleid: models.PosRoleCashier, Password: "s3cret"},
ok: true,
},
{
name: "no name",
user: models.User{Roleid: models.PosRoleCashier, Pin: 7391},
},
{
name: "no role — 0 is unset, not a role",
user: models.User{Firstname: "Asha", Pin: 7391},
},
{
name: "no way at all to sign in",
user: models.User{Firstname: "Asha", Roleid: models.PosRoleCashier},
},
{
// 451 is what "0451" becomes in a bigint column. Accepting it here
// creates somebody who types four digits and is refused for ever.
name: "a PIN the column cannot hold",
user: models.User{Firstname: "Asha", Roleid: models.PosRoleCashier, Pin: 451},
},
{
name: "a PIN anyone would guess first",
user: models.User{Firstname: "Asha", Roleid: models.PosRoleCashier, Pin: 1234},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
user := tc.user
_, err := ValidateStaffUser(&user)
if tc.ok && err != nil {
t.Fatalf("refused a valid staff row: %v", err)
}
if !tc.ok && err == nil {
t.Fatal("accepted a staff row the till could not use")
}
})
}
}