Why: Deliver U7 account transactions and audit without losing main auth_version, restricted-session, project or migration behavior. Evidence: SELF go vet and all Go tests passed; isolated PostgreSQL store tests, 20 repeated two-process admission rounds, and real SIGTERM/SIGKILL persistence passed. Original failures remain in receipts; exact-head CI and independent Review follow. Impact: Adds schema v31, noncreating account CLI opens, preflight credential output, same-transaction audit, and bootstrap admission guards. No live database touched. Refs: Backend PR #185; Dev PR #486; original U7 r2 account specification; 2026-10-14 Root takeover
146 lines
4.1 KiB
Go
146 lines
4.1 KiB
Go
package store
|
||
|
||
// 账户生命周期并发合同(R5/R11/KTD12,2026-10-13 收尾):
|
||
// 并发建号不覆盖、首次引导单胜、兑换退避跨实例共担。
|
||
|
||
import (
|
||
"path/filepath"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 并发建号同名:恰一胜;胜者角色/密码不被败者覆盖(旧实现 UPDATE 步会互相覆盖)。
|
||
func TestConcurrentCreateAccountNoOverwrite(t *testing.T) {
|
||
dir := t.TempDir()
|
||
db := filepath.Join(dir, "shared.db")
|
||
st, err := OpenSQLite(db)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { st.Close() })
|
||
hash, err := HashPassword("winner-pass-1")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var wins, losses atomic.Int32
|
||
var wg sync.WaitGroup
|
||
for i := 0; i < 16; i++ {
|
||
admin := i%2 == 0 // 一半以 admin 建同名账户
|
||
wg.Add(1)
|
||
go func(admin bool) {
|
||
defer wg.Done()
|
||
if err := st.CreateAccount("dupe", "", hash, admin); err == nil {
|
||
wins.Add(1)
|
||
} else if err == ErrConflict {
|
||
losses.Add(1)
|
||
}
|
||
}(admin)
|
||
}
|
||
wg.Wait()
|
||
if wins.Load() != 1 || losses.Load() != 15 {
|
||
t.Fatalf("wins=%d losses=%d, want 1/15", wins.Load(), losses.Load())
|
||
}
|
||
got, err := st.GetUserAuthState("dupe")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 胜者的 hash 原样保留(没有第二个写入者覆盖密码)。
|
||
if !got.HasPassword {
|
||
t.Fatal("winner password must survive concurrent creates")
|
||
}
|
||
if _, ok := st.VerifyLogin("dupe", "winner-pass-1"); !ok {
|
||
t.Fatal("winner credentials must verify")
|
||
}
|
||
}
|
||
|
||
// 首次管理员引导并发:两个并发引导恰一胜;已含用户时拒绝接管。
|
||
func TestBootstrapFirstAdminSingleWinner(t *testing.T) {
|
||
dir := t.TempDir()
|
||
db := filepath.Join(dir, "boot.db")
|
||
st, err := OpenSQLite(db)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { st.Close() })
|
||
if err := st.EnsureSystemIdentity("bootstrap-test", "sqlite"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var wins, conflicts atomic.Int32
|
||
var wg sync.WaitGroup
|
||
for i := 0; i < 8; i++ {
|
||
owner := "admin-a"
|
||
if i%2 == 1 {
|
||
owner = "admin-b"
|
||
}
|
||
wg.Add(1)
|
||
go func(owner string) {
|
||
defer wg.Done()
|
||
if _, err := st.BootstrapFirstAdmin(owner, owner, "test"); err == nil {
|
||
wins.Add(1)
|
||
} else if err == ErrConflict {
|
||
conflicts.Add(1)
|
||
} else {
|
||
t.Errorf("bootstrap contender: %v", err)
|
||
}
|
||
}(owner)
|
||
}
|
||
wg.Wait()
|
||
if wins.Load() != 1 || conflicts.Load() != 7 {
|
||
t.Fatalf("wins=%d conflicts=%d, want 1/7", wins.Load(), conflicts.Load())
|
||
}
|
||
users, err := st.ListUsersAdmin()
|
||
if err != nil || len(users) != 1 {
|
||
t.Fatalf("exactly one admin must exist: %d %v", len(users), err)
|
||
}
|
||
if !users[0].IsAdmin {
|
||
t.Fatal("bootstrapped account must be admin")
|
||
}
|
||
// 已有用户后再次引导:拒绝接管。
|
||
if _, err := st.BootstrapFirstAdmin("admin-c", "admin-c", "test"); err != ErrConflict {
|
||
t.Fatalf("second bootstrap after users exist: %v", err)
|
||
}
|
||
}
|
||
|
||
// 兑换退避跨实例:store A 记录的失败,store B(同库另一连接)同样被限。
|
||
func TestRedeemGuardCrossStore(t *testing.T) {
|
||
dir := t.TempDir()
|
||
db := filepath.Join(dir, "guard.db")
|
||
stA, err := OpenSQLite(db)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { stA.Close() })
|
||
stB, err := OpenSQLite(db)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer stB.Close()
|
||
const max = 5
|
||
for i := 0; i < max; i++ {
|
||
if err := stA.RecordRedeemFailure("10.0.0.9"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
if blocked, err := stB.RedeemBlocked("10.0.0.9", max, time.Minute); err != nil || !blocked {
|
||
t.Fatalf("store B must see the shared limit: blocked=%v err=%v", blocked, err)
|
||
}
|
||
if blocked, err := stA.RedeemBlocked("10.0.0.8", max, time.Minute); err != nil || blocked {
|
||
t.Fatalf("other ip must pass: blocked=%v err=%v", blocked, err)
|
||
}
|
||
// 窗口外记录在下次判定时清理。
|
||
if err := stA.drainRedeemWindow(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if blocked, _ := stA.RedeemBlocked("10.0.0.9", max, time.Minute); blocked {
|
||
t.Fatal("limit must clear after the window drains")
|
||
}
|
||
}
|
||
|
||
// drainRedeemWindow 测试助手:把窗口内判定用的记录时间整体前移出窗。
|
||
func (s *Store) drainRedeemWindow() error {
|
||
_, err := s.db.Exec(s.q(`UPDATE redeem_attempts SET at=?`), time.Now().Add(-2*time.Minute).UTC().Format(time.RFC3339Nano))
|
||
return err
|
||
}
|