Files
gitocean/tests/integration/api_repo_action_artifact_test.go
T
a85c527709 feat(api): add REST API endpoints for Actions artifacts (#12140)
## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests for Go changes

- I added test coverage for Go changes...
  - [ ] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `make pr-go` before pushing

### Tests for JavaScript changes

(not applicable — Go-only change)

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [ ] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

## Summary

Add public REST API endpoints under `/api/v1/` for listing, inspecting, downloading, and deleting Actions artifacts. Previously, artifacts could only be accessed through the web UI or the internal runner API.

### New endpoints

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/repos/{owner}/{repo}/actions/artifacts` | List all artifacts for a repository |
| `GET` | `/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts` | List artifacts for a workflow run |
| `GET` | `/repos/{owner}/{repo}/actions/artifacts/{artifact_id}` | Get artifact metadata |
| `GET` | `/repos/{owner}/{repo}/actions/artifacts/{artifact_id}/zip` | Download artifact as zip |
| `DELETE` | `/repos/{owner}/{repo}/actions/artifacts/{artifact_id}` | Delete an artifact |

- List endpoints support `page`, `limit`, and `name` query parameters
- Both v1-v3 (multi-file, zip on-the-fly) and v4 (single zip) artifact backends are supported
- Expired artifacts are listed with `expired: true` but cannot be downloaded
- Delete requires write permission; all other endpoints require read permission

Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net>
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12140
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
Co-authored-by: ShellWen <me@shellwen.com>
Co-committed-by: ShellWen <me@shellwen.com>
2026-04-20 05:10:54 +02:00

286 lines
10 KiB
Go

// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package integration
import (
"fmt"
"net/http"
"testing"
auth_model "forgejo.org/models/auth"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
api "forgejo.org/modules/structs"
"forgejo.org/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAPIListActionArtifacts(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
token := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeReadRepository)
t.Run("ListRepoArtifacts", func(t *testing.T) {
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
res := MakeRequest(t, req, http.StatusOK)
var entries []*api.ActionArtifact
DecodeJSON(t, res, &entries)
// Fixture has 2 logical artifacts with confirmed status:
// "multi-file-download" (run 791, ids 19+20) and "artifact-v4-download" (run 792, id 22)
assert.Equal(t, "2", res.Header().Get("X-Total-Count"))
require.Len(t, entries, 2)
names := make([]string, len(entries))
for i, a := range entries {
names[i] = a.Name
assert.False(t, a.Expired)
assert.NotZero(t, a.SizeInBytes)
assert.Contains(t, a.ArchiveDownloadURL, "/actions/artifacts/")
assert.Contains(t, a.ArchiveDownloadURL, "/zip")
}
assert.ElementsMatch(t, []string{"multi-file-download", "artifact-v4-download"}, names)
})
t.Run("ListRepoArtifactsWithNameFilter", func(t *testing.T) {
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts?name=multi-file-download", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
res := MakeRequest(t, req, http.StatusOK)
var entries []*api.ActionArtifact
DecodeJSON(t, res, &entries)
assert.Equal(t, "1", res.Header().Get("X-Total-Count"))
require.Len(t, entries, 1)
assert.Equal(t, "multi-file-download", entries[0].Name)
// multi-file-download has 2 rows of 1024 bytes each
assert.Equal(t, int64(2048), entries[0].SizeInBytes)
})
t.Run("ListRunArtifacts", func(t *testing.T) {
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/791/artifacts", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
res := MakeRequest(t, req, http.StatusOK)
var entries []*api.ActionArtifact
DecodeJSON(t, res, &entries)
// run 791 has only "multi-file-download" (id=1 is pending, not listed)
assert.Equal(t, "1", res.Header().Get("X-Total-Count"))
require.Len(t, entries, 1)
assert.Equal(t, "multi-file-download", entries[0].Name)
})
t.Run("ListRunArtifactsNotFound", func(t *testing.T) {
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/99999/artifacts", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
})
}
func TestAPIGetActionArtifact(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
token := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeReadRepository)
t.Run("GetV1V3Artifact", func(t *testing.T) {
// id=19 is the MIN(id) for "multi-file-download" group
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/19", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
res := MakeRequest(t, req, http.StatusOK)
var art api.ActionArtifact
DecodeJSON(t, res, &art)
assert.Equal(t, int64(19), art.ID)
assert.Equal(t, "multi-file-download", art.Name)
assert.Equal(t, int64(2048), art.SizeInBytes)
assert.Equal(t, int64(791), art.RunID)
assert.False(t, art.Expired)
})
t.Run("GetV4Artifact", func(t *testing.T) {
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/22", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
res := MakeRequest(t, req, http.StatusOK)
var art api.ActionArtifact
DecodeJSON(t, res, &art)
assert.Equal(t, int64(22), art.ID)
assert.Equal(t, "artifact-v4-download", art.Name)
assert.Equal(t, int64(1024), art.SizeInBytes)
assert.Equal(t, int64(792), art.RunID)
assert.False(t, art.Expired)
})
t.Run("GetNonCanonicalID", func(t *testing.T) {
// id=20 is part of "multi-file-download" but is NOT the MIN(id), so it should 404
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/20", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("GetPendingArtifact", func(t *testing.T) {
// id=1 has status=1 (upload pending), should not be accessible
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/1", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("GetNonExistent", func(t *testing.T) {
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/99999", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("GetFromWrongRepository", func(t *testing.T) {
// artifact id=22 belongs to user5/repo4; requesting it through a repo
// the caller can access but that doesn't own the artifact must 404 —
// this is the load-bearing check that caller-side RepoID was replaced
// by a query-side constraint.
otherRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
otherUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: otherRepo.OwnerID})
otherToken := getUserToken(t, otherUser.LowerName, auth_model.AccessTokenScopeReadRepository)
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/22", otherRepo.OwnerName, otherRepo.Name),
)
req.AddTokenAuth(otherToken)
MakeRequest(t, req, http.StatusNotFound)
})
}
func TestAPIActionArtifactsRequireRepoScope(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
wrongScopeToken := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeReadNotification)
endpoints := []struct {
name string
path string
}{
{"list repo", fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts", repo.OwnerName, repo.Name)},
{"list run", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/791/artifacts", repo.OwnerName, repo.Name)},
{"get", fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/19", repo.OwnerName, repo.Name)},
{"download", fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/19/zip", repo.OwnerName, repo.Name)},
}
for _, ep := range endpoints {
t.Run(ep.name, func(t *testing.T) {
req := NewRequest(t, http.MethodGet, ep.path)
req.AddTokenAuth(wrongScopeToken)
MakeRequest(t, req, http.StatusForbidden)
})
}
}
func TestAPIDownloadActionArtifact(t *testing.T) {
defer tests.PrepareTestEnv(t)()
tests.PrepareArtifactsStorage(t)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
token := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeReadRepository)
t.Run("DownloadV1V3Artifact", func(t *testing.T) {
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/19/zip", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
res := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, res.Header().Get("Content-Disposition"), "multi-file-download.zip")
})
t.Run("DownloadV4Artifact", func(t *testing.T) {
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/22/zip", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
res := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "bytes", res.Header().Get("Accept-Ranges"))
})
t.Run("DownloadPendingArtifact", func(t *testing.T) {
req := NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/1/zip", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
})
}
func TestAPIDeleteActionArtifact(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
t.Run("DeleteRequiresWritePermission", func(t *testing.T) {
readToken := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeReadRepository)
req := NewRequest(t, http.MethodDelete,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/22", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(readToken)
MakeRequest(t, req, http.StatusForbidden)
})
t.Run("DeleteArtifact", func(t *testing.T) {
writeToken := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeWriteRepository)
req := NewRequest(t, http.MethodDelete,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/22", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(writeToken)
MakeRequest(t, req, http.StatusNoContent)
// Verify the artifact is no longer accessible
readToken := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeReadRepository)
req = NewRequest(t, http.MethodGet,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/22", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(readToken)
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("DeleteNonExistent", func(t *testing.T) {
writeToken := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeWriteRepository)
req := NewRequest(t, http.MethodDelete,
fmt.Sprintf("/api/v1/repos/%s/%s/actions/artifacts/99999", repo.OwnerName, repo.Name),
)
req.AddTokenAuth(writeToken)
MakeRequest(t, req, http.StatusNotFound)
})
}