Compare commits

...

5 Commits

Author SHA1 Message Date
Steven Noonan
c5332fdc55 add more routes to the "expensive" list (#35547)
Signed-off-by: Steven Noonan <steven@uplinklabs.net>
2025-09-29 17:33:28 +08:00
wxiaoguang
0f668145e9 Drop json-iterator dependency (#35544) 2025-09-28 22:30:28 +08:00
6543
fbe80e6df2 Add proper error message if session provider can not be created (#35520)
the middleware that creates the session provider just panics if on
creation the config is wrong.
this is not catched and so you just get an cryptic stacktrace with no
point where to look at (as user).

## Before

```
2025/09/16 03:56:37 ...xer/stats/indexer.go:87:populateRepoIndexer() [I] Done (re)populating the repo stats indexer with existing repositories
2025/09/16 03:56:37 modules/ssh/ssh.go:387:Listen() [I] Adding SSH host key: /var/lib/gitea/data/ssh/gitea.rsa
2025/09/16 03:56:37 modules/ssh/init.go:26:Init() [I] SSH server started on :1234. Cipher list ([chacha20-poly1305@openssh.com aes128-ctr aes192-ctr aes256-ctr aes128-gcm@openssh.com aes256-gcm@openssh.com]), key exchange algorithms ([curve25519-sha256 ecdh-sha2-nistp256 ecdh-sha2-nistp384 ecdh-sha2-nistp521 diffie-hellman-group14-sha256 diffie-hellman-group14-sha1]), MACs ([hmac-sha2-256-etm@openssh.com hmac-sha2-256 hmac-sha1])
2025/09/16 03:56:37 ...s/graceful/server.go:50:NewServer() [I] Starting new SSH server: tcp::1234 on PID: 83337
2025/09/16 03:56:38 cmd/web.go:231:func1() [F] PANIC: dial tcp 127.0.0.1:6379: connect: connection refused
gitea.com/go-chi/session@v0.0.0-20240316035857-16768d98ec96/session.go:239 (0x1cdb908)
code.gitea.io/gitea/routers/common/middleware.go:108 (0x2547f5a)
code.gitea.io/gitea/routers/web/web.go:270 (0x278b8e9)
code.gitea.io/gitea/routers/init.go:185 (0x2850d89)
code.gitea.io/gitea/cmd/web.go:211 (0x295c5ad)
code.gitea.io/gitea/cmd/web.go:262 (0x295cacb)
code.gitea.io/gitea/cmd/main.go:111 (0x2953422)
github.com/urfave/cli/v2@v2.27.2/command.go:276 (0x1cc3dfd)
github.com/urfave/cli/v2@v2.27.2/command.go:269 (0x1cc4084)
github.com/urfave/cli/v2@v2.27.2/app.go:333 (0x1cc086a)
github.com/urfave/cli/v2@v2.27.2/app.go:307 (0x2953f18)
code.gitea.io/gitea/cmd/main.go:172 (0x2953efc)
code.gitea.io/gitea/main.go:46 (0x2998498)
runtime/proc.go:283 (0x4471ca)
runtime/asm_amd64.s:1700 (0x484a20)
```

## After

```
2025/09/22 22:52:35 .../templates/htmlrenderer.go:118:initHTMLRenderer() [D] Creating static HTML Renderer
2025/09/22 22:52:35 routers/web/web.go:273:Routes() [F] common.Sessioner failed: failed to create session middleware: dial tcp 127.0.0.1:6379: connect: connection refused
```

---------

Signed-off-by: 6543 <6543@obermui.de>
2025-09-28 12:24:19 +00:00
junoberryferry
151ef80e28 use experimental go json v2 library (#35392)
details: https://pkg.go.dev/encoding/json/v2

---------

Co-authored-by: techknowlogick <matti@mdranta.net>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-09-28 08:03:36 +00:00
Lunny Xiao
8106d95577 Use global lock instead of status pool for cron lock (#35507) 2025-09-27 10:11:52 -07:00
33 changed files with 385 additions and 234 deletions

View File

@@ -72,13 +72,13 @@ jobs:
go-version-file: go.mod
check-latest: true
- run: make deps-backend
- run: make backend
- run: GOEXPERIMENT='' make backend
env:
TAGS: bindata gogit sqlite sqlite_unlock_notify
- name: run migration tests
run: make test-sqlite-migration
- name: run tests
run: make test-sqlite
run: GOEXPERIMENT='' make test-sqlite
timeout-minutes: 50
env:
TAGS: bindata gogit sqlite sqlite_unlock_notify
@@ -142,7 +142,7 @@ jobs:
RACE_ENABLED: true
GITHUB_READ_TOKEN: ${{ secrets.GITHUB_READ_TOKEN }}
- name: unit-tests-gogit
run: make unit-test-coverage test-check
run: GOEXPERIMENT='' make unit-test-coverage test-check
env:
TAGS: bindata gogit
RACE_ENABLED: true

View File

@@ -18,6 +18,10 @@ DIST := dist
DIST_DIRS := $(DIST)/binaries $(DIST)/release
IMPORT := code.gitea.io/gitea
# By default use go's 1.25 experimental json v2 library when building
# TODO: remove when no longer experimental
export GOEXPERIMENT ?= jsonv2
GO ?= go
SHASUM ?= shasum -a 256
HAS_GO := $(shell hash $(GO) > /dev/null 2>&1 && echo yes)
@@ -766,7 +770,7 @@ generate-go: $(TAGS_PREREQ)
.PHONY: security-check
security-check:
go run $(GOVULNCHECK_PACKAGE) -show color ./...
GOEXPERIMENT= go run $(GOVULNCHECK_PACKAGE) -show color ./...
$(EXECUTABLE): $(GO_SOURCES) $(TAGS_PREREQ)
ifneq ($(and $(STATIC),$(findstring pam,$(TAGS))),)

8
go.mod
View File

@@ -16,7 +16,7 @@ require (
gitea.com/go-chi/binding v0.0.0-20240430071103-39a851e106ed
gitea.com/go-chi/cache v0.2.1
gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098
gitea.com/go-chi/session v0.0.0-20240316035857-16768d98ec96
gitea.com/go-chi/session v0.0.0-20250926004215-636cadd82e15
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4
github.com/42wim/httpsig v1.2.3
@@ -61,6 +61,7 @@ require (
github.com/go-redsync/redsync/v4 v4.13.0
github.com/go-sql-driver/mysql v1.9.3
github.com/go-webauthn/webauthn v0.13.4
github.com/goccy/go-json v0.10.5
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f
github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85
github.com/golang-jwt/jwt/v5 v5.3.0
@@ -75,7 +76,6 @@ require (
github.com/huandu/xstrings v1.5.0
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056
github.com/jhillyerd/enmime v1.3.0
github.com/json-iterator/go v1.1.12
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/klauspost/compress v1.18.0
github.com/klauspost/cpuid/v2 v2.3.0
@@ -200,7 +200,6 @@ require (
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-webauthn/x v0.1.24 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
@@ -220,6 +219,7 @@ require (
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/libdns/libdns v1.1.1 // indirect
@@ -277,7 +277,7 @@ require (
go.uber.org/zap v1.27.0 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.36.0 // indirect

8
go.sum
View File

@@ -43,8 +43,8 @@ gitea.com/go-chi/cache v0.2.1 h1:bfAPkvXlbcZxPCpcmDVCWoHgiBSBmZN/QosnZvEC0+g=
gitea.com/go-chi/cache v0.2.1/go.mod h1:Qic0HZ8hOHW62ETGbonpwz8WYypj9NieU9659wFUJ8Q=
gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098 h1:p2ki+WK0cIeNQuqjR98IP2KZQKRzJJiV7aTeMAFwaWo=
gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098/go.mod h1:LjzIOHlRemuUyO7WR12fmm18VZIlCAaOt9L3yKw40pk=
gitea.com/go-chi/session v0.0.0-20240316035857-16768d98ec96 h1:IFDiMBObsP6CZIRaDLd54SR6zPYAffPXiXck5Xslu0Q=
gitea.com/go-chi/session v0.0.0-20240316035857-16768d98ec96/go.mod h1:0iEpFKnwO5dG0aF98O4eq6FMsAiXkNBaDIlUOlq4BtM=
gitea.com/go-chi/session v0.0.0-20250926004215-636cadd82e15 h1:qFYmz05u/s9664o7+XEgrlHXSPQ4uHO8/ccZGUb1uxA=
gitea.com/go-chi/session v0.0.0-20250926004215-636cadd82e15/go.mod h1:0iEpFKnwO5dG0aF98O4eq6FMsAiXkNBaDIlUOlq4BtM=
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96 h1:+wWBi6Qfruqu7xJgjOIrKVQGiLUZdpKYCZewJ4clqhw=
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96/go.mod h1:VyMQP6ue6MKHM8UsOXfNfuMKD0oSAWZdXVcpHIN2yaY=
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 h1:IFT+hup2xejHqdhS7keYWioqfmxdnfblFDTGoOwcZ+o=
@@ -848,8 +848,8 @@ golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=

View File

@@ -365,11 +365,11 @@ func GenerateEmbedBindata(fsRootPath, outputFile string) error {
if err = embedFiles(meta.Root, fsRootPath, ""); err != nil {
return err
}
jsonBuf, err := json.Marshal(meta) // can't use json.NewEncoder here because it writes extra EOL
jsonBuf, err := json.Marshal(meta)
if err != nil {
return err
}
_, _ = output.Write([]byte{'\n'})
_, err = output.Write(jsonBuf)
_, err = output.Write(bytes.TrimSpace(jsonBuf))
return err
}

View File

@@ -8,8 +8,6 @@ import (
"encoding/binary"
"encoding/json" //nolint:depguard // this package wraps it
"io"
jsoniter "github.com/json-iterator/go"
)
// Encoder represents an encoder for json
@@ -31,71 +29,7 @@ type Interface interface {
Indent(dst *bytes.Buffer, src []byte, prefix, indent string) error
}
var (
// DefaultJSONHandler default json handler
DefaultJSONHandler Interface = JSONiter{jsoniter.ConfigCompatibleWithStandardLibrary}
_ Interface = StdJSON{}
_ Interface = JSONiter{}
)
// StdJSON implements Interface via encoding/json
type StdJSON struct{}
// Marshal implements Interface
func (StdJSON) Marshal(v any) ([]byte, error) {
return json.Marshal(v)
}
// Unmarshal implements Interface
func (StdJSON) Unmarshal(data []byte, v any) error {
return json.Unmarshal(data, v)
}
// NewEncoder implements Interface
func (StdJSON) NewEncoder(writer io.Writer) Encoder {
return json.NewEncoder(writer)
}
// NewDecoder implements Interface
func (StdJSON) NewDecoder(reader io.Reader) Decoder {
return json.NewDecoder(reader)
}
// Indent implements Interface
func (StdJSON) Indent(dst *bytes.Buffer, src []byte, prefix, indent string) error {
return json.Indent(dst, src, prefix, indent)
}
// JSONiter implements Interface via jsoniter
type JSONiter struct {
jsoniter.API
}
// Marshal implements Interface
func (j JSONiter) Marshal(v any) ([]byte, error) {
return j.API.Marshal(v)
}
// Unmarshal implements Interface
func (j JSONiter) Unmarshal(data []byte, v any) error {
return j.API.Unmarshal(data, v)
}
// NewEncoder implements Interface
func (j JSONiter) NewEncoder(writer io.Writer) Encoder {
return j.API.NewEncoder(writer)
}
// NewDecoder implements Interface
func (j JSONiter) NewDecoder(reader io.Reader) Decoder {
return j.API.NewDecoder(reader)
}
// Indent implements Interface, since jsoniter don't support Indent, just use encoding/json's
func (j JSONiter) Indent(dst *bytes.Buffer, src []byte, prefix, indent string) error {
return json.Indent(dst, src, prefix, indent)
}
var DefaultJSONHandler = getDefaultJSONHandler()
// Marshal converts object as bytes
func Marshal(v any) ([]byte, error) {

View File

@@ -4,6 +4,7 @@
package json
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
@@ -16,3 +17,12 @@ func TestGiteaDBJSONUnmarshal(t *testing.T) {
err = UnmarshalHandleDoubleEncode([]byte(""), &m)
assert.NoError(t, err)
}
func TestIndent(t *testing.T) {
buf := &bytes.Buffer{}
err := Indent(buf, []byte(`{"a":1}`), ">", " ")
assert.NoError(t, err)
assert.Equal(t, `{
> "a": 1
>}`, buf.String())
}

35
modules/json/jsongoccy.go Normal file
View File

@@ -0,0 +1,35 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package json
import (
"bytes"
"io"
"github.com/goccy/go-json"
)
var _ Interface = jsonGoccy{}
type jsonGoccy struct{}
func (jsonGoccy) Marshal(v any) ([]byte, error) {
return json.Marshal(v)
}
func (jsonGoccy) Unmarshal(data []byte, v any) error {
return json.Unmarshal(data, v)
}
func (jsonGoccy) NewEncoder(writer io.Writer) Encoder {
return json.NewEncoder(writer)
}
func (jsonGoccy) NewDecoder(reader io.Reader) Decoder {
return json.NewDecoder(reader)
}
func (jsonGoccy) Indent(dst *bytes.Buffer, src []byte, prefix, indent string) error {
return json.Indent(dst, src, prefix, indent)
}

View File

@@ -0,0 +1,22 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !goexperiment.jsonv2
package json
import (
"io"
)
func getDefaultJSONHandler() Interface {
return jsonGoccy{}
}
func MarshalKeepOptionalEmpty(v any) ([]byte, error) {
return DefaultJSONHandler.Marshal(v)
}
func NewDecoderCaseInsensitive(reader io.Reader) Decoder {
return DefaultJSONHandler.NewDecoder(reader)
}

34
modules/json/jsonv1.go Normal file
View File

@@ -0,0 +1,34 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package json
import (
"bytes"
"encoding/json" //nolint:depguard // this package wraps it
"io"
)
type jsonV1 struct{}
var _ Interface = jsonV1{}
func (jsonV1) Marshal(v any) ([]byte, error) {
return json.Marshal(v)
}
func (jsonV1) Unmarshal(data []byte, v any) error {
return json.Unmarshal(data, v)
}
func (jsonV1) NewEncoder(writer io.Writer) Encoder {
return json.NewEncoder(writer)
}
func (jsonV1) NewDecoder(reader io.Reader) Decoder {
return json.NewDecoder(reader)
}
func (jsonV1) Indent(dst *bytes.Buffer, src []byte, prefix, indent string) error {
return json.Indent(dst, src, prefix, indent)
}

92
modules/json/jsonv2.go Normal file
View File

@@ -0,0 +1,92 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build goexperiment.jsonv2
package json
import (
"bytes"
jsonv1 "encoding/json" //nolint:depguard // this package wraps it
jsonv2 "encoding/json/v2" //nolint:depguard // this package wraps it
"io"
)
// JSONv2 implements Interface via encoding/json/v2
// Requires GOEXPERIMENT=jsonv2 to be set at build time
type JSONv2 struct {
marshalOptions jsonv2.Options
marshalKeepOptionalEmptyOptions jsonv2.Options
unmarshalOptions jsonv2.Options
unmarshalCaseInsensitiveOptions jsonv2.Options
}
var jsonV2 JSONv2
func init() {
commonMarshalOptions := []jsonv2.Options{
jsonv2.FormatNilSliceAsNull(true),
jsonv2.FormatNilMapAsNull(true),
}
jsonV2.marshalOptions = jsonv2.JoinOptions(commonMarshalOptions...)
jsonV2.unmarshalOptions = jsonv2.DefaultOptionsV2()
// By default, "json/v2" omitempty removes all `""` empty strings, no matter where it comes from.
// v1 has a different behavior: if the `""` is from a null pointer, or a Marshal function, it is kept.
// Golang issue: https://github.com/golang/go/issues/75623 encoding/json/v2: unable to make omitempty work with pointer or Optional type with goexperiment.jsonv2
jsonV2.marshalKeepOptionalEmptyOptions = jsonv2.JoinOptions(append(commonMarshalOptions, jsonv1.OmitEmptyWithLegacySemantics(true))...)
// Some legacy code uses case-insensitive matching (for example: parsing oci.ImageConfig)
jsonV2.unmarshalCaseInsensitiveOptions = jsonv2.JoinOptions(jsonv2.MatchCaseInsensitiveNames(true))
}
func getDefaultJSONHandler() Interface {
return &jsonV2
}
func MarshalKeepOptionalEmpty(v any) ([]byte, error) {
return jsonv2.Marshal(v, jsonV2.marshalKeepOptionalEmptyOptions)
}
func (j *JSONv2) Marshal(v any) ([]byte, error) {
return jsonv2.Marshal(v, j.marshalOptions)
}
func (j *JSONv2) Unmarshal(data []byte, v any) error {
return jsonv2.Unmarshal(data, v, j.unmarshalOptions)
}
func (j *JSONv2) NewEncoder(writer io.Writer) Encoder {
return &jsonV2Encoder{writer: writer, opts: j.marshalOptions}
}
func (j *JSONv2) NewDecoder(reader io.Reader) Decoder {
return &jsonV2Decoder{reader: reader, opts: j.unmarshalOptions}
}
// Indent implements Interface using standard library (JSON v2 doesn't have Indent yet)
func (*JSONv2) Indent(dst *bytes.Buffer, src []byte, prefix, indent string) error {
return jsonv1.Indent(dst, src, prefix, indent)
}
type jsonV2Encoder struct {
writer io.Writer
opts jsonv2.Options
}
func (e *jsonV2Encoder) Encode(v any) error {
return jsonv2.MarshalWrite(e.writer, v, e.opts)
}
type jsonV2Decoder struct {
reader io.Reader
opts jsonv2.Options
}
func (d *jsonV2Decoder) Decode(v any) error {
return jsonv2.UnmarshalRead(d.reader, v, d.opts)
}
func NewDecoderCaseInsensitive(reader io.Reader) Decoder {
return &jsonV2Decoder{reader: reader, opts: jsonV2.unmarshalCaseInsensitiveOptions}
}

View File

@@ -193,7 +193,7 @@ func TestHTTPClientDownload(t *testing.T) {
},
{
endpoint: "https://invalid-json-response.io",
expectedError: "invalid json",
expectedError: "/(invalid json|invalid character)/",
},
{
endpoint: "https://valid-batch-request-download.io",
@@ -258,7 +258,11 @@ func TestHTTPClientDownload(t *testing.T) {
return nil
})
if c.expectedError != "" {
assert.ErrorContains(t, err, c.expectedError)
if strings.HasPrefix(c.expectedError, "/") && strings.HasSuffix(c.expectedError, "/") {
assert.Regexp(t, strings.Trim(c.expectedError, "/"), err.Error())
} else {
assert.ErrorContains(t, err, c.expectedError)
}
} else {
assert.NoError(t, err)
}
@@ -297,7 +301,7 @@ func TestHTTPClientUpload(t *testing.T) {
},
{
endpoint: "https://invalid-json-response.io",
expectedError: "invalid json",
expectedError: "/(invalid json|invalid character)/",
},
{
endpoint: "https://valid-batch-request-upload.io",
@@ -352,7 +356,11 @@ func TestHTTPClientUpload(t *testing.T) {
return io.NopCloser(new(bytes.Buffer)), objectError
})
if c.expectedError != "" {
assert.ErrorContains(t, err, c.expectedError)
if strings.HasPrefix(c.expectedError, "/") && strings.HasSuffix(c.expectedError, "/") {
assert.Regexp(t, strings.Trim(c.expectedError, "/"), err.Error())
} else {
assert.ErrorContains(t, err, c.expectedError)
}
} else {
assert.NoError(t, err)
}

View File

@@ -15,12 +15,17 @@ import (
)
type testSerializationStruct struct {
NormalString string `json:"normal_string" yaml:"normal_string"`
NormalBool bool `json:"normal_bool" yaml:"normal_bool"`
OptBool optional.Option[bool] `json:"optional_bool,omitempty" yaml:"optional_bool,omitempty"`
OptString optional.Option[string] `json:"optional_string,omitempty" yaml:"optional_string,omitempty"`
NormalString string `json:"normal_string" yaml:"normal_string"`
NormalBool bool `json:"normal_bool" yaml:"normal_bool"`
OptBool optional.Option[bool] `json:"optional_bool,omitempty" yaml:"optional_bool,omitempty"`
// It causes an undefined behavior: should the "omitempty" tag only omit "null", or also the empty string?
// The behavior is inconsistent between json and v2 packages, and there is no such use case in Gitea.
// If anyone really needs it, they can use json.MarshalKeepOptionalEmpty to revert the v1 behavior
OptString optional.Option[string] `json:"optional_string,omitempty" yaml:"optional_string,omitempty"`
OptTwoBool optional.Option[bool] `json:"optional_two_bool" yaml:"optional_two_bool"`
OptTwoString optional.Option[string] `json:"optional_twostring" yaml:"optional_two_string"`
OptTwoString optional.Option[string] `json:"optional_two_string" yaml:"optional_two_string"`
}
func TestOptionalToJson(t *testing.T) {
@@ -32,7 +37,7 @@ func TestOptionalToJson(t *testing.T) {
{
name: "empty",
obj: new(testSerializationStruct),
want: `{"normal_string":"","normal_bool":false,"optional_two_bool":null,"optional_twostring":null}`,
want: `{"normal_string":"","normal_bool":false,"optional_two_bool":null,"optional_two_string":null}`,
},
{
name: "some",
@@ -44,12 +49,12 @@ func TestOptionalToJson(t *testing.T) {
OptTwoBool: optional.None[bool](),
OptTwoString: optional.None[string](),
},
want: `{"normal_string":"a string","normal_bool":true,"optional_bool":false,"optional_string":"","optional_two_bool":null,"optional_twostring":null}`,
want: `{"normal_string":"a string","normal_bool":true,"optional_bool":false,"optional_string":"","optional_two_bool":null,"optional_two_string":null}`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
b, err := json.Marshal(tc.obj)
b, err := json.MarshalKeepOptionalEmpty(tc.obj)
assert.NoError(t, err)
assert.Equal(t, tc.want, string(b), "gitea json module returned unexpected")
@@ -75,7 +80,7 @@ func TestOptionalFromJson(t *testing.T) {
},
{
name: "some",
data: `{"normal_string":"a string","normal_bool":true,"optional_bool":false,"optional_string":"","optional_two_bool":null,"optional_twostring":null}`,
data: `{"normal_string":"a string","normal_bool":true,"optional_bool":false,"optional_string":"","optional_two_bool":null,"optional_two_string":null}`,
want: testSerializationStruct{
NormalString: "a string",
NormalBool: true,
@@ -169,7 +174,7 @@ normal_bool: true
optional_bool: false
optional_string: ""
optional_two_bool: null
optional_twostring: null
optional_two_string: null
`,
want: testSerializationStruct{
NormalString: "a string",

View File

@@ -103,7 +103,9 @@ func ParseImageConfig(mediaType 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 {
// FIXME: JSON-KEY-CASE: here seems a abuse of the case-insensitive decoding feature, spec is case-sensitive
// https://github.com/opencontainers/image-spec/blob/main/schema/config-schema.json
if err := json.NewDecoderCaseInsensitive(r).Decode(&image); err != nil {
return nil, err
}

View File

@@ -22,6 +22,8 @@ func TestParseImageConfig(t *testing.T) {
repositoryURL := "https://gitea.com/gitea"
documentationURL := "https://docs.gitea.com"
// FIXME: JSON-KEY-CASE: the test case is not right, the config fields are capitalized in the spec
// https://github.com/opencontainers/image-spec/blob/main/schema/config-schema.json
configOCI := `{"config": {"labels": {"` + labelAuthors + `": "` + author + `", "` + labelLicenses + `": "` + license + `", "` + labelURL + `": "` + projectURL + `", "` + labelSource + `": "` + repositoryURL + `", "` + labelDocumentation + `": "` + documentationURL + `", "` + labelDescription + `": "` + description + `"}}, "history": [{"created_by": "do it 1"}, {"created_by": "dummy #(nop) do it 2"}]}`
metadata, err := ParseImageConfig(oci.MediaTypeImageManifest, strings.NewReader(configOCI))

View File

@@ -5,6 +5,7 @@ package session
import (
"context"
"fmt"
"log"
"sync"
@@ -121,12 +122,12 @@ func (p *DBProvider) Read(sid string) (session.RawStore, error) {
}
// Exist returns true if session with given ID exists.
func (p *DBProvider) Exist(sid string) bool {
func (p *DBProvider) Exist(sid string) (bool, error) {
has, err := auth.ExistSession(dbContext(), sid)
if err != nil {
panic("session/DB: error checking existence: " + err.Error())
return false, fmt.Errorf("session/DB: error checking existence: %w", err)
}
return has
return has, nil
}
// Destroy deletes a session by session ID.
@@ -155,12 +156,12 @@ func (p *DBProvider) Regenerate(oldsid, sid string) (_ session.RawStore, err err
}
// Count counts and returns number of sessions.
func (p *DBProvider) Count() int {
func (p *DBProvider) Count() (int, error) {
total, err := auth.CountSessions(dbContext())
if err != nil {
panic("session/DB: error counting records: " + err.Error())
return 0, fmt.Errorf("session/DB: error counting records: %w", err)
}
return int(total)
return int(total), nil
}
// GC calls GC to clean expired sessions.

View File

@@ -135,10 +135,12 @@ func (p *RedisProvider) Init(maxlifetime int64, configs string) (err error) {
// Read returns raw session store by session ID.
func (p *RedisProvider) Read(sid string) (session.RawStore, error) {
psid := p.prefix + sid
if !p.Exist(sid) {
if exist, err := p.Exist(sid); err == nil && !exist {
if err := p.c.Set(graceful.GetManager().HammerContext(), psid, "", p.duration).Err(); err != nil {
return nil, err
}
} else if err != nil {
return nil, err
}
var kv map[any]any
@@ -159,9 +161,9 @@ func (p *RedisProvider) Read(sid string) (session.RawStore, error) {
}
// Exist returns true if session with given ID exists.
func (p *RedisProvider) Exist(sid string) bool {
func (p *RedisProvider) Exist(sid string) (bool, error) {
v, err := p.c.Exists(graceful.GetManager().HammerContext(), p.prefix+sid).Result()
return err == nil && v == 1
return err == nil && v == 1, err
}
// Destroy deletes a session by session ID.
@@ -174,13 +176,18 @@ func (p *RedisProvider) Regenerate(oldsid, sid string) (_ session.RawStore, err
poldsid := p.prefix + oldsid
psid := p.prefix + sid
if p.Exist(sid) {
if exist, err := p.Exist(sid); err != nil {
return nil, err
} else if exist {
return nil, fmt.Errorf("new sid '%s' already exists", sid)
} else if !p.Exist(oldsid) {
}
if exist, err := p.Exist(oldsid); err == nil && !exist {
// Make a fake old session.
if err = p.c.Set(graceful.GetManager().HammerContext(), poldsid, "", p.duration).Err(); err != nil {
if err := p.c.Set(graceful.GetManager().HammerContext(), poldsid, "", p.duration).Err(); err != nil {
return nil, err
}
} else if err != nil {
return nil, err
}
// do not use Rename here, because the old sid and new sid may be in different redis cluster slot.
@@ -211,12 +218,9 @@ func (p *RedisProvider) Regenerate(oldsid, sid string) (_ session.RawStore, err
}
// Count counts and returns number of sessions.
func (p *RedisProvider) Count() int {
func (p *RedisProvider) Count() (int, error) {
size, err := p.c.DBSize(graceful.GetManager().HammerContext()).Result()
if err != nil {
return 0
}
return int(size)
return int(size), err
}
// GC calls GC to clean expired sessions.

View File

@@ -59,8 +59,10 @@ func (o *VirtualSessionProvider) Init(gcLifetime int64, config string) error {
func (o *VirtualSessionProvider) Read(sid string) (session.RawStore, error) {
o.lock.RLock()
defer o.lock.RUnlock()
if o.provider.Exist(sid) {
if exist, err := o.provider.Exist(sid); err == nil && exist {
return o.provider.Read(sid)
} else if err != nil {
return nil, fmt.Errorf("check if '%s' exist failed: %w", sid, err)
}
kv := make(map[any]any)
kv["_old_uid"] = "0"
@@ -68,8 +70,8 @@ func (o *VirtualSessionProvider) Read(sid string) (session.RawStore, error) {
}
// Exist returns true if session with given ID exists.
func (o *VirtualSessionProvider) Exist(sid string) bool {
return true
func (o *VirtualSessionProvider) Exist(sid string) (bool, error) {
return true, nil
}
// Destroy deletes a session by session ID.
@@ -87,7 +89,7 @@ func (o *VirtualSessionProvider) Regenerate(oldsid, sid string) (session.RawStor
}
// Count counts and returns number of sessions.
func (o *VirtualSessionProvider) Count() int {
func (o *VirtualSessionProvider) Count() (int, error) {
o.lock.RLock()
defer o.lock.RUnlock()
return o.provider.Count()
@@ -162,9 +164,13 @@ func (s *VirtualStore) Release() error {
// Now ensure that we don't exist!
realProvider := s.p.provider
if !s.released && realProvider.Exist(s.sid) {
// This is an error!
return fmt.Errorf("new sid '%s' already exists", s.sid)
if !s.released {
if exist, err := realProvider.Exist(s.sid); err == nil && exist {
// This is an error!
return fmt.Errorf("new sid '%s' already exists", s.sid)
} else if err != nil {
return fmt.Errorf("check if '%s' exist failed: %w", s.sid, err)
}
}
realStore, err := realProvider.Read(s.sid)
if err != nil {

View File

@@ -1,57 +0,0 @@
// Copyright 2016 The Gogs Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package sync
import (
"sync"
"code.gitea.io/gitea/modules/container"
)
// StatusTable is a table maintains true/false values.
//
// This table is particularly useful for un/marking and checking values
// in different goroutines.
type StatusTable struct {
lock sync.RWMutex
pool container.Set[string]
}
// NewStatusTable initializes and returns a new StatusTable object.
func NewStatusTable() *StatusTable {
return &StatusTable{
pool: make(container.Set[string]),
}
}
// StartIfNotRunning sets value of given name to true if not already in pool.
// Returns whether set value was set to true
func (p *StatusTable) StartIfNotRunning(name string) bool {
p.lock.Lock()
added := p.pool.Add(name)
p.lock.Unlock()
return added
}
// Start sets value of given name to true in the pool.
func (p *StatusTable) Start(name string) {
p.lock.Lock()
p.pool.Add(name)
p.lock.Unlock()
}
// Stop sets value of given name to false in the pool.
func (p *StatusTable) Stop(name string) {
p.lock.Lock()
p.pool.Remove(name)
p.lock.Unlock()
}
// IsRunning checks if value of given name is set to true in the pool.
func (p *StatusTable) IsRunning(name string) bool {
p.lock.RLock()
exists := p.pool.Contains(name)
p.lock.RUnlock()
return exists
}

View File

@@ -1,31 +0,0 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package sync
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_StatusTable(t *testing.T) {
table := NewStatusTable()
assert.False(t, table.IsRunning("xyz"))
table.Start("xyz")
assert.True(t, table.IsRunning("xyz"))
assert.False(t, table.StartIfNotRunning("xyz"))
assert.True(t, table.IsRunning("xyz"))
table.Stop("xyz")
assert.False(t, table.IsRunning("xyz"))
assert.True(t, table.StartIfNotRunning("xyz"))
assert.True(t, table.IsRunning("xyz"))
table.Stop("xyz")
assert.False(t, table.IsRunning("xyz"))
}

View File

@@ -44,9 +44,11 @@ func isRoutePathExpensive(routePattern string) bool {
"/{username}/{reponame}/blame/",
"/{username}/{reponame}/commit/",
"/{username}/{reponame}/commits/",
"/{username}/{reponame}/compare/",
"/{username}/{reponame}/graph",
"/{username}/{reponame}/media/",
"/{username}/{reponame}/raw/",
"/{username}/{reponame}/rss/branch/",
"/{username}/{reponame}/src/",
// issue & PR related (no trailing slash)

View File

@@ -107,8 +107,8 @@ func ForwardedHeadersHandler(limit int, trustedProxies []string) func(h http.Han
return proxy.ForwardedHeaders(opt)
}
func Sessioner() func(next http.Handler) http.Handler {
return session.Sessioner(session.Options{
func Sessioner() (func(next http.Handler) http.Handler, error) {
middleware, err := session.Sessioner(session.Options{
Provider: setting.SessionConfig.Provider,
ProviderConfig: setting.SessionConfig.ProviderConfig,
CookieName: setting.SessionConfig.CookieName,
@@ -119,4 +119,9 @@ func Sessioner() func(next http.Handler) http.Handler {
SameSite: setting.SessionConfig.SameSite,
Domain: setting.SessionConfig.Domain,
})
if err != nil {
return nil, fmt.Errorf("failed to create session middleware: %w", err)
}
return middleware, nil
}

View File

@@ -177,7 +177,7 @@ func InitWebInstalled(ctx context.Context) {
mustInit(repo_service.InitLicenseClassifier)
// Finally start up the cron
cron.NewContext(ctx)
cron.Init(ctx)
}
// NormalRoutes represents non install routes

View File

@@ -8,6 +8,7 @@ import (
"html"
"net/http"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web"
@@ -23,7 +24,11 @@ func Routes() *web.Router {
base.Methods("GET, HEAD", "/assets/*", public.FileHandlerFunc())
r := web.NewRouter()
r.Use(common.Sessioner(), Contexter())
if sessionMid, err := common.Sessioner(); err == nil && sessionMid != nil {
r.Use(sessionMid, Contexter())
} else {
log.Fatal("common.Sessioner failed: %v", err)
}
r.Get("/", Install) // it must be on the root, because the "install.js" use the window.location to replace the "localhost" AppURL
r.Post("/", web.Bind(forms.InstallForm{}), SubmitInstall)
r.Get("/post-install", InstallDone)

View File

@@ -267,7 +267,11 @@ func Routes() *web.Router {
routes.Get("/ssh_info", misc.SSHInfo)
routes.Get("/api/healthz", healthcheck.Check)
mid = append(mid, common.Sessioner(), context.Contexter())
if sessionMid, err := common.Sessioner(); err == nil && sessionMid != nil {
mid = append(mid, sessionMid, context.Contexter())
} else {
log.Fatal("common.Sessioner failed: %v", err)
}
// Get user from session if logged in.
mid = append(mid, webAuth(buildAuthGroup()))

View File

@@ -11,7 +11,6 @@ import (
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/process"
"code.gitea.io/gitea/modules/sync"
"code.gitea.io/gitea/modules/translation"
"github.com/go-co-op/gocron"
@@ -19,13 +18,10 @@ import (
var scheduler = gocron.NewScheduler(time.Local)
// Prevent duplicate running tasks.
var taskStatusTable = sync.NewStatusTable()
// NewContext begins cron tasks
// Init begins cron tasks
// Each cron task is run within the shutdown context as a running server
// AtShutdown the cron server is stopped
func NewContext(original context.Context) {
func Init(original context.Context) {
defer pprof.SetGoroutineLabels(original)
_, _, finished := process.GetManager().AddTypedContext(graceful.GetManager().ShutdownContext(), "Service: Cron", process.SystemProcessType, true)
initBasicTasks()

View File

@@ -14,6 +14,7 @@ import (
"code.gitea.io/gitea/models/db"
system_model "code.gitea.io/gitea/models/system"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/globallock"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/process"
@@ -71,20 +72,30 @@ func (t *Task) Run() {
}, t.config)
}
func getCronTaskLockKey(name string) string {
return "cron_task:" + name
}
// RunWithUser will run the task incrementing the cron counter at the time with User
func (t *Task) RunWithUser(doer *user_model.User, config Config) {
if !taskStatusTable.StartIfNotRunning(t.Name) {
locked, releaser, err := globallock.TryLock(graceful.GetManager().ShutdownContext(), getCronTaskLockKey(t.Name))
if err != nil {
log.Error("Failed to acquire lock for cron task %q: %v", t.Name, err)
return
}
if !locked {
log.Trace("a cron task %q is already running", t.Name)
return
}
defer releaser()
t.lock.Lock()
if config == nil {
config = t.config
}
t.ExecTimes++
t.lock.Unlock()
defer func() {
taskStatusTable.Stop(t.Name)
}()
graceful.GetManager().RunWithShutdownContext(func(baseCtx context.Context) {
defer func() {
if err := recover(); err != nil {

View File

@@ -131,24 +131,74 @@ func TestWebhookDeliverHookTask(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
done := make(chan struct{}, 1)
version2Body := `{
"body": "[[test/repo](http://localhost:3000/test/repo)] user1 pushed 2 commits to [test](http://localhost:3000/test/repo/src/branch/test):\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778): commit message - user1\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778): commit message - user1",
"msgtype": "",
"format": "org.matrix.custom.html",
"formatted_body": "[<a href=\"http://localhost:3000/test/repo\">test/repo</a>] user1 pushed 2 commits to <a href=\"http://localhost:3000/test/repo/src/branch/test\">test</a>:<br><a href=\"http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778\">2020558</a>: commit message - user1<br><a href=\"http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778\">2020558</a>: commit message - user1",
"io.gitea.commits": [
{
"id": "2020558fe2e34debb818a514715839cabd25e778",
"message": "commit message",
"url": "http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778",
"author": {
"name": "user1",
"email": "user1@localhost",
"username": "user1"
},
"committer": {
"name": "user1",
"email": "user1@localhost",
"username": "user1"
},
"verification": null,
"timestamp": "0001-01-01T00:00:00Z",
"added": null,
"removed": null,
"modified": null
},
{
"id": "2020558fe2e34debb818a514715839cabd25e778",
"message": "commit message",
"url": "http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778",
"author": {
"name": "user1",
"email": "user1@localhost",
"username": "user1"
},
"committer": {
"name": "user1",
"email": "user1@localhost",
"username": "user1"
},
"verification": null,
"timestamp": "0001-01-01T00:00:00Z",
"added": null,
"removed": null,
"modified": null
}
]
}`
testVersion := 0
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "PUT", r.Method)
switch r.URL.Path {
case "/webhook/66d222a5d6349e1311f551e50722d837e30fce98":
// Version 1
assert.True(t, strings.HasPrefix(r.URL.Path, "/webhook/"))
assert.Len(t, r.URL.Path, len("/webhook/")+40) // +40 for txnID, a unique ID from payload's sha1 hash
switch testVersion {
case 1: // Version 1
assert.Equal(t, "push", r.Header.Get("X-GitHub-Event"))
assert.Empty(t, r.Header.Get("Content-Type"))
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
assert.Equal(t, `{"data": 42}`, string(body))
case "/webhook/6db5dc1e282529a8c162c7fe93dd2667494eeb51":
// Version 2
case 2: // Version 2
assert.Equal(t, "push", r.Header.Get("X-GitHub-Event"))
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
assert.Len(t, body, 2147)
assert.JSONEq(t, version2Body, string(body))
default:
w.WriteHeader(http.StatusNotFound)
@@ -172,6 +222,7 @@ func TestWebhookDeliverHookTask(t *testing.T) {
assert.NoError(t, webhook_model.CreateWebhook(t.Context(), hook))
t.Run("Version 1", func(t *testing.T) {
testVersion = 1
hookTask := &webhook_model.HookTask{
HookID: hook.ID,
EventType: webhook_module.HookEventPush,
@@ -198,6 +249,7 @@ func TestWebhookDeliverHookTask(t *testing.T) {
data, err := p.JSONPayload()
assert.NoError(t, err)
testVersion = 2
hookTask := &webhook_model.HookTask{
HookID: hook.ID,
EventType: webhook_module.HookEventPush,

View File

@@ -274,6 +274,7 @@ func getMessageBody(htmlText string) string {
// getMatrixTxnID computes the transaction ID to ensure idempotency
func getMatrixTxnID(payload []byte) (string, error) {
payload = bytes.TrimSpace(payload)
if len(payload) >= matrixPayloadSizeLimit {
return "", fmt.Errorf("getMatrixTxnID: payload size %d > %d", len(payload), matrixPayloadSizeLimit)
}

View File

@@ -4,6 +4,7 @@
package webhook
import (
"strings"
"testing"
webhook_model "code.gitea.io/gitea/models/webhook"
@@ -216,7 +217,9 @@ func TestMatrixJSONPayload(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "PUT", req.Method)
assert.Equal(t, "/_matrix/client/r0/rooms/ROOM_ID/send/m.room.message/6db5dc1e282529a8c162c7fe93dd2667494eeb51", req.URL.Path)
txnID, ok := strings.CutPrefix(req.URL.Path, "/_matrix/client/r0/rooms/ROOM_ID/send/m.room.message/")
assert.True(t, ok)
assert.Len(t, txnID, 40) // txnID is just a unique ID for a webhook request, it is a sha1 hash from the payload
assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256"))
assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
var body MatrixPayload

View File

@@ -318,7 +318,7 @@ func TestPackageSwift(t *testing.T) {
AddBasicAuth(user.Name)
resp = MakeRequest(t, req, http.StatusOK)
assert.Equal(t, body, resp.Body.String())
assert.JSONEq(t, body, resp.Body.String())
})
t.Run("PackageVersionMetadata", func(t *testing.T) {

View File

@@ -121,10 +121,10 @@ func TestAPIRepoBranchesMirror(t *testing.T) {
resp = MakeRequest(t, req, http.StatusForbidden)
bs, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t, "{\"message\":\"Git Repository is a mirror.\",\"url\":\""+setting.AppURL+"api/swagger\"}\n", string(bs))
assert.JSONEq(t, "{\"message\":\"Git Repository is a mirror.\",\"url\":\""+setting.AppURL+"api/swagger\"}", string(bs))
resp = MakeRequest(t, NewRequest(t, "DELETE", link2.String()).AddTokenAuth(token), http.StatusForbidden)
bs, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t, "{\"message\":\"Git Repository is a mirror.\",\"url\":\""+setting.AppURL+"api/swagger\"}\n", string(bs))
assert.JSONEq(t, "{\"message\":\"Git Repository is a mirror.\",\"url\":\""+setting.AppURL+"api/swagger\"}", string(bs))
}

View File

@@ -413,7 +413,8 @@ func logUnexpectedResponse(t testing.TB, recorder *httptest.ResponseRecorder) {
func DecodeJSON(t testing.TB, resp *httptest.ResponseRecorder, v any) {
t.Helper()
decoder := json.NewDecoder(resp.Body)
// FIXME: JSON-KEY-CASE: for testing purpose only, because many structs don't provide `json` tags, they just use capitalized field names
decoder := json.NewDecoderCaseInsensitive(resp.Body)
require.NoError(t, decoder.Decode(v))
}