owpengram-server/cmd/telesrv-admin/adminauth_test.go
2026-09-08 17:56:26 +03:00

292 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
"telesrv/internal/identity"
)
func TestValidateAdminPassword(t *testing.T) {
cases := []struct {
name string
password string
want error
}{
{"ok", "correct horse battery", nil},
// No length floor: the operator picks the password, however short.
{"a single character", "x", nil},
{"blank", " ", errPasswordBlank},
{"empty", "", errPasswordBlank},
// bcrypt truncates silently past 72 bytes, so anything longer must be
// refused rather than accepted as a password the operator did not set.
{"past bcrypt's input limit", strings.Repeat("a", 73), errPasswordTooLong},
{"73 bytes of multibyte runes", strings.Repeat("é", 37), errPasswordTooLong},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateAdminPassword(tc.password)
if !errors.Is(err, tc.want) {
t.Fatalf("validateAdminPassword(%q) = %v, want %v", tc.password, err, tc.want)
}
})
}
}
func TestValidateAdminUsername(t *testing.T) {
valid := []string{"admin", "ops.lead", "on-call_2", strings.Repeat("a", 64)}
for _, username := range valid {
if err := validateAdminUsername(username); err != nil {
t.Errorf("validateAdminUsername(%q) = %v, want nil", username, err)
}
}
invalid := []string{
"",
"ab", // under the floor
strings.Repeat("a", 65), // over the ceiling
"has space",
"with\ttab",
"with\nnewline",
"аdmin", // Cyrillic 'а': renders like "admin" but is a different operator
"admin*", // permission wildcard has no business in a name
"a@b",
}
for _, username := range invalid {
if err := validateAdminUsername(username); !errors.Is(err, errUsernameInvalid) {
t.Errorf("validateAdminUsername(%q) = %v, want errUsernameInvalid", username, err)
}
}
}
func TestHashAdminPasswordRoundTrips(t *testing.T) {
const password = "a sufficiently long password"
hash, err := hashAdminPassword(password)
if err != nil {
t.Fatalf("hashAdminPassword: %v", err)
}
if strings.Contains(hash, password) {
t.Fatal("hash contains the plaintext")
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
t.Fatalf("hash does not verify against its own password: %v", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password+"x")); err == nil {
t.Fatal("hash verified against the wrong password")
}
if cost, err := bcrypt.Cost([]byte(hash)); err != nil || cost != bcryptCost {
t.Fatalf("cost = %d (err %v), want %d", cost, err, bcryptCost)
}
}
func TestHashAdminPasswordRejectsInvalid(t *testing.T) {
// A short password is fine; an absent one is not.
if _, err := hashAdminPassword("x"); err != nil {
t.Fatalf("a one-character password was refused: %v", err)
}
if _, err := hashAdminPassword(" "); !errors.Is(err, errPasswordBlank) {
t.Fatalf("err = %v, want errPasswordBlank", err)
}
if _, err := hashAdminPassword(strings.Repeat("a", 73)); !errors.Is(err, errPasswordTooLong) {
t.Fatalf("err = %v, want errPasswordTooLong", err)
}
}
// The dummy hash exists so a login against an unknown username costs the same
// bcrypt work as a real one. If it were malformed, CompareHashAndPassword would
// return early and hand back the timing signal it is there to remove.
func TestDummyBcryptHashIsWellFormedAndUnusable(t *testing.T) {
cost, err := bcrypt.Cost([]byte(dummyBcryptHash))
if err != nil {
t.Fatalf("dummy hash is not a valid bcrypt hash: %v", err)
}
if cost != bcryptCost {
t.Fatalf("dummy hash cost = %d, want %d -- it must cost the same as a real one", cost, bcryptCost)
}
for _, guess := range []string{"", "password", "admin", dummyBcryptHash} {
if err := bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(guess)); err == nil {
t.Fatalf("dummy hash authenticated %q", guess)
}
}
}
func TestNormalisePermissions(t *testing.T) {
cases := []struct {
name string
in []string
want []string
}{
{"trims and drops empties", []string{" a ", "", " ", "b"}, []string{"a", "b"}},
{"de-duplicates", []string{"a", "a", "b", "a"}, []string{"a", "b"}},
// A stored list that both names the wildcard and lists rights would read
// narrower than it actually is wherever it is displayed.
{"wildcard collapses everything", []string{"a", "*", "b"}, []string{permissionAll}},
{"wildcard alone", []string{"*"}, []string{permissionAll}},
{"empty stays empty", []string{}, []string{}},
{"only blanks", []string{"", " "}, []string{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := normalisePermissions(tc.in)
if len(got) != len(tc.want) {
t.Fatalf("normalisePermissions(%v) = %v, want %v", tc.in, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("normalisePermissions(%v) = %v, want %v", tc.in, got, tc.want)
}
}
})
}
}
// assignablePermissions drives the account editor. Offering "*" there would let
// a click hand out every right including admins.manage, which is exactly what
// the per-permission list exists to make deliberate.
func TestAssignablePermissionsExcludesWildcard(t *testing.T) {
for _, p := range assignablePermissions() {
if p == permissionAll {
t.Fatal("assignablePermissions offers the wildcard")
}
}
var sawAdminsManage bool
for _, p := range assignablePermissions() {
if p == permissionAdminsManage {
sawAdminsManage = true
}
}
if !sawAdminsManage {
t.Fatal("assignablePermissions omits admins.manage, so it could never be granted")
}
}
// A blank username must never authenticate, even with the correct break-glass
// secret. It briefly did, which made an empty field an unnamed second route to
// the highest-privilege login; the operator has to be asked for by name.
func TestBlankUsernameNeverAuthenticates(t *testing.T) {
s := &server{cfg: uiConfig{Password: "letmein", Permissions: []string{permissionAll}}}
for _, username := range []string{"", " ", "\t"} {
if _, ok := s.authenticateLogin(t.Context(), loginRequest{Username: username, Secret: "letmein"}); ok {
t.Fatalf("blank username %q authenticated", username)
}
}
// The same secret under the operator's actual name still works, so the
// check above is refusing the blank name rather than the credential.
identity, ok := s.authenticateLogin(t.Context(), loginRequest{Username: breakGlassUsername, Secret: "letmein"})
if !ok {
t.Fatal("the break-glass operator could not sign in by name")
}
if identity.actor != breakGlassUsername {
t.Fatalf("actor = %q, want %q", identity.actor, breakGlassUsername)
}
if identity.userID != 0 {
t.Fatalf("userID = %d, want 0 -- the break-glass operator has no database row", identity.userID)
}
}
// Case is not a way to get a different operator: the name resolves to the
// break-glass login however it is typed, matching the case-insensitive unique
// index that named accounts live under.
func TestBreakGlassUsernameIsCaseInsensitive(t *testing.T) {
s := &server{cfg: uiConfig{Password: "letmein", Permissions: []string{permissionAll}}}
for _, username := range []string{"owpengram", "OwpenGram", "OWPENGRAM", " owpengram "} {
identity, ok := s.authenticateLogin(t.Context(), loginRequest{Username: username, Secret: "letmein"})
if !ok {
t.Fatalf("%q did not resolve to the break-glass operator", username)
}
if identity.actor != breakGlassUsername {
t.Fatalf("%q signed in as %q", username, identity.actor)
}
}
if _, ok := s.authenticateLogin(t.Context(), loginRequest{Username: breakGlassUsername, Secret: "wrong"}); ok {
t.Fatal("the break-glass operator authenticated with the wrong secret")
}
}
// The break-glass password quickstart generates for the very first login
// must stop authenticating once the first-run wizard is done, but a
// password an operator actually chose -- even one that happens to still be
// sitting in .env from before the wizard finished -- must never be
// affected by that. This is the actual integration point between
// validSecret and identity.Store; the package's own tests cover
// SetupPending/TemporaryPasswordMatches in isolation.
func TestValidSecretRetiresOnlyTheGeneratedPassword(t *testing.T) {
dir := t.TempDir()
store := identity.NewStore(dir)
s := &server{cfg: uiConfig{Password: "generated-once", Permissions: []string{permissionAll}}, identity: store}
// No marker written at all yet (identity.Store's zero state) -- the
// password behaves like an ordinary one an operator set.
if !s.validSecret("generated-once") {
t.Fatal("password should authenticate before any wizard marker exists")
}
// Bootstrap-style: the setup-pending marker plus the matching
// temporary-password marker, exactly as tui-panel/server-panel.py's
// bootstrap_env() writes them on a fresh install.
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".setup_pending"), nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".admin_password_temporary"), []byte("generated-once"), 0o644); err != nil {
t.Fatal(err)
}
if !s.validSecret("generated-once") {
t.Fatal("the generated password must keep working while the wizard is still pending")
}
// Wizard finishes: MarkSetupComplete removes both markers.
if err := store.MarkSetupComplete(); err != nil {
t.Fatal(err)
}
if s.validSecret("generated-once") {
t.Fatal("the generated password must stop authenticating once setup is complete")
}
// An operator-chosen password behaves normally regardless: setting a
// new .env value (this test's stand-in for that) authenticates whether
// or not a wizard ever ran, because it never matches either marker.
s.cfg.Password = "an-operator-actually-chose-this"
if !s.validSecret("an-operator-actually-chose-this") {
t.Fatal("an operator-chosen password must authenticate after setup completion, same as always")
}
}
// A session for a named account must not be trusted on the strength of its
// signature alone: the account's rights are re-read per request, and a nil read
// store has to fail closed rather than fall back to the claims.
func TestCurrentSessionPermissionsFailsClosedWithoutStore(t *testing.T) {
s := &server{}
if _, ok := s.currentSessionPermissions(t.Context(), sessionClaims{
UserID: 7,
Epoch: 1,
Permissions: []string{permissionAll},
}); ok {
t.Fatal("a named-account session was accepted with no store to verify it against")
}
}
// The break-glass operator has no row to re-read, so it keeps the configured
// rights -- that login is the way back in when the database is unreachable.
func TestCurrentSessionPermissionsAllowsBreakGlass(t *testing.T) {
s := &server{}
perms, ok := s.currentSessionPermissions(t.Context(), sessionClaims{
UserID: 0,
Permissions: []string{permissionServerManage},
})
if !ok {
t.Fatal("break-glass session rejected")
}
if !perms.Has(permissionServerManage) {
t.Fatal("break-glass session lost its configured permission")
}
if perms.Has(permissionAdminsManage) {
t.Fatal("break-glass session gained a permission it was not configured with")
}
}