211 lines
6.9 KiB
Go
211 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|