141 lines
5.3 KiB
Go
141 lines
5.3 KiB
Go
package adminbootstrap
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "github.com/go-sql-driver/mysql"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// This test creates only random, synthetic accounts and is opt-in so it cannot
|
|
// run against a database unless the caller explicitly supplies a test DSN.
|
|
func TestBootstrapMySQL(t *testing.T) {
|
|
if os.Getenv("CHORUS_RUN_ADMIN_BOOTSTRAP_TESTS") != "1" {
|
|
t.Skip("set CHORUS_RUN_ADMIN_BOOTSTRAP_TESTS=1 for the isolated admin bootstrap database")
|
|
}
|
|
dsn := strings.TrimSpace(os.Getenv("CHORUS_ADMIN_BOOTSTRAP_TEST_DSN"))
|
|
if dsn == "" {
|
|
t.Fatal("CHORUS_ADMIN_BOOTSTRAP_TEST_DSN is required for the admin bootstrap integration test")
|
|
}
|
|
|
|
db, err := sql.Open("mysql", dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
|
|
defer cancel()
|
|
if err := db.PingContext(ctx); err != nil {
|
|
t.Fatal("connect isolated admin bootstrap database")
|
|
}
|
|
|
|
var roleID int64
|
|
if err := db.QueryRowContext(ctx, `SELECT role_id FROM sys_role WHERE role_key = 'chorus_operator' AND status = '2' AND deleted_at IS NULL`).Scan(&roleID); err != nil {
|
|
t.Fatal("the #20 migration baseline and #4 seed are required")
|
|
}
|
|
|
|
username := "bootstrap-test-" + randomHex(t, 8)
|
|
disabledUsername := "bootstrap-disabled-" + randomHex(t, 8)
|
|
missingRoleUsername := "bootstrap-role-" + randomHex(t, 8)
|
|
defer func() {
|
|
if _, err := db.ExecContext(context.Background(), `DELETE FROM sys_user WHERE username IN (?, ?, ?)`, username, disabledUsername, missingRoleUsername); err != nil {
|
|
t.Errorf("clean synthetic admin accounts: %v", err)
|
|
}
|
|
}()
|
|
|
|
initialPassword := randomSecret(t)
|
|
created, err := Bootstrap(ctx, db, Config{DSN: dsn, Username: username, Password: initialPassword})
|
|
if err != nil || created != Created {
|
|
t.Fatalf("initial Bootstrap() = (%v, %v), want (%v, nil)", created, err, Created)
|
|
}
|
|
|
|
storedHash := readPasswordHash(t, ctx, db, username)
|
|
if !strings.HasPrefix(storedHash, "$2") || bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(initialPassword)) != nil {
|
|
t.Fatal("created administrator password is not go-admin-compatible bcrypt")
|
|
}
|
|
var status string
|
|
var storedRoleID int64
|
|
if err := db.QueryRowContext(ctx, `SELECT status, role_id FROM sys_user WHERE username = ?`, username).Scan(&status, &storedRoleID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if status != "2" || storedRoleID != roleID {
|
|
t.Fatalf("created administrator state = (status %q, role %d), want enabled chorus operator", status, storedRoleID)
|
|
}
|
|
|
|
unchanged, err := Bootstrap(ctx, db, Config{DSN: dsn, Username: username, Password: randomSecret(t)})
|
|
if err != nil || unchanged != Unchanged {
|
|
t.Fatalf("idempotent Bootstrap() = (%v, %v), want (%v, nil)", unchanged, err, Unchanged)
|
|
}
|
|
if currentHash := readPasswordHash(t, ctx, db, username); currentHash != storedHash {
|
|
t.Fatal("bootstrap without reset changed an existing password hash")
|
|
}
|
|
|
|
resetPassword := randomSecret(t)
|
|
reset, err := Bootstrap(ctx, db, Config{DSN: dsn, Username: username, Password: resetPassword, ResetPassword: true})
|
|
if err != nil || reset != PasswordReset {
|
|
t.Fatalf("reset Bootstrap() = (%v, %v), want (%v, nil)", reset, err, PasswordReset)
|
|
}
|
|
resetHash := readPasswordHash(t, ctx, db, username)
|
|
if resetHash == storedHash || bcrypt.CompareHashAndPassword([]byte(resetHash), []byte(resetPassword)) != nil {
|
|
t.Fatal("explicit reset did not replace the administrator password hash")
|
|
}
|
|
|
|
disabledHash, err := bcrypt.GenerateFromPassword([]byte(randomSecret(t)), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.ExecContext(ctx, `INSERT INTO sys_user (username, password, role_id, status) VALUES (?, ?, ?, '1')`, disabledUsername, string(disabledHash), roleID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = Bootstrap(ctx, db, Config{DSN: dsn, Username: disabledUsername, Password: randomSecret(t)})
|
|
if !errors.Is(err, ErrAccountState) {
|
|
t.Fatalf("disabled account Bootstrap() error = %v, want ErrAccountState", err)
|
|
}
|
|
if currentHash := readPasswordHash(t, ctx, db, disabledUsername); currentHash != string(disabledHash) {
|
|
t.Fatal("disabled administrator password hash changed after failed bootstrap")
|
|
}
|
|
|
|
_, err = bootstrap(ctx, db, Config{DSN: dsn, Username: missingRoleUsername, Password: randomSecret(t)}, "missing-"+randomHex(t, 8))
|
|
if !errors.Is(err, ErrMigrationPrecondition) {
|
|
t.Fatalf("missing role bootstrap error = %v, want ErrMigrationPrecondition", err)
|
|
}
|
|
var missingRoleCount int
|
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sys_user WHERE username = ?`, missingRoleUsername).Scan(&missingRoleCount); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if missingRoleCount != 0 {
|
|
t.Fatal("missing role bootstrap unexpectedly created an administrator")
|
|
}
|
|
}
|
|
|
|
func readPasswordHash(t *testing.T, ctx context.Context, db *sql.DB, username string) string {
|
|
t.Helper()
|
|
var hash string
|
|
if err := db.QueryRowContext(ctx, `SELECT password FROM sys_user WHERE username = ?`, username).Scan(&hash); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return hash
|
|
}
|
|
|
|
func randomSecret(t *testing.T) string {
|
|
t.Helper()
|
|
return randomHex(t, 24)
|
|
}
|
|
|
|
func randomHex(t *testing.T, bytes int) string {
|
|
t.Helper()
|
|
value := make([]byte, bytes)
|
|
if _, err := rand.Read(value); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return hex.EncodeToString(value)
|
|
}
|