1
0
Fork 0
mirror of https://codeberg.org/forgejo/forgejo.git synced 2025-10-10 19:32:02 +00:00

Merge branch 'forgejo' into port-32327

This commit is contained in:
Maxim Slipenko 2025-06-09 10:58:11 +02:00
commit 826d4c7c2f
32 changed files with 885 additions and 85 deletions

View file

@ -28,7 +28,7 @@ jobs:
runs-on: docker
container:
image: data.forgejo.org/renovate/renovate:40.40.0
image: data.forgejo.org/renovate/renovate:40.48.4
steps:
- name: Load renovate repo cache

View file

@ -48,7 +48,7 @@ GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1 # renovate: datasour
DEADCODE_PACKAGE ?= golang.org/x/tools/cmd/deadcode@v0.34.0 # renovate: datasource=go
GOMOCK_PACKAGE ?= go.uber.org/mock/mockgen@v0.5.2 # renovate: datasource=go
GOPLS_PACKAGE ?= golang.org/x/tools/gopls@v0.18.1 # renovate: datasource=go
RENOVATE_NPM_PACKAGE ?= renovate@40.40.0 # renovate: datasource=docker packageName=data.forgejo.org/renovate/renovate
RENOVATE_NPM_PACKAGE ?= renovate@40.48.4 # renovate: datasource=docker packageName=data.forgejo.org/renovate/renovate
# https://github.com/disposable-email-domains/disposable-email-domains/commits/main/
DISPOSABLE_EMAILS_SHA ?= 0c27e671231d27cf66370034d7f6818037416989 # renovate: ...

View file

@ -408,7 +408,7 @@ local addIssueLabelsOverrides(labels) =
regex: '',
type: 'query',
multi: true,
allValue: '.+'
allValue: '.+',
},
)
.addTemplate(
@ -423,7 +423,7 @@ local addIssueLabelsOverrides(labels) =
regex: '',
type: 'query',
multi: true,
allValue: '.+'
allValue: '.+',
},
)
.addTemplate(

View file

@ -16,6 +16,7 @@ import (
repo_model "forgejo.org/models/repo"
"forgejo.org/models/shared/types"
user_model "forgejo.org/models/user"
"forgejo.org/modules/log"
"forgejo.org/modules/optional"
"forgejo.org/modules/timeutil"
"forgejo.org/modules/translation"
@ -353,3 +354,53 @@ func FixRunnersWithoutBelongingRepo(ctx context.Context) (int64, error) {
}
return res.RowsAffected()
}
func DeleteOfflineRunners(ctx context.Context, olderThan timeutil.TimeStamp, globalOnly bool) error {
log.Info("Doing: DeleteOfflineRunners")
if olderThan.AsTime().After(timeutil.TimeStampNow().AddDuration(-RunnerOfflineTime).AsTime()) {
return fmt.Errorf("invalid `cron.cleanup_offline_runners.older_than`value: must be at least %q", RunnerOfflineTime)
}
cond := builder.Or(
// never online
builder.And(builder.Eq{"last_online": 0}, builder.Lt{"created": olderThan}),
// was online but offline
builder.And(builder.Gt{"last_online": 0}, builder.Lt{"last_online": olderThan}),
)
if globalOnly {
cond = builder.And(cond, builder.Eq{"owner_id": 0}, builder.Eq{"repo_id": 0})
}
if err := db.Iterate(
ctx,
cond,
func(ctx context.Context, r *ActionRunner) error {
if err := DeleteRunner(ctx, r); err != nil {
return fmt.Errorf("DeleteOfflineRunners: %w", err)
}
lastOnline := r.LastOnline.AsTime()
olderThanTime := olderThan.AsTime()
if !lastOnline.IsZero() && lastOnline.Before(olderThanTime) {
log.Info(
"Deleted runner [ID: %d, Name: %s], last online %s ago",
r.ID, r.Name, olderThanTime.Sub(lastOnline).String(),
)
} else {
log.Info(
"Deleted runner [ID: %d, Name: %s], unused since %s ago",
r.ID, r.Name, olderThanTime.Sub(r.Created.AsTime()).String(),
)
}
return nil
},
); err != nil {
return err
}
log.Info("Finished: DeleteOfflineRunners")
return nil
}

View file

@ -6,10 +6,12 @@ import (
"encoding/binary"
"fmt"
"testing"
"time"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/db"
"forgejo.org/models/unittest"
"forgejo.org/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -73,3 +75,68 @@ func TestDeleteRunner(t *testing.T) {
idAsBinary[6], idAsBinary[7])
assert.Equal(t, idAsHexadecimal, after.UUID[19:])
}
func TestDeleteOfflineRunnersRunnerGlobalOnly(t *testing.T) {
baseTime := time.Date(2024, 5, 19, 7, 40, 32, 0, time.UTC)
timeutil.MockSet(baseTime)
defer timeutil.MockUnset()
require.NoError(t, unittest.PrepareTestDatabase())
olderThan := timeutil.TimeStampNow().Add(-timeutil.Hour)
require.NoError(t, DeleteOfflineRunners(db.DefaultContext, olderThan, true))
// create at test base time
unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 12345678})
// last_online test base time
unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000001})
// created one month ago but a repo
unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000002})
// last online one hour ago
unittest.AssertNotExistsBean(t, &ActionRunner{ID: 10000003})
// last online 10 seconds ago
unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000004})
// created 1 month ago
unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000005})
// created 1 hour ago
unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000006})
// last online 1 hour ago
unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000007})
}
func TestDeleteOfflineRunnersAll(t *testing.T) {
baseTime := time.Date(2024, 5, 19, 7, 40, 32, 0, time.UTC)
timeutil.MockSet(baseTime)
defer timeutil.MockUnset()
require.NoError(t, unittest.PrepareTestDatabase())
olderThan := timeutil.TimeStampNow().Add(-timeutil.Hour)
require.NoError(t, DeleteOfflineRunners(db.DefaultContext, olderThan, false))
// create at test base time
unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 12345678})
// last_online test base time
unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000001})
// created one month ago
unittest.AssertNotExistsBean(t, &ActionRunner{ID: 10000002})
// last online one hour ago
unittest.AssertNotExistsBean(t, &ActionRunner{ID: 10000003})
// last online 10 seconds ago
unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000004})
// created 1 month ago
unittest.AssertNotExistsBean(t, &ActionRunner{ID: 10000005})
// created 1 hour ago
unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000006})
// last online 1 hour ago
unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000007})
}
func TestDeleteOfflineRunnersErrorOnInvalidOlderThanValue(t *testing.T) {
baseTime := time.Date(2024, 5, 19, 7, 40, 32, 0, time.UTC)
timeutil.MockSet(baseTime)
defer timeutil.MockUnset()
require.Error(t, DeleteOfflineRunners(db.DefaultContext, timeutil.TimeStampNow(), false))
}

View file

@ -18,3 +18,122 @@
created: 1716104432
updated: 1716104432
deleted: ~
- id: 10000001
uuid: 10d3b248-6460-4bf5-b819-1f5b3109e10f
name: global-online
version: v6.3.1+7-gc4c0ca0
owner_id: 0
repo_id: 0
description: ""
base: 0
repo_range: ""
token_hash: 7e9ed71f64e98ce1f70e94c63f3cb6c41a8cb0b90de3e1daf7ec5c35361d60ed44da67c5ac393b7aaf443dcfc766007dc828
token_salt: WUcgZWl7mW
last_online: 1716104422
last_active: 0
agent_labels: '["docker"]'
created: 1716104431
updated: 1716104422
deleted: ~
- id: 10000002
uuid: 1d188484-dd97-4a70-b707-5e87b578ab6b
name: repo-never-used
version: v6.3.1+7-gc4c0ca0
owner_id: 0
repo_id: 1
description: ""
base: 0
repo_range: ""
token_hash: 51e88c17ac8b54dd101dc2e4f530a71643c703adba7170f4b1a28f1cb483b4cfb107798c521e0532ef3c6480b64518a5c6a5
token_salt: 4rh8ncXYIO
last_online: 0
last_active: 0
agent_labels: '["docker"]'
created: 1713512432
updated: 1713512432
deleted: ~
- id: 10000003
uuid: 7a039c6b-b0b2-4cf5-a93d-715d617f99e2
name: global-offline
version: v6.3.1+7-gc4c0ca0
owner_id: 0
repo_id: 0
description: ""
base: 0
repo_range: ""
token_hash: c76960c56bc6069f0d1648991ec626500abe8c15286f5c355d565c3b5ba945d7d6f1272a6c77849e592528179511b94f5d69
token_salt: TFMe2jhOkB
last_online: 1715499632
last_active: 0
agent_labels: '["docker"]'
created: 1715499632
updated: 1715499632
deleted: ~
- id: 10000004
uuid: 93ca7fdd-faca-4df6-a474-8345263ef10b
name: user-online
version: v6.3.1+7-gc4c0ca0
owner_id: 1
repo_id: 0
description: ""
base: 0
repo_range: ""
token_hash: 6ddf7f0f2301d2b3f66418145dc497a6d09fa6586e659afcb5ae2a0c5b639561d795aff8062537db9df73b396842ea826134
token_salt: QcdGuReAp4
last_online: 1716104422
last_active: 0
agent_labels: '["docker"]'
created: 1716104431
updated: 1716104422
deleted: ~
- id: 10000005
uuid: a8534df6-c4be-40f4-9714-903b69d973d9
name: user-never-used
version: v6.3.1+7-gc4c0ca0
owner_id: 1
repo_id: 0
description: desc
base: 0
repo_range: ""
token_hash: 4441de7defcfc3d21baa608dec66a562cf23307abddaabdbb836907ac5f48c8780c354891916c525b79ec7af8e95be7a09b4
token_salt: ONNqIOnj3t
last_online: 0
last_active: 0
agent_labels: '["docker"]'
created: 1713512433
updated: 1713512433
deleted: ~
- id: 10000006
uuid: e1c5bb6c-de68-4335-8955-5192f76708ac
name: orga-fresh-created
version: v6.3.1+7-gc4c0ca0
owner_id: 35
repo_id: 0
description: ""
base: 0
repo_range: ""
token_hash: a61f9ee48c6847d243ace0a8936efe80af9277c7bc46d6da6e03d1d406608b8023ee66600ad24f0effaa8e3338f92ac97ac9
token_salt: fZJKjrFGWA
last_online: 0
last_active: 0
agent_labels: '["docker"]'
created: 1716100832
updated: 1716100832
deleted: ~
- id: 10000007
uuid: ff755f06-948e-479b-8031-5b3e9f123e32
name: orga-offline
version: v6.3.1+7-gc4c0ca0
owner_id: 35
repo_id: 0
description: ""
base: 0
repo_range: ""
token_hash: 9372efb38f9b64efe65065380abe2f24ef34a59d9619f4cdc08f1151e9849f0b6e722aa10538e8730288de6e2f09acdac695
token_salt: TnU7iiIdCb
last_online: 1716100832
last_active: 0
agent_labels: '["docker"]'
created: 1736085520
updated: 1716100832
deleted: ~

View file

@ -239,3 +239,15 @@
num_members: 2
includes_all_repositories: false
can_create_org_repo: false
-
id: 25
org_id: 17
lower_name: super-user
name: super-user
description: ""
authorize: 3
num_repos: 0
num_members: 0
includes_all_repositories: 0
can_create_org_repo: 0

View file

@ -329,3 +329,10 @@
team_id: 22
type: 3
access_mode: 1
-
id: 84
org_id: 17
team_id: 25
type: 3
access_mode: 3

View file

@ -642,7 +642,7 @@
num_following: 0
num_stars: 0
num_repos: 2
num_teams: 3
num_teams: 4
num_members: 4
visibility: 0
repo_admin_change_team_access: false

View file

@ -235,7 +235,7 @@ func UpdateAttachmentByUUID(ctx context.Context, attach *Attachment, cols ...str
if attach.UUID == "" {
return errors.New("attachment uuid should be not blank")
}
if attach.ExternalURL != "" && !validation.IsValidExternalURL(attach.ExternalURL) {
if attach.ExternalURL != "" && !validation.IsValidReleaseAssetURL(attach.ExternalURL) {
return ErrInvalidExternalURL{ExternalURL: attach.ExternalURL}
}
_, err := db.GetEngine(ctx).Where("uuid=?", attach.UUID).Cols(cols...).Update(attach)
@ -244,7 +244,7 @@ func UpdateAttachmentByUUID(ctx context.Context, attach *Attachment, cols ...str
// UpdateAttachment updates the given attachment in database
func UpdateAttachment(ctx context.Context, atta *Attachment) error {
if atta.ExternalURL != "" && !validation.IsValidExternalURL(atta.ExternalURL) {
if atta.ExternalURL != "" && !validation.IsValidReleaseAssetURL(atta.ExternalURL) {
return ErrInvalidExternalURL{ExternalURL: atta.ExternalURL}
}
sess := db.GetEngine(ctx).Cols("name", "issue_id", "release_id", "comment_id", "download_count")

View file

@ -35,6 +35,8 @@ type ServeHeaderOptions struct {
Filename string
CacheDuration time.Duration // defaults to 5 minutes
LastModified time.Time
AdditionalHeaders http.Header
RedirectStatusCode int
}
// ServeSetHeaders sets necessary content serve headers
@ -82,6 +84,12 @@ func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) {
// http.TimeFormat required a UTC time, refer to https://pkg.go.dev/net/http#TimeFormat
header.Set("Last-Modified", opts.LastModified.UTC().Format(http.TimeFormat))
}
if opts.AdditionalHeaders != nil {
for k, v := range opts.AdditionalHeaders {
header[k] = v
}
}
}
// ServeData download file from io.Reader

View file

@ -84,6 +84,13 @@ func ParseImageConfig(mt string, r io.Reader) (*Metadata, error) {
func parseOCIImageConfig(r io.Reader) (*Metadata, error) {
var image oci.Image
if err := json.NewDecoder(r).Decode(&image); err != nil {
// Handle empty config blobs (common in OCI artifacts)
if err == io.EOF {
return &Metadata{
Type: TypeOCI,
Platform: DefaultPlatform,
}, nil
}
return nil, err
}

View file

@ -4,6 +4,7 @@
package container
import (
"io"
"strings"
"testing"
@ -60,3 +61,49 @@ func TestParseImageConfig(t *testing.T) {
assert.Equal(t, projectURL, metadata.ProjectURL)
assert.Equal(t, repositoryURL, metadata.RepositoryURL)
}
func TestParseImageConfigEmptyBlob(t *testing.T) {
t.Run("Empty config blob (EOF)", func(t *testing.T) {
// Test empty reader (simulates empty config blob common in OCI artifacts)
metadata, err := ParseImageConfig(oci.MediaTypeImageManifest, strings.NewReader(""))
require.NoError(t, err)
assert.Equal(t, TypeOCI, metadata.Type)
assert.Equal(t, DefaultPlatform, metadata.Platform)
assert.Empty(t, metadata.Description)
assert.Empty(t, metadata.Authors)
assert.Empty(t, metadata.Labels)
assert.Empty(t, metadata.Manifests)
})
t.Run("Empty JSON object", func(t *testing.T) {
// Test minimal valid JSON config
metadata, err := ParseImageConfig(oci.MediaTypeImageManifest, strings.NewReader("{}"))
require.NoError(t, err)
assert.Equal(t, TypeOCI, metadata.Type)
assert.Equal(t, DefaultPlatform, metadata.Platform)
assert.Empty(t, metadata.Description)
assert.Empty(t, metadata.Authors)
})
t.Run("Invalid JSON still returns error", func(t *testing.T) {
// Test that actual JSON errors (not EOF) are still returned
_, err := ParseImageConfig(oci.MediaTypeImageManifest, strings.NewReader("{invalid json"))
require.Error(t, err)
assert.NotEqual(t, io.EOF, err)
})
t.Run("OCI artifact with empty config", func(t *testing.T) {
// Test OCI artifact scenario with minimal config
configOCI := `{"config": {}}`
metadata, err := ParseImageConfig(oci.MediaTypeImageManifest, strings.NewReader(configOCI))
require.NoError(t, err)
assert.Equal(t, TypeOCI, metadata.Type)
assert.Equal(t, DefaultPlatform, metadata.Platform)
assert.Empty(t, metadata.Description)
assert.Empty(t, metadata.Authors)
assert.Empty(t, metadata.ImageLayers)
})
}

View file

@ -75,6 +75,11 @@ func IsValidExternalURL(uri string) bool {
return true
}
// IsValidReleaseAssetURL checks if the URL is valid for external release assets
func IsValidReleaseAssetURL(uri string) bool {
return IsValidURL(uri)
}
// IsValidExternalTrackerURLFormat checks if URL matches required syntax for external trackers
func IsValidExternalTrackerURLFormat(uri string) bool {
if !IsValidExternalURL(uri) {

View file

@ -2985,8 +2985,6 @@ teams.invite_team_member.list = Pending invitations
teams.delete_team_title = Delete team
teams.delete_team_desc = Deleting a team revokes repository access from its members. Continue?
teams.delete_team_success = The team has been deleted.
teams.read_permission_desc = This team grants <strong>Read</strong> access: members can view and clone team repositories.
teams.write_permission_desc = This team grants <strong>Write</strong> access: members can read from and push to team repositories.
teams.admin_permission_desc = This team grants <strong>Administrator</strong> access: members can read from, push to and add collaborators to team repositories.
teams.create_repo_permission_desc = Additionally, this team grants <strong>Create repository</strong> permission: members can create new repositories in organization.
teams.repositories = Team repositories

View file

@ -92,5 +92,6 @@
"discussion.locked": "This discussion has been locked. Commenting is limited to contributors.",
"editor.textarea.tab_hint": "Line already indented. Press <kbd>Tab</kbd> again or <kbd>Escape</kbd> to leave the editor.",
"editor.textarea.shift_tab_hint": "No indentation on this line. Press <kbd>Shift</kbd> + <kbd>Tab</kbd> again or <kbd>Escape</kbd> to leave the editor.",
"admin.dashboard.cleanup_offline_runners": "Cleanup offline runners",
"meta.last_line": "Thank you for translating Forgejo! This line isn't seen by the users but it serves other purposes in the translation management. You can place a fun fact in the translation instead of translating it."
}

View file

@ -4,6 +4,7 @@
package container
import (
"bytes"
"errors"
"fmt"
"io"
@ -62,9 +63,6 @@ func setResponseHeaders(resp http.ResponseWriter, h *containerHeaders) {
if h.ContentType != "" {
resp.Header().Set("Content-Type", h.ContentType)
}
if h.ContentLength != 0 {
resp.Header().Set("Content-Length", strconv.FormatInt(h.ContentLength, 10))
}
if h.UploadUUID != "" {
resp.Header().Set("Docker-Upload-Uuid", h.UploadUUID)
}
@ -72,17 +70,29 @@ func setResponseHeaders(resp http.ResponseWriter, h *containerHeaders) {
resp.Header().Set("Docker-Content-Digest", h.ContentDigest)
resp.Header().Set("ETag", fmt.Sprintf(`"%s"`, h.ContentDigest))
}
if h.ContentLength >= 0 {
resp.Header().Set("Content-Length", strconv.FormatInt(h.ContentLength, 10))
}
resp.Header().Set("Docker-Distribution-Api-Version", "registry/2.0")
resp.WriteHeader(h.Status)
}
func jsonResponse(ctx *context.Context, status int, obj any) {
setResponseHeaders(ctx.Resp, &containerHeaders{
Status: status,
ContentType: "application/json",
})
if err := json.NewEncoder(ctx.Resp).Encode(obj); err != nil {
// Buffer the JSON content first to calculate correct Content-Length
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(obj); err != nil {
log.Error("JSON encode: %v", err)
return
}
setResponseHeaders(ctx.Resp, &containerHeaders{
Status: status,
ContentType: "application/json",
ContentLength: int64(buf.Len()),
})
if _, err := buf.WriteTo(ctx.Resp); err != nil {
log.Error("JSON write: %v", err)
}
}
@ -691,33 +701,30 @@ func DeleteManifest(ctx *context.Context) {
func serveBlob(ctx *context.Context, pfd *packages_model.PackageFileDescriptor) {
serveDirectReqParams := make(url.Values)
serveDirectReqParams.Set("response-content-type", pfd.Properties.GetByName(container_module.PropertyMediaType))
s, u, _, err := packages_service.GetPackageBlobStream(ctx, pfd.File, pfd.Blob, serveDirectReqParams)
s, u, pf, err := packages_service.GetPackageBlobStream(ctx, pfd.File, pfd.Blob, serveDirectReqParams)
if err != nil {
if errors.Is(err, packages_model.ErrPackageFileNotExist) {
apiError(ctx, http.StatusNotFound, err)
return
}
apiError(ctx, http.StatusInternalServerError, err)
return
}
headers := &containerHeaders{
ContentDigest: pfd.Properties.GetByName(container_module.PropertyDigest),
ContentType: pfd.Properties.GetByName(container_module.PropertyMediaType),
ContentLength: pfd.Blob.Size,
Status: http.StatusOK,
opts := &context.ServeHeaderOptions{
ContentType: pfd.Properties.GetByName(container_module.PropertyMediaType),
RedirectStatusCode: http.StatusTemporaryRedirect,
AdditionalHeaders: map[string][]string{
"Docker-Distribution-Api-Version": {"registry/2.0"},
},
}
if u != nil {
headers.Status = http.StatusTemporaryRedirect
headers.Location = u.String()
setResponseHeaders(ctx.Resp, headers)
return
if d := pfd.Properties.GetByName(container_module.PropertyDigest); d != "" {
opts.AdditionalHeaders["Docker-Content-Digest"] = []string{d}
opts.AdditionalHeaders["ETag"] = []string{fmt.Sprintf(`"%s"`, d)}
}
defer s.Close()
setResponseHeaders(ctx.Resp, headers)
if _, err := io.Copy(ctx.Resp, s); err != nil {
log.Error("Error whilst copying content to response: %v", err)
}
helper.ServePackageFile(ctx, s, u, pf, opts)
}
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#content-discovery
@ -725,7 +732,7 @@ func GetTagList(ctx *context.Context) {
image := ctx.Params("image")
if _, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeContainer, image); err != nil {
if err == packages_model.ErrPackageNotExist {
if errors.Is(err, packages_model.ErrPackageNotExist) {
apiErrorDefined(ctx, errNameUnknown)
} else {
apiError(ctx, http.StatusInternalServerError, err)

View file

@ -0,0 +1,124 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSetResponseHeaders(t *testing.T) {
t.Run("Content-Length for empty content", func(t *testing.T) {
recorder := httptest.NewRecorder()
setResponseHeaders(recorder, &containerHeaders{
Status: http.StatusOK,
ContentLength: 0, // Empty blob
ContentDigest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
})
assert.Equal(t, "0", recorder.Header().Get("Content-Length"))
assert.Equal(t, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", recorder.Header().Get("Docker-Content-Digest"))
assert.Equal(t, "registry/2.0", recorder.Header().Get("Docker-Distribution-Api-Version"))
assert.Equal(t, http.StatusOK, recorder.Code)
})
t.Run("Content-Length for non-empty content", func(t *testing.T) {
recorder := httptest.NewRecorder()
setResponseHeaders(recorder, &containerHeaders{
Status: http.StatusOK,
ContentLength: 1024,
ContentDigest: "sha256:abcd1234",
})
assert.Equal(t, "1024", recorder.Header().Get("Content-Length"))
assert.Equal(t, "sha256:abcd1234", recorder.Header().Get("Docker-Content-Digest"))
})
t.Run("All headers set correctly", func(t *testing.T) {
recorder := httptest.NewRecorder()
setResponseHeaders(recorder, &containerHeaders{
Status: http.StatusAccepted,
ContentLength: 512,
ContentDigest: "sha256:test123",
ContentType: "application/vnd.oci.image.manifest.v1+json",
Location: "/v2/test/repo/blobs/uploads/uuid123",
Range: "0-511",
UploadUUID: "uuid123",
})
assert.Equal(t, "512", recorder.Header().Get("Content-Length"))
assert.Equal(t, "sha256:test123", recorder.Header().Get("Docker-Content-Digest"))
assert.Equal(t, "application/vnd.oci.image.manifest.v1+json", recorder.Header().Get("Content-Type"))
assert.Equal(t, "/v2/test/repo/blobs/uploads/uuid123", recorder.Header().Get("Location"))
assert.Equal(t, "0-511", recorder.Header().Get("Range"))
assert.Equal(t, "uuid123", recorder.Header().Get("Docker-Upload-Uuid"))
assert.Equal(t, "registry/2.0", recorder.Header().Get("Docker-Distribution-Api-Version"))
assert.Equal(t, `"sha256:test123"`, recorder.Header().Get("ETag"))
assert.Equal(t, http.StatusAccepted, recorder.Code)
})
}
// TestResponseHeadersForEmptyBlobs tests the core fix for ORAS empty blob support
func TestResponseHeadersForEmptyBlobs(t *testing.T) {
t.Run("Content-Length set for empty blob", func(t *testing.T) {
recorder := httptest.NewRecorder()
// This tests the main fix: empty blobs should have Content-Length: 0
setResponseHeaders(recorder, &containerHeaders{
Status: http.StatusOK,
ContentLength: 0, // Empty blob (like empty config in ORAS artifacts)
ContentDigest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
})
// The key fix: Content-Length should be set even for 0-byte blobs
assert.Equal(t, "0", recorder.Header().Get("Content-Length"))
assert.Equal(t, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", recorder.Header().Get("Docker-Content-Digest"))
assert.Equal(t, "registry/2.0", recorder.Header().Get("Docker-Distribution-Api-Version"))
assert.Equal(t, `"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"`, recorder.Header().Get("ETag"))
assert.Equal(t, http.StatusOK, recorder.Code)
})
t.Run("Content-Length set for regular blob", func(t *testing.T) {
recorder := httptest.NewRecorder()
setResponseHeaders(recorder, &containerHeaders{
Status: http.StatusOK,
ContentLength: 1024,
ContentDigest: "sha256:abcd1234",
})
assert.Equal(t, "1024", recorder.Header().Get("Content-Length"))
assert.Equal(t, "sha256:abcd1234", recorder.Header().Get("Docker-Content-Digest"))
})
t.Run("All headers set correctly", func(t *testing.T) {
recorder := httptest.NewRecorder()
setResponseHeaders(recorder, &containerHeaders{
Status: http.StatusAccepted,
ContentLength: 512,
ContentDigest: "sha256:test123",
ContentType: "application/vnd.oci.image.manifest.v1+json",
Location: "/v2/test/repo/blobs/uploads/uuid123",
Range: "0-511",
UploadUUID: "uuid123",
})
assert.Equal(t, "512", recorder.Header().Get("Content-Length"))
assert.Equal(t, "sha256:test123", recorder.Header().Get("Docker-Content-Digest"))
assert.Equal(t, "application/vnd.oci.image.manifest.v1+json", recorder.Header().Get("Content-Type"))
assert.Equal(t, "/v2/test/repo/blobs/uploads/uuid123", recorder.Header().Get("Location"))
assert.Equal(t, "0-511", recorder.Header().Get("Range"))
assert.Equal(t, "uuid123", recorder.Header().Get("Docker-Upload-Uuid"))
assert.Equal(t, "registry/2.0", recorder.Header().Get("Docker-Distribution-Api-Version"))
assert.Equal(t, `"sha256:test123"`, recorder.Header().Get("ETag"))
assert.Equal(t, http.StatusAccepted, recorder.Code)
})
}

View file

@ -39,16 +39,9 @@ func LogAndProcessError(ctx *context.Context, status int, obj any, cb func(strin
}
}
// Serves the content of the package file
// ServePackageFile Serves the content of the package file
// If the url is set it will redirect the request, otherwise the content is copied to the response.
func ServePackageFile(ctx *context.Context, s io.ReadSeekCloser, u *url.URL, pf *packages_model.PackageFile, forceOpts ...*context.ServeHeaderOptions) {
if u != nil {
ctx.Redirect(u.String())
return
}
defer s.Close()
var opts *context.ServeHeaderOptions
if len(forceOpts) > 0 {
opts = forceOpts[0]
@ -59,5 +52,12 @@ func ServePackageFile(ctx *context.Context, s io.ReadSeekCloser, u *url.URL, pf
}
}
if u != nil {
ctx.Redirect(u.String(), opts.RedirectStatusCode)
return
}
defer s.Close()
ctx.ServeContent(s, opts)
}

View file

@ -126,3 +126,9 @@ func CleanupLogs(ctx context.Context) error {
log.Info("Removed %d logs", count)
return nil
}
// CleanupOfflineRunners removes offline runners
func CleanupOfflineRunners(ctx context.Context, duration time.Duration, globalOnly bool) error {
olderThan := timeutil.TimeStampNow().AddDuration(-duration)
return actions_model.DeleteOfflineRunners(ctx, olderThan, globalOnly)
}

View file

@ -51,7 +51,7 @@ func NewExternalAttachment(ctx context.Context, attach *repo_model.Attachment) (
if attach.ExternalURL == "" {
return nil, fmt.Errorf("attachment %s should have a external url", attach.Name)
}
if !validation.IsValidExternalURL(attach.ExternalURL) {
if !validation.IsValidReleaseAssetURL(attach.ExternalURL) {
return nil, repo_model.ErrInvalidExternalURL{ExternalURL: attach.ExternalURL}
}

View file

@ -250,7 +250,7 @@ func (b *Base) PlainText(status int, text string) {
// Redirect redirects the request
func (b *Base) Redirect(location string, status ...int) {
code := http.StatusSeeOther
if len(status) == 1 {
if len(status) == 1 && status[0] > 0 {
code = status[0]
}

View file

@ -36,6 +36,7 @@ func TestRedirect(t *testing.T) {
cleanup()
has := resp.Header().Get("Set-Cookie") == "i_like_gitea=dummy"
assert.Equal(t, c.keep, has, "url = %q", c.url)
assert.Equal(t, http.StatusSeeOther, resp.Code)
}
req, _ = http.NewRequest("GET", "/", nil)
@ -47,3 +48,24 @@ func TestRedirect(t *testing.T) {
assert.Equal(t, "/other", resp.Header().Get("HX-Redirect"))
assert.Equal(t, http.StatusNoContent, resp.Code)
}
func TestRedirectOptionalStatus(t *testing.T) {
defer test.MockVariableValue(&setting.AppURL, "http://localhost:3000/")()
req, _ := http.NewRequest("GET", "/", nil)
cases := []struct {
expected int
actual int
}{
{expected: 303},
{http.StatusTemporaryRedirect, 307},
{http.StatusPermanentRedirect, 308},
}
for _, c := range cases {
resp := httptest.NewRecorder()
b, cleanup := NewBaseContext(resp, req)
b.Redirect("/", c.actual)
cleanup()
assert.Equal(t, c.expected, resp.Code)
}
}

View file

@ -46,6 +46,13 @@ type CleanupHookTaskConfig struct {
NumberToKeep int
}
// CleanupOfflineRunnersConfig represents a cron task with settings to clean up offline-runner
type CleanupOfflineRunnersConfig struct {
BaseConfig
OlderThan time.Duration
GlobalScopeOnly bool
}
// GetSchedule returns the schedule for the base config
func (b *BaseConfig) GetSchedule() string {
return b.Schedule

View file

@ -5,6 +5,7 @@ package cron
import (
"context"
"time"
user_model "forgejo.org/models/user"
"forgejo.org/modules/setting"
@ -20,6 +21,7 @@ func initActionsTasks() {
registerCancelAbandonedJobs()
registerScheduleTasks()
registerActionsCleanup()
registerOfflineRunnersCleanup()
}
func registerStopZombieTasks() {
@ -74,3 +76,22 @@ func registerActionsCleanup() {
return actions_service.Cleanup(ctx)
})
}
func registerOfflineRunnersCleanup() {
RegisterTaskFatal("cleanup_offline_runners", &CleanupOfflineRunnersConfig{
BaseConfig: BaseConfig{
Enabled: false,
RunAtStart: false,
Schedule: "@midnight",
},
GlobalScopeOnly: true,
OlderThan: time.Hour * 24,
}, func(ctx context.Context, _ *user_model.User, cfg Config) error {
c := cfg.(*CleanupOfflineRunnersConfig)
return actions_service.CleanupOfflineRunners(
ctx,
c.OlderThan,
c.GlobalScopeOnly,
)
})
}

View file

@ -42,11 +42,8 @@
<li>{{ctx.Locale.Tr "org.teams.can_create_org_repo"}}</li>
{{end}}
</ul>
{{if (eq .Team.AccessMode 2)}}
<h3>{{ctx.Locale.Tr "org.settings.permission"}}</h3>
{{ctx.Locale.Tr "org.teams.write_permission_desc"}}
{{else if (eq .Team.AccessMode 3)}}
<h3>{{ctx.Locale.Tr "org.settings.permission"}}</h3>
<h3>{{ctx.Locale.Tr "org.settings.permission"}}</h3>
{{if (eq .Team.AccessMode 3)}}
{{ctx.Locale.Tr "org.teams.admin_permission_desc"}}
{{else}}
<table class="ui table">

View file

@ -98,7 +98,7 @@
{{range $release.Attachments}}
{{if .ExternalURL}}
<li>
<a class="tw-flex-1 flex-text-inline tw-font-bold" target="_blank" rel="nofollow" href="{{.DownloadURL}}" download>
<a class="tw-flex-1 flex-text-inline tw-font-bold" target="_blank" rel="nofollow" href="{{.DownloadURL}}">
{{svg "octicon-link-external" 16 "tw-mr-1"}}{{.Name}}
</a>
</li>

View file

@ -0,0 +1,116 @@
// @watch start
// templates/org/team/sidebar.tmpl
// @watch end
/* eslint playwright/expect-expect: ["error", { "assertFunctionNames": ["assertPermissionsDetails", "assertRestrictedAccess", "assertOwnerPermissions"] }] */
import {expect, type Page} from '@playwright/test';
import {test} from './utils_e2e.ts';
type Permission = 'No access' | 'Write' | 'Read';
const UNIT_VALUES = [
'Code',
'Issues',
'Pull requests',
'Releases',
'Wiki',
'External Wiki',
'External issues',
'Projects',
'Packages',
'Actions',
] as const;
type Unit = typeof UNIT_VALUES[number];
const assertPermission = async (page: Page, name: Unit, permission: Permission) => {
await expect.soft(page.getByRole('row', {name}).getByRole('cell').nth(1)).toHaveText(permission);
};
const testTeamUrl = '/org/org17/teams/test_team';
const reviewTeamUrl = '/org/org17/teams/review_team';
const ownersUrl = '/org/org17/teams/owners';
const adminUrl = '/org/org17/teams/super-user';
const cases: Record<string, { read?: Unit[], write?: Unit[] }> = {
[testTeamUrl]: {write: ['Issues']},
[reviewTeamUrl]: {read: ['Code']},
};
const assertOwnerPermissions = async (page: Page, code: number = 200) => {
const response = await page.goto(ownersUrl);
expect(response?.status()).toBe(code);
await expect(page.getByText('Owners have full access to all repositories and have administrator access to the organization.')).toBeVisible();
};
const assertAdminPermissions = async (page: Page, code: number = 200) => {
const response = await page.goto(adminUrl);
expect(response?.status()).toBe(code);
await expect(page.getByText('This team grants Administrator access: members can read from, push to and add collaborators to team repositories.')).toBeVisible();
};
const assertRestrictedAccess = async (page: Page, ...urls: string[]) => {
for (const url of urls) {
expect((await page.goto(url))?.status(), 'should not see any details').toBe(404);
}
};
const assertPermissionsDetails = async (page: Page, url: (keyof typeof cases)) => {
const response = await page.goto(url);
expect(response?.status()).toBe(200);
const per = cases[url];
for (const unit of UNIT_VALUES) {
if (per.read?.includes(unit)) {
await assertPermission(page, unit, 'Read');
} else if (per.write?.includes(unit)) {
await assertPermission(page, unit, 'Write');
} else {
await assertPermission(page, unit, 'No access');
}
}
};
test.describe('Orga team overview', () => {
test.describe('admin', () => {
test.use({user: 'user1'});
test('should see all', async ({page}) => {
await assertPermissionsDetails(page, testTeamUrl);
await assertPermissionsDetails(page, reviewTeamUrl);
await assertOwnerPermissions(page);
await assertAdminPermissions(page);
});
});
test.describe('owner', () => {
test.use({user: 'user18'});
test('should see all', async ({page}) => {
await assertPermissionsDetails(page, testTeamUrl);
await assertPermissionsDetails(page, reviewTeamUrl);
await assertOwnerPermissions(page);
await assertAdminPermissions(page);
});
});
test.describe('reviewer team', () => {
test.use({user: 'user29'});
test('should only see permissions for `reviewer team` and restricted access to other resources', async ({page}) => {
await assertPermissionsDetails(page, reviewTeamUrl);
await assertRestrictedAccess(page, ownersUrl, testTeamUrl, adminUrl);
});
});
test.describe('test_team', () => {
test.use({user: 'user2'});
test('should only see permissions for test_team and restricted access to other resources', async ({page}) => {
await assertPermissionsDetails(page, testTeamUrl);
await assertRestrictedAccess(page, ownersUrl, reviewTeamUrl, adminUrl);
});
});
});

View file

@ -94,6 +94,8 @@ func createSessions(t testing.TB) {
"user1",
"user2",
"user12",
"user18",
"user29",
"user40",
}

View file

@ -333,11 +333,11 @@ func TestAPICron(t *testing.T) {
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "28", resp.Header().Get("X-Total-Count"))
assert.Equal(t, "29", resp.Header().Get("X-Total-Count"))
var crons []api.Cron
DecodeJSON(t, resp, &crons)
assert.Len(t, crons, 28)
assert.Len(t, crons, 29)
})
t.Run("Execute", func(t *testing.T) {

View file

@ -56,7 +56,7 @@ func TestPackageContainer(t *testing.T) {
return values
}
images := []string{"test", "te/st"}
images := []string{"test", "te/st", "oras-artifact"}
tags := []string{"latest", "main"}
multiTag := "multi"
@ -177,6 +177,90 @@ func TestPackageContainer(t *testing.T) {
assert.Equal(t, "registry/2.0", resp.Header().Get("Docker-Distribution-Api-Version"))
})
t.Run("ORAS Artifact Upload", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
image := "oras-artifact"
url := fmt.Sprintf("%sv2/%s/%s", setting.AppURL, user.Name, image)
// Empty config blob (common in ORAS artifacts)
emptyConfigDigest := "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
emptyConfigContent := ""
// Upload empty config blob
req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, emptyConfigDigest), bytes.NewReader([]byte(emptyConfigContent))).
AddTokenAuth(userToken)
resp := MakeRequest(t, req, http.StatusCreated)
assert.Equal(t, fmt.Sprintf("/v2/%s/%s/blobs/%s", user.Name, image, emptyConfigDigest), resp.Header().Get("Location"))
assert.Equal(t, emptyConfigDigest, resp.Header().Get("Docker-Content-Digest"))
// Verify empty blob exists and has correct Content-Length
req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, emptyConfigDigest)).
AddTokenAuth(userToken)
resp = MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "0", resp.Header().Get("Content-Length")) // This was the main fix
assert.Equal(t, emptyConfigDigest, resp.Header().Get("Docker-Content-Digest"))
// Upload a small data blob (e.g., artifacthub metadata)
artifactData := `{"name":"test-artifact","version":"1.0.0"}`
artifactDigest := fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(artifactData)))
req = NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, artifactDigest), bytes.NewReader([]byte(artifactData))).
AddTokenAuth(userToken)
resp = MakeRequest(t, req, http.StatusCreated)
assert.Equal(t, fmt.Sprintf("/v2/%s/%s/blobs/%s", user.Name, image, artifactDigest), resp.Header().Get("Location"))
// Create OCI artifact manifest
artifactManifest := fmt.Sprintf(`{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"artifactType": "application/vnd.cncf.artifacthub.config.v1+yaml",
"config": {
"mediaType": "application/vnd.cncf.artifacthub.config.v1+yaml",
"digest": "%s",
"size": %d
},
"layers": [
{
"mediaType": "application/vnd.cncf.artifacthub.repository-metadata.layer.v1.yaml",
"digest": "%s",
"size": %d
}
]
}`, emptyConfigDigest, len(emptyConfigContent), artifactDigest, len(artifactData))
artifactManifestDigest := fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(artifactManifest)))
// Upload artifact manifest
req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/artifact-v1", url), bytes.NewReader([]byte(artifactManifest))).
AddTokenAuth(userToken).
SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json")
resp = MakeRequest(t, req, http.StatusCreated)
assert.Equal(t, fmt.Sprintf("/v2/%s/%s/manifests/artifact-v1", user.Name, image), resp.Header().Get("Location"))
assert.Equal(t, artifactManifestDigest, resp.Header().Get("Docker-Content-Digest"))
// Verify manifest can be retrieved
req = NewRequest(t, "GET", fmt.Sprintf("%s/manifests/artifact-v1", url)).
AddTokenAuth(userToken).
SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json")
resp = MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "application/vnd.oci.image.manifest.v1+json", resp.Header().Get("Content-Type"))
assert.Equal(t, artifactManifestDigest, resp.Header().Get("Docker-Content-Digest"))
// Verify package was created with correct metadata
pvs, err := packages_model.GetVersionsByPackageType(db.DefaultContext, user.ID, packages_model.TypeContainer)
require.NoError(t, err)
found := false
for _, pv := range pvs {
if pv.LowerVersion == "artifact-v1" {
found = true
break
}
}
assert.True(t, found, "ORAS artifact package should be created")
})
for _, image := range images {
t.Run(fmt.Sprintf("[Image:%s]", image), func(t *testing.T) {
url := fmt.Sprintf("%sv2/%s/%s", setting.AppURL, user.Name, image)
@ -430,14 +514,19 @@ func TestPackageContainer(t *testing.T) {
assert.Equal(t, manifestDigest, resp.Header().Get("Docker-Content-Digest"))
})
t.Run("GetManifest", func(t *testing.T) {
t.Run("GetManifest unknown-tag", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", fmt.Sprintf("%s/manifests/unknown-tag", url)).
AddTokenAuth(userToken)
MakeRequest(t, req, http.StatusNotFound)
})
req = NewRequest(t, "GET", fmt.Sprintf("%s/manifests/%s", url, tag)).
t.Run("GetManifest serv indirect", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer test.MockVariableValue(&setting.Packages.Storage.MinioConfig.ServeDirect, false)()
req := NewRequest(t, "GET", fmt.Sprintf("%s/manifests/%s", url, tag)).
AddTokenAuth(userToken)
resp := MakeRequest(t, req, http.StatusOK)
@ -446,6 +535,25 @@ func TestPackageContainer(t *testing.T) {
assert.Equal(t, manifestDigest, resp.Header().Get("Docker-Content-Digest"))
assert.Equal(t, manifestContent, resp.Body.String())
})
t.Run("GetManifest serv direct", func(t *testing.T) {
if setting.Packages.Storage.Type != setting.MinioStorageType {
t.Skip("Test skipped for non-Minio-storage.")
return
}
defer tests.PrintCurrentTest(t)()
defer test.MockVariableValue(&setting.Packages.Storage.MinioConfig.ServeDirect, true)()
req := NewRequest(t, "GET", fmt.Sprintf("%s/manifests/%s", url, tag)).
AddTokenAuth(userToken)
resp := MakeRequest(t, req, http.StatusTemporaryRedirect)
assert.Empty(t, resp.Header().Get("Content-Length"))
assert.NotEmpty(t, resp.Header().Get("Location"))
assert.Equal(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type"))
assert.Empty(t, resp.Header().Get("Docker-Content-Digest"))
})
})
}
@ -580,36 +688,76 @@ func TestPackageContainer(t *testing.T) {
t.Run("GetTagList", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
cases := []struct {
var cases []struct {
URL string
ExpectedTags []string
ExpectedLink string
}{
{
URL: fmt.Sprintf("%s/tags/list", url),
ExpectedTags: []string{"latest", "main", "multi"},
ExpectedLink: fmt.Sprintf(`</v2/%s/%s/tags/list?last=multi>; rel="next"`, user.Name, image),
},
{
URL: fmt.Sprintf("%s/tags/list?n=0", url),
ExpectedTags: []string{},
ExpectedLink: "",
},
{
URL: fmt.Sprintf("%s/tags/list?n=2", url),
ExpectedTags: []string{"latest", "main"},
ExpectedLink: fmt.Sprintf(`</v2/%s/%s/tags/list?last=main&n=2>; rel="next"`, user.Name, image),
},
{
URL: fmt.Sprintf("%s/tags/list?last=main", url),
ExpectedTags: []string{"multi"},
ExpectedLink: fmt.Sprintf(`</v2/%s/%s/tags/list?last=multi>; rel="next"`, user.Name, image),
},
{
URL: fmt.Sprintf("%s/tags/list?n=1&last=latest", url),
ExpectedTags: []string{"main"},
ExpectedLink: fmt.Sprintf(`</v2/%s/%s/tags/list?last=main&n=1>; rel="next"`, user.Name, image),
},
}
if image == "oras-artifact" {
cases = []struct {
URL string
ExpectedTags []string
ExpectedLink string
}{
{
URL: fmt.Sprintf("%s/tags/list", url),
ExpectedTags: []string{"artifact-v1", "latest", "main", "multi"},
ExpectedLink: fmt.Sprintf(`</v2/%s/%s/tags/list?last=multi>; rel="next"`, user.Name, image),
},
{
URL: fmt.Sprintf("%s/tags/list?n=0", url),
ExpectedTags: []string{},
ExpectedLink: "",
},
{
URL: fmt.Sprintf("%s/tags/list?n=2", url),
ExpectedTags: []string{"artifact-v1", "latest"},
ExpectedLink: fmt.Sprintf(`</v2/%s/%s/tags/list?last=latest&n=2>; rel="next"`, user.Name, image),
},
{
URL: fmt.Sprintf("%s/tags/list?last=main", url),
ExpectedTags: []string{"multi"},
ExpectedLink: fmt.Sprintf(`</v2/%s/%s/tags/list?last=multi>; rel="next"`, user.Name, image),
},
{
URL: fmt.Sprintf("%s/tags/list?n=1&last=latest", url),
ExpectedTags: []string{"main"},
ExpectedLink: fmt.Sprintf(`</v2/%s/%s/tags/list?last=main&n=1>; rel="next"`, user.Name, image),
},
}
} else {
cases = []struct {
URL string
ExpectedTags []string
ExpectedLink string
}{
{
URL: fmt.Sprintf("%s/tags/list", url),
ExpectedTags: []string{"latest", "main", "multi"},
ExpectedLink: fmt.Sprintf(`</v2/%s/%s/tags/list?last=multi>; rel="next"`, user.Name, image),
},
{
URL: fmt.Sprintf("%s/tags/list?n=0", url),
ExpectedTags: []string{},
ExpectedLink: "",
},
{
URL: fmt.Sprintf("%s/tags/list?n=2", url),
ExpectedTags: []string{"latest", "main"},
ExpectedLink: fmt.Sprintf(`</v2/%s/%s/tags/list?last=main&n=2>; rel="next"`, user.Name, image),
},
{
URL: fmt.Sprintf("%s/tags/list?last=main", url),
ExpectedTags: []string{"multi"},
ExpectedLink: fmt.Sprintf(`</v2/%s/%s/tags/list?last=multi>; rel="next"`, user.Name, image),
},
{
URL: fmt.Sprintf("%s/tags/list?n=1&last=latest", url),
ExpectedTags: []string{"main"},
ExpectedLink: fmt.Sprintf(`</v2/%s/%s/tags/list?last=main&n=1>; rel="next"`, user.Name, image),
},
}
}
for _, c := range cases {
@ -636,7 +784,11 @@ func TestPackageContainer(t *testing.T) {
var apiPackages []*api.Package
DecodeJSON(t, resp, &apiPackages)
assert.Len(t, apiPackages, 4) // "latest", "main", "multi", "sha256:..."
if image == "oras-artifact" {
assert.Len(t, apiPackages, 5) // "artifact-v1", "latest", "main", "multi", "sha256:..."
} else {
assert.Len(t, apiPackages, 4) // "latest", "main", "multi", "sha256:..."
}
})
t.Run("Delete", func(t *testing.T) {

View file

@ -430,6 +430,30 @@ func TestAPIExternalAssetRelease(t *testing.T) {
assert.Equal(t, "external", attachment.Type)
}
func TestAPIAllowedAPIURLInRelease(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
session := loginUser(t, owner.LowerName)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
r := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test")
internalURL := "https://localhost:3003/api/packages/owner/generic/test/1.0.0/test.txt"
req := NewRequest(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets?name=test-asset&external_url=%s", owner.Name, repo.Name, r.ID, url.QueryEscape(internalURL))).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var attachment *api.Attachment
DecodeJSON(t, resp, &attachment)
assert.Equal(t, "test-asset", attachment.Name)
assert.EqualValues(t, 0, attachment.Size)
assert.Equal(t, internalURL, attachment.DownloadURL)
assert.Equal(t, "external", attachment.Type)
}
func TestAPIDuplicateAssetRelease(t *testing.T) {
defer tests.PrepareTestEnv(t)()