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" "update_server/internal/apikeys" "update_server/internal/db" ) func TestClientAPIListsAuthorizedProjectsReturnsLatestMetadataAndStreamsDownloads(t *testing.T) { t.Parallel() router, _, 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"}, "description": {"Primary desktop updater stream."}, }, http.StatusSeeOther, sessionCookie), "/admin/projects/") mobileProjectID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{ "name": {"Mobile App"}, "slug": {"mobile-app"}, }, http.StatusSeeOther, sessionCookie), "/admin/projects/") legacyProjectID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{ "name": {"Legacy App"}, "slug": {"legacy-app"}, }, http.StatusSeeOther, sessionCookie), "/admin/projects/") tagID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/tags", url.Values{ "name": {"Windows"}, "slug": {"windows"}, }, http.StatusSeeOther, sessionCookie), "/admin/tags/") submitForm(t, router, http.MethodPost, "/admin/projects/"+desktopProjectID+"/tags", url.Values{"tag_id": {tagID}}, http.StatusSeeOther, sessionCookie) submitForm(t, router, http.MethodPost, "/admin/projects/"+legacyProjectID+"/tags", url.Values{"tag_id": {tagID}}, http.StatusSeeOther, sessionCookie) submitMultipartForm(t, router, "/admin/projects/"+desktopProjectID+"/releases", map[string]string{ "version": "1.0.0", "build": "build-1", "release_notes": "Initial desktop rollout.", }, "artifact", "Desktop-App-1.0.0.zip", []byte("desktop-release-1.0.0"), sessionCookie) submitMultipartForm(t, router, "/admin/projects/"+desktopProjectID+"/releases", map[string]string{ "version": "1.1.0", "build": "build-2", "release_notes": "Improved desktop rollout.", }, "artifact", "Desktop-App-1.1.0.zip", []byte("desktop-release-1.1.0"), sessionCookie) submitMultipartForm(t, router, "/admin/projects/"+mobileProjectID+"/releases", map[string]string{ "version": "2.0.0", "build": "mobile-1", "release_notes": "Mobile rollout.", }, "artifact", "mobile-app-2.0.0.apk", []byte("mobile-release-2.0.0"), sessionCookie) submitForm(t, router, http.MethodPost, "/admin/projects/"+legacyProjectID+"/archive", url.Values{ "state": {"archive"}, }, http.StatusSeeOther, sessionCookie) keyResult, err := apikeys.NewService(store).Create(t.Context(), apikeys.CreateParams{ Name: "Windows Clients", ScopeMode: db.ScopeModeTagAllowList, CanDownload: true, TagIDs: []int64{mustParseInt64(t, tagID)}, }) if err != nil { t.Fatalf("create client api key: %v", err) } projectsRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+keyResult.RawKey) if projectsRecorder.Code != http.StatusOK { t.Fatalf("expected project list to return 200, got %d with body %s", projectsRecorder.Code, projectsRecorder.Body.String()) } var projectsPayload struct { Projects []struct { ID int64 `json:"id"` Name string `json:"name"` Slug string `json:"slug"` LatestReleaseURL string `json:"latest_release_url"` } `json:"projects"` } decodeJSONBody(t, projectsRecorder, &projectsPayload) if len(projectsPayload.Projects) != 1 { t.Fatalf("expected exactly one accessible active project, got %+v", projectsPayload.Projects) } if projectsPayload.Projects[0].Slug != "desktop-app" { t.Fatalf("expected desktop-app in accessible projects, got %+v", projectsPayload.Projects[0]) } if projectsPayload.Projects[0].LatestReleaseURL != "/api/v1/projects/desktop-app/releases/latest" { t.Fatalf("unexpected latest release url %q", projectsPayload.Projects[0].LatestReleaseURL) } latestRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects/desktop-app/releases/latest", "Bearer "+keyResult.RawKey) if latestRecorder.Code != http.StatusOK { t.Fatalf("expected latest release metadata to return 200, got %d with body %s", latestRecorder.Code, latestRecorder.Body.String()) } var latestPayload struct { Project struct { Slug string `json:"slug"` } `json:"project"` Release struct { ID int64 `json:"id"` Version string `json:"version"` Build string `json:"build"` MetadataURL string `json:"metadata_url"` DownloadURL string `json:"download_url"` CreatedAt time.Time `json:"created_at"` } `json:"release"` } decodeJSONBody(t, latestRecorder, &latestPayload) if latestPayload.Project.Slug != "desktop-app" { t.Fatalf("expected desktop-app project payload, got %+v", latestPayload.Project) } if latestPayload.Release.Version != "1.1.0" || latestPayload.Release.Build != "build-2" { t.Fatalf("expected latest release 1.1.0/build-2, got %+v", latestPayload.Release) } if latestPayload.Release.MetadataURL != "/api/v1/releases/2" { t.Fatalf("unexpected metadata url %q", latestPayload.Release.MetadataURL) } if latestPayload.Release.DownloadURL != "/api/v1/releases/2/download" { t.Fatalf("unexpected download url %q", latestPayload.Release.DownloadURL) } metadataRecorder := performAPIRequest(t, router, http.MethodGet, latestPayload.Release.MetadataURL, "Bearer "+keyResult.RawKey) if metadataRecorder.Code != http.StatusOK { t.Fatalf("expected release metadata to return 200, got %d with body %s", metadataRecorder.Code, metadataRecorder.Body.String()) } var metadataPayload struct { Project struct { Slug string `json:"slug"` } `json:"project"` Release struct { ID int64 `json:"id"` Version string `json:"version"` Filename string `json:"filename"` } `json:"release"` } decodeJSONBody(t, metadataRecorder, &metadataPayload) if metadataPayload.Release.ID != latestPayload.Release.ID || metadataPayload.Release.Version != "1.1.0" { t.Fatalf("expected matching release metadata payload, got %+v", metadataPayload.Release) } blockedLatestRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects/mobile-app/releases/latest", "Bearer "+keyResult.RawKey) if blockedLatestRecorder.Code != http.StatusNotFound { t.Fatalf("expected blocked project latest lookup to return 404, got %d with body %s", blockedLatestRecorder.Code, blockedLatestRecorder.Body.String()) } blockedMetadataRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/releases/3", "Bearer "+keyResult.RawKey) if blockedMetadataRecorder.Code != http.StatusNotFound { t.Fatalf("expected blocked release metadata to return 404, got %d with body %s", blockedMetadataRecorder.Code, blockedMetadataRecorder.Body.String()) } downloadRecorder := performAPIRequest(t, router, http.MethodGet, latestPayload.Release.DownloadURL, "Bearer "+keyResult.RawKey) if downloadRecorder.Code != http.StatusOK { t.Fatalf("expected release download to return 200, got %d with body %s", downloadRecorder.Code, downloadRecorder.Body.String()) } if body := downloadRecorder.Body.String(); body != "desktop-release-1.1.0" { t.Fatalf("unexpected download body %q", body) } if value := downloadRecorder.Header().Get("Content-Disposition"); !strings.Contains(value, "attachment") || !strings.Contains(value, "desktop-app-1.1.0.zip") { t.Fatalf("expected attachment content disposition, got %q", value) } if value := downloadRecorder.Header().Get("Content-Type"); !strings.Contains(value, "text/plain") { t.Fatalf("expected sniffed text content type, got %q", value) } blockedDownloadRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/releases/3/download", "Bearer "+keyResult.RawKey) if blockedDownloadRecorder.Code != http.StatusNotFound { t.Fatalf("expected blocked release download to return 404, got %d with body %s", blockedDownloadRecorder.Code, blockedDownloadRecorder.Body.String()) } } 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() router, _, store := newTestRouterWithStore(t) sessionCookie := loginAsAdmin(t, router) projectID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{ "name": {"Desktop App"}, "slug": {"desktop-app"}, }, http.StatusSeeOther, sessionCookie), "/admin/projects/") submitMultipartForm(t, router, "/admin/projects/"+projectID+"/releases", map[string]string{ "version": "1.0.0", "build": "build-1", "release_notes": "Initial rollout.", }, "artifact", "desktop-app-1.0.0.zip", []byte("desktop-release-1.0.0"), sessionCookie) service := apikeys.NewService(store) noPermissionKey, err := service.Create(t.Context(), apikeys.CreateParams{ Name: "No Download", ScopeMode: db.ScopeModeAllProjects, CanUpload: true, }) if err != nil { t.Fatalf("create no-permission key: %v", err) } disabledKey, err := service.Create(t.Context(), apikeys.CreateParams{ Name: "Disabled", ScopeMode: db.ScopeModeAllProjects, CanDownload: true, }) if err != nil { t.Fatalf("create disabled key: %v", err) } if err := service.SetActive(t.Context(), disabledKey.APIKey.ID, false); err != nil { t.Fatalf("disable api key: %v", err) } expiredAt := time.Now().UTC().Add(-time.Hour) expiredKey, err := service.Create(t.Context(), apikeys.CreateParams{ Name: "Expired", ScopeMode: db.ScopeModeAllProjects, CanDownload: true, ExpiresAt: &expiredAt, }) if err != nil { t.Fatalf("create expired key: %v", err) } missingAuthRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "") if missingAuthRecorder.Code != http.StatusUnauthorized { t.Fatalf("expected missing auth to return 401, got %d", missingAuthRecorder.Code) } assertHeaderContains(t, missingAuthRecorder, "WWW-Authenticate", "Bearer") noPermissionRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+noPermissionKey.RawKey) if noPermissionRecorder.Code != http.StatusForbidden { t.Fatalf("expected missing permission to return 403, got %d with body %s", noPermissionRecorder.Code, noPermissionRecorder.Body.String()) } disabledRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+disabledKey.RawKey) if disabledRecorder.Code != http.StatusUnauthorized { t.Fatalf("expected disabled key to return 401, got %d with body %s", disabledRecorder.Code, disabledRecorder.Body.String()) } assertHeaderContains(t, disabledRecorder, "WWW-Authenticate", "Bearer") expiredRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+expiredKey.RawKey) if expiredRecorder.Code != http.StatusUnauthorized { t.Fatalf("expected expired key to return 401, got %d with body %s", expiredRecorder.Code, expiredRecorder.Body.String()) } assertHeaderContains(t, expiredRecorder, "WWW-Authenticate", "Bearer") } func performAPIRequest(t *testing.T, handler http.Handler, method, target, authorization string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, target, nil) req.RemoteAddr = "127.0.0.1:12345" if authorization != "" { req.Header.Set("Authorization", authorization) } recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) 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() if err := json.Unmarshal(recorder.Body.Bytes(), target); err != nil { t.Fatalf("decode json response: %v", err) } }