package httpserver_test import ( "bytes" "context" "crypto/sha256" "encoding/hex" "fmt" "mime/multipart" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" ) func TestAdminProjectTagAndReleaseFlow(t *testing.T) { t.Parallel() router, cfg, store := newTestRouterWithStore(t) sessionCookie := loginAsAdmin(t, router) projectLocation := submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{ "name": {"Desktop App"}, "slug": {""}, "description": {"Primary desktop updater stream."}, }, http.StatusSeeOther, sessionCookie) projectID := extractResourceID(t, projectLocation, "/admin/projects/") submitForm(t, router, http.MethodPost, "/admin/projects/"+projectID, url.Values{ "name": {"Desktop App"}, "slug": {"desktop-app"}, "description": {"Primary desktop updater stream with release uploads."}, }, http.StatusSeeOther, sessionCookie) tagLocation := submitForm(t, router, http.MethodPost, "/admin/tags", url.Values{ "name": {"Windows Stable"}, "slug": {""}, "description": {"Windows production releases."}, }, http.StatusSeeOther, sessionCookie) tagID := extractResourceID(t, tagLocation, "/admin/tags/") submitForm(t, router, http.MethodPost, "/admin/projects/"+projectID+"/tags", url.Values{ "tag_id": {tagID}, }, http.StatusSeeOther, sessionCookie) artifactBody := []byte("release-payload-1.0.0") uploadLocation := submitMultipartForm(t, router, "/admin/projects/"+projectID+"/releases", map[string]string{ "version": "1.0.0", "build": "build-42", "release_notes": "Initial Windows stable rollout.", }, "artifact", "..\\Desktop App 1.0.0.zip", artifactBody, sessionCookie) if !strings.Contains(uploadLocation, "status=release-uploaded") { t.Fatalf("expected release upload redirect status, got %q", uploadLocation) } projectIDInt := mustParseInt64(t, projectID) project, err := store.Projects.GetByID(context.Background(), projectIDInt) if err != nil { t.Fatalf("load project: %v", err) } if project.Slug != "desktop-app" { t.Fatalf("expected auto-generated project slug to persist, got %q", project.Slug) } if !project.IsActive { t.Fatal("expected project to remain active until the archive action runs") } projectTags, err := store.Projects.ListTags(context.Background(), project.ID) if err != nil { t.Fatalf("load project tags: %v", err) } if len(projectTags) != 1 || projectTags[0].Slug != "windows-stable" { t.Fatalf("expected one attached tag, got %+v", projectTags) } releases, err := store.Releases.ListByProjectID(context.Background(), project.ID) if err != nil { t.Fatalf("load releases: %v", err) } if len(releases) != 1 { t.Fatalf("expected one release, got %d", len(releases)) } release := releases[0].Release if release.Filename != "desktop-app-1.0.0.zip" { t.Fatalf("expected sanitized filename, got %q", release.Filename) } expectedChecksum := sha256.Sum256(artifactBody) if release.ChecksumSHA256 != hex.EncodeToString(expectedChecksum[:]) { t.Fatalf("expected checksum %s, got %s", hex.EncodeToString(expectedChecksum[:]), release.ChecksumSHA256) } if release.StoragePath != "desktop-app/1.0.0/build-42/desktop-app-1.0.0.zip" { t.Fatalf("unexpected storage path %q", release.StoragePath) } artifactPath := filepath.Join(cfg.ArtifactsDir, filepath.FromSlash(release.StoragePath)) if strings.HasPrefix(artifactPath, cfg.StaticDir) { t.Fatalf("artifact path %q should not be inside static dir %q", artifactPath, cfg.StaticDir) } storedArtifact, err := os.ReadFile(artifactPath) if err != nil { t.Fatalf("read artifact from disk: %v", err) } if !bytes.Equal(storedArtifact, artifactBody) { t.Fatal("stored artifact body did not match uploaded payload") } recorder := performRequest(t, router, http.MethodGet, "/admin/projects/"+projectID, nil, sessionCookie) if recorder.Code != http.StatusOK { t.Fatalf("expected project detail page to load, got %d", recorder.Code) } projectBody := recorder.Body.String() if !strings.Contains(projectBody, "/api/v1/projects/desktop-app/releases/latest") { t.Fatal("expected project detail page to show latest release metadata endpoint") } if !strings.Contains(projectBody, "/api/v1/releases/1/download") { t.Fatal("expected project detail page to show release download endpoint") } submitForm(t, router, http.MethodPost, "/admin/projects/"+projectID+"/archive", url.Values{ "state": {"archive"}, }, http.StatusSeeOther, sessionCookie) project, err = store.Projects.GetByID(context.Background(), projectIDInt) if err != nil { t.Fatalf("reload project after archive: %v", err) } if project.IsActive { t.Fatal("expected archived project to be inactive") } recorder = performRequest(t, router, http.MethodGet, "/admin/projects", nil, sessionCookie) if recorder.Code != http.StatusOK { t.Fatalf("expected projects page to load, got %d", recorder.Code) } if !strings.Contains(recorder.Body.String(), "Desktop App") { t.Fatal("expected projects page to list the created project") } recorder = performRequest(t, router, http.MethodGet, "/admin/tags", nil, sessionCookie) if recorder.Code != http.StatusOK { t.Fatalf("expected tags page to load, got %d", recorder.Code) } if !strings.Contains(recorder.Body.String(), "Windows Stable") { t.Fatal("expected tags page to list the created tag") } } func loginAsAdmin(t *testing.T, router http.Handler) *http.Cookie { t.Helper() csrfCookie := ensureCSRFCookie(t, router) recorder := performRequest(t, router, http.MethodPost, "/admin/login", url.Values{ "email": {"admin@example.com"}, "password": {"correct horse battery staple"}, "next": {"/admin"}, "csrf_token": {csrfCookie.Value}, }, csrfCookie) if recorder.Code != http.StatusSeeOther { t.Fatalf("expected login redirect, got %d", recorder.Code) } for _, cookie := range recorder.Result().Cookies() { if cookie.Name == "update_server_session" { return cookie } } t.Fatal("expected session cookie after login") return nil } func submitForm(t *testing.T, router http.Handler, method, target string, form url.Values, expectedStatus int, cookies ...*http.Cookie) string { t.Helper() if strings.HasPrefix(target, "/admin/") { csrfCookie := ensureCSRFCookie(t, router, cookies...) cookies = upsertCookie(cookies, csrfCookie) if form == nil { form = url.Values{} } if form.Get("csrf_token") == "" { form.Set("csrf_token", csrfCookie.Value) } } recorder := performRequest(t, router, method, target, form, cookies...) if recorder.Code != expectedStatus { t.Fatalf("expected %d for %s %s, got %d with body %s", expectedStatus, method, target, recorder.Code, recorder.Body.String()) } return recorder.Header().Get("Location") } func submitMultipartForm(t *testing.T, router http.Handler, target string, fields map[string]string, fileField, filename string, body []byte, cookies ...*http.Cookie) string { t.Helper() if strings.HasPrefix(target, "/admin/") { csrfCookie := ensureCSRFCookie(t, router, cookies...) cookies = upsertCookie(cookies, csrfCookie) if fields == nil { fields = map[string]string{} } if fields["csrf_token"] == "" { fields["csrf_token"] = csrfCookie.Value } } var requestBody bytes.Buffer writer := multipart.NewWriter(&requestBody) for key, value := range fields { if err := writer.WriteField(key, value); err != nil { t.Fatalf("write multipart field %s: %v", key, err) } } part, err := writer.CreateFormFile(fileField, filename) if err != nil { t.Fatalf("create multipart file part: %v", err) } if _, err := part.Write(body); err != nil { t.Fatalf("write multipart file body: %v", err) } if err := writer.Close(); err != nil { t.Fatalf("close multipart writer: %v", err) } req := httptest.NewRequest(http.MethodPost, target, &requestBody) req.RemoteAddr = "127.0.0.1:12345" req.Header.Set("Content-Type", writer.FormDataContentType()) for _, cookie := range cookies { req.AddCookie(cookie) } recorder := httptest.NewRecorder() router.ServeHTTP(recorder, req) if recorder.Code != http.StatusSeeOther { t.Fatalf("expected multipart upload redirect, got %d with body %s", recorder.Code, recorder.Body.String()) } return recorder.Header().Get("Location") } func upsertCookie(cookies []*http.Cookie, cookie *http.Cookie) []*http.Cookie { if cookie == nil { return cookies } updated := make([]*http.Cookie, 0, len(cookies)+1) replaced := false for _, existing := range cookies { if existing == nil { continue } if existing.Name == cookie.Name { updated = append(updated, cookie) replaced = true continue } updated = append(updated, existing) } if !replaced { updated = append(updated, cookie) } return updated } func extractResourceID(t *testing.T, location, prefix string) string { t.Helper() if !strings.HasPrefix(location, prefix) { t.Fatalf("expected redirect location with prefix %q, got %q", prefix, location) } trimmed := strings.TrimPrefix(location, prefix) parts := strings.SplitN(trimmed, "?", 2) if parts[0] == "" { t.Fatalf("could not extract resource id from %q", location) } return parts[0] } func mustParseInt64(t *testing.T, raw string) int64 { t.Helper() value, err := url.PathUnescape(raw) if err != nil { t.Fatalf("unescape id %q: %v", raw, err) } var parsed int64 if _, err := fmt.Sscan(value, &parsed); err != nil { t.Fatalf("parse id %q: %v", raw, err) } return parsed }