Add pending POS scratch checks and portfolio notes
Untracked in the working tree before today's work; committed so the branch carries everything on disk except a stray duplicate (docs/MOBILE_ORDER_VERIFICATION copy.md). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
267
scratch/poslivecheck/main.go
Normal file
267
scratch/poslivecheck/main.go
Normal file
@@ -0,0 +1,267 @@
|
||||
// Does the mobile-number-and-PIN sign-in actually work against the live data?
|
||||
//
|
||||
// Picks a supervisor and a cashier that already have both halves of the
|
||||
// credential, prints them so they can be typed into a terminal, then runs the
|
||||
// real repository and service — the same code path the HTTP handler calls — and
|
||||
// reports what came back.
|
||||
//
|
||||
// Read-only. PosLogin issues SELECTs and mints a token in memory; nothing here
|
||||
// writes, and the token is not persisted anywhere by design.
|
||||
//
|
||||
// Numbers and PINs are masked unless -show is passed. They belong to real
|
||||
// people at real shops, and the default should not be to print them into
|
||||
// whatever is capturing this program's output.
|
||||
//
|
||||
// go run ./scratch/poslivecheck # picks a ready pair, masked
|
||||
// go run ./scratch/poslivecheck -show # prints the credentials
|
||||
// go run ./scratch/poslivecheck -show 1087 1137 # ...at a named outlet
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"nearle/models"
|
||||
"nearle/repositories"
|
||||
"nearle/services"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type account struct {
|
||||
Userid int
|
||||
Tenantid int
|
||||
Locationid int
|
||||
Roleid int
|
||||
Fullname string
|
||||
Contactno string
|
||||
Pin int64
|
||||
}
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
if strings.TrimSpace(os.Getenv("POS_TOKEN_SECRET")) == "" {
|
||||
log.Fatal("POS_TOKEN_SECRET is not set; sign-in mints a token and will fail without it")
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
|
||||
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Only accounts holding both halves are candidates. An account missing
|
||||
// either cannot sign in at all, and picking one would prove nothing except
|
||||
// that the rejection works.
|
||||
where := `COALESCE(roleid,0) = ?
|
||||
AND COALESCE(pin,0) BETWEEN 1000 AND 9999
|
||||
AND TRIM(COALESCE(contactno,'')) <> ''
|
||||
AND LOWER(COALESCE(status,'active')) <> 'inactive'`
|
||||
args := []interface{}{}
|
||||
|
||||
// -show opts into printing the credentials themselves.
|
||||
show := false
|
||||
rest := []string{}
|
||||
for _, arg := range os.Args[1:] {
|
||||
if arg == "-show" || arg == "--show" {
|
||||
show = true
|
||||
continue
|
||||
}
|
||||
rest = append(rest, arg)
|
||||
}
|
||||
|
||||
if len(rest) > 1 {
|
||||
tenantID, _ := strconv.Atoi(rest[0])
|
||||
locationID, _ := strconv.Atoi(rest[1])
|
||||
where += ` AND tenantid = ? AND locationid = ?`
|
||||
args = append(args, tenantID, locationID)
|
||||
}
|
||||
|
||||
pick := func(roleID int) *account {
|
||||
var a account
|
||||
params := append([]interface{}{roleID}, args...)
|
||||
err := db.Raw(`
|
||||
SELECT userid, COALESCE(tenantid,0) AS tenantid, COALESCE(locationid,0) AS locationid,
|
||||
COALESCE(roleid,0) AS roleid,
|
||||
TRIM(CONCAT(COALESCE(firstname,''),' ',COALESCE(lastname,''))) AS fullname,
|
||||
COALESCE(contactno,'') AS contactno, COALESCE(pin,0) AS pin
|
||||
FROM app_users WHERE `+where+` ORDER BY userid LIMIT 1`, params...).Scan(&a).Error
|
||||
if err != nil || a.Userid == 0 {
|
||||
return nil
|
||||
}
|
||||
return &a
|
||||
}
|
||||
|
||||
supervisor := pick(models.PosRoleSupervisor)
|
||||
cashier := pick(models.PosRoleCashier)
|
||||
|
||||
fmt.Println("Accounts that can sign in today")
|
||||
fmt.Println(strings.Repeat("-", 78))
|
||||
for _, pair := range []struct {
|
||||
label string
|
||||
a *account
|
||||
}{{"supervisor", supervisor}, {"cashier", cashier}} {
|
||||
a := pair.a
|
||||
if a == nil {
|
||||
fmt.Printf(" %-11s none — no account of this role has both a number and a PIN\n", pair.label)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" %-11s userid %-6d tenant %-6d outlet %-6d %s\n",
|
||||
pair.label, a.Userid, a.Tenantid, a.Locationid, a.Fullname)
|
||||
fmt.Printf(" %-11s mobile %s PIN %s\n\n", "",
|
||||
mask(a.Contactno, show), maskPin(a.Pin, show))
|
||||
}
|
||||
if !show {
|
||||
fmt.Println(" (masked — re-run with -show to print them)")
|
||||
}
|
||||
|
||||
if supervisor == nil && cashier == nil {
|
||||
log.Fatal("nothing to test with")
|
||||
}
|
||||
|
||||
repo := repositories.NewPosRepository(db)
|
||||
svc := services.NewPosService(repo, nil)
|
||||
|
||||
fmt.Println("\nSigning in (real service, live data)")
|
||||
fmt.Println(strings.Repeat("-", 78))
|
||||
|
||||
pass, fail := 0, 0
|
||||
check := func(name string, ok bool, detail string) {
|
||||
if ok {
|
||||
pass++
|
||||
fmt.Printf(" PASS %-46s %s\n", name, detail)
|
||||
return
|
||||
}
|
||||
fail++
|
||||
fmt.Printf(" FAIL %-46s %s\n", name, detail)
|
||||
}
|
||||
|
||||
signIn := func(label string, a *account) *models.PosSession {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
session, err := svc.Login(models.PosLoginRequest{
|
||||
Contactno: a.Contactno,
|
||||
Pin: strconv.FormatInt(a.Pin, 10),
|
||||
})
|
||||
if err != nil {
|
||||
check(label+" signs in", false, err.Error())
|
||||
return nil
|
||||
}
|
||||
check(label+" signs in", true, fmt.Sprintf(
|
||||
"%s at %s (outlet %d), can_manage_staff=%v",
|
||||
session.Role, session.Locationname, session.Locationid, session.Canmanagestaff))
|
||||
check(label+" gets a token", session.Token != "",
|
||||
fmt.Sprintf("%d chars, expires %s", len(session.Token), session.Expiresat))
|
||||
return session
|
||||
}
|
||||
|
||||
supSession := signIn("supervisor", supervisor)
|
||||
cashSession := signIn("cashier", cashier)
|
||||
|
||||
if supSession != nil {
|
||||
check("supervisor can manage staff", supSession.Canmanagestaff, "role 7 grants it")
|
||||
}
|
||||
if cashSession != nil {
|
||||
check("cashier cannot manage staff", !cashSession.Canmanagestaff, "role 8 does not")
|
||||
}
|
||||
|
||||
// The response must not carry anyone's PIN — the whole point of the change
|
||||
// that removed staff[].pin.
|
||||
//
|
||||
// Checked against the serialised JSON, not the Go struct. PosStaffMember.Pin
|
||||
// is still *populated* — the query needs it to drop two people sharing a PIN
|
||||
// — and is kept off the wire by `json:"-"`. Asserting on the struct field
|
||||
// tests the wrong layer and fails a correct implementation.
|
||||
if supSession != nil {
|
||||
encoded, merr := json.Marshal(supSession)
|
||||
check("session serialises", merr == nil, errText(merr))
|
||||
if merr == nil {
|
||||
check("no PIN travels in the session", !strings.Contains(string(encoded), `"pin"`),
|
||||
fmt.Sprintf("%d staff listed, %d bytes of JSON", len(supSession.Staff), len(encoded)))
|
||||
}
|
||||
}
|
||||
|
||||
// A number typed the way a person actually types it.
|
||||
if supervisor != nil && len(supervisor.Contactno) == 10 {
|
||||
for label, typed := range map[string]string{
|
||||
"+91 with spaces": "+91 " + supervisor.Contactno[:5] + " " + supervisor.Contactno[5:],
|
||||
"leading zero": "0" + supervisor.Contactno,
|
||||
"bare ten digits": supervisor.Contactno,
|
||||
} {
|
||||
_, err := svc.Login(models.PosLoginRequest{
|
||||
Contactno: typed, Pin: strconv.FormatInt(supervisor.Pin, 10),
|
||||
})
|
||||
check("number accepted as typed", err == nil, label)
|
||||
}
|
||||
}
|
||||
|
||||
// And the refusals.
|
||||
if supervisor != nil {
|
||||
wrong := supervisor.Pin + 1
|
||||
if wrong > 9999 {
|
||||
wrong = 1000
|
||||
}
|
||||
_, err := svc.Login(models.PosLoginRequest{
|
||||
Contactno: supervisor.Contactno, Pin: strconv.FormatInt(wrong, 10),
|
||||
})
|
||||
check("a wrong PIN is refused", err != nil, errText(err))
|
||||
}
|
||||
|
||||
_, err = svc.Login(models.PosLoginRequest{Contactno: "9999999999", Pin: "4821"})
|
||||
check("an unknown number is refused", err != nil, errText(err))
|
||||
|
||||
// Switching operator at an open terminal, which is what the till does when
|
||||
// a colleague takes over.
|
||||
if supSession != nil && cashier != nil && cashier.Locationid == supSession.Locationid {
|
||||
switched, err := repo.PosLoginByPin(supSession.Tenantid, supSession.Locationid,
|
||||
strconv.FormatInt(cashier.Pin, 10))
|
||||
if err != nil {
|
||||
check("operator switch by PIN", false, err.Error())
|
||||
} else {
|
||||
check("operator switch by PIN", true,
|
||||
fmt.Sprintf("now %s, can_manage_staff=%v", switched.Role, switched.Canmanagestaff))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n%d passed, %d failed\n", pass, fail)
|
||||
if fail > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// mask shows enough of a number to recognise the account, not enough to sign in
|
||||
// as it.
|
||||
func mask(number string, show bool) string {
|
||||
if show {
|
||||
return number
|
||||
}
|
||||
if len(number) != 10 {
|
||||
return strings.Repeat("*", len(number))
|
||||
}
|
||||
return number[:2] + "******" + number[8:]
|
||||
}
|
||||
|
||||
func maskPin(pin int64, show bool) string {
|
||||
if show {
|
||||
return strconv.FormatInt(pin, 10)
|
||||
}
|
||||
return "****"
|
||||
}
|
||||
|
||||
func errText(err error) string {
|
||||
if err == nil {
|
||||
return "no error"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
166
scratch/posloginready/main.go
Normal file
166
scratch/posloginready/main.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// Can the accounts that may open a till actually complete the new sign-in?
|
||||
//
|
||||
// Read-only, and deliberately prints no numbers and no PINs — only whether each
|
||||
// account has one and whether the login would find it.
|
||||
//
|
||||
// The question this answers is narrower than "does it have a number". The login
|
||||
// matches `LOWER(TRIM(contactno)) = LOWER(TRIM($1))` where $1 has already been
|
||||
// reduced to ten digits, so the comparison is exact: a row holding
|
||||
// "+91 98765 43210" is invisible to somebody typing the same number, because
|
||||
// only one side of the comparison gets normalised.
|
||||
//
|
||||
// go run ./scratch/posloginready
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"nearle/models"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type row struct {
|
||||
Userid int
|
||||
Tenantid int
|
||||
Locationid int
|
||||
Roleid int
|
||||
Contactno string
|
||||
Pin int64
|
||||
Status string
|
||||
}
|
||||
|
||||
// normalise mirrors repositories.normalisePosPhone, which is unexported.
|
||||
func normalise(raw string) string {
|
||||
digits := strings.Map(func(r rune) rune {
|
||||
if r >= '0' && r <= '9' {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, raw)
|
||||
if len(digits) == 12 && strings.HasPrefix(digits, "91") {
|
||||
digits = digits[2:]
|
||||
} else if len(digits) == 11 && strings.HasPrefix(digits, "0") {
|
||||
digits = digits[1:]
|
||||
}
|
||||
if len(digits) != 10 {
|
||||
return ""
|
||||
}
|
||||
return digits
|
||||
}
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
|
||||
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var rows []row
|
||||
if err := db.Raw(`
|
||||
SELECT userid, COALESCE(tenantid,0) AS tenantid, COALESCE(locationid,0) AS locationid,
|
||||
COALESCE(roleid,0) AS roleid, COALESCE(contactno,'') AS contactno,
|
||||
COALESCE(pin,0) AS pin, COALESCE(status,'') AS status
|
||||
FROM app_users
|
||||
WHERE COALESCE(roleid,0) IN (?, ?)
|
||||
ORDER BY tenantid, locationid, userid`,
|
||||
models.PosRoleSupervisor, models.PosRoleCashier).Scan(&rows).Error; err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// What the login would see. Only active till accounts are candidates, and a
|
||||
// number matching more than one of them is refused outright.
|
||||
byPhone := map[string][]int{}
|
||||
for _, r := range rows {
|
||||
if strings.EqualFold(strings.TrimSpace(r.Status), "inactive") {
|
||||
continue
|
||||
}
|
||||
if stored := strings.TrimSpace(r.Contactno); stored != "" {
|
||||
byPhone[strings.ToLower(stored)] = append(byPhone[strings.ToLower(stored)], r.Userid)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("till accounts (roleid %d/%d): %d\n\n", models.PosRoleSupervisor, models.PosRoleCashier, len(rows))
|
||||
fmt.Printf("%-8s %-8s %-9s %-5s %-9s %-8s %-7s %s\n",
|
||||
"userid", "tenant", "location", "role", "status", "number", "pin", "can sign in?")
|
||||
fmt.Println(strings.Repeat("-", 86))
|
||||
|
||||
var ready, noPhone, unnormalised, noPin, ambiguous, inactive int
|
||||
|
||||
for _, r := range rows {
|
||||
stored := strings.TrimSpace(r.Contactno)
|
||||
norm := normalise(stored)
|
||||
|
||||
phoneState := "missing"
|
||||
switch {
|
||||
case stored == "":
|
||||
phoneState = "missing"
|
||||
case norm == "":
|
||||
phoneState = "unusable"
|
||||
case norm != stored:
|
||||
phoneState = "STORED RAW"
|
||||
default:
|
||||
phoneState = "ok"
|
||||
}
|
||||
|
||||
pinState := "missing"
|
||||
if r.Pin >= 1000 && r.Pin <= 9999 {
|
||||
pinState = "ok"
|
||||
} else if r.Pin != 0 {
|
||||
pinState = "unusable"
|
||||
}
|
||||
|
||||
verdict := "yes"
|
||||
switch {
|
||||
case strings.EqualFold(r.Status, "inactive"):
|
||||
verdict, inactive = "no — inactive", inactive+1
|
||||
case stored == "":
|
||||
verdict, noPhone = "no — no number", noPhone+1
|
||||
case norm == "":
|
||||
verdict, noPhone = "no — number unusable", noPhone+1
|
||||
case norm != stored:
|
||||
// The one that looks fine in the console and fails at the counter.
|
||||
verdict, unnormalised = "NO — number stored unnormalised", unnormalised+1
|
||||
case pinState != "ok":
|
||||
verdict, noPin = "no — no usable PIN", noPin+1
|
||||
case len(byPhone[strings.ToLower(stored)]) > 1:
|
||||
verdict, ambiguous = "NO — number shared with another till account", ambiguous+1
|
||||
default:
|
||||
ready++
|
||||
}
|
||||
|
||||
fmt.Printf("%-8d %-8d %-9d %-5d %-9s %-8s %-7s %s\n",
|
||||
r.Userid, r.Tenantid, r.Locationid, r.Roleid, r.Status, phoneState, pinState, verdict)
|
||||
}
|
||||
|
||||
fmt.Printf("\nready to sign in with number + PIN : %d of %d\n", ready, len(rows))
|
||||
fmt.Printf(" blocked, no/unusable number : %d\n", noPhone)
|
||||
fmt.Printf(" blocked, number stored raw : %d\n", unnormalised)
|
||||
fmt.Printf(" blocked, no usable PIN : %d\n", noPin)
|
||||
fmt.Printf(" blocked, number not unique : %d\n", ambiguous)
|
||||
fmt.Printf(" inactive : %d\n", inactive)
|
||||
|
||||
// Cross-tenant collisions are the failure creation cannot prevent:
|
||||
// posPhoneTaken scopes uniqueness to one tenant, the login does not scope at
|
||||
// all, so two tenants may each hold a number that neither can then use.
|
||||
fmt.Println("\nnumbers shared by more than one active till account:")
|
||||
found := false
|
||||
for _, users := range byPhone {
|
||||
if len(users) > 1 {
|
||||
found = true
|
||||
fmt.Printf(" userids %v — all refused with \"more than one account uses these sign-in details\"\n", users)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
fmt.Println(" none")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user