Files

226 lines
9.2 KiB
Go

// 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)
}