app_users is the only thing the two products have in common, and the code was treating it as though it were the whole relationship. Both directions leaked. Back-office roles were leaking into the till. PosRoleCanManageStaff returned true for roleid 1 to 6, on the reasoning that somebody who already administers a shop from a browser is not made less privileged by standing at the counter. That sounds fine and is wrong: measured against live data it handed till-supervisor powers to 68 accounts, 59 of them Nearle Daily Super admins, not one of whom is the administrator of anybody's POS. Meanwhile the actual shop accounts carry roleid 0 and were refused, so the mapping was backwards from intent in both halves at once. Till accounts were leaking into the application. GetStaffs is WHERE tenantid with no role filter, so a Counter Cashier appeared in the tenant staff list beside the delivery riders — a row every action on that page would fail against, since a cashier has no app login, no rider shift and no back-office screen. So: eligibility for a till is now granted explicitly by provisioning a Supervisor or a Cashier, never inherited from a back-office role, and roles 7 and 8 are excluded from every Nearle Daily lookup. The exclusion lives in the queries rather than in a check after them, because a check bolted on afterwards has to be repeated at six call sites and is one edit away from being forgotten at one of them — and that one would be the hole. A till account is not rejected by the app login; it is not found. Two things this surfaced that were not visible before. A Supervisor could not open a till. PIN sign-in needs a session that already exists, so once back-office roles were refused, an outlet whose only POS accounts were PIN-only had no way in at all. Supervisors are now provisioned with a username and password as well as a PIN; cashiers deliberately get neither, because they sign on at a counter somebody has already opened and a second password would be one more credential to leak for no capability gained. UpdatePosUser silently dropped authname. It wrote the password, reported success, and left the account unreachable by either lookup — the failure surfaced at a counter as "not recognised" rather than on the screen that caused it. Contactno had the same gap. Verified against live rows rather than asserted, by scratch/posseparation: a provisioned supervisor signs in and gets the supervisor shell; five real back-office accounts including Super admins are refused; the supervisor is invisible to applogin, tenant weblogin and the password-setup lookup; and no till account appears in getallusers, while asking for role 7 by name still returns them so the console can read its own people. All five outlets that stock products now have a Supervisor and a Cashier. Also moves the loose markdown into docs/, which was already staged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
214 lines
7.2 KiB
Go
214 lines
7.2 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")
|
|
}
|
|
// A Nearle Daily role is not a POS role. This once granted staff management
|
|
// to 1 through 6, on the reasoning that a browser administrator loses
|
|
// nothing by standing at the counter — which handed till-supervisor powers
|
|
// to 68 live accounts, 59 of them platform Super admins, not one of them
|
|
// anybody's POS administrator. The back office provisions a supervisor; it
|
|
// does not become one.
|
|
for _, role := range []int{1, 2, 3, 4, 5, 6} {
|
|
if models.PosRoleCanManageStaff(role) {
|
|
t.Errorf("back-office role %d was granted till staff management", role)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The till and the Nearle Daily application share one table and nothing else.
|
|
// Eligibility is provisioned, never inherited.
|
|
func TestOnlyPosRolesCanOpenATill(t *testing.T) {
|
|
for _, role := range []int{models.PosRoleSupervisor, models.PosRoleCashier} {
|
|
if !models.PosRoleEligible(role) {
|
|
t.Errorf("POS role %d was refused a till", role)
|
|
}
|
|
}
|
|
// Zero matters most: it is not a role but the absence of one, and 22 live
|
|
// accounts carry it, including a delivery rider.
|
|
for _, role := range []int{0, 1, 2, 3, 4, 5, 6, 9, 99, -1} {
|
|
if models.PosRoleEligible(role) {
|
|
t.Errorf("non-POS role %d was allowed to open a till", 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")
|
|
}
|
|
})
|
|
}
|
|
}
|