The flow this product is sold on is three tiers: the platform admin
registers a merchant, the merchant registers their sales staff, the
staff sign in on a phone. Tier 1 handed the new owner a password. Tier 2
could not - a manager could only mint an invitation code, which the
salesperson had to redeem themselves, on their own phone, choosing their
own password. Good practice, and no use to a manager setting somebody up
before their first shift with a card and a pen.
POST /api/team/members mirrors POST /api/admin/clients: generated
password unless one is given, returned exactly once, bcrypt-hashed on
the way in and not recoverable after. Same permission shape as an
invitation - manager and above, only an owner mints an owner, admin
refused - so a manager cannot do through one door what they are refused
at the other. The invitation path stays; it is the better one whenever
the salesperson has their phone.
POST /api/team/{id}/password is the everyday case on a shop floor:
they forgot it. It sets a new one AND revokes every session they hold,
in one transaction, because the other reason a manager resets a
password is a lost phone, and a reset that left that phone signed in
would look complete while fixing nothing. Tenant-scoped in the UPDATE
itself; another company's user id is 404, never 403. No self-service
and no reset-by-email, deliberately: a floor account often has no
mailbox anyone checks, and the person who can vouch for the salesperson
standing in front of them is their manager.
RandomPassword moves from a private helper in the store to auth, so the
admin path, the merchant path and the reset all mint the same 80-bit
credential - rather than someone later writing a shorter one for the
"less important" account.
Verified: eight handler tests, and two against a real Postgres for the
things a fake cannot see - the RETURNING list scans on a row with no
last_login_at, the tenant scope holds, and the sessions row is actually
revoked. The tenant cleanup from yesterday held throughout.
API.md now documents the chain with both paths, and the note saying a
merchant could not create a login directly is gone because it is no
longer true.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
202 lines
7.9 KiB
Go
202 lines
7.9 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The second way a salesperson gets a login: their manager creates it and hands
|
|
// it over. Everything here is a property of the one rule that path lives by -
|
|
// the password is shown once, to the manager, and to nobody afterwards.
|
|
|
|
func createMember(t *testing.T, s *Server, token string, body map[string]any) (int, NewMemberResult, string) {
|
|
t.Helper()
|
|
rec := do(t, s, "POST", "/api/team/members", token, body)
|
|
var out NewMemberResult
|
|
if rec.Code == http.StatusCreated {
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return rec.Code, out, rec.Body.String()
|
|
}
|
|
|
|
func TestAManagerCanCreateALoginAndHandItOver(t *testing.T) {
|
|
s, fs := newServer(t)
|
|
seedUser(fs)
|
|
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
|
|
|
code, out, body := createMember(t, s, mgr.Token, map[string]any{
|
|
"email": "Priya@Acme.com", "full_name": "Priya R", "role": "staff"})
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("create: got %d, body %s", code, body)
|
|
}
|
|
// Generated, not blank, and long enough to be a credential rather than a
|
|
// suggestion. The manager reads this off the screen onto a card.
|
|
if len(out.Password) < 12 {
|
|
t.Fatalf("password should be generated when not given, got %q", out.Password)
|
|
}
|
|
if out.Email != "priya@acme.com" || out.Role != "staff" || !out.Active {
|
|
t.Fatalf("member not as created: %+v", out.TeamMember)
|
|
}
|
|
|
|
// The whole point: the salesperson can sign in with what the manager was
|
|
// shown, right now, on their own phone.
|
|
sess := login(t, s, "priya@acme.com", out.Password)
|
|
if sess.User.Client != "Acme Retail" || sess.User.Role != "staff" {
|
|
t.Fatalf("the new member landed somewhere odd: %+v", sess.User)
|
|
}
|
|
}
|
|
|
|
// The password is returned by the request that set it and by nothing else. A
|
|
// credential a manager can look up later is one anybody at that screen can
|
|
// read off, and the team list is on screen all day.
|
|
func TestThePasswordIsShownOnceAndNeverListed(t *testing.T) {
|
|
s, fs := newServer(t)
|
|
seedUser(fs)
|
|
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
|
|
|
_, out, _ := createMember(t, s, mgr.Token, map[string]any{"email": "sam@acme.com"})
|
|
|
|
rec := do(t, s, "GET", "/api/team", mgr.Token, nil)
|
|
if strings.Contains(rec.Body.String(), out.Password) {
|
|
t.Fatal("the team list carries a password")
|
|
}
|
|
if strings.Contains(rec.Body.String(), `"password"`) {
|
|
t.Fatal("the team list has a password field at all")
|
|
}
|
|
}
|
|
|
|
// Same shape of permission as an invitation, on purpose: the two paths create
|
|
// the same thing, so a manager must not be able to do through one what they
|
|
// are refused through the other.
|
|
func TestStaffCannotCreateAndAManagerCannotCreateAnOwner(t *testing.T) {
|
|
s, fs := newServer(t)
|
|
seedUser(fs)
|
|
seedMember(fs, acmeStaffID, "staff@acme.com", "Sam", "staff")
|
|
seedMember(fs, acmeOwnerID, "owner@acme.com", "Olu", "owner")
|
|
|
|
staff := login(t, s, "staff@acme.com", "correct horse battery")
|
|
if code, _, _ := createMember(t, s, staff.Token, map[string]any{"email": "x@acme.com"}); code != http.StatusForbidden {
|
|
t.Fatalf("staff creating a login: want 403, got %d", code)
|
|
}
|
|
|
|
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
|
if code, _, _ := createMember(t, s, mgr.Token, map[string]any{"email": "boss@acme.com", "role": "owner"}); code != http.StatusForbidden {
|
|
t.Fatalf("manager minting an owner: want 403, got %d", code)
|
|
}
|
|
|
|
owner := login(t, s, "owner@acme.com", "correct horse battery")
|
|
if code, _, body := createMember(t, s, owner.Token, map[string]any{"email": "boss@acme.com", "role": "owner"}); code != http.StatusCreated {
|
|
t.Fatalf("owner minting an owner: want 201, got %d %s", code, body)
|
|
}
|
|
|
|
// Never admin. A platform admin is defined by having no company, so this
|
|
// could only ever mint the tenant-scoped role='admin' row that adminOnly
|
|
// exists to reject.
|
|
if code, _, _ := createMember(t, s, owner.Token, map[string]any{"email": "root@acme.com", "role": "admin"}); code != http.StatusBadRequest {
|
|
t.Fatalf("role=admin: want 400, got %d", code)
|
|
}
|
|
}
|
|
|
|
func TestAnAddressThatAlreadyExistsIsAConflictNotAFault(t *testing.T) {
|
|
s, fs := newServer(t)
|
|
seedUser(fs)
|
|
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
|
|
|
code, _, body := createMember(t, s, mgr.Token, map[string]any{"email": "manager@acme.com"})
|
|
if code != http.StatusConflict {
|
|
t.Fatalf("want 409, got %d %s", code, body)
|
|
}
|
|
if !strings.Contains(body, "already has an account") {
|
|
t.Fatalf("the message should say what to do about it: %s", body)
|
|
}
|
|
}
|
|
|
|
// A manager may choose the password, but not a bad one. The floor is the same
|
|
// as everywhere else, and the policy message goes to them unchanged.
|
|
func TestAChosenPasswordStillMeetsTheFloor(t *testing.T) {
|
|
s, fs := newServer(t)
|
|
seedUser(fs)
|
|
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
|
|
|
code, _, body := createMember(t, s, mgr.Token, map[string]any{"email": "a@acme.com", "password": "short"})
|
|
if code != http.StatusBadRequest {
|
|
t.Fatalf("want 400, got %d %s", code, body)
|
|
}
|
|
|
|
code, out, _ := createMember(t, s, mgr.Token, map[string]any{"email": "b@acme.com", "password": "chosen-by-manager"})
|
|
if code != http.StatusCreated || out.Password != "chosen-by-manager" {
|
|
t.Fatalf("a valid chosen password should be used and echoed once, got %d %q", code, out.Password)
|
|
}
|
|
}
|
|
|
|
// Why a manager resets a password: the salesperson forgot it, or lost the
|
|
// phone it was saved on. In the second case the phone is the problem, so the
|
|
// reset that fixes the first must also fix the second.
|
|
func TestAResetSignsTheOldPhoneOutAndTheNewPasswordIn(t *testing.T) {
|
|
s, fs := newServer(t)
|
|
seedUser(fs)
|
|
seedMember(fs, acmeStaffID, "priya@acme.com", "Priya", "staff")
|
|
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
|
lostPhone := login(t, s, "priya@acme.com", "correct horse battery")
|
|
|
|
rec := do(t, s, "POST", "/api/team/"+acmeStaffID+"/password", mgr.Token, nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("reset: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var out PasswordReset
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(out.Password) < 12 {
|
|
t.Fatalf("reset should hand back a generated password, got %q", out.Password)
|
|
}
|
|
|
|
// The lost phone is out.
|
|
if rec := do(t, s, "GET", "/api/auth/me", lostPhone.Token, nil); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("the old session should be revoked by a reset, got %d", rec.Code)
|
|
}
|
|
// The old password is dead.
|
|
if rec := do(t, s, "POST", "/api/auth/login", "", map[string]any{
|
|
"email": "priya@acme.com", "password": "correct horse battery"}); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("the old password still works after a reset, got %d", rec.Code)
|
|
}
|
|
// The new one is alive.
|
|
login(t, s, "priya@acme.com", out.Password)
|
|
}
|
|
|
|
// A user id is not a secret and this endpoint hands out a credential, so it
|
|
// must not be reachable across tenants - and it must read as "no such person",
|
|
// not as "that id is real but not yours".
|
|
func TestAResetCannotReachAnotherCompanysStaff(t *testing.T) {
|
|
s, fs := newServer(t)
|
|
seedUser(fs)
|
|
fs.addUser("theirs@other.com", "correct horse battery", UserRecord{
|
|
ID: acmeOtherID, ClientID: "client-other", ClientName: "Other Ltd",
|
|
FullName: "Theo", Role: "staff", Active: true,
|
|
})
|
|
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
|
|
|
rec := do(t, s, "POST", "/api/team/"+acmeOtherID+"/password", mgr.Token, nil)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("cross-tenant reset: want 404, got %d", rec.Code)
|
|
}
|
|
// And nothing happened to them.
|
|
login(t, s, "theirs@other.com", "correct horse battery")
|
|
}
|
|
|
|
func TestStaffCannotResetAnyonesPassword(t *testing.T) {
|
|
s, fs := newServer(t)
|
|
seedUser(fs)
|
|
seedMember(fs, acmeStaffID, "staff@acme.com", "Sam", "staff")
|
|
staff := login(t, s, "staff@acme.com", "correct horse battery")
|
|
|
|
rec := do(t, s, "POST", "/api/team/"+acmeStaffID+"/password", staff.Token, nil)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("want 403, got %d", rec.Code)
|
|
}
|
|
}
|