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