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:
commit
826d4c7c2f
32 changed files with 885 additions and 85 deletions
|
@ -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
|
||||
|
|
2
Makefile
2
Makefile
|
@ -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: ...
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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: ~
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
124
routers/api/packages/container/container_test.go
Normal file
124
routers/api/packages/container/container_test.go
Normal 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)
|
||||
})
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
}
|
||||
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
116
tests/e2e/org-teams-overview.test.e2e.ts
Normal file
116
tests/e2e/org-teams-overview.test.e2e.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -94,6 +94,8 @@ func createSessions(t testing.TB) {
|
|||
"user1",
|
||||
"user2",
|
||||
"user12",
|
||||
"user18",
|
||||
"user29",
|
||||
"user40",
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)()
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue