A shop had no way to add the people who work in it. The terminal fell back to
three names and three PINs compiled into the app — the same three on every
install — because there was nothing for it to fall back *from*.
Two roles now exist in `app_roles`: Supervisor (7) runs the terminal and creates
staff, Cashier (8) bills. Fixed ids, written by hand, because that table has no
sequence and every id in it was assigned the same way. configid is left NULL
rather than duplicated per portal: a till is a till whichever portal a tenant
uses, and Admin already appears twice in that table for exactly that reason.
`/pos/users` is CRUD over them, and `/pos/login/pin` signs a cashier on at a
terminal a supervisor has already opened.
The rule every one of these follows: **tenant and outlet come from the caller's
token, never from the request.** There is no location field on the create body
to get wrong. A supervisor at Selvapuram cannot create staff at R mart, for the
same reason a till cannot bill into another shop's books — it is the same
inversion applied to people instead of sales.
PIN sign-in is deliberately behind the guard. Four digits is ten thousand
guesses, which is no barrier to an anonymous caller; requiring a session means a
real password opened the terminal first and the guesses are confined to one
outlet's own staff. The session it mints is fresh rather than derived, so a
cashier taking over from a supervisor drops their permissions instead of
inheriting them.
Three things the schema forced:
- A PIN cannot start with zero. `app_users.pin` is a bigint, so "0451" stores as
451 and reads back as three digits — a cashier would type four and be refused
for ever. Live data already holds one such account. Rendering refuses to show
a PIN it cannot represent, rather than showing a short one nobody can type.
- `app_users` has no sequence either, so the next id is read and written inside
one transaction behind an advisory lock. Two supervisors creating staff at the
same moment would otherwise compute the same id and one insert would lose.
- 1234, 1111 and friends are refused outright. Live data has 1234 on eleven
accounts and 1111 on nine.
Proven against outlet 1135, which had zero staff and was the reason the built-in
PINs were still load-bearing:
created 9188 Store Supervisor Supervisor can_manage_staff=true
created 9189 Counter Cashier Cashier can_manage_staff=false
/pos/staff now returns 2 an unknown PIN is refused
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
133 lines
4.5 KiB
Go
133 lines
4.5 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)
|
|
}
|
|
}
|
|
}
|