Stop reporting a failed login lookup as "Invalid Email"

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>
This commit is contained in:
2026-09-15 17:04:33 +05:30
parent 4474479735
commit 1633617dc4
4 changed files with 249 additions and 68 deletions

View File

@@ -1,6 +1,8 @@
package repositories package repositories
import ( import (
"database/sql"
"errors"
"fmt" "fmt"
"strings" "strings"
@@ -15,13 +17,11 @@ type UserRepository interface {
Login(user models.User) (models.UserInfo, error) Login(user models.User) (models.UserInfo, error)
FindUserID(authname, contactno string, configid int) (int, error) FindUserID(authname, contactno string, configid int) (int, error)
UpdateStaff(user models.User) error UpdateStaff(user models.User) error
GetUserByAuthname(authname string, configid int) (int, string, string)
GetUserByContactNo(contactno string, configid int) (int, string, string)
UpdateFCMToken(userid int, token string) error UpdateFCMToken(userid int, token string) error
GetTenantUserById(userid int) models.TenantUserInfo GetTenantUserById(userid int) models.TenantUserInfo
CreateUser(user models.User) (int, error) CreateUser(user models.User) (int, error)
GetUserById(uid int) (models.UserInfo, error) GetUserById(uid int) (models.UserInfo, error)
GetUserLogin(field, value string, configid int) (int, string, string, int) GetUserLogin(field, value string, configid int) (int, string, string, int, error)
UpdateUserFcmToken(uid int, token string) error UpdateUserFcmToken(uid int, token string) error
GetLocationStatus(locationid int) string GetLocationStatus(locationid int) string
DeleteUser(userid int) error DeleteUser(userid int) error
@@ -164,7 +164,6 @@ func (r *userRepository) Login(user models.User) (models.UserInfo, error) {
return userInfo, nil return userInfo, nil
} }
func (r *userRepository) FindUserID(authname, contactno string, configid int) (int, error) { func (r *userRepository) FindUserID(authname, contactno string, configid int) (int, error) {
var uid int var uid int
var query string var query string
@@ -187,39 +186,10 @@ func (r *userRepository) FindUserID(authname, contactno string, configid int) (i
return uid, nil return uid, nil
} }
func (r *userRepository) UpdateStaff(user models.User) error { func (r *userRepository) UpdateStaff(user models.User) error {
return r.db.Table("app_users").Where("userid = ?", user.Userid).Updates(&user).Error return r.db.Table("app_users").Where("userid = ?", user.Userid).Updates(&user).Error
} }
// A till account is not a Nearle Daily user. The two products share this table
// and nothing else, so every way into the application excludes roles 7 and 8 in
// the lookup itself: a cashier is not "refused", they are simply not found.
//
// Doing it in the query rather than after it is deliberate. A check bolted on
// afterwards has to be repeated at each of these call sites and is one edit away
// from being forgotten at one of them, and that one would be the hole.
func (r *userRepository) GetUserByAuthname(authname string, configid int) (int, string, string) {
var uid int
var password, status string
query := `SELECT userid, password, status FROM app_users
WHERE authname = ? AND configid = ?
AND COALESCE(roleid, 0) NOT IN (7, 8)`
r.db.Raw(query, authname, configid).Row().Scan(&uid, &password, &status)
return uid, password, status
}
func (r *userRepository) GetUserByContactNo(contactno string, configid int) (int, string, string) {
var uid int
var password, status string
query := `SELECT userid, password, status FROM app_users
WHERE contactno = ? AND configid = ?
AND COALESCE(roleid, 0) NOT IN (7, 8)`
r.db.Raw(query, contactno, configid).Row().Scan(&uid, &password, &status)
return uid, password, status
}
func (r *userRepository) UpdateFCMToken(userid int, token string) error { func (r *userRepository) UpdateFCMToken(userid int, token string) error {
query := `UPDATE app_users SET userfcmtoken = ? WHERE userid = ?` query := `UPDATE app_users SET userfcmtoken = ? WHERE userid = ?`
return r.db.Exec(query, token, userid).Error return r.db.Exec(query, token, userid).Error
@@ -329,9 +299,40 @@ func (r *userRepository) GetUserById(uid int) (models.UserInfo, error) {
return user, nil return user, nil
} }
func (r *userRepository) GetUserLogin(field, value string, configid int) (int, string, string, int) { // GetUserLogin is the one sign-in lookup, for the app and the console alike.
var uid, roleid int //
var password, status string // `field` is the column matched — "authname" or "contactno", nothing else is
// accepted — and it is interpolated, so the whitelist is what keeps this from
// being an injection point.
//
// A till account is not a Nearle Daily user. The two products share this table
// and nothing else, so the lookup itself excludes roles 7 and 8: a cashier is
// not "refused", they are simply not found. Doing it in the query rather than
// after it is deliberate — a check bolted on afterwards has to be repeated at
// every call site and is one edit away from being forgotten at one of them.
//
// Three outcomes, and the caller must tell them apart:
//
// - found: uid > 0, err == nil
// - not found: uid == 0, err == nil
// - failed: err != nil — the database could not answer at all
//
// The third used to be invisible. `Row().Scan`'s error was discarded, so a
// database that was down, a connection pool that was exhausted or a
// misconfigured `configid` all came back as uid 0 — which the service then
// reported as "Invalid Email". On 2026-07-20 the deployment lost its
// ConfigMaps/Secrets and every user on the platform was told their email was
// wrong, and nothing in the logs said otherwise.
func (r *userRepository) GetUserLogin(field, value string, configid int) (int, string, string, int, error) {
switch field {
case "authname", "contactno":
default:
return 0, "", "", 0, fmt.Errorf("login: %q is not a sign-in field", field)
}
var uid int
var password, status sql.NullString
var roleid sql.NullInt64
query := fmt.Sprintf(` query := fmt.Sprintf(`
SELECT userid, password, status, roleid SELECT userid, password, status, roleid
@@ -339,9 +340,16 @@ func (r *userRepository) GetUserLogin(field, value string, configid int) (int, s
WHERE %s = ? AND configid = ? WHERE %s = ? AND configid = ?
AND COALESCE(roleid, 0) NOT IN (7, 8)`, field) AND COALESCE(roleid, 0) NOT IN (7, 8)`, field)
r.db.Raw(query, value, configid).Row().Scan(&uid, &password, &status, &roleid) err := r.db.Raw(query, value, configid).Row().Scan(&uid, &password, &status, &roleid)
if errors.Is(err, sql.ErrNoRows) {
return uid, password, status, roleid return 0, "", "", 0, nil
}
if err != nil {
return 0, "", "", 0, err
}
// Nullable columns scanned through sql.Null* so that a NULL password or
// role — both exist on real rows — does not itself read as a failed query.
return uid, password.String, status.String, int(roleid.Int64), nil
} }
func (r *userRepository) UpdateUserFcmToken(userid int, fcmToken string) error { func (r *userRepository) UpdateUserFcmToken(userid int, fcmToken string) error {
@@ -359,5 +367,3 @@ func (r *userRepository) GetLocationStatus(locationid int) string {
func (r *userRepository) DeleteUser(userid int) error { func (r *userRepository) DeleteUser(userid int) error {
return r.db.Table("app_users").Where("userid = ?", userid).Delete(&models.User{}).Error return r.db.Table("app_users").Where("userid = ?", userid).Delete(&models.User{}).Error
} }

View File

@@ -137,15 +137,11 @@ func main() {
} }
fmt.Println("\n3. a till account cannot reach the Nearle Daily application") fmt.Println("\n3. a till account cannot reach the Nearle Daily application")
uid, _, _ := users.GetUserByAuthname(sup.Authname, sup.Configid) // Both the app and the console sign-in now go through GetUserLogin.
check("applogin lookup does not find the supervisor", uid2, _, _, _, lookupErr := users.GetUserLogin("authname", sup.Authname, sup.Configid)
uid == 0, check("app and tenant web login do not find the supervisor",
fmt.Sprintf("GetUserByAuthname(%s) -> userid %d", sup.Authname, uid)) uid2 == 0 && lookupErr == nil,
fmt.Sprintf("GetUserLogin(%s) -> userid %d err=%v", sup.Authname, uid2, lookupErr))
uid2, _, _, _ := users.GetUserLogin("authname", sup.Authname, sup.Configid)
check("tenant web login does not find the supervisor",
uid2 == 0,
fmt.Sprintf("GetUserLogin(%s) -> userid %d", sup.Authname, uid2))
uid3, _ := users.FindUserID(sup.Authname, "", sup.Configid) uid3, _ := users.FindUserID(sup.Authname, "", sup.Configid)
check("password-setup lookup does not find the supervisor", check("password-setup lookup does not find the supervisor",

139
services/userLogin_test.go Normal file
View File

@@ -0,0 +1,139 @@
package services
import (
"errors"
"testing"
"nearle/models"
"nearle/repositories"
)
/*
"Invalid Email" has to mean one thing: the sign-in query ran and matched
nobody.
It used to also mean "the database did not answer". The repository discarded
the Scan error, so an outage, an exhausted pool or a deployment that had lost
its environment all surfaced as uid 0 — and every user on the platform was told
their email was wrong (2026-07-20). The console makes it worse: it reads a 409
as "this address is not registered" and sends the person to sign-up.
*/
// loginRepo is a UserRepository that answers only the sign-in path. Anything
// else panics on the nil embedded interface, which is the point: these tests
// must not reach further than the lookup.
type loginRepo struct {
repositories.UserRepository
uid int
password string
status string
roleid int
err error
field, value string
configid int
}
func (r *loginRepo) GetUserLogin(field, value string, configid int) (int, string, string, int, error) {
r.field, r.value, r.configid = field, value, configid
if r.err != nil {
return 0, "", "", 0, r.err
}
return r.uid, r.password, r.status, r.roleid, nil
}
func (r *loginRepo) UpdateFCMToken(int, string) error { return nil }
func (r *loginRepo) UpdateUserFcmToken(int, string) error { return nil }
func (r *loginRepo) GetTenantUserById(uid int) models.TenantUserInfo {
return models.TenantUserInfo{Userid: uid}
}
func TestADatabaseFailureIsNotAnInvalidEmail(t *testing.T) {
repo := &loginRepo{err: errors.New("dial tcp 10.0.0.5:5433: connection refused")}
svc := NewUserService(repo)
user := models.User{Authname: "owner@shop.example", Password: "pw", Configid: 1}
t.Run("app login", func(t *testing.T) {
_, resp, err := svc.AppLogin(user)
if err == nil {
t.Fatal("a failed lookup must be an error to the controller")
}
if resp["code"] != 500 || resp["status"] != false {
t.Fatalf("want a 500/false envelope, got %v", resp)
}
if resp["message"] == "Invalid Email" {
t.Fatal("the database being down was reported as a wrong email")
}
})
t.Run("tenant web login", func(t *testing.T) {
_, resp := svc.TenantWebLogin(user)
if resp["code"] != 500 || resp["status"] != false {
t.Fatalf("want a 500/false envelope, got %v", resp)
}
if resp["message"] == "Invalid Email" {
t.Fatal("the database being down was reported as a wrong email")
}
})
}
// The console depends on this exact shape — code 409 — to tell "not
// registered" from every other failure, so it must survive the refactor.
func TestNobodyMatchingIsStillInvalidEmail(t *testing.T) {
repo := &loginRepo{} // uid 0, no error: the query ran and found no row
svc := NewUserService(repo)
user := models.User{Authname: "nobody@shop.example", Password: "pw", Configid: 1}
_, resp, err := svc.AppLogin(user)
if err == nil || resp["code"] != 409 || resp["message"] != "Invalid Email" {
t.Fatalf("app login: want 409 Invalid Email, got %v / %v", resp, err)
}
_, wresp := svc.TenantWebLogin(user)
if wresp["code"] != 409 || wresp["message"] != "Invalid Email" {
t.Fatalf("web login: want 409 Invalid Email, got %v", wresp)
}
}
func TestLookupPrefersAuthnameAndFallsBackToContactNo(t *testing.T) {
repo := &loginRepo{uid: 7, password: "pw", status: "Active"}
svc := NewUserService(repo)
svc.AppLogin(models.User{Authname: "owner@shop.example", Contactno: "9999999999", Password: "pw", Configid: 1})
if repo.field != "authname" || repo.value != "owner@shop.example" || repo.configid != 1 {
t.Fatalf("authname should win when both are sent, looked up %s=%q configid=%d", repo.field, repo.value, repo.configid)
}
svc.AppLogin(models.User{Contactno: "9999999999", Password: "pw", Configid: 1})
if repo.field != "contactno" || repo.value != "9999999999" {
t.Fatalf("contactno should be used when authname is blank, looked up %s=%q", repo.field, repo.value)
}
}
func TestNeitherIdentifierIsRefusedBeforeTheLookup(t *testing.T) {
repo := &loginRepo{err: errors.New("must not be called")}
svc := NewUserService(repo)
_, resp, err := svc.AppLogin(models.User{Password: "pw", Configid: 1})
if err == nil || resp["code"] != 400 {
t.Fatalf("want 400, got %v / %v", resp, err)
}
if repo.field != "" {
t.Fatal("the repository was queried with nothing to match on")
}
}
func TestAMatchedAccountStillSignsIn(t *testing.T) {
repo := &loginRepo{uid: 42, password: "secret", status: "Active", roleid: 2}
svc := NewUserService(repo)
info, resp, err := svc.AppLogin(models.User{Authname: "owner@shop.example", Password: "secret", Configid: 1})
if err != nil || resp["code"] != 200 || info.Userid != 42 {
t.Fatalf("app login: want success for userid 42, got %v / %v / %+v", resp, err, info)
}
winfo, wresp := svc.TenantWebLogin(models.User{Authname: "owner@shop.example", Password: "secret", Configid: 1, Roleid: 2})
if wresp["code"] != 200 || winfo.Userid != 42 {
t.Fatalf("web login: want success for userid 42, got %v / %+v", wresp, winfo)
}
}

View File

@@ -2,6 +2,7 @@ package services
import ( import (
"errors" "errors"
"log"
"nearle/models" "nearle/models"
"nearle/repositories" "nearle/repositories"
"strings" "strings"
@@ -9,6 +10,48 @@ import (
"github.com/gofiber/fiber" "github.com/gofiber/fiber"
) )
// errLoginUnavailable is returned by lookupLogin when the database could not
// answer the sign-in query. It is deliberately not "invalid user": the account
// may well exist, and the person needs to be told to try again, not to check
// their spelling.
var errLoginUnavailable = errors.New("login lookup failed")
// loginUnavailableResponse is the body for that case. 500 rather than 409,
// because the console reads `409` as "this email does not exist" (see
// daily_merchant_web/src/services/auth.ts) and would otherwise send a
// perfectly good account to the sign-up form while the database was down.
func loginUnavailableResponse() map[string]interface{} {
return map[string]interface{}{
"status": false,
"code": 500,
"message": "Login is temporarily unavailable. Please try again in a moment.",
}
}
// lookupLogin resolves who is signing in, by authname first and contact number
// second, and separates "not found" from "could not look".
//
// Both login paths used to run their own copy of this and both discarded the
// repository's error, so a database that was down came back as uid 0 and was
// reported as "Invalid Email". That message now means exactly one thing: the
// query ran and matched nobody.
func (s *userService) lookupLogin(user models.User) (uid int, password, status string, roleid int, err error) {
field, value := "authname", user.Authname
if user.Authname == "" {
field, value = "contactno", user.Contactno
}
uid, password, status, roleid, err = s.repo.GetUserLogin(field, value, user.Configid)
if err != nil {
// The value is what the caller typed — an email or a phone number —
// and is safe to log; the password never reaches this function's
// output.
log.Printf("login: lookup by %s=%q configid=%d failed: %v", field, value, user.Configid, err)
return 0, "", "", 0, errLoginUnavailable
}
return uid, password, status, roleid, nil
}
type UserService interface { type UserService interface {
GetAllUsers(roleID, tenantID, pageno, pagesize int, keyword string) ([]models.UserInfo, error) GetAllUsers(roleID, tenantID, pageno, pagesize int, keyword string) ([]models.UserInfo, error)
GetUserByID(uid int) (models.UserInfo, error) GetUserByID(uid int) (models.UserInfo, error)
@@ -80,15 +123,7 @@ func (s *userService) UpdateStaff(user models.User) error {
} }
func (s *userService) AppLogin(user models.User) (models.TenantUserInfo, fiber.Map, error) { func (s *userService) AppLogin(user models.User) (models.TenantUserInfo, fiber.Map, error) {
var uid int if user.Authname == "" && user.Contactno == "" {
var status, dbPassword string
// Get user by authname or contactno
if user.Authname != "" {
uid, dbPassword, status = s.repo.GetUserByAuthname(user.Authname, user.Configid)
} else if user.Contactno != "" {
uid, dbPassword, status = s.repo.GetUserByContactNo(user.Contactno, user.Configid)
} else {
resp := fiber.Map{ resp := fiber.Map{
"code": 400, "code": 400,
"status": false, "status": false,
@@ -97,7 +132,12 @@ func (s *userService) AppLogin(user models.User) (models.TenantUserInfo, fiber.M
return models.TenantUserInfo{}, resp, errors.New("missing authname or contactno") return models.TenantUserInfo{}, resp, errors.New("missing authname or contactno")
} }
// Invalid user uid, dbPassword, status, _, err := s.lookupLogin(user)
if err != nil {
return models.TenantUserInfo{}, fiber.Map(loginUnavailableResponse()), err
}
// Nobody matched. This is the only way to reach "Invalid Email" now.
if uid == 0 { if uid == 0 {
resp := fiber.Map{ resp := fiber.Map{
"status": false, "status": false,
@@ -212,14 +252,8 @@ func (s *userService) CreateUser(user models.User) (models.UserInfo, error) {
func (s *userService) TenantWebLogin(user models.User) (models.TenantUserInfo, map[string]interface{}) { func (s *userService) TenantWebLogin(user models.User) (models.TenantUserInfo, map[string]interface{}) {
tenantFormExists := true tenantFormExists := true
uid, dbPassword, status, roleid := 0, "", "", 0
// Step 1: Login by authname or contactno // Step 1: Login by authname or contactno
if user.Authname != "" { if user.Authname == "" && user.Contactno == "" {
uid, dbPassword, status, roleid = s.repo.GetUserLogin("authname", user.Authname, user.Configid)
} else if user.Contactno != "" {
uid, dbPassword, status, roleid = s.repo.GetUserLogin("contactno", user.Contactno, user.Configid)
} else {
return models.TenantUserInfo{}, map[string]interface{}{ return models.TenantUserInfo{}, map[string]interface{}{
"status": true, "status": true,
"code": 400, "code": 400,
@@ -227,7 +261,13 @@ func (s *userService) TenantWebLogin(user models.User) (models.TenantUserInfo, m
} }
} }
// Step 2: Validate user uid, dbPassword, status, roleid, err := s.lookupLogin(user)
if err != nil {
return models.TenantUserInfo{}, loginUnavailableResponse()
}
// Step 2: Validate user. Nobody matched — the only way to reach
// "Invalid Email" now; a database that could not answer is a 500 above.
if uid == 0 { if uid == 0 {
return models.TenantUserInfo{}, map[string]interface{}{ return models.TenantUserInfo{}, map[string]interface{}{
"status": false, "status": false,