agents: fail-closed lookups, an owned agentByID, the cap and dup check in the store
Task 1 review fix round 1: 1. agentByToken/agentByID no longer fall back to a bare-ID Person when the operator row is missing; both resolve the operator through store.personByID and answer false (not-found) when it is gone, deleted this round's operatorFor helper. 2. agentByID gains an operator parameter and is scoped on operator_person_id itself (the 404 shape every owned row gets); handlers_settings.go's agentForOperator now leans on that scoping instead of comparing OperatorPersonID by hand. 3. createAgent refuses a zero-value operator. 4. createAgent refuses a 21st live agent with errAgentLimit (moved from handlers_settings.go, along with maxLiveAgents); the handler drops its own pre-check and renders the store's error instead. 5. createAgent refuses a duplicate live name with errAgentDuplicate (a Go-side count, not a unique index, so a revoked name is free to reuse) — moved from handlers_settings.go the same way, with a new handler-level test. 6. Unit-test hygiene: no test prints a token on failure, only its shape (prefix, length, body charset); two mints are asserted to differ; a new test mints two agents, revokes the older, and checks both newest-first ordering and that the revoked row stays listed. Both errAgentLimit and errAgentDuplicate now live in agents.go, next to errAgentName. Note for the record: a developer database that had already applied the pre-rename 0002_create_agents migration (Task 1, before the rename to 0002_agents.sql) would fail to boot against this tree, since the ledger would carry an ID this Schema no longer has. That migration never shipped, so no such database exists.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
4 files changed,
+302
−102
internal/meet/agents.go+69 −26internal/meet/agents_test.go+191 −8internal/meet/handlers_settings.go+10 −68internal/meettest/agents_test.go+32 −0
CI passed — run details
diff --git a/internal/meet/agents.go b/internal/meet/agents.go| index 26bc192..955f7b4 100644 |
| --- a/internal/meet/agents.go |
| +++ b/internal/meet/agents.go |
| @@ -18,6 +18,25 @@ var agentNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) |
| var errAgentName = errors.New("meet: an agent name is 1 to 64 letters, digits, - or _") |
| +// errAgentOperator guards against a caller that forgot to load a real |
| +// Person before minting: a zero Person is never a legitimate operator. |
| +var errAgentOperator = errors.New("meet: an agent needs a real operator") |
| + |
| +// maxLiveAgents is the operator-level ceiling (fix round 1, item 4): |
| +// an operator who can mint agents without limit can mint without |
| +// limit, and twenty is generous for the handful-of-automations-per- |
| +// person shape this slice is aimed at. Revoked agents do not count |
| +// against it — only live ones do. |
| +const maxLiveAgents = 20 |
| + |
| +var errAgentLimit = errors.New("meet: an operator may hold at most 20 live agents") |
| + |
| +// errAgentDuplicate is a Go-side check (fix round 1, item 5), not a |
| +// unique index: a name is free to reuse the moment its old row is |
| +// revoked, which a partial unique index would not express as cleanly |
| +// as a plain query does. |
| +var errAgentDuplicate = errors.New("meet: an operator may not have two live agents with the same name") |
| + |
| // newAgentToken mints "mt_" + 32 random bytes, base64url without |
| // padding, and its SHA-256 hex. The token exists in plaintext only on |
| // its way to the one page that shows it (§8.1). |
| @@ -42,17 +61,47 @@ func principal(ag Agent, operator Person) string { return ag.Name + "~" + operat |
| func actorForAgent(ag Agent) string { return "agent:" + strconv.FormatInt(ag.ID, 10) } |
| +// createAgent validates the name, refuses a made-up operator, and — |
| +// inside one transaction, so a concurrent create from the same |
| +// operator cannot slip past either check — refuses past the live cap |
| +// or a live duplicate name before minting and storing the token's |
| +// hash. |
| func (s *store) createAgent(ctx context.Context, name string, operator Person) (Agent, string, error) { |
| name = strings.TrimSpace(name) |
| if !agentNameRe.MatchString(name) { |
| return Agent{}, "", errAgentName |
| } |
| + if operator.ID == 0 { |
| + return Agent{}, "", errAgentOperator |
| + } |
| token, hash, err := newAgentToken() |
| if err != nil { |
| return Agent{}, "", err |
| } |
| - ag := Agent{Name: name, OperatorPersonID: operator.ID, TokenHash: hash, CreatedAt: s.now().UTC()} |
| - if err := s.g.WithContext(ctx).Create(&ag).Error; err != nil { |
| + var ag Agent |
| + err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + var live int64 |
| + if err := tx.Model(&Agent{}). |
| + Where("operator_person_id = ? AND revoked_at IS NULL", operator.ID). |
| + Count(&live).Error; err != nil { |
| + return err |
| + } |
| + if live >= maxLiveAgents { |
| + return errAgentLimit |
| + } |
| + var dup int64 |
| + if err := tx.Model(&Agent{}). |
| + Where("operator_person_id = ? AND name = ? AND revoked_at IS NULL", operator.ID, name). |
| + Count(&dup).Error; err != nil { |
| + return err |
| + } |
| + if dup > 0 { |
| + return errAgentDuplicate |
| + } |
| + ag = Agent{Name: name, OperatorPersonID: operator.ID, TokenHash: hash, CreatedAt: s.now().UTC()} |
| + return tx.Create(&ag).Error |
| + }) |
| + if err != nil { |
| return Agent{}, "", err |
| } |
| return ag, token, nil |
| @@ -73,11 +122,12 @@ func (s *store) revokeAgent(ctx context.Context, id int64, operator Person) (boo |
| return res.RowsAffected == 1, res.Error |
| } |
| -// agentByToken resolves a bearer to its agent and operator. Unknown and |
| -// revoked both answer false; the caller must not tell them apart. |
| -// Whether the operator currently holds member standing is a separate |
| -// question the caller re-derives (spec §8.1, §13); finding the agent |
| -// row is all this answers. |
| +// agentByToken resolves a bearer to its agent and operator. Unknown, |
| +// revoked, and an agent whose operator row no longer exists all |
| +// answer false with no error — the caller must not tell them apart |
| +// (fix round 1, item 1: fail closed, not a bare-ID Person). Whether |
| +// the operator currently holds member standing is a separate question |
| +// the caller re-derives (spec §8.1, §13). |
| func (s *store) agentByToken(ctx context.Context, token string) (Agent, Person, bool, error) { |
| var ag Agent |
| err := s.g.WithContext(ctx).Where("token_hash = ? AND revoked_at IS NULL", hashAgentToken(token)).Take(&ag).Error |
| @@ -87,41 +137,34 @@ func (s *store) agentByToken(ctx context.Context, token string) (Agent, Person, |
| if err != nil { |
| return Agent{}, Person{}, false, err |
| } |
| - op, err := s.operatorFor(ctx, ag) |
| + op, found, err := s.personByID(ctx, ag.OperatorPersonID) |
| if err != nil { |
| return Agent{}, Person{}, false, err |
| } |
| + if !found { |
| + return Agent{}, Person{}, false, nil |
| + } |
| return ag, op, true, nil |
| } |
| -func (s *store) agentByID(ctx context.Context, id int64) (Agent, Person, bool, error) { |
| +// agentByID resolves an agent scoped to its operator (fix round 1, |
| +// item 2): a foreign or unknown id both answer false, the same 404 |
| +// shape scope.Owned gives every other owned row. |
| +func (s *store) agentByID(ctx context.Context, id int64, operator Person) (Agent, Person, bool, error) { |
| var ag Agent |
| - err := s.g.WithContext(ctx).Where("id = ?", id).Take(&ag).Error |
| + err := s.g.WithContext(ctx).Where("id = ? AND operator_person_id = ?", id, operator.ID).Take(&ag).Error |
| if errors.Is(err, gorm.ErrRecordNotFound) { |
| return Agent{}, Person{}, false, nil |
| } |
| if err != nil { |
| return Agent{}, Person{}, false, err |
| } |
| - op, err := s.operatorFor(ctx, ag) |
| - if err != nil { |
| - return Agent{}, Person{}, false, err |
| - } |
| - return ag, op, true, nil |
| -} |
| - |
| -// operatorFor loads the agent's operator by id, falling back to a |
| -// bare-ID Person when the row cannot be found: resolving the agent |
| -// itself never fails on that account, since the operator's current |
| -// standing is re-derived by the caller (an absent row simply derives |
| -// to no standing at all). |
| -func (s *store) operatorFor(ctx context.Context, ag Agent) (Person, error) { |
| op, found, err := s.personByID(ctx, ag.OperatorPersonID) |
| if err != nil { |
| - return Person{}, err |
| + return Agent{}, Person{}, false, err |
| } |
| if !found { |
| - return Person{ID: ag.OperatorPersonID}, nil |
| + return Agent{}, Person{}, false, nil |
| } |
| - return op, nil |
| + return ag, op, true, nil |
| } |
diff --git a/internal/meet/agents_test.go b/internal/meet/agents_test.go| index 174fb26..e6fb824 100644 |
| --- a/internal/meet/agents_test.go |
| +++ b/internal/meet/agents_test.go |
| @@ -2,32 +2,61 @@ package meet |
| import ( |
| "context" |
| + "errors" |
| + "regexp" |
| "strconv" |
| "strings" |
| "testing" |
| + "time" |
| ) |
| // itoa is the test package's own decimal formatter, used only to spell |
| // out the actor string an assertion expects. |
| func itoa(id int64) string { return strconv.FormatInt(id, 10) } |
| +var agentTokenBodyRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) |
| + |
| func TestAgentTokenIsShownOnceAndStoredHashed(t *testing.T) { |
| s, _ := testStore(t) |
| ctx := context.Background() |
| - op := Person{ID: 1, Email: "paul@example.com", Name: "paul"} |
| + op, err := s.ensurePerson(ctx, "paul@example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| ag, token, err := s.createAgent(ctx, "claude", op) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if !strings.HasPrefix(token, "mt_") || len(token) < 40 { |
| - t.Fatalf("token shape %q", token) |
| + // The token's own value must never appear in a failure message — |
| + // only its shape does (fix round 1, item 6). |
| + if !strings.HasPrefix(token, "mt_") { |
| + t.Fatal("token lacks the mt_ prefix") |
| + } |
| + if len(token) != 46 { |
| + t.Fatalf("token length %d, want 46", len(token)) |
| + } |
| + if !agentTokenBodyRe.MatchString(token[3:]) { |
| + t.Fatal("token body after mt_ is not [A-Za-z0-9_-]+") |
| + } |
| + if ag.TokenHash != hashAgentToken(token) || len(ag.TokenHash) != 64 { |
| + t.Fatalf("stored hash %q does not match the minted token's hash", ag.TokenHash) |
| + } |
| + if ag.TokenHash == token { |
| + t.Fatal("the hash equals the plaintext token") |
| + } |
| + |
| + // Two mints never coincide. |
| + token2, _, err := newAgentToken() |
| + if err != nil { |
| + t.Fatal(err) |
| } |
| - if ag.TokenHash != hashAgentToken(token) || ag.TokenHash == token || len(ag.TokenHash) != 64 { |
| - t.Fatalf("stored %q for %q", ag.TokenHash, token) |
| + if token2 == token { |
| + t.Fatal("two mints produced the same token") |
| } |
| + |
| got, opGot, ok, err := s.agentByToken(ctx, token) |
| if err != nil || !ok || got.ID != ag.ID || opGot.ID != op.ID { |
| - t.Fatalf("lookup %+v %+v %v %v", got, opGot, ok, err) |
| + t.Fatalf("lookup: ok=%v err=%v got.ID=%d opGot.ID=%d", ok, err, got.ID, opGot.ID) |
| } |
| if _, _, ok, _ := s.agentByToken(ctx, "mt_nope"); ok { |
| t.Fatal("an unknown token must not resolve") |
| @@ -40,11 +69,42 @@ func TestAgentTokenIsShownOnceAndStoredHashed(t *testing.T) { |
| } |
| } |
| +// Fix round 1, item 1: resolving an agent whose operator row no longer |
| +// exists must fail closed (false, no error, indistinguishable from an |
| +// unknown token) rather than fall back to a bare-ID Person. |
| +func TestAgentByTokenFailsClosedWhenTheOperatorRowIsGone(t *testing.T) { |
| + s, _ := testStore(t) |
| + ctx := context.Background() |
| + op, err := s.ensurePerson(ctx, "ghost@example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ag, token, err := s.createAgent(ctx, "claude", op) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.g.WithContext(ctx).Where("id = ?", op.ID).Delete(&Person{}).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, ok, err := s.agentByToken(ctx, token); ok || err != nil { |
| + t.Fatalf("agentByToken with a deleted operator: ok=%v err=%v", ok, err) |
| + } |
| + if _, _, ok, err := s.agentByID(ctx, ag.ID, op); ok || err != nil { |
| + t.Fatalf("agentByID with a deleted operator: ok=%v err=%v", ok, err) |
| + } |
| +} |
| + |
| func TestRevokedAgentDoesNotResolveAndOnlyTheOperatorMayRevoke(t *testing.T) { |
| s, _ := testStore(t) |
| ctx := context.Background() |
| - op := Person{ID: 1, Email: "paul@example.com", Name: "paul"} |
| - other := Person{ID: 2, Email: "bea@example.com", Name: "bea"} |
| + op, err := s.ensurePerson(ctx, "paul@example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + other, err := s.ensurePerson(ctx, "bea@example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| ag, token, _ := s.createAgent(ctx, "claude", op) |
| if ok, _ := s.revokeAgent(ctx, ag.ID, other); ok { |
| t.Fatal("another person revoked the agent") |
| @@ -67,6 +127,66 @@ func TestRevokedAgentDoesNotResolveAndOnlyTheOperatorMayRevoke(t *testing.T) { |
| } |
| } |
| +// Fix round 1, item 2: agentByID is scoped to its operator, the same |
| +// 404 shape as every other owned row. |
| +func TestAgentByIDIsScopedToItsOperator(t *testing.T) { |
| + s, _ := testStore(t) |
| + ctx := context.Background() |
| + op, _ := s.ensurePerson(ctx, "paul@example.com") |
| + other, _ := s.ensurePerson(ctx, "bea@example.com") |
| + ag, _, err := s.createAgent(ctx, "claude", op) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, ok, err := s.agentByID(ctx, ag.ID, other); ok || err != nil { |
| + t.Fatalf("another operator's agentByID: ok=%v err=%v", ok, err) |
| + } |
| + got, gotOp, ok, err := s.agentByID(ctx, ag.ID, op) |
| + if err != nil || !ok || got.ID != ag.ID || gotOp.ID != op.ID { |
| + t.Fatalf("the real operator's agentByID: got=%+v ok=%v err=%v", got, ok, err) |
| + } |
| + if _, _, ok, err := s.agentByID(ctx, 999999, op); ok || err != nil { |
| + t.Fatalf("unknown id: ok=%v err=%v", ok, err) |
| + } |
| +} |
| + |
| +// Fix round 1, item 6: mint two agents, revoke the older, and check |
| +// both that the list stays newest-first and that the revoked row is |
| +// still there (revoking removes nothing, only marks it). |
| +func TestAgentsForOrdersNewestFirstAndKeepsRevokedRows(t *testing.T) { |
| + s, now := testStore(t) |
| + ctx := context.Background() |
| + op, _ := s.ensurePerson(ctx, "paul@example.com") |
| + older, _, err := s.createAgent(ctx, "older", op) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + *now = now.Add(time.Minute) |
| + newer, _, err := s.createAgent(ctx, "newer", op) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if ok, err := s.revokeAgent(ctx, older.ID, op); !ok || err != nil { |
| + t.Fatalf("revoke: ok=%v err=%v", ok, err) |
| + } |
| + list, err := s.agentsFor(ctx, op) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(list) != 2 { |
| + t.Fatalf("agentsFor returned %d rows, want 2", len(list)) |
| + } |
| + if list[0].ID != newer.ID || list[1].ID != older.ID { |
| + t.Fatalf("order = [%d, %d], want newest [%d] then oldest [%d]", list[0].ID, list[1].ID, newer.ID, older.ID) |
| + } |
| + if list[1].RevokedAt == nil { |
| + t.Fatal("the revoked row is missing from the list") |
| + } |
| + if list[0].RevokedAt != nil { |
| + t.Fatal("the live row was reported revoked") |
| + } |
| +} |
| + |
| func TestAgentNameRules(t *testing.T) { |
| s, _ := testStore(t) |
| ctx := context.Background() |
| @@ -80,3 +200,66 @@ func TestAgentNameRules(t *testing.T) { |
| t.Fatalf("trimmed valid name refused: %v", err) |
| } |
| } |
| + |
| +// Fix round 1, item 3: a zero Person is never a legitimate operator. |
| +func TestCreateAgentRefusesAZeroOperator(t *testing.T) { |
| + s, _ := testStore(t) |
| + ctx := context.Background() |
| + if _, _, err := s.createAgent(ctx, "claude", Person{}); err == nil { |
| + t.Fatal("created an agent for a zero-value operator") |
| + } |
| +} |
| + |
| +// Fix round 1, item 4: the cap lives in the store now, as |
| +// errAgentLimit; revoked agents never count against it. |
| +func TestCreateAgentRefusesTheTwentyFirstLiveAgent(t *testing.T) { |
| + s, _ := testStore(t) |
| + ctx := context.Background() |
| + op, _ := s.ensurePerson(ctx, "paul@example.com") |
| + for i := 0; i < maxLiveAgents; i++ { |
| + if _, _, err := s.createAgent(ctx, "agent"+itoa(int64(i)), op); err != nil { |
| + t.Fatalf("create agent %d: %v", i, err) |
| + } |
| + } |
| + if _, _, err := s.createAgent(ctx, "one-too-many", op); !errors.Is(err, errAgentLimit) { |
| + t.Fatalf("21st create returned %v, want errAgentLimit", err) |
| + } |
| + list, _ := s.agentsFor(ctx, op) |
| + if len(list) != maxLiveAgents { |
| + t.Fatalf("agent count after the refused create: %d, want %d", len(list), maxLiveAgents) |
| + } |
| + |
| + // Revoking one frees a slot. |
| + if ok, _ := s.revokeAgent(ctx, list[0].ID, op); !ok { |
| + t.Fatal("revoke to free a slot failed") |
| + } |
| + if _, _, err := s.createAgent(ctx, "one-more", op); err != nil { |
| + t.Fatalf("create after revoking one: %v", err) |
| + } |
| +} |
| + |
| +// Fix round 1, item 5: a duplicate live name is a Go-side check, not a |
| +// unique index — so a revoked name is free to reuse. |
| +func TestCreateAgentRefusesADuplicateLiveNameButAllowsReuseAfterRevoke(t *testing.T) { |
| + s, _ := testStore(t) |
| + ctx := context.Background() |
| + op, _ := s.ensurePerson(ctx, "paul@example.com") |
| + first, _, err := s.createAgent(ctx, "scheduler", op) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := s.createAgent(ctx, "scheduler", op); !errors.Is(err, errAgentDuplicate) { |
| + t.Fatalf("duplicate create returned %v, want errAgentDuplicate", err) |
| + } |
| + // Another operator may use the same name freely. |
| + other, _ := s.ensurePerson(ctx, "bea@example.com") |
| + if _, _, err := s.createAgent(ctx, "scheduler", other); err != nil { |
| + t.Fatalf("another operator's same name was refused: %v", err) |
| + } |
| + if ok, _ := s.revokeAgent(ctx, first.ID, op); !ok { |
| + t.Fatal("revoke failed") |
| + } |
| + if _, _, err := s.createAgent(ctx, "scheduler", op); err != nil { |
| + t.Fatalf("reuse after revoke was refused: %v", err) |
| + } |
| +} |
diff --git a/internal/meet/handlers_settings.go b/internal/meet/handlers_settings.go| index c4af922..4a9a575 100644 |
| --- a/internal/meet/handlers_settings.go |
| +++ b/internal/meet/handlers_settings.go |
| @@ -8,32 +8,6 @@ import ( |
| "github.com/go-chi/chi/v5" |
| ) |
| -// maxLiveAgents is the operator-level ceiling the reviewer asked for |
| -// ahead of a written spec number: an operator who can mint agents |
| -// without limit can mint without limit, and twenty is generous for the |
| -// handful-of-automations-per-person shape this slice is aimed at. |
| -// Revoked agents do not count against it — only live ones do. |
| -// |
| -// Enforced here, in the handler, rather than in store.createAgent: |
| -// the Task 1 review flagged this cap (and a duplicate-live-name |
| -// refusal) for a fix round that moves both into createAgent as |
| -// sentinel errors on the store side. errAgentLimit and errAgentDuplicate |
| -// below are that fix round's sentinels, declared here rather than in |
| -// agents.go because this task does not write that file. Today |
| -// store.createAgent never returns either one, so the switch in |
| -// createAgentHandler that matches them is inert scaffolding and this |
| -// constant plus liveAgentCount below is what actually enforces the |
| -// cap. When the fix round lands createAgent's own check, delete |
| -// maxLiveAgents, liveAgentCount and the handler's own count (keeping |
| -// only the switch's existing cases), and delete these two var |
| -// declarations in favour of agents.go's real ones. |
| -const maxLiveAgents = 20 |
| - |
| -var ( |
| - errAgentLimit = errors.New("meet: an operator may hold at most 20 live agents") |
| - errAgentDuplicate = errors.New("meet: an operator may not have two live agents with the same name") |
| -) |
| - |
| // settingsRow is one line of the agent list: never the token, which |
| // exists in plaintext only on the page that minted it (spec §8.1). |
| // A revoked row shows that and nothing else; a live one shows the |
| @@ -73,38 +47,16 @@ func (a *app) renderSettings(w http.ResponseWriter, r *http.Request, name, nameE |
| })) |
| } |
| -// liveAgentCount is agentsFor filtered to the rows still worth |
| -// something: this task's cap is a handler-level rule, not a store |
| -// method, so it counts here rather than growing the store's interface |
| -// for one caller. |
| -func (a *app) liveAgentCount(agents []Agent) int { |
| - live := 0 |
| - for _, ag := range agents { |
| - if ag.RevokedAt == nil { |
| - live++ |
| - } |
| - } |
| - return live |
| -} |
| - |
| // createAgentHandler is POST /settings/agents: mints a token and shows |
| // it exactly once, on agent_once.html, alongside the principal string |
| -// the meetings list will show for anything this agent creates. |
| +// the meetings list will show for anything this agent creates. The |
| +// cap and the duplicate-name refusal are store.createAgent's own |
| +// checks (fix round 1, items 4-5); this handler only renders whatever |
| +// it answers. |
| func (a *app) createAgentHandler(w http.ResponseWriter, r *http.Request) { |
| _ = r.ParseForm() |
| name := r.FormValue("name") |
| p, _ := currentPerson(r) |
| - agents, err := a.store.agentsFor(r.Context(), p) |
| - if err != nil { |
| - a.logger.Error("meet: list agents", "err", err) |
| - a.ErrorPage(w, r, http.StatusInternalServerError, "") |
| - return |
| - } |
| - if a.liveAgentCount(agents) >= maxLiveAgents { |
| - w.WriteHeader(http.StatusUnprocessableEntity) |
| - a.renderSettings(w, r, name, "You already have 20 live agents. Revoke one before creating another.") |
| - return |
| - } |
| ag, token, err := a.store.createAgent(r.Context(), name, p) |
| if err != nil { |
| switch { |
| @@ -112,9 +64,6 @@ func (a *app) createAgentHandler(w http.ResponseWriter, r *http.Request) { |
| w.WriteHeader(http.StatusUnprocessableEntity) |
| a.renderSettings(w, r, name, "Give the agent a name of 1 to 64 letters, digits, - or _.") |
| case errors.Is(err, errAgentLimit): |
| - // Not reachable today (see maxLiveAgents' comment); kept so |
| - // the fix round's store-side cap renders correctly the |
| - // moment createAgent starts returning it. |
| w.WriteHeader(http.StatusUnprocessableEntity) |
| a.renderSettings(w, r, name, "You already have 20 live agents. Revoke one before creating another.") |
| case errors.Is(err, errAgentDuplicate): |
| @@ -133,30 +82,23 @@ func (a *app) createAgentHandler(w http.ResponseWriter, r *http.Request) { |
| // agentForOperator loads the agent named by the URL's id, refusing with |
| // the same 404 shape meetingForMember uses (never a 403) whether the id |
| -// is unknown or belongs to another operator: the two must be |
| -// indistinguishable from outside. |
| -// |
| -// store.agentByID is not itself ownership-scoped (Task 1 review): it |
| -// answers whether the id exists at all, for anyone. The comparison |
| -// below is what actually keeps a foreign agent out — done before this |
| -// function returns true, so nothing downstream ever sees a row it |
| -// should not. When agentByID's signature grows an operator parameter, |
| -// call it with p here and this comparison becomes redundant defense in |
| -// depth rather than the only check. |
| +// is unknown or belongs to another operator: store.agentByID is itself |
| +// ownership-scoped now (fix round 1, item 2), so there is nothing left |
| +// for this function to check beyond what agentByID already answers. |
| func (a *app) agentForOperator(w http.ResponseWriter, r *http.Request) (Agent, bool) { |
| id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| if err != nil { |
| a.ErrorPage(w, r, http.StatusNotFound, "") |
| return Agent{}, false |
| } |
| - ag, _, ok, err := a.store.agentByID(r.Context(), id) |
| + p, _ := currentPerson(r) |
| + ag, _, ok, err := a.store.agentByID(r.Context(), id, p) |
| if err != nil { |
| a.logger.Error("meet: load agent", "err", err) |
| a.ErrorPage(w, r, http.StatusInternalServerError, "") |
| return Agent{}, false |
| } |
| - p, _ := currentPerson(r) |
| - if !ok || ag.OperatorPersonID != p.ID { |
| + if !ok { |
| a.ErrorPage(w, r, http.StatusNotFound, "") |
| return Agent{}, false |
| } |
diff --git a/internal/meettest/agents_test.go b/internal/meettest/agents_test.go| index 732bb0e..1273fa0 100644 |
| --- a/internal/meettest/agents_test.go |
| +++ b/internal/meettest/agents_test.go |
| @@ -255,6 +255,38 @@ func TestCreateIsRefusedAtTwentyLiveAgents(t *testing.T) { |
| } |
| } |
| +// Fix round 1, item 5: a duplicate live name is refused with a |
| +// sentence and mints nothing; revoking the original frees the name. |
| +func TestCreateIsRefusedForADuplicateLiveNameButFreedByRevoke(t *testing.T) { |
| + x, _, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + |
| + rec := c.post("/settings/agents", url.Values{"name": {"scheduler"}}) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("first create: %d\n%s", rec.Code, rec.Body.String()) |
| + } |
| + rec = c.post("/settings/agents", url.Values{"name": {"scheduler"}}) |
| + if rec.Code != http.StatusUnprocessableEntity { |
| + t.Fatalf("duplicate create: %d\n%s", rec.Code, rec.Body.String()) |
| + } |
| + if !strings.Contains(rec.Body.String(), "live agent with that name") { |
| + t.Fatalf("no duplicate-name sentence on the refusal:\n%s", rec.Body.String()) |
| + } |
| + list := c.get("/settings").Body.String() |
| + if got := strings.Count(list, "Revoke"); got != 1 { |
| + t.Fatalf("expected exactly 1 live agent, found %d Revoke links:\n%s", got, list) |
| + } |
| + |
| + id := agentRevokeID(t, list) |
| + if rec := c.post("/settings/agents/"+id+"/revoke", nil); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("revoke: %d", rec.Code) |
| + } |
| + if rec := c.post("/settings/agents", url.Values{"name": {"scheduler"}}); rec.Code != http.StatusOK { |
| + t.Fatalf("recreate after revoke: %d\n%s", rec.Code, rec.Body.String()) |
| + } |
| +} |
| + |
| // spec §0.1/§4.4's rule, applied to the mint token: nothing an app logs |
| // may ever carry it. |
| func TestMintedAgentTokenNeverAppearsInTheLog(t *testing.T) { |