package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"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/oltp"
)
// 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: "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" {
t.Errorf("revoked %v; want exactly the live tokens scoped to 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)
}
}
// 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)
}
}