Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9bb1efc7c | ||
|
|
bfdbc937c6 |
@@ -5,6 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -79,3 +80,14 @@ func (d *Database) ExportSetBannerOut(w io.Writer) {
|
|||||||
func DummyPasswordHashForTest() string {
|
func DummyPasswordHashForTest() string {
|
||||||
return dummyPasswordHash()
|
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"
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
"golang.org/x/crypto/argon2"
|
"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) {
|
func HashPassword(password string) (string, error) {
|
||||||
config := DefaultPasswordConfig()
|
config := DefaultPasswordConfig()
|
||||||
|
|
||||||
|
if testing.Testing() && !hashAtShippedCostInTest {
|
||||||
|
config.Memory = testArgon2Memory
|
||||||
|
}
|
||||||
|
|
||||||
// Generate a salt
|
// Generate a salt
|
||||||
salt := make([]byte, config.SaltLen)
|
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
|
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
|
||||||
// path. Login charges an unknown username a verification against a
|
// path. Login charges an unknown username a verification against a
|
||||||
// dummy hash so that a nonexistent account is not answered in
|
// 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.
|
// and the database, exactly as internal/handlers builds them.
|
||||||
//
|
//
|
||||||
// One application per test function, not per case: every start that
|
// 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.
|
// budget is not the place to spend that repeatedly.
|
||||||
func newServerApp(
|
func newServerApp(
|
||||||
t *testing.T, dir string,
|
t *testing.T, dir string,
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ type noopNotifier struct{}
|
|||||||
func (n *noopNotifier) Notify([]delivery.Task) {}
|
func (n *noopNotifier) Notify([]delivery.Task) {}
|
||||||
|
|
||||||
// noopEvictor satisfies handlers.New's delivery.WebhookEvictor
|
// noopEvictor satisfies handlers.New's delivery.WebhookEvictor
|
||||||
// dependency. These tests never delete a webhook, so there is
|
// dependency. No test here checks what gets evicted, so it records
|
||||||
// nothing to record.
|
// nothing.
|
||||||
type noopEvictor struct{}
|
type noopEvictor struct{}
|
||||||
|
|
||||||
func (e *noopEvictor) EvictWebhook(string) {}
|
func (e *noopEvictor) EvictWebhook(string) {}
|
||||||
@@ -763,11 +763,12 @@ func TestPasswordChange_UnderLimit_Succeeds(t *testing.T) {
|
|||||||
|
|
||||||
// --- /hooks group ---
|
// --- /hooks group ---
|
||||||
|
|
||||||
// TestHooks_ListAndNewWebhookForm gets the webhook list and the
|
// TestHooks_ListAndNewWebhookForm gets the webhook list through the
|
||||||
// new-webhook form through the production router, then submits the
|
// production router, follows both of its links to the new-webhook
|
||||||
// form to the action and with the token the page rendered. A
|
// form, then submits the form to the action and with the token the
|
||||||
// mistyped route or form action fails here; the handler tests
|
// page rendered. A mistyped route, link or form action fails here;
|
||||||
// cannot catch either, because they never route a request.
|
// the handler tests cannot catch any of them, because they never
|
||||||
|
// route a request.
|
||||||
func TestHooks_ListAndNewWebhookForm(t *testing.T) {
|
func TestHooks_ListAndNewWebhookForm(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -775,6 +776,12 @@ func TestHooks_ListAndNewWebhookForm(t *testing.T) {
|
|||||||
|
|
||||||
userID, _ := env.seedUser(t, "lister", "somepassword")
|
userID, _ := env.seedUser(t, "lister", "somepassword")
|
||||||
cookies := env.authCookies(t, userID, "lister")
|
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)
|
existing := env.seedWebhook(t, userID)
|
||||||
|
|
||||||
list := env.get("/hooks", cookies)
|
list := env.get("/hooks", cookies)
|
||||||
@@ -784,16 +791,25 @@ func TestHooks_ListAndNewWebhookForm(t *testing.T) {
|
|||||||
"the list should link the user's webhook",
|
"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 := url.Values{}
|
||||||
form.Set("csrf_token", token)
|
form.Set("csrf_token", token)
|
||||||
form.Set("name", "created")
|
form.Set("name", "created")
|
||||||
|
|
||||||
w := env.post(
|
w := env.post(action, form, cookies)
|
||||||
env.urlFrom(t, "/hooks/new", `action="(/hooks[^"]*)"`, cookies),
|
|
||||||
form, cookies,
|
|
||||||
)
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
|
||||||
var created database.Webhook
|
var created database.Webhook
|
||||||
@@ -980,9 +996,10 @@ func TestHook_TargetActions(t *testing.T) {
|
|||||||
assert.Zero(t, left, "the delete should remove the target")
|
assert.Zero(t, left, "the delete should remove the target")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHook_ResubmitFromEventLog resubmits a stored event with the
|
// TestHook_ResubmitFromEventLog follows the webhook page's "Full
|
||||||
// form on the event log page, submitted to the action and with the
|
// Event Log" link, then resubmits a stored event with the form on
|
||||||
// token the page rendered.
|
// that page, submitted to the action and with the token the page
|
||||||
|
// rendered.
|
||||||
func TestHook_ResubmitFromEventLog(t *testing.T) {
|
func TestHook_ResubmitFromEventLog(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -993,7 +1010,9 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
|
|||||||
wh := env.seedWebhook(t, userID)
|
wh := env.seedWebhook(t, userID)
|
||||||
env.seedEvent(t, wh.ID, `{"resubmit":"me"}`)
|
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)
|
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
|
# 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
|
# 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.
|
# 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
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
@@ -29,7 +34,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
"$ROOT/script/assets"
|
"$ROOT/script/assets"
|
||||||
go test -v -race -timeout 90s ./...
|
go test -v -race -p 4 -parallel 8 -timeout 90s ./...
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user