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>
268 lines
8.1 KiB
Go
268 lines
8.1 KiB
Go
// 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()
|
|
}
|