226 lines
9.2 KiB
Go
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)
|
|
}
|