A tenant had exactly the users somebody had created with a command on the
server. That is not a missing screen: a shop with an owner and four staff
either shared one password or raised a ticket per person, and a phone app
for the shop floor could not exist while there was one account to sign in
as.
Registration is by invitation, never open signup - the same line already
drawn around creating a company. The code carries the address and the role
and the request carries only a password, so a code that gets forwarded
cannot become somebody else's account, and a staff invitation cannot be
redeemed as an owner. Single use lives in the UPDATE and the account is
created in the same transaction.
Deactivating a member revokes their sessions in that transaction too. An
access token lives twelve hours, so without it "remove their access"
removed it sometime tomorrow. The session list and revoke that go with it
are the benefit of opaque tokens the product had been paying for and never
collecting: nothing could say what was signed in, let alone stop one.
Face images now work on a deployment with no object storage, which was
every local install and every self-hosted site - the arrivals feed said
"not storing customer photos" for every customer forever, on the screen
whose whole job is to show a face. Bounded to one row per visitor, so it
grows with the customer base and not with footfall; the bucket stays
primary wherever one exists.
Image.auth says whether a URL needs the session, because a browser img
cannot load one that does, a mobile image view can, and a webview can do
neither - the desktop client resolves those to a data URI in Go.
Found by running it, not by tests:
* UPDATE ... RETURNING gives the value AFTER the update, so the prune
read back empty keys, deleted nothing, and the table grew with
footfall exactly as if it were not there. The fake agreed with either
version; only the live Postgres test caught it.
* Trusting only the auth flag broke every shop card, because Sites.jsx
rebuilt a partial snapshot object and dropped it. A relative URL is
now sufficient on its own.
* ago() renders a future time as "just now", so a code valid for a week
read "expires just now".
Verified live against real Postgres: invite, preview, escalation refused,
register into a session, replay 404, staff forbidden, device revoked and
401 at once, last owner refused, and a 92,405-byte camera JPEG stored,
served to its owner, 401 with no session, 404 to another tenant, and
rendered in a browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
350 lines
14 KiB
Go
350 lines
14 KiB
Go
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)
|
|
}
|
|
}
|