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) } }