add upload api

This commit is contained in:
delete 2026-06-11 17:44:06 +03:00
parent a90dd9bc50
commit 77da1ffd94
4 changed files with 249 additions and 0 deletions

View file

@ -1,10 +1,17 @@
package httpserver_test
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
@ -192,6 +199,134 @@ func TestClientAPIListsAuthorizedProjectsReturnsLatestMetadataAndStreamsDownload
}
}
func TestClientAPIUploadsReleaseWithUploadPermission(t *testing.T) {
t.Parallel()
router, cfg, store := newTestRouterWithStore(t)
sessionCookie := loginAsAdmin(t, router)
desktopProjectID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
"name": {"Desktop App"},
"slug": {"desktop-app"},
}, http.StatusSeeOther, sessionCookie), "/admin/projects/")
extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
"name": {"Mobile App"},
"slug": {"mobile-app"},
}, http.StatusSeeOther, sessionCookie), "/admin/projects/")
desktopProjectIDInt := mustParseInt64(t, desktopProjectID)
apiKeyService := apikeys.NewService(store)
uploadKey, err := apiKeyService.Create(t.Context(), apikeys.CreateParams{
Name: "Release Uploader",
ScopeMode: db.ScopeModeProjectAllowList,
CanUpload: true,
ProjectIDs: []int64{desktopProjectIDInt},
})
if err != nil {
t.Fatalf("create upload api key: %v", err)
}
downloadOnlyKey, err := apiKeyService.Create(t.Context(), apikeys.CreateParams{
Name: "Download Only",
ScopeMode: db.ScopeModeAllProjects,
CanDownload: true,
})
if err != nil {
t.Fatalf("create download-only api key: %v", err)
}
artifactBody := []byte("api-release-payload-1.2.3")
uploadRecorder := performAPIMultipartRequest(t, router, "/api/v1/projects/desktop-app/releases", map[string]string{
"version": "1.2.3",
"build": "api-build-7",
"release_notes": "Uploaded through bearer API.",
}, "artifact", "..\\Desktop API 1.2.3.ZIP", artifactBody, "Bearer "+uploadKey.RawKey)
if uploadRecorder.Code != http.StatusCreated {
t.Fatalf("expected API upload to return 201, got %d with body %s", uploadRecorder.Code, uploadRecorder.Body.String())
}
var uploadPayload struct {
Project struct {
Slug string `json:"slug"`
} `json:"project"`
Release struct {
ID int64 `json:"id"`
Version string `json:"version"`
Build string `json:"build"`
Filename string `json:"filename"`
ChecksumSHA256 string `json:"checksum_sha256"`
MetadataURL string `json:"metadata_url"`
DownloadURL string `json:"download_url"`
} `json:"release"`
}
decodeJSONBody(t, uploadRecorder, &uploadPayload)
if uploadPayload.Project.Slug != "desktop-app" {
t.Fatalf("expected desktop-app project payload, got %+v", uploadPayload.Project)
}
if uploadPayload.Release.Version != "1.2.3" || uploadPayload.Release.Build != "api-build-7" {
t.Fatalf("unexpected uploaded release payload %+v", uploadPayload.Release)
}
if uploadPayload.Release.Filename != "desktop-api-1.2.3.zip" {
t.Fatalf("expected sanitized filename, got %q", uploadPayload.Release.Filename)
}
if uploadRecorder.Header().Get("Location") != uploadPayload.Release.MetadataURL {
t.Fatalf("expected Location to point at metadata URL, got %q", uploadRecorder.Header().Get("Location"))
}
expectedChecksum := sha256.Sum256(artifactBody)
if uploadPayload.Release.ChecksumSHA256 != hex.EncodeToString(expectedChecksum[:]) {
t.Fatalf("expected checksum %s, got %s", hex.EncodeToString(expectedChecksum[:]), uploadPayload.Release.ChecksumSHA256)
}
releases, err := store.Releases.ListByProjectID(context.Background(), desktopProjectIDInt)
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.UploadedByUserID != nil {
t.Fatalf("expected API-uploaded release to have no admin uploader, got %d", *release.UploadedByUserID)
}
if release.StoragePath != "desktop-app/1.2.3/api-build-7/desktop-api-1.2.3.zip" {
t.Fatalf("unexpected storage path %q", release.StoragePath)
}
storedArtifact, err := os.ReadFile(filepath.Join(cfg.ArtifactsDir, filepath.FromSlash(release.StoragePath)))
if err != nil {
t.Fatalf("read stored artifact: %v", err)
}
if !bytes.Equal(storedArtifact, artifactBody) {
t.Fatal("stored artifact body did not match uploaded payload")
}
duplicateRecorder := performAPIMultipartRequest(t, router, "/api/v1/projects/desktop-app/releases", map[string]string{
"version": "1.2.3",
"build": "api-build-7",
}, "artifact", "desktop-api-alt.zip", []byte("duplicate"), "Bearer "+uploadKey.RawKey)
if duplicateRecorder.Code != http.StatusConflict {
t.Fatalf("expected duplicate version/build to return 409, got %d with body %s", duplicateRecorder.Code, duplicateRecorder.Body.String())
}
forbiddenRecorder := performAPIMultipartRequest(t, router, "/api/v1/projects/desktop-app/releases", map[string]string{
"version": "2.0.0",
}, "artifact", "desktop-api-2.0.0.zip", []byte("forbidden"), "Bearer "+downloadOnlyKey.RawKey)
if forbiddenRecorder.Code != http.StatusForbidden {
t.Fatalf("expected key without upload permission to return 403, got %d with body %s", forbiddenRecorder.Code, forbiddenRecorder.Body.String())
}
blockedProjectRecorder := performAPIMultipartRequest(t, router, "/api/v1/projects/mobile-app/releases", map[string]string{
"version": "2.0.0",
}, "artifact", "mobile-api-2.0.0.apk", []byte("blocked"), "Bearer "+uploadKey.RawKey)
if blockedProjectRecorder.Code != http.StatusNotFound {
t.Fatalf("expected out-of-scope project upload to return 404, got %d with body %s", blockedProjectRecorder.Code, blockedProjectRecorder.Body.String())
}
}
func TestClientAPIRejectsMissingPermissionDisabledAndExpiredKeys(t *testing.T) {
t.Parallel()
@ -281,6 +416,40 @@ func performAPIRequest(t *testing.T, handler http.Handler, method, target, autho
return recorder
}
func performAPIMultipartRequest(t *testing.T, handler http.Handler, target string, fields map[string]string, fileField, filename string, body []byte, authorization string) *httptest.ResponseRecorder {
t.Helper()
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())
if authorization != "" {
req.Header.Set("Authorization", authorization)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
return recorder
}
func decodeJSONBody(t *testing.T, recorder *httptest.ResponseRecorder, target any) {
t.Helper()