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")
}