Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9bb1efc7c | ||
|
|
bfdbc937c6 |
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
@@ -79,3 +80,14 @@ func (d *Database) ExportSetBannerOut(w io.Writer) {
|
||||
func DummyPasswordHashForTest() string {
|
||||
return dummyPasswordHash()
|
||||
}
|
||||
|
||||
// HashAtShippedCostForTest makes HashPassword hash at the shipped
|
||||
// memory cost until t ends. t must not run in parallel with other
|
||||
// tests, which would hash at that cost alongside it.
|
||||
func HashAtShippedCostForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
hashAtShippedCostInTest = true
|
||||
|
||||
t.Cleanup(func() { hashAtShippedCostInTest = false })
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
@@ -63,10 +64,30 @@ func DefaultPasswordConfig() *PasswordConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// HashPassword generates an Argon2id hash of the password
|
||||
// testArgon2Memory is the Argon2id memory cost, in KiB, that a test
|
||||
// binary hashes with: 1 MB instead of the shipped 64 MB. Every test
|
||||
// that starts a database hashes the bootstrap admin password, dozens
|
||||
// of them run in parallel, and under the race detector each 64 MB hash
|
||||
// holds about 150 MB. VerifyPassword reads the cost from the hash it
|
||||
// checks, so verification follows.
|
||||
const testArgon2Memory = 1024
|
||||
|
||||
// hashAtShippedCostInTest makes a test binary hash at the shipped
|
||||
// memory cost. Only TestHashPassword_ShippedParameters sets it.
|
||||
//
|
||||
//nolint:gochecknoglobals // set by one test, see above
|
||||
var hashAtShippedCostInTest bool
|
||||
|
||||
// HashPassword generates an Argon2id hash of the password. A binary
|
||||
// built by go test hashes at testArgon2Memory; one built by go build
|
||||
// always hashes at the defaults.
|
||||
func HashPassword(password string) (string, error) {
|
||||
config := DefaultPasswordConfig()
|
||||
|
||||
if testing.Testing() && !hashAtShippedCostInTest {
|
||||
config.Memory = testArgon2Memory
|
||||
}
|
||||
|
||||
// Generate a salt
|
||||
salt := make([]byte, config.SaltLen)
|
||||
|
||||
|
||||
@@ -192,6 +192,39 @@ func TestHashPasswordUniqueness(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHashPassword_ShippedParameters hashes and verifies through
|
||||
// HashPassword at the shipped Argon2id parameters. Every other test
|
||||
// hashes at the lower memory cost a test binary uses, so this is the
|
||||
// one that keeps production hashing covered. One hash and one
|
||||
// verification: each costs 64 MB.
|
||||
//
|
||||
//nolint:paralleltest // changes the hashing cost for the whole binary
|
||||
func TestHashPassword_ShippedParameters(t *testing.T) {
|
||||
database.HashAtShippedCostForTest(t)
|
||||
|
||||
password := "correct horse battery staple"
|
||||
|
||||
hash, err := database.HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("hashing with the shipped parameters: %v", err)
|
||||
}
|
||||
|
||||
const shipped = "$argon2id$v=19$m=65536,t=1,p=4$"
|
||||
|
||||
if !strings.HasPrefix(hash, shipped) {
|
||||
t.Errorf("hash = %q, want prefix %q", hash, shipped)
|
||||
}
|
||||
|
||||
valid, err := database.VerifyPassword(password, hash)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword() error = %v", err)
|
||||
}
|
||||
|
||||
if !valid {
|
||||
t.Error("VerifyPassword() returned false for correct password")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
|
||||
// path. Login charges an unknown username a verification against a
|
||||
// dummy hash so that a nonexistent account is not answered in
|
||||
|
||||
@@ -140,7 +140,7 @@ func (n *noopEvictor) EvictWebhook(string) {}
|
||||
// and the database, exactly as internal/handlers builds them.
|
||||
//
|
||||
// One application per test function, not per case: every start that
|
||||
// finds no account seeds one at 64 MB of Argon2id, and this package's
|
||||
// finds no account seeds one with an Argon2id hash, and this package's
|
||||
// budget is not the place to spend that repeatedly.
|
||||
func newServerApp(
|
||||
t *testing.T, dir string,
|
||||
|
||||
@@ -47,8 +47,8 @@ type noopNotifier struct{}
|
||||
func (n *noopNotifier) Notify([]delivery.Task) {}
|
||||
|
||||
// noopEvictor satisfies handlers.New's delivery.WebhookEvictor
|
||||
// dependency. These tests never delete a webhook, so there is
|
||||
// nothing to record.
|
||||
// dependency. No test here checks what gets evicted, so it records
|
||||
// nothing.
|
||||
type noopEvictor struct{}
|
||||
|
||||
func (e *noopEvictor) EvictWebhook(string) {}
|
||||
@@ -763,11 +763,12 @@ func TestPasswordChange_UnderLimit_Succeeds(t *testing.T) {
|
||||
|
||||
// --- /hooks group ---
|
||||
|
||||
// TestHooks_ListAndNewWebhookForm gets the webhook list and the
|
||||
// new-webhook form through the production router, then submits the
|
||||
// form to the action and with the token the page rendered. A
|
||||
// mistyped route or form action fails here; the handler tests
|
||||
// cannot catch either, because they never route a request.
|
||||
// TestHooks_ListAndNewWebhookForm gets the webhook list through the
|
||||
// production router, follows both of its links to the new-webhook
|
||||
// form, then submits the form to the action and with the token the
|
||||
// page rendered. A mistyped route, link or form action fails here;
|
||||
// the handler tests cannot catch any of them, because they never
|
||||
// route a request.
|
||||
func TestHooks_ListAndNewWebhookForm(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -775,6 +776,12 @@ func TestHooks_ListAndNewWebhookForm(t *testing.T) {
|
||||
|
||||
userID, _ := env.seedUser(t, "lister", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "lister")
|
||||
|
||||
// The list shows its "Create Webhook" link only while it is empty.
|
||||
createLink := env.urlFrom(
|
||||
t, "/hooks", `href="([^"]+)"[^>]*>Create Webhook<`, cookies,
|
||||
)
|
||||
|
||||
existing := env.seedWebhook(t, userID)
|
||||
|
||||
list := env.get("/hooks", cookies)
|
||||
@@ -784,16 +791,25 @@ func TestHooks_ListAndNewWebhookForm(t *testing.T) {
|
||||
"the list should link the user's webhook",
|
||||
)
|
||||
|
||||
token, cookies := env.csrfFrom(t, "/hooks/new", cookies)
|
||||
// The "New Webhook" link has an icon between its href and its text.
|
||||
newLink := env.urlFrom(
|
||||
t, "/hooks", `href="([^"]+)"[^>]*>(?:\s*<[^>]*>)*\s*New Webhook`,
|
||||
cookies,
|
||||
)
|
||||
|
||||
token, cookies := env.csrfFrom(t, newLink, cookies)
|
||||
action := env.urlFrom(t, newLink, `action="(/hooks[^"]*)"`, cookies)
|
||||
assert.Equal(
|
||||
t, action,
|
||||
env.urlFrom(t, createLink, `action="(/hooks[^"]*)"`, cookies),
|
||||
"both links should open the new-webhook form",
|
||||
)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", token)
|
||||
form.Set("name", "created")
|
||||
|
||||
w := env.post(
|
||||
env.urlFrom(t, "/hooks/new", `action="(/hooks[^"]*)"`, cookies),
|
||||
form, cookies,
|
||||
)
|
||||
w := env.post(action, form, cookies)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
|
||||
var created database.Webhook
|
||||
@@ -980,9 +996,10 @@ func TestHook_TargetActions(t *testing.T) {
|
||||
assert.Zero(t, left, "the delete should remove the target")
|
||||
}
|
||||
|
||||
// TestHook_ResubmitFromEventLog resubmits a stored event with the
|
||||
// form on the event log page, submitted to the action and with the
|
||||
// token the page rendered.
|
||||
// TestHook_ResubmitFromEventLog follows the webhook page's "Full
|
||||
// Event Log" link, then resubmits a stored event with the form on
|
||||
// that page, submitted to the action and with the token the page
|
||||
// rendered.
|
||||
func TestHook_ResubmitFromEventLog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -993,7 +1010,9 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
|
||||
wh := env.seedWebhook(t, userID)
|
||||
env.seedEvent(t, wh.ID, `{"resubmit":"me"}`)
|
||||
|
||||
logsPath := "/hook/" + wh.ID + "/events"
|
||||
logsPath := env.urlFrom(
|
||||
t, "/hook/"+wh.ID, `href="([^"]+)"[^>]*>Full Event Log<`, cookies,
|
||||
)
|
||||
|
||||
token, cookies := env.csrfFrom(t, logsPath, cookies)
|
||||
|
||||
|
||||
+6
-1
@@ -22,6 +22,11 @@
|
||||
# The one figure above 90s is GOMAXPROCS 1, a synthetic core floor rather than
|
||||
# a condition CI runs under. If a CPU-limited runner ever puts a real run near
|
||||
# 67s, that is the datum to revisit the org figure with.
|
||||
#
|
||||
# -p 4 -parallel 8 keep the run under 2 GB of memory: at most four test
|
||||
# binaries build or run at once, each with at most eight parallel tests. Under
|
||||
# -race every test binary and every link costs a few hundred MB, so the
|
||||
# defaults (one per core) add up to several GB on a many-core host.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
@@ -29,7 +34,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
"$ROOT/script/assets"
|
||||
go test -v -race -timeout 90s ./...
|
||||
go test -v -race -p 4 -parallel 8 -timeout 90s ./...
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
Reference in New Issue
Block a user