Files
chorus/internal/adminbootstrap/mysql_integration_test.go
T

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)
}