josie / simplegit

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