package web import ( "database/sql" "errors" "net/http" "os" "path/filepath" "strconv" "strings" "testing" "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) } }