Files
mgsh/deleteremote_test.go
T
2026-08-11 11:40:32 +02:00

267 lines
9.3 KiB
Go

package main
// deleteremote_test.go — the command that deletes a repository on a server mgsh
// does not own.
//
// Nothing it does can be undone from here, so the tests are less about the happy
// path than about the ways it must refuse: no target named, an unknown target, a
// question answered with no, a repository that is not there. Each of those has
// to leave the server untouched, which is asserted on the requests the fake
// provider received — the DELETE either went out or it did not.
import (
"strings"
"testing"
)
// mirrorServer is a fake Gitea holding one repository owned by "mike".
func mirrorServer(t *testing.T, project string) *fakeProvider {
t.Helper()
f := newFakeProvider(t)
f.route("GET /api/v1/user", 200, `{"login":"mike"}`)
f.route("GET /api/v1/repos/mike/"+project, 200, `{}`)
f.route("DELETE /api/v1/repos/mike/"+project, 204, "")
return f
}
// mirrorProject points the globals at a project with f as its single mirror
// target, and answers every y/n question with answer. It returns the project
// directory and the recorded questions.
func mirrorProject(t *testing.T, f *fakeProvider, name string, answer bool) (string, *[]string) {
t.Helper()
dir := useProject(t, name)
cfg.Remotes = []RemoteTarget{{Name: "gitea", URL: f.URL, Key: "tok", Type: "gitea"}}
return dir, fakeAnswers(t, answer)
}
// deleted reports whether the DELETE for project actually went to the server.
func deleted(f *fakeProvider, project string) bool {
return f.find("DELETE /api/v1/repos/mike/"+project) != nil
}
// TestDeleteRepoPerProvider pins the request each provider expects. GitLab
// answers 202 — the deletion is only scheduled — which still counts as success.
func TestDeleteRepoPerProvider(t *testing.T) {
for _, c := range []struct {
typ, path string
code int
}{
{"gitea", "/api/v1/repos/mike/mgsh", 204},
{"github", "/api/v3/repos/mike/mgsh", 204},
{"gitlab", "/api/v4/projects/mike%2Fmgsh", 202},
} {
f := newFakeProvider(t)
f.route("DELETE "+c.path, c.code, "")
if err := newRemoteAPI(f.URL, "tok", c.typ).deleteRepo("mike", "mgsh"); err != nil {
t.Fatalf("%s: deleteRepo: %v (requests: %v)", c.typ, err, f.paths())
}
if f.find("DELETE "+c.path) == nil {
t.Errorf("%s: expected DELETE %s, got %v", c.typ, c.path, f.paths())
}
}
}
// TestDeleteRepoNamesTheMissingPermission: deleting needs more of a token than
// pushing does, so a 403 here is the likeliest failure of all. Passing the bare
// status on would send people to the wrong setting.
func TestDeleteRepoNamesTheMissingPermission(t *testing.T) {
f := newFakeProvider(t)
f.route("DELETE /api/v3/repos/mike/mgsh", 403, `{"message":"Must have admin rights"}`)
err := newRemoteAPI(f.URL, "tok", "github").deleteRepo("mike", "mgsh")
if err == nil || !strings.Contains(err.Error(), "delete_repo") {
t.Errorf("403 error = %v, want it to name the delete_repo scope", err)
}
}
// TestDeleteRepoTreatsUnroutedAsFailure: anything the server did not accept must
// surface as an error, or a repository that is still there looks deleted.
func TestDeleteRepoReportsFailure(t *testing.T) {
f := newFakeProvider(t) // everything 404s
if err := newRemoteAPI(f.URL, "tok", "gitea").deleteRepo("mike", "mgsh"); err == nil {
t.Error("deleteRepo accepted a 404 as success")
}
}
// TestDeleteRemoteNeedsATarget is the one that matters most: `pushremote` with
// no @name means "every configured server", and inheriting that here would wipe
// the project off all of them at once.
func TestDeleteRemoteNeedsATarget(t *testing.T) {
f := mirrorServer(t, "notes")
_, asked := mirrorProject(t, f, "notes", true) // even a standing "yes"
out := captureStdout(t, func() { runCommand("deleteremote") })
if len(f.got) != 0 {
t.Fatalf("deleteremote talked to the server without being told where: %v", f.paths())
}
if len(*asked) != 0 {
t.Errorf("asked %q although no target was named", *asked)
}
if !strings.Contains(out, "@gitea") {
t.Errorf("output does not say which targets exist: %q", out)
}
}
// TestDeleteRemoteRejectsUnknownTarget: a mistyped server name must not fall
// back to some other target.
func TestDeleteRemoteRejectsUnknownTarget(t *testing.T) {
f := mirrorServer(t, "notes")
_, asked := mirrorProject(t, f, "notes", true)
out := captureStdout(t, func() { runCommand("deleteremote @gitae") })
if len(f.got) != 0 {
t.Fatalf("an unknown target still reached a server: %v", f.paths())
}
if len(*asked) != 0 {
t.Errorf("asked %q for an unknown target", *asked)
}
if !strings.Contains(out, "gitae") {
t.Errorf("unknown target not reported: %q", out)
}
}
// TestDeleteRemoteKeepsRepoWhenDeclined: the question is the last guard, so a
// "no" has to stop the DELETE, not just the message about it.
func TestDeleteRemoteKeepsRepoWhenDeclined(t *testing.T) {
f := mirrorServer(t, "notes")
_, asked := mirrorProject(t, f, "notes", false)
captureStdout(t, func() { runCommand("deleteremote @gitea") })
if len(*asked) != 1 {
t.Fatalf("questions asked = %q, want exactly one", *asked)
}
if !strings.Contains((*asked)[0], "mike/notes") {
t.Errorf("question %q does not name the repository being deleted", (*asked)[0])
}
if deleted(f, "notes") {
t.Fatalf("deleted the repository after the user declined: %v", f.paths())
}
}
// TestDeleteRemoteWithoutRepositoryAsksNothing: a project that was never
// mirrored (or a typo in the project name) is a no-op, not a question.
func TestDeleteRemoteWithoutRepositoryAsksNothing(t *testing.T) {
f := newFakeProvider(t)
f.route("GET /api/v1/user", 200, `{"login":"mike"}`) // the repo lookup 404s
_, asked := mirrorProject(t, f, "notes", true)
out := captureStdout(t, func() { runCommand("deleteremote @gitea") })
if len(*asked) != 0 {
t.Errorf("asked %q about a repository that is not there", *asked)
}
if deleted(f, "notes") {
t.Error("sent a DELETE for a repository the server does not have")
}
if !strings.Contains(out, "nothing to delete") {
t.Errorf("output = %q, want it to say there is nothing to delete", out)
}
}
// TestDeleteRemoteByHostAndGitRemoteCleanup covers the whole accepted path: the
// server picked by its host rather than its configured name, the DELETE sent,
// and the now-dangling git remote dropped — while an unrelated remote stays.
func TestDeleteRemoteByHostAndGitRemoteCleanup(t *testing.T) {
f := mirrorServer(t, "notes")
dir, asked := mirrorProject(t, f, "notes", true)
mustGit(t, dir, "init", "-q")
mustGit(t, dir, "remote", "add", "gitea", f.URL+"/mike/notes.git")
mustGit(t, dir, "remote", "add", "origin", "git@git.example:notes.git")
captureStdout(t, func() { runCommand("deleteremote " + remoteHost(f.URL)) })
if len(*asked) != 1 {
t.Fatalf("questions asked = %q, want exactly one", *asked)
}
if !deleted(f, "notes") {
t.Fatalf("no DELETE sent, requests: %v", f.paths())
}
remotes, err := gitCapture(dir, "remote")
if err != nil {
t.Fatal(err)
}
names := splitLines(strings.TrimSpace(remotes))
for _, n := range names {
if n == "gitea" {
t.Errorf("git remote gitea survived the deletion: %q", names)
}
}
if len(names) != 1 || names[0] != "origin" {
t.Errorf("git remotes = %q, want origin left alone", names)
}
}
// TestDeleteRemoteKeepsARepointedGitRemote: the local remote is only dropped
// while it still points at what was deleted. One the user has since aimed
// somewhere else is theirs.
func TestDeleteRemoteKeepsARepointedGitRemote(t *testing.T) {
f := mirrorServer(t, "notes")
dir, _ := mirrorProject(t, f, "notes", true)
mustGit(t, dir, "init", "-q")
mustGit(t, dir, "remote", "add", "gitea", "https://elsewhere.example/mike/notes.git")
captureStdout(t, func() { runCommand("deleteremote @gitea") })
if !deleted(f, "notes") {
t.Fatalf("no DELETE sent, requests: %v", f.paths())
}
url, err := gitCapture(dir, "remote", "get-url", "gitea")
if err != nil {
t.Fatalf("git remote gitea was removed although it pointed elsewhere: %v", err)
}
if strings.TrimSpace(url) != "https://elsewhere.example/mike/notes.git" {
t.Errorf("git remote url = %q, want it untouched", strings.TrimSpace(url))
}
}
// TestRemoteMatches: a target answers to its configured name and to the host of
// its url, in any case and with or without the '@'.
func TestRemoteMatches(t *testing.T) {
tgt := RemoteTarget{Name: "gitea", URL: "https://git.example.com:3000/root"}
for _, c := range []struct {
sel string
want bool
}{
{"gitea", true},
{"GITEA", true},
{"@gitea", true},
{"git.example.com", true},
{"https://git.example.com/mike/x", true},
{"git@git.example.com:mike/x.git", true},
{"example.com", false}, // a suffix is not the host
{"other", false},
{"@", false},
{"", false},
} {
if got := remoteMatches(tgt, c.sel); got != c.want {
t.Errorf("remoteMatches(%q) = %v, want %v", c.sel, got, c.want)
}
}
}
// TestParseRemoteSelectors: everything on the line selects a server, '@' or not
// — the command takes nothing else that a bare word could be confused with.
func TestParseRemoteSelectors(t *testing.T) {
for _, c := range []struct {
in string
want []string
}{
{"", nil},
{"@gitea", []string{"gitea"}},
{"git.example.com", []string{"git.example.com"}},
{"@gitea git.example.com", []string{"gitea", "git.example.com"}},
{"@", nil},
} {
got := parseRemoteSelectors(c.in)
if strings.Join(got, ",") != strings.Join(c.want, ",") {
t.Errorf("parseRemoteSelectors(%q) = %v, want %v", c.in, got, c.want)
}
}
}