auto mail generation

This commit is contained in:
2026-09-29 16:53:21 +05:30
parent b18080d429
commit b46902f51b
27 changed files with 2765 additions and 80 deletions

View File

@@ -0,0 +1,210 @@
package controllers
import (
"io"
"net/http/httptest"
"strings"
"testing"
"time"
"nearle/middleware"
"nearle/services"
"nearle/utils"
"github.com/gofiber/fiber/v2"
)
/*
Who may re-issue a first-password link, and for whom.
This endpoint mints a credential, so most of what matters is what it refuses.
The service layer refuses the business cases — an account that already has a
password, a tenant whose primary email matches no login — and those are covered
in `services/resendInvite_test.go`. This file is about the door: who gets
through it, and which account a request actually names.
*/
// resendService answers both resends and records which was called. Only the two
// methods under test are real; the rest of TenantService is embedded nil, which
// panics if anything else is reached — exactly the signal wanted.
type resendService struct {
services.TenantService
byTenant int
byUser int
outcome services.InviteOutcome
err error
}
func (s *resendService) ResendInvite(tenantID int) (services.InviteOutcome, error) {
s.byTenant = tenantID
return s.outcome, s.err
}
func (s *resendService) ResendInviteToUser(userID int) (services.InviteOutcome, error) {
s.byUser = userID
return s.outcome, s.err
}
func resendApp(t *testing.T, service *resendService) *fiber.App {
t.Helper()
t.Setenv("POS_TOKEN_SECRET", testSecret)
app := fiber.New()
// The real guard, mounted as routes.go mounts it: this endpoint sits behind
// the session, and the handler then requires a platform account on top.
app.Use("/live/api/v1/web", middleware.WebAuth(nil))
app.Post("/live/api/v1/web/tenants/resendinvite", NewTenantController(service).ResendInvite)
return app
}
// staffToken is a signed session for a Nearle staff account.
//
// `Superadmin` is the signal, and it is minted from `app_users.issuperadmin` —
// not from the tenant being zero and not from a role id. Both of those look
// equivalent and are not: `app_roles` calls roleid 1 "Super admin" and
// onboarding wrote 1 for every shop owner, and a zero tenant is what an
// unfilled column looks like. See `utils.WebClaims`.
func staffToken(t *testing.T) string {
t.Helper()
token, _, err := utils.MintWebToken(utils.WebClaims{
Userid: 12, Roleid: 1, Configid: 1, Superadmin: true,
}, time.Now())
if err != nil {
t.Fatalf("mint: %v", err)
}
return token
}
// merchantToken is a signed session for a shop's own admin.
func merchantToken(t *testing.T) string {
t.Helper()
token, _, err := utils.MintWebToken(utils.WebClaims{
Userid: 904, Tenantid: 1147, Roleid: 3, Configid: 1,
}, time.Now())
if err != nil {
t.Fatalf("mint: %v", err)
}
return token
}
func postAs(t *testing.T, app *fiber.App, token, body string) (int, string) {
t.Helper()
req := httptest.NewRequest("POST", "/live/api/v1/web/tenants/resendinvite",
strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("resendinvite: %v", err)
}
raw, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(raw)
}
func TestAMerchantCannotResendAnything(t *testing.T) {
// A merchant's session is pinned to their own tenant, so the worst they could
// do is re-invite themselves — and the service refuses that, because an
// account signing in to ask already has a password. Refusing here as well
// means the endpoint does not rely on two other checks to make the wrong case
// impossible.
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
app := resendApp(t, service)
status, body := postAs(t, app, merchantToken(t), `{"tenantid":1147}`)
if status != 403 {
t.Fatalf("a merchant was let through: %d %s", status, body)
}
if service.byTenant != 0 || service.byUser != 0 {
t.Fatal("the service was reached by a caller who should have been refused")
}
}
func TestNearleStaffCanResendToATenantsOwner(t *testing.T) {
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
app := resendApp(t, service)
status, body := postAs(t, app, staffToken(t), `{"tenantid":1147}`)
if status != 200 {
t.Fatalf("refused Nearle staff: %d %s", status, body)
}
if service.byTenant != 1147 {
t.Fatalf("resent for tenant %d, want 1147", service.byTenant)
}
}
func TestAUseridNamesOnePersonRatherThanTheOwner(t *testing.T) {
// The reason this parameter exists. Staff added after onboarding, and the
// login every branch spawns, are created with no password too — and a
// business has many of them, so "the tenant's invitation" cannot reach them.
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
app := resendApp(t, service)
status, body := postAs(t, app, staffToken(t), `{"userid":7781}`)
if status != 200 {
t.Fatalf("refused: %d %s", status, body)
}
if service.byUser != 7781 {
t.Fatalf("resent for user %d, want 7781", service.byUser)
}
if service.byTenant != 0 {
t.Fatal("emailed the owner when a person was named")
}
}
func TestAUseridWinsOverATenantid(t *testing.T) {
// A caller that sent a person's id meant that person. Falling back to the
// owner would be the wrong mailbox with nothing on the response to say so.
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
app := resendApp(t, service)
if status, body := postAs(t, app, staffToken(t), `{"tenantid":1147,"userid":7781}`); status != 200 {
t.Fatalf("refused: %d %s", status, body)
}
if service.byUser != 7781 || service.byTenant != 0 {
t.Fatalf("resolved to the wrong account: user=%d tenant=%d", service.byUser, service.byTenant)
}
}
func TestAnEmptyBodyIsRefusedRatherThanSentToTenantZero(t *testing.T) {
// `{}` parses cleanly into two zeroes. Without this check it would reach the
// service as tenant 0 and come back "tenant 0 has no account matching its
// primary email address", which describes nothing the caller did.
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
app := resendApp(t, service)
status, body := postAs(t, app, staffToken(t), `{}`)
if status != 400 {
t.Fatalf("an empty request was accepted: %d %s", status, body)
}
if service.byTenant != 0 || service.byUser != 0 {
t.Fatal("the service was called with nothing to act on")
}
if !strings.Contains(body, "tenantid") || !strings.Contains(body, "userid") {
t.Errorf("the refusal does not say what to send: %s", body)
}
}
func TestMailThatDidNotLeaveIsReportedAsAFailure(t *testing.T) {
// The operator pressed a button expecting an email to go. "Success" with no
// mail sent is the one answer they cannot act on.
service := &resendService{outcome: services.InviteOutcome{
Sent: false, Reason: "MAIL_HOST is not set",
}}
app := resendApp(t, service)
status, body := postAs(t, app, staffToken(t), `{"tenantid":1147}`)
if status != 409 {
t.Fatalf("an unsent invitation was reported as sent: %d %s", status, body)
}
if !strings.Contains(body, "MAIL_HOST") {
t.Errorf("the reason was lost: %s", body)
}
}

View File

@@ -6,9 +6,12 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"nearle/middleware"
"nearle/models"
"nearle/services"
"nearle/utils"
fiberv1 "github.com/gofiber/fiber"
"github.com/gofiber/fiber/v2"
@@ -38,11 +41,13 @@ session, and it must refuse everything except the one case it exists for.
type fakePasswords struct {
// set records what reached the write, so a refusal can be shown to have
// refused rather than merely reported.
set []string
refuseIt error
set []string
lastUserid int
refuseIt error
}
func (f *fakePasswords) SetInitialPassword(userid int, password string) error {
f.lastUserid = userid
if f.refuseIt != nil {
return f.refuseIt
}
@@ -65,8 +70,8 @@ func (f *fakePasswords) UpdateStaff(models.User) error { return nil }
func (f *fakePasswords) AppLogin(models.User) (models.TenantUserInfo, fiberv1.Map, error) {
return models.TenantUserInfo{}, fiberv1.Map{}, nil
}
func (f *fakePasswords) CreateUser(models.User) (models.UserInfo, error) {
return models.UserInfo{}, nil
func (f *fakePasswords) CreateUser(models.User) (models.UserInfo, services.InviteOutcome, error) {
return models.UserInfo{}, services.InviteOutcome{}, nil
}
func passwordApp(t *testing.T, service *fakePasswords) *fiber.App {
@@ -103,7 +108,7 @@ func TestAFirstPasswordCanBeSetWithoutASession(t *testing.T) {
app := passwordApp(t, service)
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
`{"userid":904,"password":"opensesame"}`)
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
if status == fiber.StatusUnauthorized {
t.Fatalf("the guard blocked the one call that cannot present a token: %s", body)
@@ -139,7 +144,7 @@ func TestAnAccountThatAlreadyHasOneIsRefusedAsAConflict(t *testing.T) {
app := passwordApp(t, service)
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
`{"userid":904,"password":"opensesame"}`)
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
if status != fiber.StatusConflict {
t.Fatalf("expected 409, got %d: %s", status, body)
@@ -157,7 +162,7 @@ func TestTheRefusalDoesNotSayWhichAccountsExist(t *testing.T) {
app := passwordApp(t, service)
_, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
`{"userid":904,"password":"opensesame"}`)
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
for _, leak := range []string{"not found", "no such", "does not exist"} {
if strings.Contains(strings.ToLower(body), leak) {
@@ -179,7 +184,7 @@ func TestTheAnswerIsTheEnvelopeTheConsoleUnwraps(t *testing.T) {
// A handler answering at the top level passes a service test and hands the
// console `undefined`.
_, body := send(t, passwordApp(t, &fakePasswords{}), "POST",
"/live/api/v1/web/users/setpassword", `{"userid":904,"password":"opensesame"}`)
"/live/api/v1/web/users/setpassword", `{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
var envelope struct {
Status bool `json:"status"`
@@ -204,3 +209,94 @@ func (f *fakePasswords) TenantWebLogin(models.User) (models.TenantUserInfo, map[
return models.TenantUserInfo{}, map[string]interface{}{}
}
func (f *fakePasswords) DeleteUser(int) error { return nil }
// invite mints a real invitation for the test's account.
//
// A helper rather than a literal, because the token is signed: a hand-written
// string would test the refusal path and nothing else, and the point of these
// is what happens when a genuine invitation arrives.
func invite(t *testing.T, userid int) string {
t.Helper()
token, _, err := utils.MintInviteToken(utils.InviteClaims{Userid: userid, Tenantid: 1147}, time.Now())
if err != nil {
t.Fatalf("minting an invitation: %v", err)
}
return token
}
/*
The invitation replaced a userid, and that was a security fix rather than a
tidy-up.
`applogin` answers a POST carrying an email and no password with 409 and the
userid, for any account that has not set one. So the recipe was: know a
merchant's primary email — usually printed on their shopfront — POST it, receive
their userid, set their password, own the business's admin account. No guessing
at any step, and the empty-password check was no defence because an un-set-up
account is exactly what such an attacker wants.
*/
func TestAUseridIsNoLongerEnoughToSetAPassword(t *testing.T) {
// The hole, asserted closed. A body carrying a userid and no invitation
// must not set anything, whatever the userid is.
service := &fakePasswords{}
app := passwordApp(t, service)
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
`{"userid":904,"password":"opensesame"}`)
if status == fiber.StatusOK {
t.Fatalf("a bare userid still set a password: %s", body)
}
if len(service.set) != 0 {
t.Fatalf("a bare userid reached the service: %v", service.set)
}
}
func TestAnInvitationSetsThePasswordForTheAccountItNames(t *testing.T) {
service := &fakePasswords{}
app := passwordApp(t, service)
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
if status != fiber.StatusOK {
t.Fatalf("HTTP %d: %s", status, body)
}
if len(service.set) != 1 || service.set[0] != "opensesame" {
t.Fatalf("the password did not reach the service: %v", service.set)
}
}
func TestTheUseridComesFromTheSignatureNotTheRequest(t *testing.T) {
// An invitation for 904 with a `userid` field claiming 999 must set 904's
// password. If the body could override it, the token would be decoration.
service := &fakePasswords{}
app := passwordApp(t, service)
status, _ := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
`{"token":"`+invite(t, 904)+`","userid":999,"password":"opensesame"}`)
if status != fiber.StatusOK {
t.Fatalf("a valid invitation was refused: %d", status)
}
if service.lastUserid != 904 {
t.Fatalf("the request's userid won: set the password for %d", service.lastUserid)
}
}
func TestAForgedInvitationIsRefused(t *testing.T) {
service := &fakePasswords{}
app := passwordApp(t, service)
for _, token := range []string{"", "i1.forged.signature", "not-a-token", "w1.a.b"} {
status, _ := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
`{"token":"`+token+`","password":"opensesame"}`)
if status == fiber.StatusOK {
t.Fatalf("%q was accepted as an invitation", token)
}
}
if len(service.set) != 0 {
t.Fatalf("a forged invitation wrote: %v", service.set)
}
}

View File

@@ -3,6 +3,7 @@ package controllers
import (
"fmt"
"log"
"nearle/middleware"
"nearle/models"
"nearle/services"
"net/http"
@@ -346,7 +347,8 @@ func (ctl *TenantController) CreateStaff(c *fiber.Ctx) error {
})
}
if err := ctl.tenantService.CreateStaff(data); err != nil {
invite, err := ctl.tenantService.CreateStaff(data)
if err != nil {
// A rejected PIN, a missing name, a role nobody set — these are things
// the person filling in the form can fix, so they come back as 400 with
// the reason. This answered 500 with a body claiming 409, which told a
@@ -358,10 +360,17 @@ func (ctl *TenantController) CreateStaff(c *fiber.Ctx) error {
})
}
// The person was hired either way. Whether they were emailed their
// first-password link is reported beside that rather than folded into
// `status`: this account is created with no password and the link is the only
// way in, so an operator who is not told cannot know they have added somebody
// who cannot sign in.
return c.JSON(fiber.Map{
"code": http.StatusCreated,
"message": "Staff created successfully",
"status": true,
"code": http.StatusCreated,
"message": "Staff created successfully",
"status": true,
"invited": invite.Sent,
"invitereason": invite.Reason,
})
}
@@ -436,7 +445,7 @@ func (ctl *TenantController) CreateTenantUser(c *fiber.Ctx) error {
})
}
result, err := ctl.tenantService.CreateTenantUser(data)
result, invite, err := ctl.tenantService.CreateTenantUser(data)
if err != nil {
if err.Error() == "Tenant Already Exists" {
return c.Status(http.StatusConflict).JSON(fiber.Map{
@@ -453,11 +462,21 @@ func (ctl *TenantController) CreateTenantUser(c *fiber.Ctx) error {
})
}
// The tenant was created either way. The invitation is reported beside it
// rather than folded into `status`, because a merchant who exists and has
// not been emailed is a task for the operator — resend, or correct the
// address — and not a failed onboarding to be retried.
//
// `invited: false` with a reason is the state the platform console shows on
// the tenant, so it never has to guess whether the email went.
return c.Status(http.StatusCreated).JSON(fiber.Map{
"code": 201,
"status": true,
"message": "Successfully Created",
"details": result,
"invited": invite.Sent,
// Omitted when it sent, so a successful onboarding carries no apology.
"invitereason": invite.Reason,
})
}
@@ -776,3 +795,79 @@ func (ctl *TenantController) AssignPartner(c *fiber.Ctx) error {
"code": http.StatusOK, "status": true, "message": "Successfully Updated",
})
}
// ResendInvite re-issues a merchant's first-password link.
//
// ── Why this is platform staff only ─────────────────────────────────────────
//
// It mints a credential. `middleware.WebAuth` already pins a merchant's session
// to their own tenant, so a shop could at most re-invite itself — but the
// account it would be inviting is the one signing in to ask, which can only
// happen if that account already has a password, and the service refuses that
// case outright.
//
// So the only caller this is for is Nearle's own staff, chasing a merchant who
// never received the mail. Saying so explicitly is better than relying on two
// other checks to make the wrong case impossible.
func (ctl *TenantController) ResendInvite(c *fiber.Ctx) error {
claims, ok := middleware.WebClaimsFrom(c)
if !ok || !claims.IsPlatformAccount() {
return c.Status(http.StatusForbidden).JSON(fiber.Map{
"code": http.StatusForbidden, "status": false,
"message": "Only Nearle staff can resend an invitation.",
})
}
// Either a tenant — meaning its owner, the one account onboarding created —
// or one named person. Staff added later and the login every branch spawns
// are created with no password too, and a business has many of them, so
// "the tenant's invitation" cannot reach them.
var req struct {
Tenantid int `json:"tenantid"`
Userid int `json:"userid"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
})
}
if req.Tenantid <= 0 && req.Userid <= 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false,
"message": "Send a tenantid to re-invite the owner, or a userid to re-invite one person.",
})
}
// `userid` wins when both arrive. It is the more specific of the two, and a
// caller that sent a person's id meant that person — silently emailing the
// owner instead would be the wrong mailbox with no sign anything was off.
var (
outcome services.InviteOutcome
err error
)
if req.Userid > 0 {
outcome, err = ctl.tenantService.ResendInviteToUser(req.Userid)
} else {
outcome, err = ctl.tenantService.ResendInvite(req.Tenantid)
}
if err != nil {
// 409, not 500. Every failure here is a business fact the operator can
// act on — no such tenant, an address that matches no login, a merchant
// already set up — rather than a fault in the server.
return c.Status(http.StatusConflict).JSON(fiber.Map{
"code": http.StatusConflict, "status": false, "message": err.Error(),
})
}
if !outcome.Sent {
// The tenant is fine and the mail did not go. Reported as a failure
// because the operator pressed a button expecting an email to leave,
// and the reason names what to fix.
return c.Status(http.StatusConflict).JSON(fiber.Map{
"code": http.StatusConflict, "status": false, "message": outcome.Reason,
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true, "message": "Invitation sent.",
})
}

View File

@@ -255,7 +255,7 @@ func (ctl *UserController) CreateUser(c *fiber.Ctx) error {
}
// Call service
info, err := ctl.userService.CreateUser(user)
info, invite, err := ctl.userService.CreateUser(user)
if err != nil {
return c.Status(http.StatusConflict).JSON(fiber.Map{
"code": http.StatusConflict,
@@ -264,11 +264,17 @@ func (ctl *UserController) CreateUser(c *fiber.Ctx) error {
})
}
// The account was created either way. Whether its first-password invitation
// was emailed is reported beside it rather than folded into `status`: the
// account has no password and the link is the only way to set one, so an
// operator who is not told has hired somebody who cannot sign in.
return c.Status(http.StatusCreated).JSON(fiber.Map{
"code": http.StatusCreated,
"status": true,
"message": "Success",
"details": info,
"code": http.StatusCreated,
"status": true,
"message": "Success",
"details": info,
"invited": invite.Sent,
"invitereason": invite.Reason,
})
}
@@ -347,7 +353,9 @@ func (ctl *UserController) DeleteUser(c *fiber.Ctx) error {
// what makes it safe to leave open. See the repository for the rest.
func (ctl *UserController) SetPassword(c *fiber.Ctx) error {
var req struct {
Userid int `json:"userid"`
// The invitation, exactly as it arrived in the emailed link. The userid
// is read out of the signature and never out of the request — see below.
Token string `json:"token"`
Password string `json:"password"`
}
if err := c.BodyParser(&req); err != nil {
@@ -356,7 +364,27 @@ func (ctl *UserController) SetPassword(c *fiber.Ctx) error {
})
}
if err := ctl.userService.SetInitialPassword(req.Userid, req.Password); err != nil {
// ── Why this takes a token and no longer takes a userid ─────────────────
//
// It used to accept `{userid, password}`, and that was an account takeover
// waiting to be noticed. `applogin` answers a POST carrying an email and NO
// password with 409 and the userid, for any account that has not set one —
// which is how the console's own setup step learned it. So the whole recipe
// was: know a merchant's primary email, which is usually printed on their
// shopfront, POST it here, receive their userid, then set their password
// and own the business's admin account. No guessing at any step.
//
// The invitation closes it. It is signed with the deployment's key, names
// the account in a payload the server produced, and expires. Knowing an
// email is no longer enough, and neither is knowing a userid.
claims, err := utils.ParseInviteToken(req.Token, time.Now())
if err != nil {
return c.Status(http.StatusConflict).JSON(fiber.Map{
"status": false, "code": http.StatusConflict, "message": err.Error(),
})
}
if err := ctl.userService.SetInitialPassword(claims.Userid, req.Password); err != nil {
// 409, not 401. Nothing about this is an authentication failure — the
// caller is not supposed to have a session — and answering 401 would
// send the console into its sign-out-and-reload path on the one screen