console: pin the member-view invite-token secrecy on /team
TestTeamPage now signs in as a non-owner member with a live pending invite present and asserts the token never appears, alongside the absence of the invite/administration forms — closing the gap where a future edit could hoist PendingInvites out of the owner-only branch and the suite would stay green. Also: trim three stale comments left behind by the members/invites move to /team (handleAccountHome's doc comment, the Trash-load comment, and the Members/Invites page-struct field comments), and move the /team route registration off the "mux prefers this literal" comment it doesn't belong next to.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+33
−8
internal/console/server.go+7 −8internal/console/server_test.go+26 −0
diff --git a/internal/console/server.go b/internal/console/server.go| index 8611e758c..9b9cc91ef 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -252,10 +252,10 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| // Account-scoped and explicit: /a/<sqid>/apps/<app> rather than |
| // account-by-session, so a link a teammate opens lands where you meant. |
| s.mux.HandleFunc("GET /a/{sqid}/{$}", s.handleAccountHome) |
| + s.mux.HandleFunc("GET /a/{sqid}/team", s.handleTeam) |
| // Go 1.22's mux prefers this literal over apps/{app} below — exactly why |
| // "new" must be a reserved app name (claimApp): an app so named could |
| // never be viewed. |
| - s.mux.HandleFunc("GET /a/{sqid}/team", s.handleTeam) |
| s.mux.HandleFunc("GET /a/{sqid}/apps/new", s.handleNewAppForm) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/new", s.handleNewApp) |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}", s.handleApp) |
| @@ -344,8 +344,8 @@ type page struct { |
| Base string // "/a/<sqid>" — what every form on the page posts under |
| Nav string // "/a/<name-or-sqid>" — what LINKS use; forms keep posting to Base |
| Accounts []Account // the switcher: every account the viewer belongs to |
| - Members []Account // account home: memberships (Name carries the address) |
| - Invites []Invite // account home, owners only: pending invitations |
| + Members []Account // team page: memberships (Name carries the address) |
| + Invites []Invite // team page, owners only: pending invitations |
| // The Trash (ownership.go): the account's deleted-but-restorable apps on |
| // /a/{sqid}/trash, and the count the account home links with. Zero hides |
| @@ -1123,8 +1123,8 @@ func (s *Server) handleFleetMetrics(w http.ResponseWriter, r *http.Request) { |
| // ---- app pages -------------------------------------------------------------- |
| -// handleAccountHome is the account's app list, its switcher, its members and |
| -// its pending invitations — the page / lands you on. |
| +// handleAccountHome is the account's app list and switcher — the page / |
| +// lands you on. Members and pending invitations live on /team (handleTeam). |
| func (s *Server) handleAccountHome(w http.ResponseWriter, r *http.Request) { |
| addr, raw, acct, role, ok := s.account(w, r) |
| if !ok { |
| @@ -1152,9 +1152,8 @@ func (s *Server) handleAccountHome(w http.ResponseWriter, r *http.Request) { |
| p.Apps = append(p.Apps, summarize(v)) |
| } |
| } |
| - // The Trash link, and only when there is something in it. Best-effort like |
| - // the members and invites reads below: a failed count costs a link, never |
| - // the page. |
| + // The Trash link, and only when there is something in it. Best-effort: a |
| + // failed count costs a link, never the page. |
| if trashed, err := s.store.TrashedAppsIn(acct.ID); err != nil { |
| log.Printf("console: trashed apps: %v", err) |
| } else { |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index 67334d91e..b4ff5c4b4 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -515,6 +515,10 @@ func TestTeamPage(t *testing.T) { |
| if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil { |
| t.Fatal(err) |
| } |
| + tok, err := fx.st.CreateInvite(OpsAccountID, "sam@keymail.dev", "paul@keymail.dev", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| c := signInPaul(t, fx) |
| status, _, body := get(t, c, fx.u("/team")) |
| if status != 200 { |
| @@ -536,6 +540,28 @@ func TestTeamPage(t *testing.T) { |
| } |
| } |
| assertNonMember404(t, fx, "/team") |
| + |
| + // A non-owner member sees the roster and nothing an owner alone should |
| + // hold: no invite form, no member-administration forms, and — the one |
| + // that matters — not the pending invite's TOKEN. That token is a bearer |
| + // capability (whoever holds it can accept the invite as its addressee), |
| + // so PendingInvites must stay behind the role==RoleOwner branch, not just |
| + // behind membership. |
| + fx.setUser("kim@keymail.dev") |
| + member := newBrowser(t) |
| + signUp(t, fx, member, "code-kim-team") |
| + mStatus, _, mBody := get(t, member, fx.u("/team")) |
| + if mStatus != 200 { |
| + t.Fatalf("member GET /team = %d", mStatus) |
| + } |
| + if strings.Contains(mBody, tok) { |
| + t.Fatal("member view leaked a pending invite's token") |
| + } |
| + for _, gone := range []string{"Invite a teammate", "members/remove", "members/transfer"} { |
| + if strings.Contains(mBody, gone) { |
| + t.Fatalf("member view of /team still shows %q", gone) |
| + } |
| + } |
| } |
| func TestAppNamedNewIsRefused(t *testing.T) { |