Skip to content
quarry Artifact [ee58976e4f]
Login

Artifact [ee58976e4f]

Artifact ee58976e4fdb3a7bf4be71095560350977d469b39d60b3ff7525b41b359a9a45:


package api

import (
	"context"
	"errors"
	"sync"
	"testing"

	"craftdesign.group/quarry/internal/evt"
	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// memRoster is the stored capability string, in memory.
type memRoster struct {
	mu      sync.Mutex
	stored  map[string]string
	refuse  error
	warning string
}

func (m *memRoster) CapsOf(account, _ string) (string, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.stored[account], nil
}

func (m *memRoster) SetCaps(_, account, _, caps string) (string, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.refuse != nil {
		return "", m.refuse
	}
	if m.stored == nil {
		m.stored = map[string]string{}
	}
	m.stored[account] = caps
	return m.warning, nil
}

// loudEvents records kind and payload, because what the payload CARRIES
// is half of what was broken: an event with only a kind makes every
// surface that renders it say the kind.
type loudEvents struct {
	mu    sync.Mutex
	kinds []evt.Kind
	last  map[string]any
}

func (l *loudEvents) Raise(kind evt.Kind, _ string, payload map[string]any) error {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.kinds = append(l.kinds, kind)
	l.last = payload
	return nil
}

func (l *loudEvents) seen() []evt.Kind {
	l.mu.Lock()
	defer l.mu.Unlock()
	return append([]evt.Kind(nil), l.kinds...)
}

func capsDeps(t *testing.T, roster *memRoster, ev *loudEvents) *ops.Registry {
	t.Helper()
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v",
		Repos:  testRepos(wikiIndex(t)),
		Roster: roster, Events: ev}); err != nil {
		t.Fatal(err)
	}
	return r
}

func setCaps(t *testing.T, r *ops.Registry, account, caps string) accountCapsOut {
	t.Helper()
	out, err := r.Invoke(ops.WithUser(context.Background(), "dan"), "account.caps",
		accountCapsIn{Repo: "test", Account: account, Caps: caps})
	if err != nil {
		t.Fatal(err)
	}
	got, ok := out.(accountCapsOut)
	if !ok {
		t.Fatalf("account.caps answered %T", out)
	}
	return got
}

// The three kinds RFC 0005 declared, in the transitions that produce
// them. The roster IS the per-repository capability string, so joining
// and leaving are the moves in and out of empty.
func TestTheThreeAccountKindsNameRealTransitions(t *testing.T) {
	roster, ev := &memRoster{stored: map[string]string{}}, &loudEvents{}
	r := capsDeps(t, roster, ev)

	setCaps(t, r, "bob", oltp.Write.Caps())
	setCaps(t, r, "bob", oltp.Maintain.Caps())
	setCaps(t, r, "bob", "")

	want := []evt.Kind{evt.AccountAdded, evt.AccountCaps, evt.AccountRemoved}
	got := ev.seen()
	if len(got) != len(want) {
		t.Fatalf("FAIL: raised %v, want %v", got, want)
	}
	for i := range want {
		if got[i] != want[i] {
			t.Errorf("FAIL: raise %d was %s, want %s", i, got[i], want[i])
		}
	}
}

// The payload carries a SENTENCE. An event with only a kind makes the
// settings trail, the webhook body and the chat robot's line all say
// the kind — which is what "silently dead" looked like from the other
// side.
func TestTheEventCarriesTheSentenceThePageShows(t *testing.T) {
	roster, ev := &memRoster{stored: map[string]string{}}, &loudEvents{}
	r := capsDeps(t, roster, ev)
	out := setCaps(t, r, "bob", oltp.Maintain.Caps())

	if out.Changed == "" {
		t.Fatal("FAIL: the op reported no change for a first grant")
	}
	summary, _ := ev.last["summary"].(string)
	if summary == "" {
		t.Fatal("FAIL: the event carries no summary")
	}
	// The same sentence, from the same function, so the event and the
	// page cannot describe one change two ways.
	if want := "bob " + out.Changed; summary != want {
		t.Errorf("FAIL: summary = %q, want %q", summary, want)
	}
	for _, k := range []string{"repo", "account", "caps", "actor"} {
		if _, ok := ev.last[k]; !ok {
			t.Errorf("FAIL: the payload has no %q", k)
		}
	}
	if ev.last["actor"] != "dan" {
		t.Errorf("FAIL: actor = %v, want dan", ev.last["actor"])
	}
}

// A save that changed nothing raises nothing. These are operational
// events — if the delivery is missed the fact is gone — so they must
// name real transitions, and a page that re-posts the same role must
// not put a line in somebody's inbox saying so.
func TestANoOpSaveRaisesNothing(t *testing.T) {
	roster, ev := &memRoster{stored: map[string]string{"bob": oltp.Write.Caps()}}, &loudEvents{}
	r := capsDeps(t, roster, ev)
	out := setCaps(t, r, "bob", oltp.Write.Caps())
	if out.Changed != "" {
		t.Errorf("FAIL: a no-op reported %q", out.Changed)
	}
	if got := ev.seen(); len(got) != 0 {
		t.Errorf("FAIL: a no-op raised %v", got)
	}
}

// The guard is asked BEFORE the write, and a refusal writes nothing and
// raises nothing.
func TestAGuardRefusalWritesNothingAndRaisesNothing(t *testing.T) {
	roster := &memRoster{stored: map[string]string{"bob": "a"},
		refuse: errors.New("that would leave the repository with no admin")}
	ev := &loudEvents{}
	r := capsDeps(t, roster, ev)
	if _, err := r.Invoke(ops.WithUser(context.Background(), "dan"), "account.caps",
		accountCapsIn{Repo: "test", Account: "bob", Caps: ""}); err == nil {
		t.Fatal("FAIL: a refused save went through")
	}
	if roster.stored["bob"] != "a" {
		t.Errorf("FAIL: the refused string was written anyway: %q", roster.stored["bob"])
	}
	if got := ev.seen(); len(got) != 0 {
		t.Errorf("FAIL: a refused save raised %v", got)
	}
}

// A forge with no roster seam answers unavailable rather than
// pretending to save.
func TestWithoutARosterTheDoorSaysSo(t *testing.T) {
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(wikiIndex(t))}); err != nil {
		t.Fatal(err)
	}
	_, err := r.Invoke(ops.WithUser(context.Background(), "dan"), "account.caps",
		accountCapsIn{Repo: "test", Account: "bob", Caps: "a"})
	if !errors.Is(err, ops.ErrUnavailable) {
		t.Errorf("FAIL: err = %v, want unavailable", err)
	}
}

// account.caps takes an arbitrary STRING where account.grant takes one
// of four preset roles. That is the gap it exists to fill: the settings
// editor writes letters, and until now it wrote them through no op at
// all.
func TestTheStringDoorTakesLettersNoPresetHas(t *testing.T) {
	roster, ev := &memRoster{stored: map[string]string{}}, &loudEvents{}
	r := capsDeps(t, roster, ev)
	// A string that is no preset role.
	const odd = "2i"
	setCaps(t, r, "bob", odd)
	if roster.stored["bob"] != odd {
		t.Errorf("FAIL: stored %q, want %q", roster.stored["bob"], odd)
	}
	var preset bool
	for _, p := range oltp.Presets {
		if p.Caps == odd {
			preset = true
		}
	}
	if preset {
		t.Skip("the string chosen for this test became a preset; pick another")
	}
}

// ONE raise per change. AccessChanged used to raise account.caps as
// well, which made two events for one grant once the write paths grew
// their own — and made an ARRIVAL read as account.caps when
// account.added is what it is.
func TestOneChangeRaisesOneEvent(t *testing.T) {
	ev := &loudEvents{}
	d := Deps{Version: "v", Events: ev}
	d.AccessChanged(context.Background(), "dan", "bob", "test", "", "maintainer")
	if got := ev.seen(); len(got) != 0 {
		t.Errorf("FAIL: telling the person also raised %v; the write path is the raise point", got)
	}
}