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) } } }