Files
Agent-System-Backend/internal/store/account_lifecycle_test.go
T
weichen 6dbe025ec1 Integrate atomic account maintenance with current authentication
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
2026-10-14 03:42:03 +00:00

146 lines
4.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}