Files
gitea-mcp/internal/tools/create_project_from_template_test.go
mathiasandClaude Opus 4.8 09a7fad6ba
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 21s
CD / Deploy via GitOps (push) Has been skipped
feat(create_project): dispatch_allow also registers the repo into dispatch's allowlist (#54)
Extends the existing dispatch_allow flag (which already injects .dispatch-allow,
#43/#51/#53) to also append owner/name to mathias/dispatch's git-tracked
dispatch-repos.txt (dispatch#19) — the second of the two required dispatch
gates. Being listed there is necessary but not sufficient on its own (the repo
still needs its own .dispatch-allow marker) — both are applied independently,
neither implies the other, matching dispatch#3's structural trust-zone design
where both must say yes.

Idempotent: a repo already listed (e.g. dispatch_allow re-requested on resume)
is a no-op, not a duplicate line. Failure is reported in its own
dispatch_allowlist_failure field, distinct from partial_failure (substitution)
and dispatch_allow_failure (the marker file) — the three gates can each fail
independently, never conflated (same principle as #51).

The allowlist owner/repo/path/branch are hardcoded to match mathias/dispatch's
own DISPATCH_ALLOWLIST_* env defaults, confirmed unoverridden in the live
CronJob (2026-07-06) before implementing.

Tests: fresh registration (existing entries preserved, not clobbered),
already-listed no-op (zero writes), allowlist-write failure as a distinct
field, dispatch_allow=false never touches the file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 00:33:39 +02:00

577 lines
26 KiB
Go

package tools_test
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func encb64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
func templateRepoJSON(name string, isTemplate bool) string {
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":%v}`,
name, name, name, name, isTemplate)
}
// fakeTemplateServer serves the whole create-from-template flow off an in-memory
// file map, driving the tool's tree-walk. Records writes/deletes/put-bodies.
type fakeTemplateServer struct {
mu sync.Mutex
files map[string]string // path -> raw (un-substituted) content
genBranch string // default_branch returned by /generate ("" to force fallback)
generated bool
generateCalls int // # times POST .../generate was hit — resume must never increment this
puts []string
deletes []string
putBodies map[string]string // path -> decoded written content
repoGetsPost int // GET dest after generate (branch fallback)
// dispatchRepos simulates mathias/dispatch:dispatch-repos.txt (gitea-mcp#54).
// "" (default) 404s the read, matching a repo that hasn't seeded the file
// (a distinct error path); tests that care set it explicitly.
dispatchRepos string
dispatchReposPuts int
}
func newFakeTemplateServer(files map[string]string, genBranch string) *fakeTemplateServer {
return &fakeTemplateServer{files: files, genBranch: genBranch, putBodies: map[string]string{}}
}
// newFakeTemplateServerResumed simulates a repo that already exists from a
// prior (real) generate call — GET dest succeeds immediately, without a
// /generate call first. Used for resume:true tests.
func newFakeTemplateServerResumed(files map[string]string, genBranch string) *fakeTemplateServer {
f := newFakeTemplateServer(files, genBranch)
f.generated = true
return f
}
func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
p := r.URL.Path
const dispatchReposPath = "/api/v1/repos/mathias/dispatch/contents/dispatch-repos.txt"
switch {
case r.Method == http.MethodGet && p == dispatchReposPath:
if f.dispatchRepos == "" {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
return
}
_, _ = fmt.Fprintf(w, `{"path":"dispatch-repos.txt","sha":"repos-sha","content":%q,"encoding":"base64"}`, encb64(f.dispatchRepos))
case r.Method == http.MethodPut && p == dispatchReposPath:
raw, _ := io.ReadAll(r.Body)
var args struct {
Content string `json:"content"`
}
_ = json.Unmarshal(raw, &args)
dec, _ := base64.StdEncoding.DecodeString(args.Content)
f.dispatchRepos = string(dec)
f.dispatchReposPuts++
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"content":{"path":"dispatch-repos.txt","sha":"repos-sha2"},"commit":{"sha":"c"}}`))
case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+tmpl:
_, _ = w.Write([]byte(templateRepoJSON(tmpl, true)))
case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+dest:
if !f.generated {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
return
}
f.repoGetsPost++
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":"main","clone_url":"c","html_url":"h","template":false}`, dest, dest)
case r.Method == http.MethodPost && p == "/api/v1/repos/mathias/"+tmpl+"/generate":
f.generated = true
f.generateCalls++
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":%q,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`,
dest, dest, f.genBranch, dest, dest)
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/git/trees/"):
var entries []string
for path := range f.files {
entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"sha-%s"}`, path, strings.ReplaceAll(path, "/", "-")))
}
// include a tree (directory) entry to exercise the blob filter
entries = append(entries, `{"path":"cmd","type":"tree","sha":"treesha"}`)
_, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ","))
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
body, ok := f.files[path]
if !ok {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
return
}
_, _ = fmt.Fprintf(w, `{"path":%q,"sha":"sha-%s","size":1,"content":%q,"encoding":"base64"}`,
path, strings.ReplaceAll(path, "/", "-"), encb64(body))
// POST = create (new/renamed file, no sha), PUT = update (existing, with sha).
case (r.Method == http.MethodPost || r.Method == http.MethodPut) && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
raw, _ := io.ReadAll(r.Body)
var args struct {
Content string `json:"content"`
}
_ = json.Unmarshal(raw, &args)
dec, _ := base64.StdEncoding.DecodeString(args.Content)
f.puts = append(f.puts, path)
f.putBodies[path] = string(dec)
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
} else {
w.WriteHeader(http.StatusOK)
}
_, _ = w.Write([]byte(`{"content":{"path":"x","sha":"n"},"commit":{"sha":"c"}}`))
case r.Method == http.MethodDelete && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
f.deletes = append(f.deletes, path)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"content":null,"commit":{"sha":"c"}}`))
default:
t.Errorf("unexpected request: %s %s", r.Method, p)
w.WriteHeader(http.StatusNotFound)
}
}
}
func newTool(srvURL, tmpl string) *tools.CreateProjectFromTemplate {
return tools.NewCreateProjectFromTemplate(
gitea.NewClient(srvURL, "tok"), allowlist.New([]string{"mathias"}), "mathias", tmpl)
}
func callTool(t *testing.T, srvURL, tmpl, argsJSON string) createOut {
t.Helper()
res, err := newTool(srvURL, tmpl).Call(context.Background(), json.RawMessage(argsJSON))
require.NoError(t, err)
var out createOut
require.NoError(t, json.Unmarshal(res, &out))
return out
}
type createOut struct {
FullName string `json:"full_name"`
DefaultBranch string `json:"default_branch"`
FilesSubstituted []string `json:"files_substituted"`
PartialFailure string `json:"partial_failure,omitempty"`
DispatchAllowFailure string `json:"dispatch_allow_failure,omitempty"`
DispatchAllowlisted bool `json:"dispatch_allowlisted,omitempty"`
DispatchAllowlistFailure string `json:"dispatch_allowlist_failure,omitempty"`
}
// Happy path: whole-tree substitution, content + path rename, correct module host.
func TestCreateProject_TreeWalk_SubstitutesAndRenames(t *testing.T) {
files := map[string]string{
"go.mod": "module __MODULE_PATH__\n\ngo 1.26\n",
"README.md": "# __PROJECT_NAME__\n",
"cmd/__PROJECT_NAME__/main.go": "package main\nimport \"__MODULE_PATH__/pkg/litellm\"\nconst n = \"__PROJECT_NAME__\"\n",
"pkg/litellm/x.go": "package litellm\n", // no placeholder → untouched
}
f := newFakeTemplateServer(files, "main")
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
assert.Equal(t, "main", out.DefaultBranch)
assert.Empty(t, out.PartialFailure)
// content-substituted files present; untouched file absent
assert.Contains(t, out.FilesSubstituted, "go.mod")
assert.Contains(t, out.FilesSubstituted, "README.md")
assert.NotContains(t, out.FilesSubstituted, "pkg/litellm/x.go")
// path rename recorded as "old -> new"
assert.Contains(t, out.FilesSubstituted, "cmd/__PROJECT_NAME__/main.go -> cmd/new-svc/main.go")
// module host substituted correctly (git.d-ma.be, not gitea.d-ma.be)
assert.Equal(t, "module git.d-ma.be/mathias/new-svc\n\ngo 1.26\n", f.putBodies["go.mod"])
// rename: new path written, old path deleted
assert.Contains(t, f.puts, "cmd/new-svc/main.go")
assert.Contains(t, f.deletes, "cmd/__PROJECT_NAME__/main.go")
assert.Equal(t, "package main\nimport \"git.d-ma.be/mathias/new-svc/pkg/litellm\"\nconst n = \"new-svc\"\n",
f.putBodies["cmd/new-svc/main.go"])
// the untouched file was never written
assert.NotContains(t, f.puts, "pkg/litellm/x.go")
}
// The /generate response omits default_branch (the live gitea behavior the old
// mock hid) → tool must re-fetch the repo and still substitute.
func TestCreateProject_EmptyGenerateBranch_FallsBack(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "") // generate returns default_branch:""
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
assert.Equal(t, "main", out.DefaultBranch, "must resolve branch via GetRepo fallback")
assert.GreaterOrEqual(t, f.repoGetsPost, 1, "must re-fetch repo to resolve empty default_branch")
assert.Contains(t, out.FilesSubstituted, "go.mod")
assert.Equal(t, "module git.d-ma.be/mathias/new-svc\n", f.putBodies["go.mod"])
assert.Empty(t, out.PartialFailure)
}
// Fail loud: a template whose files carry no placeholders yields nothing
// substituted — surface it rather than returning silent success.
func TestCreateProject_NothingSubstituted_IsLoud(t *testing.T) {
files := map[string]string{"README.md": "# static, no placeholders\n"}
f := newFakeTemplateServer(files, "main")
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
assert.Empty(t, out.FilesSubstituted)
assert.NotEmpty(t, out.PartialFailure, "nothing substituted must not be silent success")
}
// Write failure mid-pass → partial_failure populated, no Go error.
func TestCreateProject_WriteFailure_PartialFailure(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "main")
base := f.handler(t, "template-go-agent", "new-svc")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/contents/go.mod") {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"boom"}`))
return
}
base(w, r)
}))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
assert.NotEmpty(t, out.PartialFailure)
assert.Contains(t, out.PartialFailure, "go.mod")
}
// dispatch_allow injects a .dispatch-allow file (dispatch#3) only when true.
func TestCreateProject_DispatchAllow(t *testing.T) {
tests := []struct {
name string
argsJSON string
wantFile bool
}{
{"true injects .dispatch-allow", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`, true},
{"false does not inject", `{"owner":"mathias","name":"new-svc","dispatch_allow":false}`, false},
{"omitted does not inject", `{"owner":"mathias","name":"new-svc"}`, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "main")
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", tc.argsJSON)
require.Empty(t, out.PartialFailure)
if tc.wantFile {
assert.Contains(t, out.FilesSubstituted, ".dispatch-allow")
assert.Contains(t, f.puts, ".dispatch-allow")
assert.Contains(t, f.putBodies[".dispatch-allow"], "dispatch#3")
} else {
assert.NotContains(t, out.FilesSubstituted, ".dispatch-allow")
assert.NotContains(t, f.puts, ".dispatch-allow")
}
})
}
}
// ── resume: durable substitution against infra#179 (gitea-mcp#50) ───────────
// resume:true requires an ALREADY-CREATED destination — there is nothing to
// resume otherwise.
func TestCreateProject_Resume_NoDestination_Errors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
}))
defer srv.Close()
_, err := newTool(srv.URL, "template-go-agent").Call(context.Background(),
json.RawMessage(`{"owner":"mathias","name":"new-svc","resume":true}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
assert.Contains(t, err.Error(), "nothing to resume")
}
// resume:true on an existing repo continues substitution — fixes only what's
// still wrong, leaves already-correct files untouched, and never calls
// /generate again (the whole point: no re-creation, just continuation).
func TestCreateProject_Resume_ContinuesPartialSubstitution(t *testing.T) {
files := map[string]string{
"go.mod": "module git.d-ma.be/mathias/new-svc\n", // already correct from a prior partial run
"README.md": "# __PROJECT_NAME__\n", // still needs substitution
}
f := newFakeTemplateServerResumed(files, "main")
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`)
assert.Empty(t, out.PartialFailure)
assert.Equal(t, 0, f.generateCalls, "resume must never call /generate")
assert.Contains(t, out.FilesSubstituted, "README.md")
assert.NotContains(t, out.FilesSubstituted, "go.mod", "already-correct file must not be re-reported")
assert.NotContains(t, f.puts, "go.mod", "already-correct file must not be rewritten")
assert.Contains(t, f.puts, "README.md")
}
// resume:true when everything is already substituted is the expected steady
// state (a prior resume already finished the job, or this is a redundant
// re-invoke) — success, NOT the "no placeholders substituted" loud failure
// that a fresh (non-resume) create would trigger.
func TestCreateProject_Resume_AlreadyFullyDone_IsSuccess(t *testing.T) {
files := map[string]string{
"go.mod": "module git.d-ma.be/mathias/new-svc\n",
"README.md": "# new-svc\n",
}
f := newFakeTemplateServerResumed(files, "main")
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`)
assert.Empty(t, out.FilesSubstituted)
assert.Empty(t, out.PartialFailure, "nothing left to do on resume must be success, not a loud failure")
}
// A resume where a prior partial run's rename write SUCCEEDED but its paired
// delete FAILED (both are separate, non-atomic API calls) must complete
// cleanly: recognize the new path is already correct, skip re-writing it, and
// just finish the outstanding delete of the stray old path (gitea-mcp#53).
func TestCreateProject_Resume_StrayRenamedOldPath_CompletesCleanly(t *testing.T) {
files := map[string]string{
// stray: delete never completed in the prior run
"cmd/__PROJECT_NAME__/main.go": "package main\nimport \"__MODULE_PATH__/pkg/litellm\"\nconst n = \"__PROJECT_NAME__\"\n",
// already correct: the write half of the same prior rename DID complete
"cmd/new-svc/main.go": "package main\nimport \"git.d-ma.be/mathias/new-svc/pkg/litellm\"\nconst n = \"new-svc\"\n",
}
f := newFakeTemplateServerResumed(files, "main")
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`)
assert.Empty(t, out.PartialFailure)
assert.Contains(t, out.FilesSubstituted, "cmd/__PROJECT_NAME__/main.go -> cmd/new-svc/main.go")
assert.NotContains(t, f.puts, "cmd/new-svc/main.go", "already-correct new path must not be rewritten")
assert.Contains(t, f.deletes, "cmd/__PROJECT_NAME__/main.go", "the outstanding delete must still happen")
}
// dispatch_allow injection is idempotent on resume: if .dispatch-allow already
// has the correct content (from an earlier successful injection), re-invoking
// must not attempt another write — and must not error the way a naive
// create-only write would (gitea 409/422 on an existing path with no sha).
func TestCreateProject_Resume_DispatchAllowIdempotent(t *testing.T) {
// Phase 1: a normal (non-resume) call captures the REAL content the tool
// writes for .dispatch-allow, without the test needing to know the exact
// unexported constant.
seedFiles := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
seedSrv := newFakeTemplateServer(seedFiles, "main")
srv1 := httptest.NewServer(seedSrv.handler(t, "template-go-agent", "new-svc"))
out1 := callTool(t, srv1.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
srv1.Close()
require.Empty(t, out1.PartialFailure)
realContent := seedSrv.putBodies[".dispatch-allow"]
require.NotEmpty(t, realContent, "phase 1 must have written .dispatch-allow")
// Phase 2: resume with .dispatch-allow ALREADY at that exact content, plus
// one file still needing substitution.
files := map[string]string{
".dispatch-allow": realContent,
"README.md": "# __PROJECT_NAME__\n",
}
f := newFakeTemplateServerResumed(files, "main")
srv2 := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv2.Close()
out2 := callTool(t, srv2.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true,"dispatch_allow":true}`)
assert.Empty(t, out2.PartialFailure)
assert.Empty(t, out2.DispatchAllowFailure)
assert.NotContains(t, f.puts, ".dispatch-allow", "already-correct .dispatch-allow must not be rewritten")
assert.NotContains(t, out2.FilesSubstituted, ".dispatch-allow", "unchanged file must not be reported as substituted")
assert.Contains(t, out2.FilesSubstituted, "README.md")
}
// dispatch_allow injection failing is reported in its OWN field, distinct from
// PartialFailure (gitea-mcp#51) — substitution can succeed while dispatch
// eligibility still fails, and the caller must be able to tell them apart.
func TestCreateProject_DispatchAllowFailure_IsDistinctField(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "main")
base := f.handler(t, "template-go-agent", "new-svc")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/contents/.dispatch-allow") {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"boom"}`))
return
}
base(w, r)
}))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
assert.Empty(t, out.PartialFailure, "substitution itself succeeded — must not be conflated with the dispatch failure")
assert.NotEmpty(t, out.DispatchAllowFailure)
assert.Contains(t, out.DispatchAllowFailure, ".dispatch-allow")
assert.Contains(t, out.FilesSubstituted, "go.mod", "substitution must still be reported despite the separate dispatch failure")
}
// dispatch_allow now ALSO registers the new repo into mathias/dispatch's
// git-tracked allowlist (dispatch-repos.txt) — the second of the two required
// dispatch gates, independent of the .dispatch-allow marker (gitea-mcp#54).
func TestCreateProject_DispatchAllow_RegistersAllowlist(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "main")
f.dispatchRepos = "# comment\nmathias/dispatch-sandbox\nmathias/cobalt-dingo\n"
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
assert.Empty(t, out.PartialFailure)
assert.Empty(t, out.DispatchAllowlistFailure)
assert.True(t, out.DispatchAllowlisted)
assert.Equal(t, 1, f.dispatchReposPuts)
assert.Contains(t, f.dispatchRepos, "mathias/new-svc")
// existing entries preserved, not clobbered
assert.Contains(t, f.dispatchRepos, "mathias/dispatch-sandbox")
assert.Contains(t, f.dispatchRepos, "mathias/cobalt-dingo")
}
// Registering is idempotent: a repo already listed (e.g. a resume re-running
// with dispatch_allow:true) must not produce a duplicate line or a redundant write.
func TestCreateProject_DispatchAllow_RegistersAllowlist_AlreadyListedIsNoop(t *testing.T) {
files := map[string]string{"go.mod": "module git.d-ma.be/mathias/new-svc\n"} // already substituted
f := newFakeTemplateServerResumed(files, "main")
f.dispatchRepos = "mathias/dispatch-sandbox\nmathias/new-svc\n"
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true,"dispatch_allow":true}`)
assert.Empty(t, out.PartialFailure)
assert.Empty(t, out.DispatchAllowlistFailure)
assert.False(t, out.DispatchAllowlisted, "already-listed must not be reported as a fresh registration")
assert.Equal(t, 0, f.dispatchReposPuts, "already-listed repo must not trigger a write")
}
// A failure registering the allowlist is reported in its OWN field — distinct
// from PartialFailure (substitution) AND DispatchAllowFailure (the marker
// file) — since all three are independent gates that can fail independently.
func TestCreateProject_DispatchAllowlistFailure_IsDistinctField(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "main")
f.dispatchRepos = "mathias/dispatch-sandbox\n"
base := f.handler(t, "template-go-agent", "new-svc")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut && r.URL.Path == "/api/v1/repos/mathias/dispatch/contents/dispatch-repos.txt" {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"boom"}`))
return
}
base(w, r)
}))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
assert.Empty(t, out.PartialFailure, "substitution succeeded — must not be conflated")
assert.Empty(t, out.DispatchAllowFailure, ".dispatch-allow marker succeeded — must not be conflated")
assert.NotEmpty(t, out.DispatchAllowlistFailure)
assert.Contains(t, out.DispatchAllowlistFailure, "dispatch-repos.txt")
assert.Contains(t, out.FilesSubstituted, "go.mod", "substitution must still be reported despite the separate allowlist failure")
}
// dispatch_allow=false/omitted must never touch the allowlist file at all.
func TestCreateProject_DispatchAllowFalse_DoesNotTouchAllowlist(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "main")
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
assert.Empty(t, out.PartialFailure)
assert.False(t, out.DispatchAllowlisted)
assert.Equal(t, 0, f.dispatchReposPuts)
}
// ── guardrails unchanged by the rewrite ──────────────────────────────────────
func TestCreateProject_NameRegexFailure(t *testing.T) {
_, err := tools.NewCreateProjectFromTemplate(
gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-agent",
).Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"INVALID_NAME"}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
func TestCreateProject_AllowlistRejects(t *testing.T) {
_, err := tools.NewCreateProjectFromTemplate(
gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-agent",
).Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`))
require.Error(t, err)
assert.Contains(t, err.Error(), "allowlist")
}
func TestCreateProject_NotTemplate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/repos/mathias/template-go-agent" {
_, _ = w.Write([]byte(templateRepoJSON("template-go-agent", false)))
return
}
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
_, err := newTool(srv.URL, "template-go-agent").Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
func TestCreateProject_DestinationExists(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/repos/mathias/template-go-agent":
_, _ = w.Write([]byte(templateRepoJSON("template-go-agent", true)))
case "/api/v1/repos/mathias/new-svc":
_, _ = w.Write([]byte(templateRepoJSON("new-svc", false)))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
_, err := newTool(srv.URL, "template-go-agent").Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrConflict)
}