package api import ( "encoding/json" "net/http" "strings" "testing" ) // Registration is by invitation, and almost everything worth testing here is a // property of that choice: what the code decides versus what the request // decides, and who is allowed to mint one. // Real user ids are uuids and the id-addressed routes check the shape before // spending a database round trip. A fixture using "u5" would 404 on the guard // rather than on the rule under test - which is a test that passes for the // wrong reason, and would keep passing if tenant scoping were removed. const ( acmeStaffID = "11111111-1111-4111-8111-111111111111" acmeOwnerID = "22222222-2222-4222-8222-222222222222" acmeOtherID = "33333333-3333-4333-8333-333333333333" ) func seedMember(fs *fakeStore, id, email, name, role string) { fs.addUser(email, "correct horse battery", UserRecord{ ID: id, ClientID: "client-acme", ClientName: "Acme Retail", FullName: name, Role: role, Active: true, }) } func invite(t *testing.T, s *Server, token string, body map[string]any) Invitation { t.Helper() rec := do(t, s, "POST", "/api/team/invitations", token, body) if rec.Code != http.StatusCreated { t.Fatalf("invite: got %d, body %s", rec.Code, rec.Body.String()) } var inv Invitation if err := json.Unmarshal(rec.Body.Bytes(), &inv); err != nil { t.Fatal(err) } return inv } func TestAnInvitationBecomesAnAccountAndASession(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") inv := invite(t, s, sess.Token, map[string]any{ "email": "Nikhil@Acme.com", "full_name": "Nikhil", "role": "staff"}) if inv.Code == "" { t.Fatal("the response that mints a code must carry it - it is not recoverable later") } // Normalised on the way in, so the address somebody types at sign-in is the // one that was invited whatever case they used. if inv.Email != "nikhil@acme.com" { t.Errorf("email should be normalised, got %q", inv.Email) } rec := do(t, s, "POST", "/api/auth/register", "", map[string]any{ "code": inv.Code, "password": "a-good-long-password", "device": "Pixel 8"}) if rec.Code != http.StatusCreated { t.Fatalf("register: got %d, body %s", rec.Code, rec.Body.String()) } var out Session if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatal(err) } // A session, not just a 201. Sending somebody who has just chosen a // password to a sign-in form to type it again is the sort of thing that // gets blamed on the password. if out.Token == "" || out.RefreshToken == "" { t.Fatal("registration should sign the new member in") } if out.User.Email != "nikhil@acme.com" || out.User.Role != "staff" { t.Errorf("wrong account: %+v", out.User) } if out.User.ClientID != "client-acme" { t.Errorf("joined the wrong company: %q", out.User.ClientID) } if strings.Contains(rec.Body.String(), "$2a$") { t.Error("password hash leaked into the registration response") } // And the account works. again := login(t, s, "nikhil@acme.com", "a-good-long-password") if again.User.ID != out.User.ID { t.Error("registered account cannot sign in as itself") } } // The single most important test in this file. A code is forwarded, pasted into // a chat, screenshotted; if the body could name the address or the role, one // staff invitation would be an owner account for anybody who saw it. func TestTheCodeDecidesTheAddressAndTheRoleNotTheRequest(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") inv := invite(t, s, sess.Token, map[string]any{ "email": "nikhil@acme.com", "role": "staff"}) // Unknown fields are refused outright, which is the strongest form of this: // a client cannot even ask. rec := do(t, s, "POST", "/api/auth/register", "", map[string]any{ "code": inv.Code, "password": "a-good-long-password", "email": "attacker@example.com", "role": "owner"}) if rec.Code != http.StatusBadRequest { t.Fatalf("a body naming an address or a role must be refused, got %d: %s", rec.Code, rec.Body.String()) } // And redeemed properly, the account is still staff at the invited address. rec = do(t, s, "POST", "/api/auth/register", "", map[string]any{ "code": inv.Code, "password": "a-good-long-password"}) var out Session _ = json.Unmarshal(rec.Body.Bytes(), &out) if out.User.Email != "nikhil@acme.com" || out.User.Role != "staff" { t.Fatalf("the invitation did not decide the account: %+v", out.User) } } func TestAnInvitationIsSingleUse(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") inv := invite(t, s, sess.Token, map[string]any{"email": "one@acme.com"}) first := do(t, s, "POST", "/api/auth/register", "", map[string]any{ "code": inv.Code, "password": "a-good-long-password"}) if first.Code != http.StatusCreated { t.Fatalf("first redemption: %d %s", first.Code, first.Body.String()) } second := do(t, s, "POST", "/api/auth/register", "", map[string]any{ "code": inv.Code, "password": "another-long-password"}) if second.Code == http.StatusCreated { t.Fatal("a spent invitation created a second account") } } func TestARevokedInvitationCannotBeRedeemed(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") inv := invite(t, s, sess.Token, map[string]any{"email": "gone@acme.com"}) if rec := do(t, s, "DELETE", "/api/team/invitations/"+inv.ID, sess.Token, nil); rec.Code != http.StatusNoContent { t.Fatalf("revoke: %d %s", rec.Code, rec.Body.String()) } rec := do(t, s, "POST", "/api/auth/register", "", map[string]any{ "code": inv.Code, "password": "a-good-long-password"}) if rec.Code == http.StatusCreated { t.Fatal("a withdrawn invitation still worked") } } // Unknown, expired, spent and revoked are one answer. The difference only ever // helps somebody guessing, and the holder's next step is identical in all four. func TestAnInvalidCodeSaysNothingAboutWhy(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") inv := invite(t, s, sess.Token, map[string]any{"email": "used@acme.com"}) _ = do(t, s, "POST", "/api/auth/register", "", map[string]any{ "code": inv.Code, "password": "a-good-long-password"}) spent := do(t, s, "POST", "/api/auth/register", "", map[string]any{ "code": inv.Code, "password": "a-good-long-password"}) invented := do(t, s, "POST", "/api/auth/register", "", map[string]any{ "code": "AAAAAA-BBBBBB-CCCCCC-DDDDDD", "password": "a-good-long-password"}) if spent.Code != invented.Code || spent.Body.String() != invented.Body.String() { t.Fatalf("a spent code is distinguishable from an invented one:\n%d %s\n%d %s", spent.Code, spent.Body.String(), invented.Code, invented.Body.String()) } } func TestStaffCannotInviteAndAManagerCannotMintAnOwner(t *testing.T) { s, fs := newServer(t) seedMember(fs, acmeStaffID, "sam@acme.com", "Sam", "staff") seedUser(fs) staff := login(t, s, "sam@acme.com", "correct horse battery") if rec := do(t, s, "POST", "/api/team/invitations", staff.Token, map[string]any{"email": "x@acme.com"}); rec.Code != http.StatusForbidden { t.Errorf("staff should not be able to invite, got %d", rec.Code) } // A manager promoting somebody past themselves is an escalation, and it is // the shape of this endpoint that would matter if a manager account were // ever taken over. mgr := login(t, s, "manager@acme.com", "correct horse battery") if rec := do(t, s, "POST", "/api/team/invitations", mgr.Token, map[string]any{"email": "boss@acme.com", "role": "owner"}); rec.Code != http.StatusForbidden { t.Errorf("a manager minted an owner invitation, got %d", rec.Code) } } // 'admin' is a platform administrator, which is defined by having no company at // all. An invitation always carries one, so the role could never work - what it // could do is create the tenant-scoped row with role='admin' that adminOnly // exists to reject. func TestAnInvitationCannotMintAPlatformAdmin(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") rec := do(t, s, "POST", "/api/team/invitations", sess.Token, map[string]any{"email": "root@acme.com", "role": "admin"}) if rec.Code != http.StatusBadRequest { t.Fatalf("admin should not be an invitable role, got %d: %s", rec.Code, rec.Body.String()) } } func TestAnInvitationIsScopedToTheInvitersCompany(t *testing.T) { s, fs := newServer(t) seedUser(fs) fs.addUser("other@beta.com", "correct horse battery", UserRecord{ ID: "u2", ClientID: "client-beta", ClientName: "Beta Ltd", FullName: "Bo", Role: "manager", Active: true, }) acme := login(t, s, "manager@acme.com", "correct horse battery") beta := login(t, s, "other@beta.com", "correct horse battery") inv := invite(t, s, acme.Token, map[string]any{"email": "new@acme.com"}) // Beta cannot see it... rec := do(t, s, "GET", "/api/team/invitations", beta.Token, nil) if strings.Contains(rec.Body.String(), "new@acme.com") { t.Fatalf("another tenant can see Acme's invitations: %s", rec.Body.String()) } // ...nor withdraw it. if rec := do(t, s, "DELETE", "/api/team/invitations/"+inv.ID, beta.Token, nil); rec.Code == http.StatusNoContent { t.Fatal("another tenant withdrew Acme's invitation") } } // The preview is unauthenticated by necessity - the holder has no account yet - // so what it discloses is the whole question. func TestThePreviewShowsWhatToJoinAndNothingElse(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") inv := invite(t, s, sess.Token, map[string]any{ "email": "nikhil@acme.com", "full_name": "Nikhil", "role": "manager"}) rec := do(t, s, "GET", "/api/auth/invitation?code="+inv.Code, "", nil) if rec.Code != http.StatusOK { t.Fatalf("preview: %d %s", rec.Code, rec.Body.String()) } var prev InvitationPreview if err := json.Unmarshal(rec.Body.Bytes(), &prev); err != nil { t.Fatal(err) } if prev.Role != "manager" || prev.Email != "nikhil@acme.com" { t.Errorf("preview should say what is being joined: %+v", prev) } // It must not become a way to read a company's staff list or anything else // about it beyond the one line the code already asserts. if strings.Contains(rec.Body.String(), "manager@acme.com") { t.Error("the preview disclosed the inviter's address") } if rec := do(t, s, "GET", "/api/auth/invitation?code=NOPE", "", nil); rec.Code != http.StatusNotFound { t.Errorf("an invented code should 404, got %d", rec.Code) } } func TestRegistrationEnforcesThePasswordFloor(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") inv := invite(t, s, sess.Token, map[string]any{"email": "short@acme.com"}) rec := do(t, s, "POST", "/api/auth/register", "", map[string]any{ "code": inv.Code, "password": "short"}) if rec.Code != http.StatusBadRequest { t.Fatalf("a short password was accepted, got %d", rec.Code) } // And the invitation is NOT spent by a rejected attempt - otherwise one // mistyped password would cost the person their invitation. ok := do(t, s, "POST", "/api/auth/register", "", map[string]any{ "code": inv.Code, "password": "a-good-long-password"}) if ok.Code != http.StatusCreated { t.Fatalf("a failed attempt burned the invitation: %d %s", ok.Code, ok.Body.String()) } } // ------------------------------------------------------------------- team -- func TestDeactivatingSomebodySignsThemOutNow(t *testing.T) { s, fs := newServer(t) seedUser(fs) seedMember(fs, acmeStaffID, "leaver@acme.com", "Lee", "staff") mgr := login(t, s, "manager@acme.com", "correct horse battery") leaver := login(t, s, "leaver@acme.com", "correct horse battery") if rec := do(t, s, "GET", "/api/auth/me", leaver.Token, nil); rec.Code != http.StatusOK { t.Fatalf("the leaver should be signed in to begin with, got %d", rec.Code) } rec := do(t, s, "PATCH", "/api/team/"+acmeStaffID, mgr.Token, map[string]any{"active": false}) if rec.Code != http.StatusOK { t.Fatalf("deactivate: %d %s", rec.Code, rec.Body.String()) } // The whole point. An access token lives twelve hours, so without revoking // the session, "remove their access" would remove it sometime tomorrow - // which is not what anybody pressing that button believes they have done. if rec := do(t, s, "GET", "/api/auth/me", leaver.Token, nil); rec.Code != http.StatusUnauthorized { t.Fatalf("a deactivated account is still signed in, got %d", rec.Code) } } func TestTheLastOwnerCannotRemoveThemselves(t *testing.T) { s, fs := newServer(t) seedMember(fs, acmeOwnerID, "boss@acme.com", "Bea", "owner") sess := login(t, s, "boss@acme.com", "correct horse battery") for _, body := range []map[string]any{{"active": false}, {"role": "staff"}} { rec := do(t, s, "PATCH", "/api/team/"+acmeOwnerID, sess.Token, body) if rec.Code != http.StatusConflict { t.Fatalf("the only owner removed themselves with %v: %d %s", body, rec.Code, rec.Body.String()) } } } func TestTeamIsScopedToTheCallersCompany(t *testing.T) { s, fs := newServer(t) seedUser(fs) fs.addUser("other@beta.com", "correct horse battery", UserRecord{ ID: "u2", ClientID: "client-beta", ClientName: "Beta Ltd", FullName: "Bo", Role: "manager", Active: true, }) seedMember(fs, acmeOtherID, "asha@acme.com", "Asha", "manager") beta := login(t, s, "other@beta.com", "correct horse battery") rec := do(t, s, "GET", "/api/team", beta.Token, nil) if strings.Contains(rec.Body.String(), "manager@acme.com") { t.Fatalf("another tenant's staff are visible: %s", rec.Body.String()) } // And a uuid guessed from elsewhere changes nothing. // A real, well-formed id belonging to the OTHER tenant. The 404 must come // from the client scope in the UPDATE, not from the shape check above it. if rec := do(t, s, "PATCH", "/api/team/"+acmeOtherID, beta.Token, map[string]any{"role": "staff"}); rec.Code != http.StatusNotFound { t.Errorf("cross-tenant team edit was not refused, got %d", rec.Code) } }