check / check (push) Waiting to run
Target.MaxRetries (max_retries) becomes Target.MaxAttempts (max_attempts), and the form field, parser and ceiling are renamed to match. The column is NOT NULL and a check constraint refuses a value below 1, so every target, whatever its type, is stored with at least 1. 1 is a single attempt with no circuit breaker; above 1 retries behind the breaker up to that many attempts. Both target forms refuse 0 and less; a blank field on the add target form gives 1, and on the edit form keeps the stored value. The target page shows the plain number, and the README's two paragraphs on 0 against 1 become one sentence. Model: opus-5-5
110 lines
2.6 KiB
Go
110 lines
2.6 KiB
Go
package database_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// keptField is a non-secret value planted alongside each secret, so
|
|
// the assertions below cannot pass by the model marshalling to nothing.
|
|
const keptField = "keepme"
|
|
|
|
// marshalModel encodes a model the way a future JSON handler would.
|
|
func marshalModel(t *testing.T, v any) string {
|
|
t.Helper()
|
|
|
|
encoded, err := json.Marshal(v)
|
|
require.NoError(t, err)
|
|
|
|
return string(encoded)
|
|
}
|
|
|
|
// TestModelsDoNotMarshalTheirSecrets pins the barrier for the JSON
|
|
// path. The /api/v1 route group exists and is empty; delivery's
|
|
// TargetView masks the credential for the HTML path only, so without
|
|
// these tags the first handler that marshals a model serialises the
|
|
// secret with it. Each field below is a live credential:
|
|
//
|
|
// - Target.Config holds an incoming-webhook URL whose path segments
|
|
// are the bearer token.
|
|
// - APIKey.Key is a bearer token outright.
|
|
// - Setting.Value holds the session encryption key.
|
|
// - User.Password holds the Argon2 hash, and was already tagged.
|
|
func TestModelsDoNotMarshalTheirSecrets(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const marker = "QQMODELMARKERQQ"
|
|
|
|
cases := []struct {
|
|
name string
|
|
model any
|
|
}{
|
|
{
|
|
name: "target config",
|
|
model: database.Target{
|
|
Name: keptField,
|
|
Type: database.TargetTypeSlack,
|
|
Config: `{"webhookUrl":"https://h/s/` + marker + `"}`,
|
|
MaxAttempts: 1,
|
|
},
|
|
},
|
|
{
|
|
name: "api key",
|
|
model: database.APIKey{
|
|
Description: keptField,
|
|
Key: marker,
|
|
},
|
|
},
|
|
{
|
|
name: "setting value",
|
|
model: database.Setting{
|
|
Key: keptField,
|
|
Value: marker,
|
|
},
|
|
},
|
|
{
|
|
name: "user password hash",
|
|
model: database.User{
|
|
Username: keptField,
|
|
Password: marker,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
encoded := marshalModel(t, tc.model)
|
|
|
|
assert.NotContains(t, encoded, marker)
|
|
assert.Contains(t, encoded, keptField)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWebhookMarshalsNoTargetConfig covers the nested case: a webhook
|
|
// marshalled with its targets preloaded must not carry the credential
|
|
// through the association either.
|
|
func TestWebhookMarshalsNoTargetConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const marker = "QQNESTEDMARKERQQ"
|
|
|
|
encoded := marshalModel(t, database.Webhook{
|
|
Name: keptField,
|
|
Targets: []database.Target{{
|
|
Name: "slack",
|
|
Config: `{"webhookUrl":"https://h/s/` + marker + `"}`,
|
|
MaxAttempts: 1,
|
|
}},
|
|
})
|
|
|
|
assert.NotContains(t, encoded, marker)
|
|
assert.Contains(t, encoded, keptField)
|
|
}
|