// siteadmin_test.go proves #318 end to end: a site admin administers the
// forge, not the repositories under it. With no grant, a private
// repository does not exist for them on any door, a public one reads as
// it does for any signed-in account, and the lifecycle acts they keep
// are operator paths that leave a receipt with the owner.
package harness
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/chat"
"craftdesign.group/quarry/internal/events"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/ingest"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/web"
"craftdesign.group/quarry/internal/zitcli"
)
const sitePW = "pw12345678"
type siteForge struct {
srv *httptest.Server
odb *oltp.DB
reg *fakeRepoRegistry
}
// buildSiteForge wires the gates the way cmd/quarry does: the verifier
// asks MayAt, the read gate mirrors registrar.CanRead, the pages hide a
// private repository through WithPrivateRepos, and the registry's
// witness is the one main installs. alpha is public and vault private;
// dan administers and owns both, root is a site admin granted nothing,
// and plain is an ordinary account granted nothing.
func buildSiteForge(t *testing.T) siteForge {
t.Helper()
dataDir := t.TempDir()
ev, err := events.Start(filepath.Join(dataDir, "nats"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(ev.Close)
odb, err := oltp.Open(filepath.Join(dataDir, "oltp.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { odb.Close() })
room, err := chat.New(ev.Conn(), ev.JS())
if err != nil {
t.Fatal(err)
}
reg := &fakeRepoRegistry{}
deps := api.Deps{Version: "0.0.0-harness", Repos: api.NewRepos(nil), Chat: room,
Accounts: harnessAccounts{db: odb}, Forum: odb, Votes: odb, ChatRefs: odb,
Owners: odb, Acts: odb, Registry: reg}
deps.CapsFor = func(account, repo string) api.CapSet { return odb.EffectiveCaps(account, repo) }
private := func(repo string) bool {
set, err := odb.PrivateRepos()
return err != nil || set[repo]
}
deps.CanRead = func(ctx context.Context, repo string) bool {
if !private(repo) {
return true
}
user := ops.User(ctx)
return user != "" && odb.EffectiveCaps(user, repo).HasAny("gjoh2r")
}
for name, vis := range map[string]string{"alpha": oltp.Public, "vault": oltp.Private} {
path := buildStore(t, name+"seed")
if err := odb.AddRepo(name, path, "", vis); err != nil {
t.Fatal(err)
}
ix, err := index.Open(filepath.Join(dataDir, name+".db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
cli := zitcli.New(path)
pipe := ingest.NewPipeline(cli, ev, ix, name, "harness")
deps.Repos.Put(name, &api.Repo{Index: ix, Zit: cli, Sync: pipe.Sync, Code: name,
Reindex: func() (int, error) { return 1, nil }})
if _, err := pipe.Sync(); err != nil {
t.Fatal(err)
}
}
for _, a := range []struct {
name string
admin bool
}{{"root", true}, {"plain", false}, {"dan", false}, {"eve", false}} {
if err := odb.AddAccount(a.name, sitePW, a.admin); err != nil {
t.Fatal(err)
}
}
for _, repo := range []string{"alpha", "vault"} {
if err := odb.Grant("dan", repo, oltp.RepoAdmin); err != nil {
t.Fatal(err)
}
if err := odb.SetOwner(repo, "dan"); err != nil {
t.Fatal(err)
}
}
verify := func(repo, user, pass string, need ops.Auth) api.Decision {
acct, ok := odb.Authenticate(user, pass)
if !ok {
return api.Denied
}
if odb.MayAt(acct, repo, need == ops.Admin) {
return api.Allowed
}
return api.Insufficient
}
r := ops.NewRegistry()
if err := api.RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
r.Observe(deps.WitnessOperator())
apiRouter, err := api.RouterWithCaps(r, "0.0.0-harness", deps.CapsFor, deps.CanRead, verify)
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("/api/", apiRouter)
mux.Handle("/", web.HandlerWith(r, nil, nil,
[]web.Option{web.WithCaps(siteCapsPlane{harnessCaps{odb: odb}}), web.WithPrivateRepos(private),
web.WithSessions(siteSessions{odb: odb})}, web.Verify(verify)))
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return siteForge{srv: srv, odb: odb, reg: reg}
}
// siteCapsPlane is harnessCaps with the administration seam cmd/quarry's
// capsPlane gives the settings page, so its people list renders.
type siteCapsPlane struct{ harnessCaps }
func (c siteCapsPlane) CapHolders(repo string) ([]web.CapHolder, error) {
rows, err := c.odb.CapHolders(repo)
if err != nil {
return nil, err
}
out := make([]web.CapHolder, 0, len(rows))
for _, r := range rows {
out = append(out, web.CapHolder{Account: r.Account, Caps: r.Caps, Team: r.Team, TeamCaps: r.TeamCaps})
}
return out, nil
}
func (c siteCapsPlane) CategoryCaps(repo, category string) string {
return c.odb.CategoryCaps(repo, category)
}
func (c siteCapsPlane) SetCaps(actor, account, repo, caps string) (string, error) {
return c.odb.SetCapsGuarded(actor, account, repo, caps)
}
func (c siteCapsPlane) SetCategoryCaps(repo, category, caps string) error {
return c.odb.SetCategoryCaps(repo, category, caps)
}
// siteSessions is cmd/quarry's sessionPlane with the account name as the
// token: Allow asks MayAt, so a cookie is worth what a password is.
type siteSessions struct{ odb *oltp.DB }
func (s siteSessions) Login(user, password string) (string, time.Time, bool) {
if _, ok := s.odb.Authenticate(user, password); !ok {
return "", time.Time{}, false
}
return user, time.Now().Add(time.Hour), true
}
func (s siteSessions) Resolve(token string) (string, bool) {
_, ok := s.odb.AccountByName(token)
return token, ok
}
func (s siteSessions) Logout(string) {}
func (s siteSessions) Allow(user, repo string, need ops.Auth) bool {
a, ok := s.odb.AccountByName(user)
return ok && s.odb.MayAt(a, repo, need == ops.Admin)
}
// call speaks as a browser on a page and as an API client on /api/.
func (f siteForge) call(t *testing.T, user, method, path string, form url.Values) (int, string) {
t.Helper()
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
req, _ := http.NewRequest(method, f.srv.URL+path, body)
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if strings.HasPrefix(path, "/api/") {
req.SetBasicAuth(user, sitePW)
} else {
req.AddCookie(&http.Cookie{Name: "quarry_session", Value: user})
}
client := f.srv.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
// Every door a private repository has answers a site admin with no
// grant as it answers a stranger: not found. On a public repository
// the same requests answer the site admin exactly as they answer an
// ordinary signed-in account.
func TestASiteAdminWithNoGrantGetsNoRepositoryDoor(t *testing.T) {
f := buildSiteForge(t)
doors := []struct {
door, method, path string
form url.Values
}{
{"web page", "GET", "/r/%s/timeline", nil},
{"web settings", "GET", "/r/%s/settings", nil},
{"api read", "GET", "/api/v1/r/%s/timeline", nil},
{"api write: forum post", "POST", "/api/v1/r/%s/forum", url.Values{"title": {"t"}, "body": {"b"}}},
{"api write: landing accept", "POST", "/api/v1/r/%s/landing/1/accept", url.Values{}},
{"api write: settings", "POST", "/api/v1/repos/update", url.Values{"description": {"mine now"}}},
{"api write: grant themselves", "POST", "/api/v1/accounts/grant", url.Values{"name": {"root"}, "role": {"admin"}}},
}
at := func(path, repo string, form url.Values) (string, url.Values) {
if form == nil {
return strings.Replace(path, "%s", repo, 1), nil
}
out := url.Values{}
for k, v := range form {
out[k] = v
}
if !strings.Contains(path, "%s") {
if path == "/api/v1/repos/update" {
out.Set("name", repo)
} else {
out.Set("repo", repo)
}
}
return strings.Replace(path, "%s", repo, 1), out
}
for _, d := range doors {
path, form := at(d.path, "vault", d.form)
if got, _ := f.call(t, "root", d.method, path, form); got != http.StatusNotFound {
t.Errorf("FAIL %s: a site admin with no grant got %d on a private repository, want 404", d.door, got)
}
if d.method == "GET" {
if got, _ := f.call(t, "dan", d.method, path, form); got == http.StatusNotFound {
t.Errorf("FAIL %s: the private repository's own admin got 404; the door proves nothing", d.door)
}
}
path, form = at(d.path, "alpha", d.form)
plain, _ := f.call(t, "plain", d.method, path, form)
root, _ := f.call(t, "root", d.method, path, form)
if root != plain {
t.Errorf("FAIL %s: on a public repository a site admin got %d where a signed-in account got %d", d.door, root, plain)
}
}
if caps, _ := f.odb.CapsOf("root", "vault"); caps != "" {
t.Errorf("FAIL: a refused grant was written: root holds %q on vault", caps)
}
// The push credential is derived from the check-in letter, and
// with no grant there is none to derive.
if f.odb.MayWrite("vault", "root") || f.odb.MayWrite("alpha", "root") {
t.Error("FAIL: a site admin with no grant may push")
}
}
// Search never consults a repository the caller may not read, and the
// dashboard is what the caller's grants reach.
func TestASiteAdminSearchesAndFollowsOnlyWhatTheyReach(t *testing.T) {
f := buildSiteForge(t)
type search struct {
Results []struct {
Repo string `json:"repo"`
} `json:"results"`
}
hits := func(user string) map[string]bool {
code, body := f.call(t, user, "GET", "/api/v1/search?q=vaultseed", nil)
if code != http.StatusOK {
t.Fatalf("search as %s: %d %s", user, code, body)
}
var out search
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Fatal(err)
}
seen := map[string]bool{}
for _, h := range out.Results {
seen[h.Repo] = true
}
return seen
}
if !hits("dan")["vault"] {
t.Fatal("the private repository's own admin finds nothing; the search proves nothing")
}
if hits("root")["vault"] {
t.Error("FAIL: a site admin with no grant found a private repository's content")
}
code, body := f.call(t, "root", "GET", "/api/v1/me/feed", nil)
if code != http.StatusOK {
t.Fatalf("me.feed: %d %s", code, body)
}
var feed struct {
Repos []struct {
Name string `json:"name"`
} `json:"repos"`
Feed []struct {
Repo string `json:"repo"`
} `json:"feed"`
}
if err := json.Unmarshal([]byte(body), &feed); err != nil {
t.Fatal(err)
}
if len(feed.Repos) != 0 || len(feed.Feed) != 0 {
t.Errorf("FAIL: a site admin with no grant is shown repositories %v and %d events", feed.Repos, len(feed.Feed))
}
}
// The people list on a repository's settings is its members. A site
// admin is not one unless granted a role.
func TestTheRosterDoesNotListSiteAdmins(t *testing.T) {
f := buildSiteForge(t)
code, body := f.call(t, "dan", "GET", "/r/alpha/settings", nil)
if code != http.StatusOK {
t.Fatalf("settings as the repository's admin: %d", code)
}
if !strings.Contains(body, `/people/dan"`) {
t.Fatal("the roster does not list the repository's own admin; the check proves nothing")
}
if strings.Contains(body, `/people/root"`) {
t.Error("FAIL: the roster lists a site admin who holds no role here")
}
}
// The lifecycle acts a site admin keeps are operator paths: they work
// on a repository the admin holds nothing on, they reveal none of its
// content, and each leaves a receipt in the owner's forge log.
func TestOperatorActsWorkWithoutAGrantAndLeaveAReceipt(t *testing.T) {
f := buildSiteForge(t)
receipt := func(repo, op string) bool {
acts, err := f.odb.ForgeActs(repo, 50)
if err != nil {
t.Fatal(err)
}
for _, a := range acts {
if a.Op == op && a.Actor == "root" && a.Owner == "dan" {
return true
}
}
return false
}
if code, body := f.call(t, "root", "POST", "/api/v1/r/vault/reindex", url.Values{}); code != http.StatusOK {
t.Errorf("FAIL: a site admin could not reindex a private repository: %d %s", code, body)
} else if !receipt("vault", "repo.reindex") {
t.Error("FAIL: reindexing left the owner no receipt")
}
// A transfer on request names a recipient who already administers
// the repository. Taking the deed for themselves is not an operator
// act: it would make the operator a party to the repository.
if code, _ := f.call(t, "root", "POST", "/api/v1/r/vault/claim", url.Values{}); code == http.StatusOK {
t.Error("FAIL: a site admin with no grant took the deed to a private repository for themselves")
}
if err := f.odb.Grant("eve", "vault", oltp.RepoAdmin); err != nil {
t.Fatal(err)
}
if code, body := f.call(t, "root", "POST", "/api/v1/r/vault/claim", url.Values{"account": {"eve"}}); code != http.StatusOK {
t.Errorf("FAIL: a site admin could not hand a private repository to its other admin: %d %s", code, body)
} else {
if owner, _ := f.odb.OwnerOf("vault"); owner != "eve" {
t.Errorf("FAIL: the transfer did not land: vault is held by %q", owner)
}
if !receipt("vault", "repo.claim") {
t.Error("FAIL: the transfer left the owner no receipt")
}
}
if code, body := f.call(t, "root", "POST", "/api/v1/repos/delete",
url.Values{"name": {"alpha"}, "confirm": {"alpha"}}); code != http.StatusOK {
t.Errorf("FAIL: a site admin could not delete a repository they do not own: %d %s", code, body)
} else {
if len(f.reg.deleted) != 1 || f.reg.deleted[0] != "alpha" {
t.Errorf("FAIL: delete was not carried out: %v", f.reg.deleted)
}
if !receipt("alpha", "repo.delete") {
t.Error("FAIL: deleting left the owner no receipt")
}
}
// The operator path is the site admin's alone: an ordinary account
// with no role is still refused every one of them.
for _, c := range []struct{ path string }{{"/api/v1/r/vault/reindex"}, {"/api/v1/r/alpha/claim"}} {
if code, _ := f.call(t, "plain", "POST", c.path, url.Values{"account": {"eve"}}); code == http.StatusOK {
t.Errorf("FAIL: an ordinary account with no role passed the operator path %s", c.path)
}
}
}
// Abuse handling is an operator act where it needs no private content:
// a public repository's posts are public, so a site admin may hide one
// there with a receipt to the owner. On a private repository the post
// would have to be read to be judged, so there is no operator path.
func TestAnOperatorHidesAbuseOnlyWhereItIsPublic(t *testing.T) {
f := buildSiteForge(t)
post := func(repo string) string {
code, body := f.call(t, "dan", "POST", "/api/v1/r/"+repo+"/forum",
url.Values{"title": {"a thread"}, "body": {"something abusive"}})
if code != http.StatusOK {
t.Fatalf("posting to %s: %d %s", repo, code, body)
}
var out struct {
Hash string `json:"hash"`
}
if err := json.Unmarshal([]byte(body), &out); err != nil || out.Hash == "" {
t.Fatalf("posting to %s gave no hash: %s", repo, body)
}
return out.Hash
}
hide := func(repo, hash string) int {
code, _ := f.call(t, "root", "POST", "/api/v1/r/"+repo+"/forum-hide",
url.Values{"post": {hash}, "action": {"hide"}, "reason": {"abuse"}})
return code
}
hash := post("alpha")
if got := hide("alpha", hash); got != http.StatusOK {
t.Errorf("FAIL: a site admin could not hide an abusive post on a public repository: %d", got)
}
// Hiding is abuse handling; restoring a post is the repository's
// moderators' call, so the operator path stops at hide.
if code, _ := f.call(t, "root", "POST", "/api/v1/r/alpha/forum-hide",
url.Values{"post": {hash}, "action": {"unhide"}, "reason": {"changed my mind"}}); code == http.StatusOK {
t.Error("FAIL: a site admin with no role unhid a post")
}
acts, err := f.odb.ForgeActs("alpha", 10)
if err != nil {
t.Fatal(err)
}
if len(acts) != 1 || acts[0].Op != "forum.hide" || acts[0].Owner != "dan" {
t.Errorf("FAIL: hiding left the owner no receipt: %+v", acts)
}
if got := hide("vault", post("vault")); got != http.StatusNotFound {
t.Errorf("FAIL: a site admin hiding a post on a private repository got %d, want 404", got)
}
if code, _ := f.call(t, "root", "POST", "/api/v1/r/alpha/forum-queue",
url.Values{"id": {"1"}, "action": {"approve"}}); code == http.StatusOK {
t.Error("FAIL: a site admin with no role resolved a held submission, which is unpublished content")
}
}
// The fleet page is gated at the forge, and its mirror forms act on one
// named repository. Reconfiguring, bridging, detaching or opening a
// mirror to writes is that repository's business, so each form asks for
// `a` on it and a site admin with no role there is refused before the op
// runs (#318).
func TestTheFleetMirrorFormsAskTheRepositorysLetter(t *testing.T) {
f := buildSiteForge(t)
for _, c := range []struct {
path string
form url.Values
}{
{"/fleet/mirror", url.Values{"repo": {"vault"}, "url": {"https://upstream.example/x"}}},
{"/fleet/mirror/chatbridge", url.Values{"repo": {"vault"}, "detach": {"1"}}},
{"/fleet/mirror/detach", url.Values{"repo": {"vault"}, "ack": {"fork"}}},
{"/fleet/mirror/writes", url.Values{"repo": {"vault"}, "accept": {"true"}}},
} {
if got, _ := f.call(t, "root", "POST", c.path, c.form); got != http.StatusForbidden && got != http.StatusNotFound {
t.Errorf("FAIL: %s answered a site admin with no role on vault %d, want a refusal before the op", c.path, got)
}
}
}