github_release_service_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. package repository
  2. import (
  3. "bytes"
  4. "context"
  5. "io"
  6. "net/http"
  7. "net/http/httptest"
  8. "os"
  9. "path/filepath"
  10. "strings"
  11. "testing"
  12. "github.com/stretchr/testify/require"
  13. "github.com/stretchr/testify/suite"
  14. )
  15. type GitHubReleaseServiceSuite struct {
  16. suite.Suite
  17. srv *httptest.Server
  18. client *githubReleaseClient
  19. tempDir string
  20. }
  21. // testTransport redirects requests to the test server
  22. type testTransport struct {
  23. testServerURL string
  24. }
  25. func (t *testTransport) RoundTrip(req *http.Request) (*http.Response, error) {
  26. // Rewrite the URL to point to our test server
  27. testURL := t.testServerURL + req.URL.Path
  28. if req.URL.RawQuery != "" {
  29. testURL += "?" + req.URL.RawQuery
  30. }
  31. newReq, err := http.NewRequestWithContext(req.Context(), req.Method, testURL, req.Body)
  32. if err != nil {
  33. return nil, err
  34. }
  35. newReq.Header = req.Header
  36. return http.DefaultTransport.RoundTrip(newReq)
  37. }
  38. func newTestGitHubReleaseClient() *githubReleaseClient {
  39. return &githubReleaseClient{
  40. httpClient: &http.Client{},
  41. downloadHTTPClient: &http.Client{},
  42. }
  43. }
  44. func TestGitHubReleaseClientAPIRequestAuthorization(t *testing.T) {
  45. tests := []struct {
  46. name string
  47. url string
  48. wantAuth string
  49. }{
  50. {name: "exact HTTPS authority", url: "https://api.github.com/repos/test/repo", wantAuth: "Bearer update-secret"},
  51. {name: "HTTP", url: "http://api.github.com/repos/test/repo"},
  52. {name: "subdomain", url: "https://sub.api.github.com/repos/test/repo"},
  53. {name: "userinfo", url: "https://user@api.github.com/repos/test/repo"},
  54. {name: "explicit default port", url: "https://api.github.com:443/repos/test/repo"},
  55. {name: "custom port", url: "https://api.github.com:8443/repos/test/repo"},
  56. {name: "different host", url: "https://github.com/test/repo"},
  57. }
  58. for _, tt := range tests {
  59. t.Run(tt.name, func(t *testing.T) {
  60. client := newTestGitHubReleaseClient()
  61. client.updateGitHubToken = "update-secret"
  62. req, err := client.newAPIRequest(context.Background(), tt.url)
  63. require.NoError(t, err)
  64. require.Equal(t, tt.wantAuth, req.Header.Get("Authorization"))
  65. })
  66. }
  67. client := newTestGitHubReleaseClient()
  68. req, err := client.newAPIRequest(context.Background(), "https://api.github.com/repos/test/repo")
  69. require.NoError(t, err)
  70. require.Empty(t, req.Header.Get("Authorization"))
  71. }
  72. func TestGitHubReleaseClientRedirectAuthorization(t *testing.T) {
  73. tests := []struct {
  74. name string
  75. url string
  76. wantAuth string
  77. }{
  78. {name: "same HTTPS authority", url: "https://api.github.com/redirected", wantAuth: "Bearer update-secret"},
  79. {name: "HTTP", url: "http://api.github.com/redirected"},
  80. {name: "subdomain", url: "https://sub.api.github.com/redirected"},
  81. {name: "userinfo", url: "https://user@api.github.com/redirected"},
  82. {name: "custom port", url: "https://api.github.com:8443/redirected"},
  83. {name: "different host", url: "https://example.com/redirected"},
  84. }
  85. checkRedirect := githubAPICheckRedirect(nil)
  86. for _, tt := range tests {
  87. t.Run(tt.name, func(t *testing.T) {
  88. req, err := http.NewRequest(http.MethodGet, tt.url, nil)
  89. require.NoError(t, err)
  90. req.Header.Set("Authorization", "Bearer update-secret")
  91. require.NoError(t, checkRedirect(req, nil))
  92. require.Equal(t, tt.wantAuth, req.Header.Get("Authorization"))
  93. })
  94. }
  95. }
  96. func TestGitHubReleaseClientDoesNotAuthorizeDownloads(t *testing.T) {
  97. client := newTestGitHubReleaseClient()
  98. client.updateGitHubToken = "update-secret"
  99. var headers []http.Header
  100. transport := githubReleaseRoundTripFunc(func(req *http.Request) (*http.Response, error) {
  101. headers = append(headers, req.Header.Clone())
  102. return &http.Response{
  103. StatusCode: http.StatusOK,
  104. Header: make(http.Header),
  105. Body: io.NopCloser(strings.NewReader("checksum")),
  106. Request: req,
  107. }, nil
  108. })
  109. client.httpClient.Transport = transport
  110. client.downloadHTTPClient.Transport = transport
  111. dest := filepath.Join(t.TempDir(), "asset")
  112. require.NoError(t, client.DownloadFile(context.Background(), "https://objects.githubusercontent.com/asset", dest, 100))
  113. _, err := client.FetchChecksumFile(context.Background(), "https://github.com/test/repo/releases/download/v1/checksums.txt")
  114. require.NoError(t, err)
  115. require.Len(t, headers, 2)
  116. for _, header := range headers {
  117. require.Empty(t, header.Get("Authorization"))
  118. }
  119. }
  120. type githubReleaseRoundTripFunc func(*http.Request) (*http.Response, error)
  121. func (f githubReleaseRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
  122. return f(req)
  123. }
  124. func (s *GitHubReleaseServiceSuite) SetupTest() {
  125. s.tempDir = s.T().TempDir()
  126. }
  127. func (s *GitHubReleaseServiceSuite) TearDownTest() {
  128. if s.srv != nil {
  129. s.srv.Close()
  130. s.srv = nil
  131. }
  132. }
  133. func (s *GitHubReleaseServiceSuite) TestDownloadFile_EnforcesMaxSize_ContentLength() {
  134. s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  135. w.Header().Set("Content-Length", "100")
  136. w.WriteHeader(http.StatusOK)
  137. _, _ = w.Write(bytes.Repeat([]byte("a"), 100))
  138. }))
  139. s.client = newTestGitHubReleaseClient()
  140. dest := filepath.Join(s.tempDir, "file1.bin")
  141. err := s.client.DownloadFile(context.Background(), s.srv.URL, dest, 10)
  142. require.Error(s.T(), err, "expected error for oversized download with Content-Length")
  143. _, statErr := os.Stat(dest)
  144. require.Error(s.T(), statErr, "expected file to not exist for rejected download")
  145. }
  146. func (s *GitHubReleaseServiceSuite) TestDownloadFile_EnforcesMaxSize_Chunked() {
  147. s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  148. // Force chunked encoding (unknown Content-Length) by flushing headers before writing.
  149. w.WriteHeader(http.StatusOK)
  150. if fl, ok := w.(http.Flusher); ok {
  151. fl.Flush()
  152. }
  153. for i := 0; i < 10; i++ {
  154. _, _ = w.Write(bytes.Repeat([]byte("b"), 10))
  155. if fl, ok := w.(http.Flusher); ok {
  156. fl.Flush()
  157. }
  158. }
  159. }))
  160. s.client = newTestGitHubReleaseClient()
  161. dest := filepath.Join(s.tempDir, "file2.bin")
  162. err := s.client.DownloadFile(context.Background(), s.srv.URL, dest, 10)
  163. require.Error(s.T(), err, "expected error for oversized chunked download")
  164. _, statErr := os.Stat(dest)
  165. require.Error(s.T(), statErr, "expected file to be cleaned up for oversized chunked download")
  166. }
  167. func (s *GitHubReleaseServiceSuite) TestDownloadFile_Success() {
  168. s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  169. w.WriteHeader(http.StatusOK)
  170. if fl, ok := w.(http.Flusher); ok {
  171. fl.Flush()
  172. }
  173. for i := 0; i < 10; i++ {
  174. _, _ = w.Write(bytes.Repeat([]byte("b"), 10))
  175. if fl, ok := w.(http.Flusher); ok {
  176. fl.Flush()
  177. }
  178. }
  179. }))
  180. s.client = newTestGitHubReleaseClient()
  181. dest := filepath.Join(s.tempDir, "file3.bin")
  182. err := s.client.DownloadFile(context.Background(), s.srv.URL, dest, 200)
  183. require.NoError(s.T(), err, "expected success")
  184. b, err := os.ReadFile(dest)
  185. require.NoError(s.T(), err, "read")
  186. require.True(s.T(), strings.HasPrefix(string(b), "b"), "downloaded content should start with 'b'")
  187. require.Len(s.T(), b, 100, "downloaded content length mismatch")
  188. }
  189. func (s *GitHubReleaseServiceSuite) TestDownloadFile_404() {
  190. s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  191. w.WriteHeader(http.StatusNotFound)
  192. }))
  193. s.client = newTestGitHubReleaseClient()
  194. dest := filepath.Join(s.tempDir, "notfound.bin")
  195. err := s.client.DownloadFile(context.Background(), s.srv.URL, dest, 100)
  196. require.Error(s.T(), err, "expected error for 404")
  197. _, statErr := os.Stat(dest)
  198. require.Error(s.T(), statErr, "expected file to not exist for 404")
  199. }
  200. func (s *GitHubReleaseServiceSuite) TestFetchChecksumFile_Success() {
  201. s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  202. w.WriteHeader(http.StatusOK)
  203. _, _ = w.Write([]byte("sum"))
  204. }))
  205. s.client = newTestGitHubReleaseClient()
  206. body, err := s.client.FetchChecksumFile(context.Background(), s.srv.URL)
  207. require.NoError(s.T(), err, "FetchChecksumFile")
  208. require.Equal(s.T(), "sum", string(body), "checksum body mismatch")
  209. }
  210. func (s *GitHubReleaseServiceSuite) TestFetchChecksumFile_Non200() {
  211. s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  212. w.WriteHeader(http.StatusInternalServerError)
  213. }))
  214. s.client = newTestGitHubReleaseClient()
  215. _, err := s.client.FetchChecksumFile(context.Background(), s.srv.URL)
  216. require.Error(s.T(), err, "expected error for non-200")
  217. }
  218. func (s *GitHubReleaseServiceSuite) TestDownloadFile_ContextCancel() {
  219. s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  220. <-r.Context().Done()
  221. }))
  222. s.client = newTestGitHubReleaseClient()
  223. ctx, cancel := context.WithCancel(context.Background())
  224. cancel()
  225. dest := filepath.Join(s.tempDir, "cancelled.bin")
  226. err := s.client.DownloadFile(ctx, s.srv.URL, dest, 100)
  227. require.Error(s.T(), err, "expected error for cancelled context")
  228. }
  229. func (s *GitHubReleaseServiceSuite) TestDownloadFile_InvalidURL() {
  230. s.client = newTestGitHubReleaseClient()
  231. dest := filepath.Join(s.tempDir, "invalid.bin")
  232. err := s.client.DownloadFile(context.Background(), "://invalid-url", dest, 100)
  233. require.Error(s.T(), err, "expected error for invalid URL")
  234. }
  235. func (s *GitHubReleaseServiceSuite) TestDownloadFile_InvalidDestPath() {
  236. s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  237. w.WriteHeader(http.StatusOK)
  238. _, _ = w.Write([]byte("content"))
  239. }))
  240. s.client = newTestGitHubReleaseClient()
  241. // Use a path that cannot be created (directory doesn't exist)
  242. dest := filepath.Join(s.tempDir, "nonexistent", "subdir", "file.bin")
  243. err := s.client.DownloadFile(context.Background(), s.srv.URL, dest, 100)
  244. require.Error(s.T(), err, "expected error for invalid destination path")
  245. }
  246. func (s *GitHubReleaseServiceSuite) TestFetchChecksumFile_InvalidURL() {
  247. s.client = newTestGitHubReleaseClient()
  248. _, err := s.client.FetchChecksumFile(context.Background(), "://invalid-url")
  249. require.Error(s.T(), err, "expected error for invalid URL")
  250. }
  251. func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_Success() {
  252. releaseJSON := `{
  253. "tag_name": "v1.0.0",
  254. "name": "Release 1.0.0",
  255. "body": "Release notes",
  256. "html_url": "https://github.com/test/repo/releases/v1.0.0",
  257. "assets": [
  258. {
  259. "name": "app-linux-amd64.tar.gz",
  260. "browser_download_url": "https://github.com/test/repo/releases/download/v1.0.0/app-linux-amd64.tar.gz"
  261. }
  262. ]
  263. }`
  264. s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  265. require.Equal(s.T(), "/repos/test/repo/releases/latest", r.URL.Path)
  266. require.Equal(s.T(), "application/vnd.github.v3+json", r.Header.Get("Accept"))
  267. require.Equal(s.T(), "Sub2API-Updater", r.Header.Get("User-Agent"))
  268. w.Header().Set("Content-Type", "application/json")
  269. w.WriteHeader(http.StatusOK)
  270. _, _ = w.Write([]byte(releaseJSON))
  271. }))
  272. // Use custom transport to redirect requests to test server
  273. s.client = &githubReleaseClient{
  274. httpClient: &http.Client{
  275. Transport: &testTransport{testServerURL: s.srv.URL},
  276. },
  277. downloadHTTPClient: &http.Client{},
  278. }
  279. release, err := s.client.FetchLatestRelease(context.Background(), "test/repo")
  280. require.NoError(s.T(), err)
  281. require.Equal(s.T(), "v1.0.0", release.TagName)
  282. require.Equal(s.T(), "Release 1.0.0", release.Name)
  283. require.Len(s.T(), release.Assets, 1)
  284. require.Equal(s.T(), "app-linux-amd64.tar.gz", release.Assets[0].Name)
  285. }
  286. func (s *GitHubReleaseServiceSuite) TestFetchRecentReleases_Success() {
  287. releasesJSON := `[
  288. {
  289. "tag_name": "v1.0.1",
  290. "name": "Release 1.0.1",
  291. "html_url": "https://github.com/test/repo/releases/v1.0.1",
  292. "published_at": "2026-07-08T00:00:00Z",
  293. "prerelease": false,
  294. "assets": [
  295. {
  296. "name": "app-linux-amd64.tar.gz",
  297. "browser_download_url": "https://github.com/test/repo/releases/download/v1.0.1/app-linux-amd64.tar.gz"
  298. }
  299. ]
  300. },
  301. {
  302. "tag_name": "v1.0.1-rc1",
  303. "name": "Release 1.0.1-rc1",
  304. "prerelease": true
  305. },
  306. {
  307. "tag_name": "v1.0.0",
  308. "name": "Release 1.0.0"
  309. }
  310. ]`
  311. s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  312. require.Equal(s.T(), "/repos/test/repo/releases", r.URL.Path)
  313. require.Equal(s.T(), "15", r.URL.Query().Get("per_page"))
  314. require.Equal(s.T(), "application/vnd.github.v3+json", r.Header.Get("Accept"))
  315. require.Equal(s.T(), "Sub2API-Updater", r.Header.Get("User-Agent"))
  316. w.Header().Set("Content-Type", "application/json")
  317. w.WriteHeader(http.StatusOK)
  318. _, _ = w.Write([]byte(releasesJSON))
  319. }))
  320. s.client = &githubReleaseClient{
  321. httpClient: &http.Client{
  322. Transport: &testTransport{testServerURL: s.srv.URL},
  323. },
  324. downloadHTTPClient: &http.Client{},
  325. }
  326. releases, err := s.client.FetchRecentReleases(context.Background(), "test/repo", 15)
  327. require.NoError(s.T(), err)
  328. require.Len(s.T(), releases, 3)
  329. require.Equal(s.T(), "v1.0.1", releases[0].TagName)
  330. require.False(s.T(), releases[0].Prerelease)
  331. require.Len(s.T(), releases[0].Assets, 1)
  332. require.True(s.T(), releases[1].Prerelease)
  333. require.Equal(s.T(), "v1.0.0", releases[2].TagName)
  334. }
  335. func (s *GitHubReleaseServiceSuite) TestFetchRecentReleases_Non200() {
  336. s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  337. w.WriteHeader(http.StatusForbidden)
  338. }))
  339. s.client = &githubReleaseClient{
  340. httpClient: &http.Client{
  341. Transport: &testTransport{testServerURL: s.srv.URL},
  342. },
  343. downloadHTTPClient: &http.Client{},
  344. }
  345. _, err := s.client.FetchRecentReleases(context.Background(), "test/repo", 15)
  346. require.Error(s.T(), err)
  347. require.Contains(s.T(), err.Error(), "403")
  348. }
  349. func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_Non200() {
  350. s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  351. w.WriteHeader(http.StatusNotFound)
  352. }))
  353. s.client = &githubReleaseClient{
  354. httpClient: &http.Client{
  355. Transport: &testTransport{testServerURL: s.srv.URL},
  356. },
  357. downloadHTTPClient: &http.Client{},
  358. }
  359. _, err := s.client.FetchLatestRelease(context.Background(), "test/repo")
  360. require.Error(s.T(), err)
  361. require.Contains(s.T(), err.Error(), "404")
  362. }
  363. func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_InvalidJSON() {
  364. s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  365. w.WriteHeader(http.StatusOK)
  366. _, _ = w.Write([]byte("not valid json"))
  367. }))
  368. s.client = &githubReleaseClient{
  369. httpClient: &http.Client{
  370. Transport: &testTransport{testServerURL: s.srv.URL},
  371. },
  372. downloadHTTPClient: &http.Client{},
  373. }
  374. _, err := s.client.FetchLatestRelease(context.Background(), "test/repo")
  375. require.Error(s.T(), err)
  376. }
  377. func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_ContextCancel() {
  378. s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  379. <-r.Context().Done()
  380. }))
  381. s.client = &githubReleaseClient{
  382. httpClient: &http.Client{
  383. Transport: &testTransport{testServerURL: s.srv.URL},
  384. },
  385. downloadHTTPClient: &http.Client{},
  386. }
  387. ctx, cancel := context.WithCancel(context.Background())
  388. cancel()
  389. _, err := s.client.FetchLatestRelease(ctx, "test/repo")
  390. require.Error(s.T(), err)
  391. }
  392. func (s *GitHubReleaseServiceSuite) TestFetchChecksumFile_ContextCancel() {
  393. s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  394. <-r.Context().Done()
  395. }))
  396. s.client = newTestGitHubReleaseClient()
  397. ctx, cancel := context.WithCancel(context.Background())
  398. cancel()
  399. _, err := s.client.FetchChecksumFile(ctx, s.srv.URL)
  400. require.Error(s.T(), err)
  401. }
  402. func TestGitHubReleaseServiceSuite(t *testing.T) {
  403. suite.Run(t, new(GitHubReleaseServiceSuite))
  404. }