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

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 (
"errors"
"log"
"nearle/models"
"nearle/repositories"
"strings"
@@ -9,6 +10,48 @@ import (
"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 {
GetAllUsers(roleID, tenantID, pageno, pagesize int, keyword string) ([]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) {
var uid int
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 {
if user.Authname == "" && user.Contactno == "" {
resp := fiber.Map{
"code": 400,
"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")
}
// 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 {
resp := fiber.Map{
"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{}) {
tenantFormExists := true
uid, dbPassword, status, roleid := 0, "", "", 0
// Step 1: Login by authname or contactno
if user.Authname != "" {
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 {
if user.Authname == "" && user.Contactno == "" {
return models.TenantUserInfo{}, map[string]interface{}{
"status": true,
"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 {
return models.TenantUserInfo{}, map[string]interface{}{
"status": false,