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