Skip to content
quarry Artifact [8a0474ae7c]
Login

Artifact [8a0474ae7c]

Artifact 8a0474ae7c273764cb5e0c94365795955b24eabe2facc871f0da1b3533675851:


package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"slices"
	"strings"
	"sync"
	"testing"

	"craftdesign.group/quarry/internal/api"
	"craftdesign.group/quarry/internal/chat"
	"craftdesign.group/quarry/internal/ci"
	"craftdesign.group/quarry/internal/events"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/ingest"
	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/xfer"
	"craftdesign.group/quarry/internal/zitcli"
	"github.com/nats-io/nats.go"
)

// tokenEngine is dagnats' worker-token surface: a list that marks what
// it revokes, and the ids it was asked to revoke. listStatus, when set, is what the list
// answers instead.
type tokenEngine struct {
	mu         sync.Mutex
	tokens     []ci.WorkerToken
	revoked    []string
	listStatus int
}

func (e *tokenEngine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	e.mu.Lock()
	defer e.mu.Unlock()
	switch {
	case req.Method == http.MethodGet && req.URL.Path == "/v1/tokens":
		if e.listStatus != 0 {
			w.WriteHeader(e.listStatus)
			return
		}
		_ = json.NewEncoder(w).Encode(map[string]any{"tokens": e.tokens})
	case req.Method == http.MethodDelete && strings.HasPrefix(req.URL.Path, "/v1/tokens/"):
		id := strings.TrimPrefix(req.URL.Path, "/v1/tokens/")
		e.revoked = append(e.revoked, id)
		for i := range e.tokens {
			if e.tokens[i].ID == id {
				e.tokens[i].RevokedAt = "2026-09-30T00:00:00Z"
			}
		}
		w.WriteHeader(http.StatusNoContent)
	default:
		http.NotFound(w, req)
	}
}

// deleteFixture is a registrar holding one deletable repository, "gone",
// on project code c0de, beside a served neighbour "kept" on beef, with
// real chat and event planes.
func deleteFixture(t *testing.T, eng *tokenEngine) (*registrar, *chat.Room, *events.Server, string) {
	t.Helper()
	rg := newTestRegistrar(t, 25)
	rg.dir, rg.reposRoot = t.TempDir(), t.TempDir()
	store := filepath.Join(rg.reposRoot, "gone")
	if err := os.MkdirAll(store, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := rg.odb.AddRepo("gone", store, "", oltp.Public); err != nil {
		t.Fatal(err)
	}
	var mu sync.Mutex
	rg.codes, rg.codeMu = map[string]string{"gone": "c0de", "kept": "beef"}, &mu

	ev, err := events.Start(t.TempDir())
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(ev.Close)
	room, err := chat.New(ev.Conn(), ev.JS())
	if err != nil {
		t.Fatal(err)
	}
	rg.ev, rg.deps = ev, &api.Deps{Repos: api.NewRepos(nil), Chat: room}

	srv := httptest.NewServer(eng)
	t.Cleanup(srv.Close)
	rg.engine = ci.New(srv.URL)
	rg.engine.SetAdminToken("admin")
	return rg, room, ev, store
}

// Deleting a repository takes what it held outside the forge database
// too: its worker tokens are revoked at the engine, its chat channel is
// emptied, and nobody's inbox still carries it — so a repository created
// later under the same name starts with none of them (quarry#294).
func TestDeleteForgetsTheRepositoryEverywhere(t *testing.T) {
	gone, kept := ci.GroupFor("gone"), ci.GroupFor("kept")
	eng := &tokenEngine{tokens: []ci.WorkerToken{
		{ID: "own", WorkerGroups: []string{gone}},
		{ID: "shared", WorkerGroups: []string{kept, gone}},
		{ID: "neighbour", WorkerGroups: []string{kept}},
		{ID: "fleet"},
		{ID: "fleet-scoped", TaskTypePrefixes: []string{gone}},
		{ID: "fleet-narrower", TaskTypePrefixes: []string{"build", gone + ".nightly"}},
		{ID: "fleet-other", TaskTypePrefixes: []string{kept, gone + "-ish"}},
		{ID: "done", WorkerGroups: []string{gone}, RevokedAt: "2026-09-01T00:00:00Z"},
	}}
	rg, room, ev, store := deleteFixture(t, eng)
	for _, code := range []string{"c0de", "beef"} {
		if _, err := room.Post(code, chat.Message{User: "dan", Text: "hello " + code}); err != nil {
			t.Fatal(err)
		}
	}
	for _, repo := range []string{"gone", "kept"} {
		if _, err := ev.Attend("ann", events.AttentionEvent{Kind: "mention", Repo: repo}); err != nil {
			t.Fatal(err)
		}
	}
	tok, err := rg.odb.MintReporterToken("gone", "ci")
	if err != nil {
		t.Fatal(err)
	}

	if err := rg.Delete("gone", "gone"); err != nil {
		t.Fatal(err)
	}

	if strings.Join(eng.revoked, ",") != "own,shared,fleet-scoped,fleet-narrower" {
		t.Errorf("revoked %v; want exactly the live tokens that reach the deleted repository's group", eng.revoked)
	}
	if msgs, err := room.Range("c0de", 0, 0); err != nil || len(msgs) != 0 {
		t.Errorf("the deleted repository's chat holds %d messages, %v", len(msgs), err)
	}
	if msgs, err := room.Range("beef", 0, 0); err != nil || len(msgs) != 1 {
		t.Errorf("the neighbour's chat holds %d messages, %v; want 1", len(msgs), err)
	}
	evs, err := ev.RecentAttention("ann", 50)
	if err != nil {
		t.Fatal(err)
	}
	if len(evs) != 1 || evs[0].Repo != "kept" {
		t.Errorf("ann's attention after the delete = %+v; want only kept's", evs)
	}
	if _, err := os.Stat(store); !os.IsNotExist(err) {
		t.Errorf("the store survived: %v", err)
	}

	// Somebody else takes the name.
	if err := rg.odb.AddAccount("eve", "pw12345678", false); err != nil {
		t.Fatal(err)
	}
	if err := rg.odb.AddRepo("gone", t.TempDir(), "", oltp.Public); err != nil {
		t.Fatal(err)
	}
	if err := rg.odb.SetOwner("gone", "eve"); err != nil {
		t.Fatal(err)
	}
	if rg.odb.CheckReporterToken("gone", tok) {
		t.Error("the deleted repository's reporter token authenticates for its successor")
	}
}

// A chat channel another served repository also answers to — the same
// project code registered twice — is not the deleted one's to empty.
func TestDeleteLeavesASharedChannel(t *testing.T) {
	rg, room, _, _ := deleteFixture(t, &tokenEngine{})
	rg.codes["twin"] = "c0de"
	if _, err := room.Post("c0de", chat.Message{User: "dan", Text: "ours too"}); err != nil {
		t.Fatal(err)
	}
	if err := rg.Delete("gone", "gone"); err != nil {
		t.Fatal(err)
	}
	if msgs, err := room.Range("c0de", 0, 0); err != nil || len(msgs) != 1 {
		t.Fatalf("the twin's channel holds %d messages, %v; want 1", len(msgs), err)
	}
}

// The same holds for a twin that is registered but not served right
// now: its code is asked of its store, since only served repositories
// are in the registrar's map.
func TestDeleteLeavesAChannelAnUnservedTwinShares(t *testing.T) {
	if _, err := exec.LookPath("zit"); err != nil {
		t.Skip("zit not on PATH")
	}
	rg, room, _, _ := deleteFixture(t, &tokenEngine{})
	twin := t.TempDir()
	zitIn(t, twin, "init", ".")
	code, err := xfer.ProjectCode(twin)
	if err != nil {
		t.Fatal(err)
	}
	if err := rg.odb.AddRepo("twin", twin, "", oltp.Public); err != nil {
		t.Fatal(err)
	}
	rg.codes["gone"] = code
	if _, err := room.Post(code, chat.Message{User: "dan", Text: "the twin's"}); err != nil {
		t.Fatal(err)
	}
	if err := rg.Delete("gone", "gone"); err != nil {
		t.Fatal(err)
	}
	if msgs, err := room.Range(code, 0, 0); err != nil || len(msgs) != 1 {
		t.Fatalf("the unserved twin's channel holds %d messages, %v; want 1", len(msgs), err)
	}
}

// An engine that cannot say which tokens exist refuses the delete
// before anything is destroyed: a deletion that left a live worker
// token behind is the bug, and the owner can retry.
func TestDeleteRefusesWhenWorkerTokensCannotBeRevoked(t *testing.T) {
	rg, _, _, store := deleteFixture(t, &tokenEngine{listStatus: http.StatusUnauthorized})
	if err := rg.Delete("gone", "gone"); err == nil {
		t.Fatal("the delete went ahead with the worker tokens unaccounted for")
	}
	if _, found, _ := rg.odb.RepoByName("gone"); !found {
		t.Error("the registration went although the delete failed")
	}
	if _, err := os.Stat(store); err != nil {
		t.Errorf("the store went although the delete failed: %v", err)
	}
}

// A configured engine that cannot list the repository's worker tokens
// refuses the delete, whatever the reason — no admin credential at the
// engine, no token routes, or none held here — and says what to do. A
// token minted there earlier would otherwise outlive the repository.
// Only a forge with no CI engine at all has nothing to revoke.
func TestDeleteRefusesWhenTheEngineCannotListTokens(t *testing.T) {
	for _, c := range []struct {
		status int
		admin  string
	}{
		{http.StatusServiceUnavailable, "admin"},
		{http.StatusNotFound, "admin"},
		{http.StatusUnauthorized, ""},
	} {
		rg, _, _, store := deleteFixture(t, &tokenEngine{listStatus: c.status})
		rg.engine.SetAdminToken(c.admin)
		err := rg.Delete("gone", "gone")
		if err == nil {
			t.Errorf("engine answering %d, admin %q: the delete went ahead", c.status, c.admin)
			continue
		}
		if !strings.Contains(err.Error(), "--dagnats-token") || !strings.Contains(err.Error(), ci.GroupFor("gone")) {
			t.Errorf("engine answering %d: the refusal does not say what to do: %v", c.status, err)
		}
		if _, found, _ := rg.odb.RepoByName("gone"); !found {
			t.Errorf("engine answering %d: the registration went although the delete was refused", c.status)
		}
		if _, err := os.Stat(store); err != nil {
			t.Errorf("engine answering %d: the store went although the delete was refused: %v", c.status, err)
		}
	}
	rg, _, _, _ := deleteFixture(t, &tokenEngine{})
	rg.engine = nil
	if err := rg.Delete("gone", "gone"); err != nil {
		t.Errorf("a forge with no CI engine: %v", err)
	}
}

// commitIn commits n files to the zit store at dir, initialising it.
func commitIn(t *testing.T, dir string, n int) {
	t.Helper()
	zitIn(t, dir, "init", ".")
	for i := 0; i < n; i++ {
		f := fmt.Sprintf("f%d.txt", i)
		if err := os.WriteFile(filepath.Join(dir, f), []byte(f+"\n"), 0o644); err != nil {
			t.Fatal(err)
		}
		zitIn(t, dir, "add", f)
		zitIn(t, dir, "commit", "-m", f, "--user", "u")
	}
}

// A repository added again under a deleted one's name gets its census
// published onto its fresh index. The ingest pipeline keeps its cursor,
// publish record and fold mark under the repository's name, so a
// cursor the delete left behind would resume past the new store's
// check-ins — even when another repository answers to the same project
// code, since the keys are the name's alone (quarry#294).
func TestDeleteLetsASameNamedRepositoryRepublish(t *testing.T) {
	if _, err := exec.LookPath("zit"); err != nil {
		t.Skip("zit not on PATH")
	}
	rg, _, ev, store := deleteFixture(t, &tokenEngine{})
	rg.codes["twin"], rg.node = "c0de", "n1"
	commitIn(t, store, 4)
	sync := func(dir string) *index.Index {
		ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
		if err != nil {
			t.Fatal(err)
		}
		t.Cleanup(func() { ix.Close() })
		if _, err := ingest.NewPipeline(zitcli.New(dir), ev, ix, "gone", rg.node).Sync(); err != nil {
			t.Fatal(err)
		}
		return ix
	}
	sync(store)
	kv, err := ev.Cursors()
	if err != nil {
		t.Fatal(err)
	}
	keys, err := kv.Keys()
	if err != nil {
		t.Fatal(err)
	}
	if !slices.Contains(keys, "gone") || !slices.Contains(keys, "pub.gone") {
		t.Fatalf("the pipeline wrote %v; the test no longer seeds its real keys", keys)
	}

	if err := rg.Delete("gone", "gone"); err != nil {
		t.Fatal(err)
	}
	keys, err = kv.Keys()
	if err != nil && !errors.Is(err, nats.ErrNoKeysFound) {
		t.Fatal(err)
	}
	if len(keys) != 0 {
		t.Errorf("the deleted repository's cursor keys survived: %v", keys)
	}

	again := t.TempDir()
	commitIn(t, again, 1)
	if n, err := sync(again).CheckinCount(); err != nil || n == 0 {
		t.Errorf("the new repository's index holds %d check-ins, %v; its census was never published", n, err)
	}
}