From b1484ff75fca2880d98a7722b0995baf60c5c4cd Mon Sep 17 00:00:00 2001 From: Faks <1909645+Faks@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:33:30 +0300 Subject: [PATCH] test(handlers): setup password length against bcrypt's 72-byte limit Table-driven: 71 bytes passes, exactly 72 passes, 73 is refused, and 37 multibyte characters are refused because len(string) counts bytes. The final assertion proves bcrypt itself still refuses 73 bytes, so raising the validation constant without the library limit fails the test instead of bringing the generic 500 back quietly. White-box because the setup route is guarded by RequireSetupNotComplete and the shared fixture completes setup before any test runs, so no public path exposes this validation after setup. No build tag, because nothing here depends on a test-only production hook. core.ref not touched. Run against a Core checkout carrying the paired change, on core.ref. Commands are listed in the pull request body. --- .../handlers/setup_password_length_test.go | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 internal/handlers/setup_password_length_test.go diff --git a/internal/handlers/setup_password_length_test.go b/internal/handlers/setup_password_length_test.go new file mode 100644 index 0000000..bc789b4 --- /dev/null +++ b/internal/handlers/setup_password_length_test.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "strings" + "testing" + + "golang.org/x/crypto/bcrypt" + + "windshift/internal/models" +) + +// TestSetupPasswordLengthRejectsInputsOverBcryptLimit pins the setup validation +// boundary. x/crypto/bcrypt refuses inputs longer than 72 bytes instead of +// truncating them, so without the length check an over-long password reaches +// hashing and the request fails as a generic 500 with no usable message. +// +// White-box on purpose. The setup route cannot be exercised through HTTP in this +// suite, because the shared fixture completes setup before any test runs, and the +// route is guarded by RequireSetupNotComplete. The invariant here is the validation +// itself, which no public path exposes after setup. +func TestSetupPasswordLengthRejectsInputsOverBcryptLimit(t *testing.T) { + h := &SetupHandler{} + + valid := models.SetupRequest{AdminUser: models.SetupUser{ + Email: "setup-limit@example.test", + Username: "setup-limit", + FirstName: "Setup", + LastName: "Limit", + }} + + cases := []struct { + name string + password string + wantError bool + }{ + {"71 bytes passes", strings.Repeat("a", 71), false}, + {"exactly 72 bytes passes", strings.Repeat("a", 72), false}, + {"73 bytes is refused", strings.Repeat("a", 73), true}, + {"multibyte characters count as bytes", strings.Repeat("é", 37), true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := valid + req.AdminUser.Password = tc.password + + err := h.validateSetupRequest(req) + if tc.wantError { + if err == nil { + t.Fatalf("%d bytes: expected an error, got none", len(tc.password)) + } + if !strings.Contains(err.Error(), "72 bytes") { + t.Fatalf("%d bytes: error does not state the limit: %v", len(tc.password), err) + } + return + } + + if err != nil { + t.Fatalf("%d bytes: unexpected error: %v", len(tc.password), err) + } + }) + } + + // The check has to run before hashing. If the validation limit is raised + // without bcrypt's, the generic 500 comes back, so assert that bcrypt itself + // still refuses 73 bytes. + if _, err := bcrypt.GenerateFromPassword([]byte(strings.Repeat("a", 73)), bcrypt.DefaultCost); err == nil { + t.Fatal("bcrypt accepted 73 bytes, so the validation message is stale") + } +}