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", "bold", "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)) } }