GetUserByAuthname / GetUserByContactNo / GetUserLogin discarded the Scan error, so a database that could not answer — down, pool exhausted, or booted without its config (2026-07-20) — came back as uid 0 and every user was told their email was wrong. One lookup, GetUserLogin, now returns an error; sql.ErrNoRows is "not found" and anything else reaches the service, which answers 500 "Login is temporarily unavailable" and logs the cause. 409 "Invalid Email" is unchanged for a genuine no-match: the console reads that exact shape as "not registered". NULL password/role columns scan through sql.Null* so they do not become 500s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
176 lines
5.9 KiB
Go
176 lines
5.9 KiB
Go
// Proves the till and the Nearle Daily application no longer share accounts.
|
|
//
|
|
// Read-only. Four claims, each checked against live rows rather than asserted:
|
|
//
|
|
// 1. a provisioned supervisor can open a closed terminal;
|
|
//
|
|
// 2. a back-office account cannot, however senior it is;
|
|
//
|
|
// 3. a till account cannot reach the Nearle Daily application; and
|
|
//
|
|
// 4. a till account is not listed as though it were an app user.
|
|
//
|
|
// go run ./scratch/posseparation
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"nearle/models"
|
|
"nearle/repositories"
|
|
|
|
"github.com/joho/godotenv"
|
|
"gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
var failures int
|
|
|
|
func check(claim string, ok bool, detail string) {
|
|
mark := "PASS"
|
|
if !ok {
|
|
mark = "FAIL"
|
|
failures++
|
|
}
|
|
fmt.Printf(" [%s] %s\n %s\n", mark, claim, detail)
|
|
}
|
|
|
|
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 {
|
|
fmt.Println(err)
|
|
os.Exit(1)
|
|
}
|
|
pos := repositories.NewPosRepository(db)
|
|
users := repositories.NewUserRepository(db)
|
|
|
|
// A real provisioned supervisor, and its password, read back out.
|
|
var sup struct {
|
|
Userid int
|
|
Authname, Password string
|
|
Configid, Tenantid, Location int
|
|
}
|
|
db.Raw(`SELECT userid, COALESCE(authname,'') authname, COALESCE(password,'') password,
|
|
COALESCE(configid,0) configid, COALESCE(tenantid,0) tenantid,
|
|
COALESCE(locationid,0) location
|
|
FROM app_users
|
|
WHERE COALESCE(roleid,0) = ? AND COALESCE(authname,'') <> ''
|
|
ORDER BY userid LIMIT 1`, models.PosRoleSupervisor).Scan(&sup)
|
|
if sup.Userid == 0 {
|
|
fmt.Println("no provisioned supervisor to test with")
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Printf("supervisor under test: %d %s (tenant %d, outlet %d)\n\n",
|
|
sup.Userid, sup.Authname, sup.Tenantid, sup.Location)
|
|
|
|
fmt.Println("1. a provisioned supervisor opens a closed terminal")
|
|
session, err := pos.PosLogin(models.PosLoginRequest{
|
|
Authname: sup.Authname, Password: sup.Password,
|
|
})
|
|
check("supervisor signs in at the till",
|
|
err == nil && session != nil,
|
|
fmt.Sprintf("err=%v", err))
|
|
if session != nil {
|
|
check("and gets the supervisor shell",
|
|
session.Canmanagestaff && session.Roleid == models.PosRoleSupervisor,
|
|
fmt.Sprintf("role=%s can_manage_staff=%v", session.Role, session.Canmanagestaff))
|
|
}
|
|
|
|
// The same, for a cashier. A cashier opening a till on their own credentials
|
|
// is the point of this: a shop should not need two people present before it
|
|
// can sell anything.
|
|
var cash struct {
|
|
Userid int
|
|
Authname, Password string
|
|
}
|
|
db.Raw(`SELECT userid, COALESCE(authname,'') authname, COALESCE(password,'') password
|
|
FROM app_users
|
|
WHERE COALESCE(roleid,0) = ? AND COALESCE(authname,'') <> ''
|
|
ORDER BY userid LIMIT 1`, models.PosRoleCashier).Scan(&cash)
|
|
|
|
if cash.Userid == 0 {
|
|
check("a cashier has their own login", false, "no cashier has an authname")
|
|
} else {
|
|
cs, err := pos.PosLogin(models.PosLoginRequest{
|
|
Authname: cash.Authname, Password: cash.Password,
|
|
})
|
|
check(fmt.Sprintf("cashier %s opens a closed terminal alone", cash.Authname),
|
|
err == nil && cs != nil,
|
|
fmt.Sprintf("err=%v", err))
|
|
if cs != nil {
|
|
check("and is held to the billing-only shell",
|
|
!cs.Canmanagestaff && cs.Roleid == models.PosRoleCashier,
|
|
fmt.Sprintf("role=%s can_manage_staff=%v", cs.Role, cs.Canmanagestaff))
|
|
}
|
|
}
|
|
|
|
fmt.Println("\n2. back-office accounts cannot open a terminal at all")
|
|
var backOffice []struct {
|
|
Userid int
|
|
Authname, Password string
|
|
Roleid int
|
|
}
|
|
db.Raw(`SELECT userid, COALESCE(authname,'') authname, COALESCE(password,'') password,
|
|
COALESCE(roleid,0) roleid
|
|
FROM app_users
|
|
WHERE COALESCE(roleid,0) IN (1,2,3,4,5,6)
|
|
AND COALESCE(authname,'') <> '' AND COALESCE(password,'') <> ''
|
|
AND LOWER(COALESCE(status,'active')) <> 'inactive'
|
|
ORDER BY userid LIMIT 5`).Scan(&backOffice)
|
|
for _, b := range backOffice {
|
|
_, err := pos.PosLogin(models.PosLoginRequest{
|
|
Authname: b.Authname, Password: b.Password,
|
|
})
|
|
check(fmt.Sprintf("roleid %d (%s) refused at the till", b.Roleid, b.Authname),
|
|
err != nil && strings.Contains(err.Error(), "not set up for the till"),
|
|
fmt.Sprintf("err=%v", err))
|
|
}
|
|
|
|
fmt.Println("\n3. a till account cannot reach the Nearle Daily application")
|
|
// Both the app and the console sign-in now go through GetUserLogin.
|
|
uid2, _, _, _, lookupErr := users.GetUserLogin("authname", sup.Authname, sup.Configid)
|
|
check("app and tenant web login do not find the supervisor",
|
|
uid2 == 0 && lookupErr == nil,
|
|
fmt.Sprintf("GetUserLogin(%s) -> userid %d err=%v", sup.Authname, uid2, lookupErr))
|
|
|
|
uid3, _ := users.FindUserID(sup.Authname, "", sup.Configid)
|
|
check("password-setup lookup does not find the supervisor",
|
|
uid3 == 0,
|
|
fmt.Sprintf("FindUserID(%s) -> userid %d", sup.Authname, uid3))
|
|
|
|
fmt.Println("\n4. till accounts are not listed as app users")
|
|
list, err := users.GetAllUsers(0, sup.Tenantid, 1, 500, "")
|
|
leaked := 0
|
|
for _, u := range list {
|
|
if u.Roleid == models.PosRoleSupervisor || u.Roleid == models.PosRoleCashier {
|
|
leaked++
|
|
}
|
|
}
|
|
check("getallusers hides till accounts",
|
|
err == nil && leaked == 0,
|
|
fmt.Sprintf("%d of %d rows were till accounts", leaked, len(list)))
|
|
|
|
// ...but the POS console can still read its own people by asking for them.
|
|
sups, err := users.GetAllUsers(models.PosRoleSupervisor, sup.Tenantid, 1, 500, "")
|
|
check("asking for role 7 explicitly still works",
|
|
err == nil && len(sups) > 0,
|
|
fmt.Sprintf("%d supervisor(s) returned", len(sups)))
|
|
|
|
fmt.Println()
|
|
if failures > 0 {
|
|
fmt.Printf("%d CHECK(S) FAILED\n", failures)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println("all checks passed")
|
|
}
|