Files
backend_fiesta/repositories/posUserRepository_test.go
Suriya ca846f9cd6 require a mobile number when creating a till account
A till signs in with a mobile number and a PIN, but createposuser still
accepted an account without a number. Such an account cannot reach the
sign-in screen at all, and the failure surfaces at a counter in front of
a queue rather than at the point of creation. Enforced in CreatePosUser,
so both doors are covered: POST /pos/users from the terminal and
createposuser from the console share that path.

Two checks, not one. normalisePosPhone answers ("", nil) rather than an
error for a value holding no digits, so "abc" would have passed an
emptiness check, then been written as a blank and skipped the uniqueness
check below it — which is the hole this closes.

Scope is new accounts only. The column stays nullable and UpdatePosUser
still reads an empty contactno as "leave alone", so the accounts that
predate the number keep working through the backfill and cannot have
theirs cleared. The PIN stays optional at creation.

Also in this change:

- docs: correct both phone-login handovers, which claimed creation
  already required a number. The sequencing note in the PIN handover
  said step 2 was a backfill that could never be finished; it now is
  one, and POS_LOGIN.md says which half of the pair creation enforces.

- docs: remove credentials from the examples. POS_PHONE_LOGIN_HANDOVER
  carried a real-looking back-office pair and a generated till password,
  and POS_LOGIN.md a second one.

- posController.Staff: the comment justified scoping by token because
  "the answer carries PINs". It has not since the PIN left the wire. The
  scoping is still right for a different reason, which the comment now
  gives.

- scratch/posstaffsetup: takes both mobile numbers as arguments and
  refuses to run without them. Generating stand-ins would have produced
  exactly what this change prevents. Validated before the database is
  opened, in plan mode too, so a dry run cannot print a plan that apply
  would reject halfway through and leave half a shop set up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:26:41 +05:30

247 lines
8.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)
}
}
// A till account with no mobile number cannot reach the sign-in screen, so
// creating one is refused rather than deferred to the counter.
//
// Reaches the check with a nil database on purpose: it has to run before
// anything is written, and a test that needed a connection would not prove that.
func TestATillAccountCannotBeCreatedWithoutAMobileNumber(t *testing.T) {
repo := &posRepository{}
// "abc" is the case worth pinning. normalisePosPhone answers ("", nil) for a
// value holding no digits, so it arrives looking like a number rather than
// like an absence — and would have been written as a blank.
for _, contactno := range []string{"", " ", "abc"} {
_, err := repo.CreatePosUser(1087, 1135, 1, models.PosUserRequest{
Fullname: "Priya Raman",
Role: "cashier",
Pin: "4731",
Contactno: contactno,
})
if err == nil {
t.Errorf("contactno %q was accepted; an account created this way cannot sign in", contactno)
}
}
}
// The accounts that predate the number keep working: an edit that does not
// mention contactno leaves the stored one alone rather than clearing it, so the
// rule above cannot strand somebody mid-backfill.
func TestAnEditThatOmitsTheNumberLeavesItAlone(t *testing.T) {
if _, err := normalisePosPhone(""); err != nil {
t.Fatalf("an absent number was treated as malformed: %v", err)
}
}
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")
}
})
}
}