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) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.stored == nil {
m.stored = map[string]string{}
}
m.stored[account] = caps
return nil
}
func (m *memRoster) GuardCaps(_, _, _, _ string) (string, error) { return m.warning, m.refuse }
// 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)
}
}