2026-07-26 15:18:48 +08:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"cmroubao/backend-api/internal/config"
|
|
|
|
|
"cmroubao/backend-api/internal/platform/database"
|
|
|
|
|
"cmroubao/backend-api/internal/platform/migration"
|
|
|
|
|
|
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestRunCreatesUserWithoutExposingPassword(t *testing.T) {
|
|
|
|
|
databasePath := migratedDatabase(t)
|
|
|
|
|
const secret = "correct-horse-password"
|
|
|
|
|
lookup := testLookup(databasePath, secret)
|
|
|
|
|
var output bytes.Buffer
|
|
|
|
|
|
|
|
|
|
err := run(
|
|
|
|
|
[]string{"create-user", "ADMIN", "Admin01"},
|
|
|
|
|
lookup,
|
|
|
|
|
&output,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("run(create-user) error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(output.String(), secret) ||
|
|
|
|
|
!strings.Contains(output.String(), "username=admin01 role=ADMIN") {
|
|
|
|
|
t.Fatalf("output = %q", output.String())
|
|
|
|
|
}
|
|
|
|
|
db, err := database.Open(context.Background(), databasePath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("database.Open() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer db.Close()
|
|
|
|
|
var hash string
|
|
|
|
|
if err := db.QueryRow(
|
|
|
|
|
"SELECT password_hash FROM users WHERE username = 'admin01'",
|
|
|
|
|
).Scan(&hash); err != nil {
|
|
|
|
|
t.Fatalf("query user: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if hash == secret ||
|
|
|
|
|
bcrypt.CompareHashAndPassword([]byte(hash), []byte(secret)) != nil {
|
|
|
|
|
t.Fatal("stored password is missing, plaintext, or invalid")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
output.Reset()
|
|
|
|
|
err = run(
|
|
|
|
|
[]string{"create-user", "ADMIN", "admin01"},
|
|
|
|
|
lookup,
|
|
|
|
|
&output,
|
|
|
|
|
)
|
|
|
|
|
if err == nil ||
|
|
|
|
|
strings.Contains(err.Error(), secret) ||
|
|
|
|
|
output.Len() != 0 {
|
|
|
|
|
t.Fatalf("duplicate error/output = %v / %q", err, output.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 08:43:35 +08:00
|
|
|
func TestRunResetsPasswordWithoutExposingIt(t *testing.T) {
|
|
|
|
|
databasePath := migratedDatabase(t)
|
|
|
|
|
var output bytes.Buffer
|
|
|
|
|
if err := run(
|
|
|
|
|
[]string{"create-user", "ADMIN", "Admin"},
|
|
|
|
|
testLookup(databasePath, "old-password"),
|
|
|
|
|
&output,
|
|
|
|
|
); err != nil {
|
|
|
|
|
t.Fatalf("create user: %v", err)
|
|
|
|
|
}
|
|
|
|
|
output.Reset()
|
|
|
|
|
|
|
|
|
|
err := run(
|
|
|
|
|
[]string{"reset-password", "ADMIN"},
|
|
|
|
|
testLookup(databasePath, "admin"),
|
|
|
|
|
&output,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("run(reset-password) error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(output.String(), "password=admin") ||
|
|
|
|
|
output.String() != "username=admin password_updated=true\n" {
|
|
|
|
|
t.Fatalf("output = %q", output.String())
|
|
|
|
|
}
|
|
|
|
|
db, err := database.Open(context.Background(), databasePath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("database.Open() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer db.Close()
|
|
|
|
|
var hash string
|
|
|
|
|
if err := db.QueryRow(
|
|
|
|
|
"SELECT password_hash FROM users WHERE username = 'admin'",
|
|
|
|
|
).Scan(&hash); err != nil {
|
|
|
|
|
t.Fatalf("query user: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte("admin")) != nil ||
|
|
|
|
|
bcrypt.CompareHashAndPassword([]byte(hash), []byte("old-password")) == nil {
|
|
|
|
|
t.Fatal("password was not replaced with a bcrypt hash")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-26 15:18:48 +08:00
|
|
|
func TestRunCreatesDeviceAndStoresOnlyTokenHash(t *testing.T) {
|
|
|
|
|
databasePath := migratedDatabase(t)
|
|
|
|
|
var output bytes.Buffer
|
|
|
|
|
|
|
|
|
|
err := run(
|
|
|
|
|
[]string{"create-device", "buyer-phone-01"},
|
|
|
|
|
testLookup(databasePath, ""),
|
|
|
|
|
&output,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("run(create-device) error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
lines := strings.Split(strings.TrimSpace(output.String()), "\n")
|
|
|
|
|
if len(lines) != 2 ||
|
|
|
|
|
!strings.HasPrefix(lines[0], "device_id=") ||
|
|
|
|
|
!strings.HasPrefix(lines[1], "device_token=") {
|
|
|
|
|
t.Fatalf("output = %q", output.String())
|
|
|
|
|
}
|
|
|
|
|
deviceID := strings.TrimPrefix(lines[0], "device_id=")
|
|
|
|
|
token := strings.TrimPrefix(lines[1], "device_token=")
|
|
|
|
|
db, err := database.Open(context.Background(), databasePath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("database.Open() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer db.Close()
|
|
|
|
|
var tokenHash string
|
|
|
|
|
if err := db.QueryRow(
|
|
|
|
|
"SELECT token_hash FROM devices WHERE id = ?",
|
|
|
|
|
deviceID,
|
|
|
|
|
).Scan(&tokenHash); err != nil {
|
|
|
|
|
t.Fatalf("query device: %v", err)
|
|
|
|
|
}
|
|
|
|
|
sum := sha256.Sum256([]byte(token))
|
|
|
|
|
if tokenHash == token ||
|
|
|
|
|
tokenHash != hex.EncodeToString(sum[:]) {
|
|
|
|
|
t.Fatal("stored device token is not the expected hash")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRunEnablesAndDisablesUsersAndDevices(t *testing.T) {
|
|
|
|
|
databasePath := migratedDatabase(t)
|
|
|
|
|
lookup := testLookup(databasePath, "correct-horse-password")
|
|
|
|
|
if err := run(
|
|
|
|
|
[]string{"create-user", "BUYER", "buyer01"},
|
|
|
|
|
lookup,
|
|
|
|
|
&bytes.Buffer{},
|
|
|
|
|
); err != nil {
|
|
|
|
|
t.Fatalf("create user: %v", err)
|
|
|
|
|
}
|
|
|
|
|
var deviceOutput bytes.Buffer
|
|
|
|
|
if err := run(
|
|
|
|
|
[]string{"create-device", "buyer-phone-01"},
|
|
|
|
|
lookup,
|
|
|
|
|
&deviceOutput,
|
|
|
|
|
); err != nil {
|
|
|
|
|
t.Fatalf("create device: %v", err)
|
|
|
|
|
}
|
|
|
|
|
deviceID := strings.TrimPrefix(
|
|
|
|
|
strings.Split(strings.TrimSpace(deviceOutput.String()), "\n")[0],
|
|
|
|
|
"device_id=",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var output bytes.Buffer
|
|
|
|
|
if err := run(
|
|
|
|
|
[]string{"disable-user", "BUYER01"},
|
|
|
|
|
lookup,
|
|
|
|
|
&output,
|
|
|
|
|
); err != nil {
|
|
|
|
|
t.Fatalf("disable user: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(output.String(), "username=buyer01 active=false") {
|
|
|
|
|
t.Fatalf("disable user output = %q", output.String())
|
|
|
|
|
}
|
|
|
|
|
output.Reset()
|
|
|
|
|
if err := run(
|
|
|
|
|
[]string{"disable-device", deviceID},
|
|
|
|
|
lookup,
|
|
|
|
|
&output,
|
|
|
|
|
); err != nil {
|
|
|
|
|
t.Fatalf("disable device: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
db, err := database.Open(context.Background(), databasePath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("database.Open() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer db.Close()
|
|
|
|
|
var userActive, deviceEnabled bool
|
|
|
|
|
if err := db.QueryRow(
|
|
|
|
|
"SELECT is_active FROM users WHERE username = 'buyer01'",
|
|
|
|
|
).Scan(&userActive); err != nil {
|
|
|
|
|
t.Fatalf("query user status: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := db.QueryRow(
|
|
|
|
|
"SELECT is_enabled FROM devices WHERE id = ?",
|
|
|
|
|
deviceID,
|
|
|
|
|
).Scan(&deviceEnabled); err != nil {
|
|
|
|
|
t.Fatalf("query device status: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if userActive || deviceEnabled {
|
|
|
|
|
t.Fatalf(
|
|
|
|
|
"user/device enabled = %v/%v",
|
|
|
|
|
userActive,
|
|
|
|
|
deviceEnabled,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRunRejectsWeakPasswordAndInvalidCommands(t *testing.T) {
|
|
|
|
|
databasePath := migratedDatabase(t)
|
|
|
|
|
err := run(
|
|
|
|
|
[]string{"create-user", "BUYER", "buyer"},
|
2026-07-29 08:43:35 +08:00
|
|
|
testLookup(databasePath, "tiny"),
|
2026-07-26 15:18:48 +08:00
|
|
|
&bytes.Buffer{},
|
|
|
|
|
)
|
|
|
|
|
if err == nil || err.Error() != "authentication input is invalid" {
|
|
|
|
|
t.Fatalf("weak password error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
for _, arguments := range [][]string{
|
|
|
|
|
nil,
|
|
|
|
|
{"create-user", "OWNER", "user"},
|
|
|
|
|
{"create-device"},
|
|
|
|
|
{"unknown"},
|
|
|
|
|
} {
|
|
|
|
|
if err := run(
|
|
|
|
|
arguments,
|
|
|
|
|
testLookup(databasePath, ""),
|
|
|
|
|
&bytes.Buffer{},
|
|
|
|
|
); err == nil {
|
|
|
|
|
t.Fatalf("run(%v) error = nil", arguments)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRunRequiresCurrentMigrations(t *testing.T) {
|
|
|
|
|
databasePath := filepath.Join(t.TempDir(), "pending.db")
|
|
|
|
|
err := run(
|
|
|
|
|
[]string{"create-device", "phone"},
|
|
|
|
|
testLookup(databasePath, ""),
|
|
|
|
|
&bytes.Buffer{},
|
|
|
|
|
)
|
|
|
|
|
if err == nil ||
|
|
|
|
|
err.Error() != "database migrations are pending; run migrate up" {
|
|
|
|
|
t.Fatalf("pending migration error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func migratedDatabase(t *testing.T) string {
|
|
|
|
|
t.Helper()
|
|
|
|
|
databasePath := filepath.Join(t.TempDir(), "authctl.db")
|
|
|
|
|
db, err := database.Open(context.Background(), databasePath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("database.Open() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
runner, err := migration.New(db)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("migration.New() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := runner.Up(context.Background()); err != nil {
|
|
|
|
|
t.Fatalf("migration.Up() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := db.Close(); err != nil {
|
|
|
|
|
t.Fatalf("db.Close() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
return databasePath
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testLookup(
|
|
|
|
|
databasePath string,
|
|
|
|
|
password string,
|
|
|
|
|
) config.LookupEnvironment {
|
|
|
|
|
return func(name string) (string, bool) {
|
|
|
|
|
switch name {
|
|
|
|
|
case config.DatabasePathEnvironment:
|
|
|
|
|
return databasePath, true
|
|
|
|
|
case passwordEnvironment:
|
|
|
|
|
if password == "" {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
return password, true
|
|
|
|
|
default:
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|