forgejo/models/quota/quota_group_test.go
Brook Miles 9354efceb1 fix: quota evaluation rules not working properly (#9033)
This patch is mainly intended to fix forgejo/forgejo#7721, and to fix forgejo/forgejo#9019.

It also changes the evaluation of 0 limits to prevent all writes, instead of allowing one write and then failing on subsequent writes after the limit has been exceeded.  This matches the expectation of the existing tests, and I believe it will better match the expectations of users.

Tests have been updated accordingly where necessary, and some additional test coverage added.

The fixes in this PR depend on each other in order for the quota system to function correctly, so I'm submitting them as a single PR instead of individually.

## Test Cases

### Quota subjects not covered by their parent subjects

Before enabling quotas, create a test user and test repository for that user.

Enable quotas, and set a default total to some large value.  (Do not use unit suffixes forgejo/forgejo#8996)

```ini
[quota]
ENABLED = true

[quota.default]
TOTAL = 1073741824
```

With the test user, navigate to "Storage overview" and verify that the quota group "Global quota" is the only group listed, containing the rule "Default", and displays the configured limit, and that the limit has not been exceeded (eg. `42 MiB / 1 GiB`).

The default quota rule has the subject `size:all`, so any write action should be allowed.

#### Attempt to create a new repository.

Expected result: Repository is created.
Actual result: Error 413, You have exhausted your quota.

#### Attempt to create a new file in the existing repository.

Expected result: File is created.
Actual result: Error 413, You have exhausted your quota.

#### Create an issue on the test repository, and attempt to upload an image to the issue.

Expected result: Image is uploaded.
Actual Result: Quota exceeded. Displays error message: `JavaScript promise rejection: can't access property "submitted", oi[ji.uuid] is undefined. Open browser console to see more details.`

### Unlimited quota rules incorrectly allow all writes

With quotas enabled, [Use the API](https://forgejo.org/docs/latest/admin/advanced/quota/#advanced-usage-via-api) to create a quota group containing a single rule with a subject of `size:git:lfs`, and a limit of `-1` (Unlimited).  Add the test user to this group.

```json
{
  "name": "git-lfs-unlimited",
  "rules": [
    {
      "name": "git-lfs-unlimited",
      "limit": -1,
      "subjects": ["size:git:lfs"]
    }
  ]
}
```

With the test user, navigate to "Storage overview" and verify that the user has been added to this group, that it is the only group the user is assigned to, and that the rule limit displays as "Unlimited".

The user should only have the ability to write to Git LFS storage, all other writes should be denied.

#### Attempt to create a new repository.

Expected result: Error 413, You have exhausted your quota.
Actual result: Repository is created.

#### Attempt to create a new file in the test repository.

Expected result: Error 413, You have exhausted your quota.
Actual result: File is created.

#### Create an issue on the test repository, and attempt to upload an image to the issue.

Expected Result: Quota exceeded.
Actual result: Image is uploaded.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/9033
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: Brook Miles <brook@noreply.codeberg.org>
Co-committed-by: Brook Miles <brook@noreply.codeberg.org>
2025-09-08 01:05:55 +02:00

228 lines
5.7 KiB
Go

// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package quota_test
import (
"testing"
quota_model "forgejo.org/models/quota"
"forgejo.org/modules/setting"
"forgejo.org/modules/test"
"github.com/stretchr/testify/assert"
)
func TestQuotaGroupAllRulesMustAllow(t *testing.T) {
unlimitedRule := quota_model.Rule{
Limit: -1,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
denyRule := quota_model.Rule{
Limit: 0,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
group := quota_model.Group{
Rules: []quota_model.Rule{
unlimitedRule,
denyRule,
},
}
used := quota_model.Used{}
used.Size.Repos.Public = 1024
// Within a group, *all* matching rules must allow. Thus, if we have a deny-all rule,
// and an unlimited rule, the deny rule wins.
match, allow := group.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.True(t, match)
assert.False(t, allow)
}
func TestQuotaGroupRuleScenario1(t *testing.T) {
group := quota_model.Group{
Rules: []quota_model.Rule{
{
Limit: 1024,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAssetsAttachmentsReleases,
quota_model.LimitSubjectSizeGitLFS,
quota_model.LimitSubjectSizeAssetsPackagesAll,
},
},
{
Limit: 0,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeGitLFS,
},
},
},
}
used := quota_model.Used{}
used.Size.Assets.Attachments.Releases = 512
used.Size.Assets.Packages.All = 256
used.Size.Git.LFS = 16
match, allow := group.Evaluate(used, quota_model.LimitSubjectSizeAssetsAttachmentsReleases)
assert.True(t, match, "size:assets:attachments:releases is covered")
assert.True(t, allow, "size:assets:attachments:releases is allowed")
match, allow = group.Evaluate(used, quota_model.LimitSubjectSizeAssetsPackagesAll)
assert.True(t, match, "size:assets:packages:all is covered")
assert.True(t, allow, "size:assets:packages:all is allowed")
match, allow = group.Evaluate(used, quota_model.LimitSubjectSizeGitLFS)
assert.True(t, match, "size:git:lfs is covered")
assert.False(t, allow, "size:git:lfs is denied")
match, allow = group.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.False(t, match, "size:all is not covered")
assert.False(t, allow, "size:all is not allowed (not covered)")
}
func TestQuotaGroupRuleCombination(t *testing.T) {
repoRule := quota_model.Rule{
Limit: 4096,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeReposAll,
},
}
packagesRule := quota_model.Rule{
Limit: 0,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAssetsPackagesAll,
},
}
used := quota_model.Used{}
used.Size.Repos.Public = 1024
used.Size.Assets.Packages.All = 1024
group := quota_model.Group{
Rules: []quota_model.Rule{
repoRule,
packagesRule,
},
}
// Git LFS does not match any rule
match, allow := group.Evaluate(used, quota_model.LimitSubjectSizeGitLFS)
assert.False(t, match)
assert.False(t, allow)
// repos:all has a matching rule and is allowed
match, allow = group.Evaluate(used, quota_model.LimitSubjectSizeReposAll)
assert.True(t, match)
assert.True(t, allow)
// packages:all has a matching rule and is denied
match, allow = group.Evaluate(used, quota_model.LimitSubjectSizeAssetsPackagesAll)
assert.True(t, match)
assert.False(t, allow)
}
func TestQuotaGroupListsRequireOnlyOneAllow(t *testing.T) {
unlimitedRule := quota_model.Rule{
Limit: -1,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
denyRule := quota_model.Rule{
Limit: 0,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
denyGroup := quota_model.Group{
Rules: []quota_model.Rule{
denyRule,
},
}
unlimitedGroup := quota_model.Group{
Rules: []quota_model.Rule{
unlimitedRule,
},
}
groups := quota_model.GroupList{&denyGroup, &unlimitedGroup}
used := quota_model.Used{}
used.Size.Repos.Public = 1024
// In a group list, an action is allowed if any group matches and allows it.
allow := groups.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.True(t, allow)
}
func TestQuotaGroupListAllDeny(t *testing.T) {
denyRule := quota_model.Rule{
Limit: 0,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
limitedRule := quota_model.Rule{
Limit: 1024,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
denyGroup := quota_model.Group{
Rules: []quota_model.Rule{
denyRule,
},
}
limitedGroup := quota_model.Group{
Rules: []quota_model.Rule{
limitedRule,
},
}
groups := quota_model.GroupList{&denyGroup, &limitedGroup}
used := quota_model.Used{}
used.Size.Repos.Public = 2048
allow := groups.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.False(t, allow)
}
// An empty group list should result in the use of the built in Default
// group: size:all defaulting to unlimited
func TestQuotaDefaultGroup(t *testing.T) {
groups := quota_model.GroupList{}
used := quota_model.Used{}
used.Size.Repos.Public = 2048
testSets := []struct {
name string
limit int64
expectAllow bool
}{
{"unlimited", -1, true},
{"limit-allow", 1024 * 1024, true},
{"limit-deny", 1024, false},
}
for _, testSet := range testSets {
t.Run(testSet.name, func(t *testing.T) {
defer test.MockVariableValue(&setting.Quota.Default.Total, testSet.limit)()
for subject := quota_model.LimitSubjectFirst; subject <= quota_model.LimitSubjectLast; subject++ {
t.Run(subject.String(), func(t *testing.T) {
allow := groups.Evaluate(used, subject)
assert.Equal(t, testSet.expectAllow, allow)
})
}
})
}
}