Skip to content
quarry Artifact [c5b5d22d01]
Login

Artifact [c5b5d22d01]

Artifact c5b5d22d016418da1c4d4a061893fc3a007d33d1d3ae83e51830326360c8b148:


package web

import (
	"net/http"
	"net/http/httptest"
	"net/url"
	"path/filepath"
	"regexp"
	"strings"
	"testing"

	"craftdesign.group/quarry/internal/api"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// agentAccts is the production adapter's shape over a real store: the
// account list in the api's own types, and the explain seam. Embedding
// carries the agent half (NewAgent, MintToken, AgentGrant ...) as it is.
type agentAccts struct{ *oltp.DB }

func (a agentAccts) Accounts() ([]api.Account, error) {
	rows, err := a.DB.Accounts()
	if err != nil {
		return nil, err
	}
	out := make([]api.Account, 0, len(rows))
	for _, r := range rows {
		out = append(out, api.Account{Name: r.Name, Admin: r.Admin, Created: r.Created,
			Disabled: r.Disabled, Pending: r.Pending, Operator: r.Operator})
	}
	return out, nil
}

func (a agentAccts) Grant(actor, account, repo, role string) (string, error) {
	return a.DB.GrantGuarded(actor, account, repo, oltp.Role(0))
}

func (a agentAccts) Revoke(actor, account, repo string) (string, error) {
	return a.DB.GrantGuarded(actor, account, repo, oltp.NoRole)
}

func (a agentAccts) GrantsFor(account string) (map[string]string, error) {
	got, err := a.DB.GrantsFor(account)
	if err != nil {
		return nil, err
	}
	out := map[string]string{}
	for repo, role := range got {
		out[repo] = role.String()
	}
	return out, nil
}

func (a agentAccts) CapsAt(account, repo string) (string, string, error) {
	stored, err := a.DB.CapsOf(account, repo)
	if err != nil {
		return "", "", err
	}
	return stored, string(a.DB.EffectiveCaps(account, repo)), nil
}

// agentForge is the account page over a real store with jva signed in,
// holding Developer on alpha and beta, and room for perPerson agents.
func agentForge(t *testing.T, perPerson int) (prefsForge, *oltp.DB) {
	t.Helper()
	dir := t.TempDir()
	ix, err := index.Open(filepath.Join(dir, "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	db, err := oltp.Open(filepath.Join(dir, "oltp.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { db.Close() })
	for _, who := range []string{"root", "jva"} {
		if err := db.AddAccount(who, "pw-"+who+"-12345", who == "root"); err != nil {
			t.Fatal(err)
		}
	}
	for _, repo := range []string{"alpha", "beta"} {
		if err := db.AddRepo(repo, t.TempDir(), "", oltp.Public); err != nil {
			t.Fatal(err)
		}
		if _, err := db.GrantGuarded("root", "jva", repo, oltp.Write); err != nil {
			t.Fatal(err)
		}
	}
	prefs := newMemPrefs()
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "t",
		Repos: api.NewRepos(map[string]*api.Repo{
			"alpha": {Index: ix, Code: "c1"}, "beta": {Index: ix, Code: "c2"}}),
		Prefs: prefs, SyncTokens: db, Accounts: agentAccts{db}, AgentsPerPerson: perPerson,
		CapsFor: func(a, repo string) api.CapSet { return db.EffectiveCaps(a, repo) },
	}); err != nil {
		t.Fatal(err)
	}
	h := HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{user: "jva"}), WithPrefs(prefs)})
	return prefsForge{h: h, prefs: prefs}, db
}

func hasHook(body, hook string) bool { return strings.Contains(body, `data-hook="`+hook+`"`) }

func hookCount(body, hook string) int { return strings.Count(body, `data-hook="`+hook+`"`) }

var agentTokenRe = regexp.MustCompile(`data-hook="agent-token" data-token="([0-9A-Za-z]+)" data-state="([a-z]+)"`)

// The whole life of an agent through the pages: created from /me, put
// to work on a repository, given a token whose value is shown once,
// that token revoked, and the agent deleted.
func TestAnAgentLivesItsLifeThroughThePages(t *testing.T) {
	f, _ := agentForge(t, 3)

	_, body := f.get(t, "/me")
	if !hasHook(body, "account-agents") || !hasHook(body, "agent-new") {
		t.Fatalf("FAIL: an empty list does not offer to make an agent")
	}
	if hasHook(body, "agent-list") || hasHook(body, "agents-revoke-all") {
		t.Errorf("FAIL: an empty list draws a table or a revoke with nothing to act on")
	}

	rec := f.post(t, "/me/agents", url.Values{"name": {"jva-coder"}})
	if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/me/agents/jva-coder" {
		t.Fatalf("FAIL: creating answered %d to %q", rec.Code, rec.Header().Get("Location"))
	}
	_, body = f.get(t, "/me")
	if hookCount(body, "agent-row") != 1 || !strings.Contains(body, `data-agent="jva-coder"`) {
		t.Fatalf("FAIL: the list after creating has %d rows", hookCount(body, "agent-row"))
	}
	t.Log("ok: created, listed")

	// An agent's page names its operator and offers a grant.
	code, page := f.get(t, "/me/agents/jva-coder")
	if code != 200 || !hasHook(page, "agent-operator") || !strings.Contains(page, `/people/jva"`) {
		t.Fatalf("FAIL: the page lacks its operator (%d)", code)
	}
	if !hasHook(page, "agent-grant-new") {
		t.Errorf("FAIL: no way to give an agent access")
	}
	if hasHook(page, "agent-token-list") {
		t.Errorf("FAIL: a token table with no tokens")
	}

	// Developer on alpha: stored as the preset, resolved under jva's own
	// letters, which are Developer's, so nothing is narrowed.
	rec = f.post(t, "/me/agents/jva-coder/grant", url.Values{"repo": {"alpha"}, "role": {"Developer"}})
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("FAIL: granting answered %d: %s", rec.Code, rec.Body.String())
	}
	_, page = f.get(t, "/me/agents/jva-coder")
	if !strings.Contains(page, `data-hook="agent-grant" data-repo="alpha"`) {
		t.Fatalf("FAIL: the grant is not listed")
	}
	if eff := dataAttr2(page, "alpha"); eff == "" {
		t.Errorf("FAIL: a Developer grant resolves to nothing")
	}
	if hasHook(page, "agent-narrowed") {
		t.Errorf("FAIL: a grant within the operator's letters is marked narrowed")
	}
	// Admin is more than jva holds: it is stored clipped, and the page
	// says so in its own response rather than leaving it to be found.
	rec = f.post(t, "/me/agents/jva-coder/grant", url.Values{"repo": {"beta"}, "role": {"Admin"}})
	if rec.Code != http.StatusOK || !hasHook(rec.Body.String(), "agent-warning") {
		t.Errorf("FAIL: a clipped grant answered %d without its warning", rec.Code)
	}
	_, page = f.get(t, "/me/agents/jva-coder")
	if strings.Contains(dataAttr2(page, "beta"), "a") {
		t.Errorf("FAIL: the agent resolves to admin on a repository its operator only develops on")
	}
	t.Log("ok: access, and the clip")

	// A token: the value is in the response that made it and in no other.
	rec = f.post(t, "/me/agents/jva-coder/token", url.Values{
		"name": {"ci"}, "scope": {"sync", "api"}, "repo": {"alpha"}, "ceiling": {""}, "days": {"30"}})
	if rec.Code != 200 || !hasHook(rec.Body.String(), "agent-token-value") || !hasHook(rec.Body.String(), "agent-token-copy") {
		t.Fatalf("FAIL: minting answered %d without a value and a copy button", rec.Code)
	}
	if rec.Header().Get("Cache-Control") != "no-store" || rec.Header().Get("Referrer-Policy") != "no-referrer" {
		t.Errorf("FAIL: the one-time response carries Cache-Control %q and Referrer-Policy %q",
			rec.Header().Get("Cache-Control"), rec.Header().Get("Referrer-Policy"))
	}
	value := between(rec.Body.String(), `data-hook="agent-token-value">`, "</pre>")
	if len(value) < 16 {
		t.Fatalf("FAIL: the shown value is %q", value)
	}
	_, page = f.get(t, "/me/agents/jva-coder")
	if strings.Contains(page, value) || hasHook(page, "agent-token-once") {
		t.Errorf("FAIL: the value is on a page that did not mint it")
	}
	_, body = f.get(t, "/me")
	if strings.Contains(body, value) || !hasHook(body, "agent-newest") {
		t.Errorf("FAIL: /me leaks the value or lacks the newest token")
	}
	rows := agentTokenRe.FindAllStringSubmatch(page, -1)
	if len(rows) != 1 || rows[0][2] != "live" {
		t.Fatalf("FAIL: the token rows are %v", rows)
	}
	for _, h := range []string{"agent-token-name", "agent-token-scope", "agent-token-repos", "agent-token-limit", "agent-token-expires", "agent-token-used", "agent-token-revoke"} {
		if hookCount(page, h) != 1 {
			t.Errorf("FAIL: the token row lacks %s", h)
		}
	}
	if !hasHook(body, "agents-revoke-all") {
		t.Errorf("FAIL: a live token and no revoke-all")
	}

	// Revoked, it stays listed and offers nothing.
	rec = f.post(t, "/me/agents/jva-coder/token/revoke", url.Values{"id": {rows[0][1]}})
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("FAIL: revoking answered %d", rec.Code)
	}
	_, page = f.get(t, "/me/agents/jva-coder")
	rows = agentTokenRe.FindAllStringSubmatch(page, -1)
	if len(rows) != 1 || rows[0][2] != "revoked" || hasHook(page, "agent-token-revoke") {
		t.Errorf("FAIL: after revoking: %v", rows)
	}
	_, body = f.get(t, "/me")
	if hasHook(body, "agents-revoke-all") {
		t.Errorf("FAIL: revoke-all with no live token to revoke")
	}
	t.Log("ok: minted once, revoked, kept for the record")

	// Revoke-all ends every live token of every agent.
	f.post(t, "/me/agents/jva-coder/token", url.Values{"name": {"two"}, "scope": {"api"}})
	if rec := f.post(t, "/me/agents/revoke-all", url.Values{}); rec.Code != http.StatusSeeOther {
		t.Fatalf("FAIL: revoke-all answered %d", rec.Code)
	}
	_, page = f.get(t, "/me/agents/jva-coder")
	for _, m := range agentTokenRe.FindAllStringSubmatch(page, -1) {
		if m[2] == "live" {
			t.Errorf("FAIL: a token survived revoke-all")
		}
	}

	// Removing the grant, then deleting the agent. Remove is an
	// only-where-set agent's: an inheriting one narrows instead
	// (TestAnAgentInheritsThroughThePages).
	f.post(t, "/me/agents/jva-coder/inherit", url.Values{"on": {"false"}})
	f.post(t, "/me/agents/jva-coder/grant", url.Values{"repo": {"alpha"}, "revoke": {"1"}})
	_, page = f.get(t, "/me/agents/jva-coder")
	if strings.Contains(page, `data-hook="agent-grant" data-repo="alpha"`) {
		t.Errorf("FAIL: the removed grant is still listed")
	}
	if rec := f.post(t, "/me/agents/jva-coder/delete", url.Values{}); rec.Code != http.StatusSeeOther {
		t.Fatalf("FAIL: deleting answered %d", rec.Code)
	}
	_, body = f.get(t, "/me")
	if hasHook(body, "agent-row") {
		t.Errorf("FAIL: the deleted agent is still listed")
	}
	if code, _ := f.get(t, "/me/agents/jva-coder"); code != http.StatusNotFound {
		t.Errorf("FAIL: the deleted agent's page answered %d", code)
	}
	t.Log("ok: access removed, agent deleted")
}

// A control the viewer cannot use is not drawn: at the cap there is no
// create form, and with the forge not letting people make agents and
// none to list there is no section.
func TestNoCreateControlWhereCreatingIsRefused(t *testing.T) {
	f, _ := agentForge(t, 1)
	f.post(t, "/me/agents", url.Values{"name": {"jva-one"}})
	_, body := f.get(t, "/me")
	if hasHook(body, "agent-new") || !hasHook(body, "agent-row") {
		t.Errorf("FAIL: at the cap the create form is drawn (%v) or the list is not (%v)",
			hasHook(body, "agent-new"), !hasHook(body, "agent-row"))
	}
	rec := f.post(t, "/me/agents", url.Values{"name": {"jva-two"}})
	if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `role="alert"`) {
		t.Errorf("FAIL: creating past the cap answered %d without an alert", rec.Code)
	}

	off, _ := agentForge(t, 0)
	_, body = off.get(t, "/me")
	if hasHook(body, "account-agents") || strings.Contains(body, `href="#agents"`) {
		t.Errorf("FAIL: a forge that lets nobody make agents draws the section")
	}
}

// Somebody else's agent is not found, not forbidden: the page does not
// say it exists.
func TestAnotherPersonsAgentHasNoPage(t *testing.T) {
	f, db := agentForge(t, 3)
	if err := db.AddAccount("eve", "pw-eve-12345", false); err != nil {
		t.Fatal(err)
	}
	if err := db.NewAgent("eve-bot", "eve", 3); err != nil {
		t.Fatal(err)
	}
	if code, _ := f.get(t, "/me/agents/eve-bot"); code != http.StatusNotFound {
		t.Errorf("FAIL: another person's agent answered %d", code)
	}
	if rec := f.post(t, "/me/agents/eve-bot/delete", url.Values{}); rec.Code == http.StatusSeeOther {
		t.Errorf("FAIL: another person's agent was deleted from here")
	}
	if _, ok := db.AccountByName("eve-bot"); !ok {
		t.Errorf("FAIL: the agent is gone")
	}
}

// A repository's People list marks an agent and names its operator.
func TestAnAgentAmongARepositorysPeopleNamesItsOperator(t *testing.T) {
	plane := &memCaps{stored: map[string]string{"dan": "a", "bot": oltp.Write.Caps(), "forgebot": oltp.Write.Caps(), "orphan": oltp.Write.Caps(), "eve": oltp.Write.Caps()}}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos:  api.NewRepos(map[string]*api.Repo{"alpha": {Index: landingIndex(t, false)}}),
		Roster: capsRoster{plane}}); err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{
		WithSessions(fakeSessions{user: "dan", roles: map[string]ops.Auth{"alpha": ops.Admin}}),
		WithCaps(plane),
		WithAgents(func(n string) (string, string) {
			switch n {
			case "bot":
				return "dan", ""
			case "forgebot":
				return "forge", "dan"
			case "orphan":
				return "forge", ""
			}
			return "", ""
		})}))
	t.Cleanup(srv.Close)
	body := asDan(t, srv, "/r/alpha/settings")
	if hookCount(body, "people-agent") != 3 || hookCount(body, "people-operator") != 2 {
		t.Fatalf("FAIL: %d agent marks, %d operators", hookCount(body, "people-agent"), hookCount(body, "people-operator"))
	}
	// A forge agent wears the forge badge and names its answerable admin;
	// one with nobody answerable says so rather than linking nobody.
	if strings.Count(body, `class="agent-tag forge"`) != 2 || hookCount(body, "people-answerable-nobody") != 1 {
		t.Errorf("FAIL: forge marks %d, nobody notes %d", strings.Count(body, `class="agent-tag forge"`), hookCount(body, "people-answerable-nobody"))
	}
	if !strings.Contains(body, `data-hook="people-operator" href="/people/dan"`) {
		t.Errorf("FAIL: the operator is not linked")
	}
}

// between is the text after open up to the next end.
func between(s, open, end string) string {
	i := strings.Index(s, open)
	if i < 0 {
		return ""
	}
	s = s[i+len(open):]
	if j := strings.Index(s, end); j >= 0 {
		return s[:j]
	}
	return s
}

// dataAttr is attr of the first element carrying data-hook=hook.
func dataAttr(body, hook, attr string) string {
	i := strings.Index(body, `data-hook="`+hook+`"`)
	if i < 0 {
		return ""
	}
	tag := body[i:]
	tag = tag[:strings.Index(tag, ">")]
	return between(tag, attr+`="`, `"`)
}

// dataAttr2 is the effective letters of the grant row for repo.
func dataAttr2(body, repo string) string {
	i := strings.Index(body, `data-hook="agent-grant" data-repo="`+repo+`"`)
	if i < 0 {
		return ""
	}
	return dataAttr(body[i:], "agent-grant", "data-effective")
}

// A request on a token, such as a sign-in-link session carries, manages
// no credentials: the pages do not act and nothing is minted.
func TestATokenBoundRequestManagesNoAgents(t *testing.T) {
	f, db := agentForge(t, 3)
	if err := db.NewAgent("jva-coder", "jva", 3); err != nil {
		t.Fatal(err)
	}
	inner := f.h
	f.h = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		tok := ops.TokenAuth{ID: "t1", Caps: func(string) ops.Caps { return oltp.CapSet("gjorzh2bceikmnptuvw378BC") }}
		inner.ServeHTTP(w, r.WithContext(ops.WithToken(r.Context(), tok)))
	})
	if code, body := f.get(t, "/me/agents/jva-coder"); code != http.StatusNotFound || hasHook(body, "agent-token-new") {
		t.Errorf("FAIL: the agent page answered %d to a token", code)
	}
	if _, body := f.get(t, "/me"); hasHook(body, "account-agents") {
		t.Errorf("FAIL: /me draws Your agents for a token")
	}
	for _, p := range []struct {
		path string
		form url.Values
	}{
		{"/me/agents/jva-coder/token", url.Values{"name": {"x"}, "scope": {"api"}}},
		{"/me/agents/jva-coder/token/revoke", url.Values{"id": {"x"}}},
		{"/me/agents/jva-coder/delete", url.Values{}},
		{"/me/agents/revoke-all", url.Values{}},
		{"/me/agents", url.Values{"name": {"sneaky"}}},
	} {
		if rec := f.post(t, p.path, p.form); rec.Code == http.StatusSeeOther || rec.Code == http.StatusOK {
			t.Errorf("FAIL: %s answered %d to a token", p.path, rec.Code)
		}
	}
	if toks, _ := db.Tokens("jva-coder"); len(toks) != 0 {
		t.Errorf("FAIL: a token minted %d tokens", len(toks))
	}
	if _, ok := db.AccountByName("jva-coder"); !ok {
		t.Error("FAIL: a token deleted the agent")
	}
	if _, ok := db.AccountByName("sneaky"); ok {
		t.Error("FAIL: a token made an agent")
	}
}

// RFC 0026 through the pages: a new agent inherits, a narrowing to no
// access closes one repository, "Inherit here" opens it again, and the
// switch turns the agent to only-where-set.
func TestAnAgentInheritsThroughThePages(t *testing.T) {
	f, db := agentForge(t, 3)
	if rec := f.post(t, "/me/agents", url.Values{"name": {"jva-coder"}}); rec.Code != http.StatusSeeOther {
		t.Fatalf("FAIL: creating answered %d", rec.Code)
	}
	_, page := f.get(t, "/me/agents/jva-coder")
	if dataAttr(page, "agent-inherit", "data-inherit") != "true" || !hasHook(page, "agent-inherit-off") {
		t.Fatalf("FAIL: a new agent's page does not say it inherits")
	}
	if !db.EffectiveCaps("jva-coder", "alpha").HasAny("i") {
		t.Errorf("FAIL: with no grant the agent lacks its operator's check-in on alpha")
	}
	if rec := f.post(t, "/me/agents/jva-coder/grant", url.Values{"repo": {"alpha"}, "role": {"none"}}); rec.Code != http.StatusSeeOther {
		t.Fatalf("FAIL: narrowing to no access answered %d: %s", rec.Code, rec.Body.String())
	}
	if db.EffectiveCaps("jva-coder", "alpha").HasAny("i") {
		t.Errorf("FAIL: narrowed to no access, the agent still checks in on alpha")
	}
	_, page = f.get(t, "/me/agents/jva-coder")
	if !hasHook(page, "agent-grant-inherit") || hasHook(page, "agent-grant-remove") {
		t.Errorf("FAIL: an inheriting agent's narrowed row does not offer Inherit here")
	}
	if rec := f.post(t, "/me/agents/jva-coder/grant", url.Values{"repo": {"alpha"}, "inherit": {"1"}}); rec.Code != http.StatusSeeOther {
		t.Fatalf("FAIL: inherit here answered %d: %s", rec.Code, rec.Body.String())
	}
	if !db.EffectiveCaps("jva-coder", "alpha").HasAny("i") {
		t.Errorf("FAIL: after Inherit here the agent lacks check-in on alpha")
	}
	t.Log("ok: inherits, narrows to nothing, inherits again")

	if rec := f.post(t, "/me/agents/jva-coder/inherit", url.Values{"on": {"false"}}); rec.Code != http.StatusSeeOther {
		t.Fatalf("FAIL: the switch answered %d: %s", rec.Code, rec.Body.String())
	}
	_, page = f.get(t, "/me/agents/jva-coder")
	if dataAttr(page, "agent-inherit", "data-inherit") != "false" || !hasHook(page, "agent-inherit-on") {
		t.Errorf("FAIL: switched off, the page still says it inherits")
	}
	if db.EffectiveCaps("jva-coder", "alpha").HasAny("i") {
		t.Errorf("FAIL: only-where-set with no grant, the agent still checks in on alpha")
	}
	t.Log("ok: the switch")
}

// RFC 0026, Tokens: a token has no date and says when it lapses unused,
// and Revoke tokens ends every token and shows one fresh value once.
func TestRevokeTokensReissuesThroughThePage(t *testing.T) {
	f, _ := agentForge(t, 3)
	f.post(t, "/me/agents", url.Values{"name": {"jva-coder"}})
	rec := f.post(t, "/me/agents/jva-coder/token", url.Values{"name": {"ci"}, "scope": {"api"}})
	if rec.Code != 200 || !hasHook(rec.Body.String(), "agent-token-value") {
		t.Fatalf("FAIL: minting answered %d without a value", rec.Code)
	}
	_, page := f.get(t, "/me/agents/jva-coder")
	if dataAttr(page, "agent-token-expires", "data-idle") != "true" {
		t.Errorf("FAIL: a token minted without a date is not shown as lapsing unused")
	}
	if !hasHook(page, "agent-token-reissue") {
		t.Fatalf("FAIL: no Revoke tokens control on an agent holding a token")
	}
	rec = f.post(t, "/me/agents/jva-coder/token/reissue", url.Values{})
	if rec.Code != 200 || !hasHook(rec.Body.String(), "agent-token-value") || rec.Header().Get("Cache-Control") != "no-store" {
		t.Fatalf("FAIL: Revoke tokens answered %d without a one-time value", rec.Code)
	}
	states := map[string]int{}
	for _, m := range agentTokenRe.FindAllStringSubmatch(rec.Body.String(), -1) {
		states[m[2]]++
	}
	if states["revoked"] != 1 || states["live"] != 1 {
		t.Errorf("FAIL: after Revoke tokens the rows are %v, want one revoked and one live", states)
	}
	t.Log("ok: idle lapse shown, revoke and reissue")
}

// RFC 0026 step 3: the create form offers a repository scope, and the
// agent's page says where it reaches.
func TestAScopedAgentThroughThePages(t *testing.T) {
	f, db := agentForge(t, 3)
	_, page := f.get(t, "/me")
	if !strings.Contains(page, `value="repo:alpha"`) {
		t.Fatalf("FAIL: the create form offers no repository scope")
	}
	if rec := f.post(t, "/me/agents", url.Values{"name": {"alpha-bot"}, "scope": {"repo:alpha"}}); rec.Code != http.StatusSeeOther {
		t.Fatalf("FAIL: creating a scoped agent answered %d: %s", rec.Code, rec.Body.String())
	}
	_, page = f.get(t, "/me/agents/alpha-bot")
	if dataAttr(page, "agent-scope", "data-scope") != "repo:alpha" {
		t.Errorf("FAIL: the agent's page does not name its scope")
	}
	if !db.EffectiveCaps("alpha-bot", "alpha").HasAny("i") || db.EffectiveCaps("alpha-bot", "beta").HasAny("i") {
		t.Errorf("FAIL: the repository agent's reach is wrong: alpha %q beta %q",
			db.EffectiveCaps("alpha-bot", "alpha"), db.EffectiveCaps("alpha-bot", "beta"))
	}
	t.Log("ok: scoped through the pages")
}

// RFC 0026 step 4: a repository's own agent has a page for that
// repository's admins, saying what it belongs to, with no inherit switch.
func TestARepositoryAgentsPage(t *testing.T) {
	f, db := agentForge(t, 3)
	if _, err := db.GrantGuarded("root", "jva", "alpha", oltp.RepoAdmin); err != nil {
		t.Fatal(err)
	}
	if err := db.NewDedicatedAgent("alpha-ci", "repo:alpha", "jva"); err != nil {
		t.Fatal(err)
	}
	if err := db.NewDedicatedAgent("beta-ci", "repo:beta", "root"); err != nil {
		t.Fatal(err)
	}
	code, page := f.get(t, "/me/agents/alpha-ci")
	if code != 200 || dataAttr(page, "agent-operator", "data-owner") != "repo:alpha" || !hasHook(page, "agent-dedicated-access") {
		t.Fatalf("FAIL: the repository agent's page (%d) does not say it belongs to alpha", code)
	}
	if hasHook(page, "agent-inherit") || hasHook(page, "agent-grant-new") {
		t.Errorf("FAIL: a repository agent's page offers a person's inherit switch or grants")
	}
	if code, _ := f.get(t, "/me/agents/beta-ci"); code == 200 {
		t.Errorf("FAIL: a developer of beta opened beta's agent page")
	}
	t.Log("ok: repository agent page")
}