569340cf5fa42e3986981d057b955fb893418910 / internal/web/releases_test.go · 14923 bytes · raw
package web
import (
"bytes"
"database/sql"
"errors"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"git.josie-c.com/josie/simplegit/internal/auth"
"git.josie-c.com/josie/simplegit/internal/db"
)
// seedTag pushes a lightweight tag named tag pointing at HEAD.
func seedTag(t *testing.T, work, tag string) {
t.Helper()
runGit(t, work, "tag", tag)
runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", tag)
}
// seedRelease creates a release row plus one on-disk asset directly (there is
// no create-release UI; rows normally come from the owner's workflow).
func seedRelease(t *testing.T, database *sql.DB, dataDir, repoName, tag string) (releaseID, assetID int64) {
t.Helper()
repo, err := db.GetRepoByName(database, "josie", repoName)
if err != nil {
t.Fatalf("GetRepoByName: %v", err)
}
owner, err := db.GetUserByName(database, "josie")
if err != nil {
t.Fatalf("GetUserByName: %v", err)
}
release, err := db.CreateRelease(database, repo.ID, tag, "First release", "**bold** notes", owner.ID)
if err != nil {
t.Fatalf("CreateRelease: %v", err)
}
dir := filepath.Join(dataDir, "uploads", "releases", strconv.FormatInt(release.ID, 10))
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir assets: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "stored"), []byte("hello world"), 0o644); err != nil {
t.Fatalf("write asset: %v", err)
}
asset, err := db.CreateReleaseAsset(database, release.ID, "artifact.bin", "stored", int64(len("hello world")))
if err != nil {
t.Fatalf("CreateReleaseAsset: %v", err)
}
return release.ID, asset.ID
}
func TestReleaseRoutesRegister(t *testing.T) {
httpServer, _, _ := newTestServer(t)
client := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, client, httpServer, "pub", "public")
for _, path := range []string{"/josie/pub/releases"} {
resp, err := client.Get(httpServer.URL + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusOK {
t.Errorf("GET %s = %d, want 200", path, resp.StatusCode)
}
}
}
func TestReleaseViewAndDownload(t *testing.T) {
httpServer, database, dataDir := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
work := cloneRepo(t, httpServer, "pub")
writeWork(t, work, "a.txt", "a\n")
runGit(t, work, "add", ".")
runGit(t, work, "commit", "-qm", "a")
runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
seedTag(t, work, "v1.0.0")
_, assetID := seedRelease(t, database, dataDir, "pub", "v1.0.0")
view, err := owner.Get(httpServer.URL + "/josie/pub/releases/v1.0.0")
if err != nil {
t.Fatalf("GET release: %v", err)
}
body := readAll(t, view)
for _, want := range []string{"First release", "<strong>bold</strong>", "artifact.bin"} {
if !strings.Contains(body, want) {
t.Errorf("release view missing %q: %s", want, body)
}
}
list, _ := (&http.Client{}).Get(httpServer.URL + "/josie/pub/releases")
body = readAll(t, list)
if !strings.Contains(body, "First release") {
t.Errorf("release list missing the release: %s", body)
}
if strings.Contains(body, "Tags without a release") {
t.Errorf("released tag still listed as unreleased: %s", body)
}
download, err := owner.Get(httpServer.URL + "/josie/pub/releases/download/" + strconv.FormatInt(assetID, 10) + "/artifact.bin")
if err != nil {
t.Fatalf("download: %v", err)
}
content := readAll(t, download)
if download.StatusCode != http.StatusOK || content != "hello world" {
t.Errorf("download = %d %q, want 200 hello world", download.StatusCode, content)
}
if cd := download.Header.Get("Content-Disposition"); !strings.HasPrefix(cd, "attachment") {
t.Errorf("Content-Disposition = %q, want attachment", cd)
}
if download.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Errorf("missing nosniff on download")
}
}
func TestReleaseDeleteOwnerOnly(t *testing.T) {
httpServer, database, dataDir := newTestServer(t)
createRepo(t, noFollow(newLoggedInClient(t, httpServer)), httpServer, "pub", "public")
seedRelease(t, database, dataDir, "pub", "v1")
addUser(t, database, "mallory", "pw")
mallory := noFollow(loginAs(t, httpServer, "mallory", "pw"))
resp, err := mallory.Post(httpServer.URL+"/josie/pub/releases/v1/delete", "", nil)
if err != nil {
t.Fatalf("mallory delete: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("non-owner delete = %d, want 404", resp.StatusCode)
}
}
func TestDeleteReleaseRemovesAssets(t *testing.T) {
httpServer, database, dataDir := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
releaseID, _ := seedRelease(t, database, dataDir, "pub", "v1")
repo, _ := db.GetRepoByName(database, "josie", "pub")
release, _ := db.GetReleaseByTag(database, repo.ID, "v1")
assets, _ := db.ListReleaseAssets(database, release.ID)
if len(assets) != 1 {
t.Fatalf("assets = %+v, want one", assets)
}
storedPath := filepath.Join(dataDir, "uploads", "releases", strconv.FormatInt(releaseID, 10), assets[0].StoredName)
if _, err := os.Stat(storedPath); err != nil {
t.Fatalf("stored asset missing before delete: %v", err)
}
resp, err := owner.Post(httpServer.URL+"/josie/pub/releases/v1/delete", "", nil)
if err != nil {
t.Fatalf("delete release: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("delete release = %d, want 303", resp.StatusCode)
}
if _, err := db.GetReleaseByTag(database, repo.ID, "v1"); !errors.Is(err, db.ErrNotFound) {
t.Errorf("release after delete err = %v, want not found", err)
}
if _, err := os.Stat(storedPath); !os.IsNotExist(err) {
t.Errorf("stored asset still present after delete: %v", err)
}
view, _ := owner.Get(httpServer.URL + "/josie/pub/releases/v1")
readAll(t, view)
if view.StatusCode != http.StatusNotFound {
t.Errorf("release view after delete = %d, want 404", view.StatusCode)
}
}
// TestReleaseDownloadPrivateNeedsOwner checks the download follows the repo
// visibility gate.
func TestReleaseDownloadPrivateNeedsOwner(t *testing.T) {
httpServer, database, dataDir := newTestServer(t)
createRepo(t, noFollow(newLoggedInClient(t, httpServer)), httpServer, "sec", "private")
_, assetID := seedRelease(t, database, dataDir, "sec", "v1")
anon := noFollow(&http.Client{})
download, err := anon.Get(httpServer.URL + "/josie/sec/releases/download/" + strconv.FormatInt(assetID, 10) + "/secret.bin")
if err != nil {
t.Fatalf("anon download: %v", err)
}
readAll(t, download)
if download.StatusCode != http.StatusNotFound {
t.Errorf("anon private download = %d, want 404 (no existence oracle)", download.StatusCode)
}
}
// newAPIToken mints a git token for user directly in the DB and returns the
// plaintext, so the API tests can exercise the token path without the UI.
func newAPIToken(t *testing.T, database *sql.DB, username string) string {
t.Helper()
user, err := db.GetUserByName(database, username)
if err != nil {
t.Fatalf("GetUserByName: %v", err)
}
plaintext, err := auth.NewAPIToken()
if err != nil {
t.Fatalf("NewAPIToken: %v", err)
}
if _, err := db.CreateToken(database, user.ID, "test", auth.TokenHint(plaintext), auth.HashToken(plaintext)); err != nil {
t.Fatalf("CreateToken: %v", err)
}
return plaintext
}
// postRelease builds a multipart POST to the create-release API with basic
// auth and returns the response.
func postRelease(t *testing.T, url, user, secret, tag, title, notes string, files map[string]string) *http.Response {
t.Helper()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
for _, field := range []struct{ name, value string }{
{"tag", tag}, {"title", title}, {"notes", notes},
} {
if field.value != "" {
if err := mw.WriteField(field.name, field.value); err != nil {
t.Fatalf("writefield %s: %v", field.name, err)
}
}
}
for name, content := range files {
fw, err := mw.CreateFormFile("assets", name)
if err != nil {
t.Fatalf("createformfile: %v", err)
}
if _, err := fw.Write([]byte(content)); err != nil {
t.Fatalf("write asset: %v", err)
}
}
if err := mw.Close(); err != nil {
t.Fatalf("close multipart: %v", err)
}
req, err := http.NewRequest(http.MethodPost, url, &body)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", mw.FormDataContentType())
req.SetBasicAuth(user, secret)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatalf("POST release: %v", err)
}
return resp
}
func TestCreateReleaseBasicAuthWithAsset(t *testing.T) {
httpServer, database, dataDir := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
work := cloneRepo(t, httpServer, "pub")
writeWork(t, work, "a.txt", "a\n")
runGit(t, work, "add", ".")
runGit(t, work, "commit", "-qm", "a")
runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
seedTag(t, work, "v1.0.0")
resp := postRelease(t, httpServer.URL+"/josie/pub/releases", "josie", "hunter2",
"v1.0.0", "v1.0.0", "first release notes", map[string]string{"artifact.tar.xz": "TARBYTES"})
body := readAll(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("create release = %d, want 200: %s", resp.StatusCode, body)
}
repo, _ := db.GetRepoByName(database, "josie", "pub")
release, err := db.GetReleaseByTag(database, repo.ID, "v1.0.0")
if err != nil {
t.Fatalf("release not created: %v", err)
}
if release.Title != "v1.0.0" || release.Notes != "first release notes" {
t.Errorf("release = %q / %q", release.Title, release.Notes)
}
assets, _ := db.ListReleaseAssets(database, release.ID)
if len(assets) != 1 || assets[0].Filename != "artifact.tar.xz" {
t.Fatalf("assets = %+v, want one artifact.tar.xz", assets)
}
if _, err := os.Stat(filepath.Join(dataDir, "uploads", "releases", strconv.FormatInt(release.ID, 10), assets[0].StoredName)); err != nil {
t.Errorf("stored asset missing: %v", err)
}
view, err := owner.Get(httpServer.URL + "/josie/pub/releases/v1.0.0")
if err != nil {
t.Fatalf("GET release: %v", err)
}
viewBody := readAll(t, view)
if !strings.Contains(viewBody, "artifact.tar.xz") {
t.Errorf("release view missing the asset: %s", viewBody)
}
}
func TestCreateReleaseAPITokenAuth(t *testing.T) {
httpServer, database, _ := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
work := cloneRepo(t, httpServer, "pub")
writeWork(t, work, "a.txt", "a\n")
runGit(t, work, "add", ".")
runGit(t, work, "commit", "-qm", "a")
runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
seedTag(t, work, "v2")
token := newAPIToken(t, database, "josie")
resp := postRelease(t, httpServer.URL+"/josie/pub/releases", "josie", token, "v2", "", "", nil)
body := readAll(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("token create release = %d, want 200: %s", resp.StatusCode, body)
}
repo, _ := db.GetRepoByName(database, "josie", "pub")
release, err := db.GetReleaseByTag(database, repo.ID, "v2")
if err != nil {
t.Fatalf("release not created: %v", err)
}
if release.Title != "v2" {
t.Errorf("title = %q, want the tag name as default", release.Title)
}
}
func TestCreateReleaseNonOwnerForbidden(t *testing.T) {
httpServer, database, _ := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
work := cloneRepo(t, httpServer, "pub")
writeWork(t, work, "a.txt", "a\n")
runGit(t, work, "add", ".")
runGit(t, work, "commit", "-qm", "a")
runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
seedTag(t, work, "v1")
addUser(t, database, "mallory", "pw")
resp := postRelease(t, httpServer.URL+"/josie/pub/releases", "mallory", "pw", "v1", "x", "", nil)
readAll(t, resp)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("non-owner create = %d, want 404", resp.StatusCode)
}
repo, _ := db.GetRepoByName(database, "josie", "pub")
if _, err := db.GetReleaseByTag(database, repo.ID, "v1"); !errors.Is(err, db.ErrNotFound) {
t.Errorf("a release was created for a non-owner")
}
}
func TestCreateReleaseRequiresAuth(t *testing.T) {
httpServer, _, _ := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
req, _ := http.NewRequest(http.MethodPost, httpServer.URL+"/josie/pub/releases", strings.NewReader(""))
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatalf("POST: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("unauthenticated create = %d, want 401", resp.StatusCode)
}
}
func TestCreateReleaseUnknownTagAndDuplicate(t *testing.T) {
httpServer, database, _ := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
work := cloneRepo(t, httpServer, "pub")
writeWork(t, work, "a.txt", "a\n")
runGit(t, work, "add", ".")
runGit(t, work, "commit", "-qm", "a")
runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
seedTag(t, work, "v1")
resp := postRelease(t, httpServer.URL+"/josie/pub/releases", "josie", "hunter2", "nope", "x", "", nil)
readAll(t, resp)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("unknown tag = %d, want 422", resp.StatusCode)
}
resp = postRelease(t, httpServer.URL+"/josie/pub/releases", "josie", "hunter2", "v1", "first", "", nil)
readAll(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("first create = %d, want 200", resp.StatusCode)
}
// A repeat post for the same tag reuses the existing release (it is not
// dropped and recreated): the title/notes set at creation are preserved,
// and an asset uploaded now is added to it.
resp = postRelease(t, httpServer.URL+"/josie/pub/releases", "josie", "hunter2",
"v1", "ignored-title", "ignored-notes", map[string]string{"extra.bin": "more"})
body := readAll(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("repeat post = %d, want 200 (idempotent): %s", resp.StatusCode, body)
}
repo, _ := db.GetRepoByName(database, "josie", "pub")
release, _ := db.GetReleaseByTag(database, repo.ID, "v1")
if release.Title != "first" {
t.Errorf("title after repeat post = %q, want preserved %q", release.Title, "first")
}
assets, _ := db.ListReleaseAssets(database, release.ID)
if len(assets) != 1 || assets[0].Filename != "extra.bin" {
t.Errorf("assets after repeat post = %+v, want [extra.bin]", assets)
}
releases, _ := db.ListReleases(database, repo.ID)
if len(releases) != 1 {
t.Errorf("releases = %d, want 1", len(releases))
}
}