// Proof that a till signs in with a mobile number and a PIN. // // Runs the real repository and service against a real Postgres, because the // part most likely to be wrong is the SQL, and no amount of unit testing around // it proves a column name. Seeds a shop, a supervisor, a cashier and a // back-office account, then works through every answer the endpoint can give. // // Throwaway database, created and populated by this program: // // docker run -d --rm --name nearle-posproof -e POSTGRES_PASSWORD=proof \ // -e POSTGRES_DB=proof -p 55432:5432 postgres:16-alpine // POS_PROOF_DSN='postgres://postgres:proof@localhost:55432/proof?sslmode=disable' \ // POS_TOKEN_SECRET=proof-secret-at-least-16 go run ./scratch/posphonepinproof package main import ( "encoding/json" "fmt" "log" "os" "strings" "nearle/models" "nearle/repositories" "nearle/services" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" ) const ( tenantID = 1087 locationID = 1135 ) func main() { dsn := strings.TrimSpace(os.Getenv("POS_PROOF_DSN")) if dsn == "" { log.Fatal("POS_PROOF_DSN is not set; this never points at production") } db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { log.Fatalf("connect: %v", err) } seed(db) repo := repositories.NewPosRepository(db) svc := services.NewPosService(repo, nil) pass, fail := 0, 0 check := func(name string, ok bool, detail string) { if ok { pass++ fmt.Printf(" PASS %-52s %s\n", name, detail) return } fail++ fmt.Printf(" FAIL %-52s %s\n", name, detail) } fmt.Println("\nSigning in ------------------------------------------------------") // The whole point of the change. session, err := svc.Login(models.PosLoginRequest{Contactno: "9876543210", Pin: "4821"}) check("mobile number and PIN", err == nil && session != nil, answer(session, err)) // The console stores ten digits. A person types whatever they write down. for _, typed := range []string{"+91 98765 43210", "098765-43210", " 9876543210 "} { s, e := svc.Login(models.PosLoginRequest{Contactno: typed, Pin: "4821"}) check(fmt.Sprintf("the same account typed as %q", typed), e == nil && s != nil, answer(s, e)) } // A cashier gets a cashier's session, not whatever the last person had. cashier, err := svc.Login(models.PosLoginRequest{Contactno: "9000000002", Pin: "7391"}) check("a cashier signs in as a cashier", err == nil && cashier != nil && cashier.Roleid == models.PosRoleCashier && !cashier.Canmanagestaff, answer(cashier, err)) // Live data holds this PIN on eleven accounts. The creation rule refuses to // issue it; the sign-in rule must still admit it or those eleven are locked // out of the terminal they were signed up to. weak, err := svc.Login(models.PosLoginRequest{Contactno: "9000000003", Pin: "1234"}) check("a PIN too weak to issue still signs in", err == nil && weak != nil, answer(weak, err)) fmt.Println("\nBeing refused ---------------------------------------------------") _, err = svc.Login(models.PosLoginRequest{Contactno: "9876543210", Pin: "4822"}) check("a wrong PIN", err != nil && repositories.PosLoginRejected(err), answer(nil, err)) _, err = svc.Login(models.PosLoginRequest{Contactno: "9999999999", Pin: "4821"}) check("a number nobody signs in with", err != nil && repositories.PosLoginRejected(err), answer(nil, err)) // Everybody on the platform was in this state until the console started // asking for a PIN, so the message has to name the fix. _, err = svc.Login(models.PosLoginRequest{Contactno: "9000000004", Pin: "4821"}) check("an account with no PIN set", err != nil && !repositories.PosLoginRejected(err), answer(nil, err)) // A shop owner typing their back-office details at the till. _, err = svc.Login(models.PosLoginRequest{Contactno: "9000000005", Pin: "5150"}) check("a back-office account", err != nil && strings.Contains(err.Error(), "not set up for the till"), answer(nil, err)) // A number that cannot be ten digits is answered the same as a wrong one. _, err = svc.Login(models.PosLoginRequest{Contactno: "12345", Pin: "4821"}) check("a number that is not a number", err != nil && repositories.PosLoginRejected(err), answer(nil, err)) _, err = svc.Login(models.PosLoginRequest{Contactno: "9876543210", Pin: "12"}) check("a PIN that is not four digits", err != nil && repositories.PosLoginRejected(err), answer(nil, err)) // A deactivated cashier keeps their number and PIN and must still be shut out. _, err = svc.Login(models.PosLoginRequest{Contactno: "9000000006", Pin: "6120"}) check("somebody who has left", err != nil && repositories.PosLoginRejected(err), answer(nil, err)) fmt.Println("\nStill working ---------------------------------------------------") // Every account on the platform predates the number it now signs in with. old, err := svc.Login(models.PosLoginRequest{ Authname: "supervisor.1135@pos.nearle.in", Password: "xHegDaH55ccWic", }) check("username and password, through the backfill", err == nil && old != nil, answer(old, err)) // Switching operator at an already-open terminal. switched, err := svc.LoginWithPin(tenantID, locationID, "7391") check("PIN switch at an open terminal", err == nil && switched != nil && switched.Roleid == models.PosRoleCashier, answer(switched, err)) fmt.Println("\nThe response ----------------------------------------------------") body, _ := json.Marshal(session) check("no staff PIN reaches the wire", !strings.Contains(string(body), `"pin"`) && !strings.Contains(string(body), "7391"), fmt.Sprintf("%d staff in the session", len(session.Staff))) check("the terminal still gets its people", len(session.Staff) > 0, fmt.Sprintf("%v", staffNames(session.Staff))) check("a token was minted", session.Token != "" && session.Expiresat != "", "expires "+session.Expiresat) pretty, _ := json.MarshalIndent(session, "", " ") fmt.Printf("\nPOST /pos/login {\"contactno\":\"9876543210\",\"pin\":\"4821\"}\n\n%s\n", pretty) fmt.Printf("\n%d passed, %d failed\n", pass, fail) if fail > 0 { os.Exit(1) } } func answer(session *models.PosSession, err error) string { if err != nil { return "→ " + err.Error() } if session == nil { return "→ no session and no error" } return fmt.Sprintf("→ %s (%s) at %s", session.Fullname, session.Role, session.Locationname) } func staffNames(staff []models.PosStaffMember) []string { names := make([]string, 0, len(staff)) for _, s := range staff { names = append(names, s.Fullname) } return names } // seed builds the smallest shop the login path can read: the columns these // queries actually name, and nothing else. func seed(db *gorm.DB) { statements := []string{ `DROP TABLE IF EXISTS app_users, app_roles, tenants, tenantlocations, tenantstaffs`, `CREATE TABLE app_roles (roleid int PRIMARY KEY, rolename text)`, `CREATE TABLE tenants ( tenantid int PRIMARY KEY, tenantname text, registrationno text, primarycontact text, address text)`, `CREATE TABLE tenantlocations ( locationid int PRIMARY KEY, tenantid int, locationname text, address text, city text, status text)`, `CREATE TABLE tenantstaffs (userid int, tenantid int, locationid int, status text)`, `CREATE TABLE app_users ( userid int PRIMARY KEY, authname text, contactno text, password text, pin bigint, status text, roleid int, configid int, tenantid int, locationid int, firstname text, lastname text, email text)`, fmt.Sprintf(`INSERT INTO app_roles VALUES (%d,'Supervisor'), (%d,'Cashier'), (3,'Admin')`, models.PosRoleSupervisor, models.PosRoleCashier), `INSERT INTO tenants VALUES (1087,'R Mart','33AABCU9603R1ZM','04422334455','12 Mount Road, Chennai')`, `INSERT INTO tenantlocations VALUES (1135,1087,'Selvapuram','4 Trichy Road','Coimbatore','Active')`, } // One shop, six people, each standing for one answer the endpoint gives. people := []struct { id int authname, contactno string password string pin int64 role int status string first, last string }{ {4001, "supervisor.1135@pos.nearle.in", "9876543210", "xHegDaH55ccWic", 4821, models.PosRoleSupervisor, "Active", "Meena", "Sundaram"}, {4002, "cashier.1135@pos.nearle.in", "9000000002", "", 7391, models.PosRoleCashier, "Active", "Priya", "Raman"}, {4003, "cashier2.1135@pos.nearle.in", "9000000003", "", 1234, models.PosRoleCashier, "Active", "Karthik", "Velu"}, {4004, "cashier3.1135@pos.nearle.in", "9000000004", "", 0, models.PosRoleCashier, "Active", "Anitha", "Ravi"}, {4005, "owner@rmart.example", "9000000005", "", 5150, 3, "Active", "Suresh", "Kumar"}, {4006, "cashier4.1135@pos.nearle.in", "9000000006", "", 6120, models.PosRoleCashier, "InActive", "Divya", "R"}, } for _, p := range people { statements = append(statements, fmt.Sprintf( `INSERT INTO app_users VALUES (%d,'%s','%s','%s',%d,'%s',%d,1,%d,%d,'%s','%s','%s@example.com')`, p.id, p.authname, p.contactno, p.password, p.pin, p.status, p.role, tenantID, locationID, p.first, p.last, strings.ToLower(p.first))) } for _, statement := range statements { if err := db.Exec(statement).Error; err != nil { log.Fatalf("seed: %v\n%s", err, statement) } } fmt.Printf("Seeded %d till accounts at outlet %d.\n", len(people), locationID) }