447 lines
15 KiB
Go
447 lines
15 KiB
Go
package memory
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"telesrv/internal/store"
|
|
)
|
|
|
|
func TestCodeStoreAtomicLoginStateMachine(t *testing.T) {
|
|
ctx := context.Background()
|
|
const phone = "15550016001"
|
|
newRecord := func() store.PhoneCode {
|
|
return store.PhoneCode{
|
|
Version: store.PhoneCodeVersionCurrent,
|
|
IssuedUserID: 1000000001,
|
|
Phone: phone,
|
|
Code: "12345",
|
|
Channel: "phone",
|
|
MaxAttempts: 2,
|
|
}
|
|
}
|
|
|
|
t.Run("version mismatch fails closed for every atomic entry", func(t *testing.T) {
|
|
codes := NewCodeStore()
|
|
legacy := newRecord()
|
|
legacy.Version = 0
|
|
if err := codes.Set(ctx, "legacy-verify", legacy, time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := codes.VerifyLogin(ctx, "legacy-verify", phone, legacy.Code, false, 5)
|
|
if err != nil || result.Status != store.LoginCodeVerifyMissing {
|
|
t.Fatalf("legacy VerifyLogin = %+v err=%v, want Missing", result, err)
|
|
}
|
|
if _, found, _ := codes.Get(ctx, "legacy-verify"); found {
|
|
t.Fatal("legacy VerifyLogin record was not deleted")
|
|
}
|
|
|
|
unknown := newRecord()
|
|
unknown.Version = store.PhoneCodeVersionCurrent + 1
|
|
if err := codes.Set(ctx, "unknown-take", unknown, time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, found, err := codes.TakeLoginCode(ctx, "unknown-take", phone); err != nil || found {
|
|
t.Fatalf("unknown TakeLoginCode found=%v err=%v, want false", found, err)
|
|
}
|
|
if _, found, _ := codes.Get(ctx, "unknown-take"); found {
|
|
t.Fatal("unknown TakeLoginCode record was not deleted")
|
|
}
|
|
|
|
legacy.SignUpVerified = true
|
|
if err := codes.Set(ctx, "legacy-signup", legacy, time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, found, err := codes.ConsumeSignUpVerified(ctx, "legacy-signup", phone); err != nil || found {
|
|
t.Fatalf("legacy ConsumeSignUpVerified found=%v err=%v, want false", found, err)
|
|
}
|
|
if _, found, _ := codes.Get(ctx, "legacy-signup"); found {
|
|
t.Fatal("legacy sign-up marker was not deleted")
|
|
}
|
|
})
|
|
|
|
t.Run("scope mismatch does not burn victim attempts", func(t *testing.T) {
|
|
codes := NewCodeStore()
|
|
record := newRecord()
|
|
if err := codes.Set(ctx, "scope", record, time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := codes.VerifyLogin(ctx, "scope", "15550016999", record.Code, false, 5)
|
|
if err != nil || result.Status != store.LoginCodeVerifyInvalid || result.Record.Attempts != 0 {
|
|
t.Fatalf("wrong-phone VerifyLogin = %+v err=%v", result, err)
|
|
}
|
|
stored, found, err := codes.Get(ctx, "scope")
|
|
if err != nil || !found || stored.Attempts != 0 {
|
|
t.Fatalf("wrong-phone stored=%+v found=%v err=%v", stored, found, err)
|
|
}
|
|
if _, found, err := codes.TakeLoginCode(ctx, "scope", "15550016999"); err != nil || found {
|
|
t.Fatalf("cross-phone TakeLoginCode found=%v err=%v", found, err)
|
|
}
|
|
|
|
scoped := newRecord()
|
|
scoped.Purpose = store.PhoneCodePurposeChangePhone
|
|
scoped.UserID = 42
|
|
scoped.AuthKeyID = [8]byte{1}
|
|
if err := codes.Set(ctx, "scoped", scoped, time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err = codes.VerifyLogin(ctx, "scoped", phone, scoped.Code, false, 5)
|
|
if err != nil || result.Status != store.LoginCodeVerifyInvalid {
|
|
t.Fatalf("scoped VerifyLogin = %+v err=%v, want Invalid", result, err)
|
|
}
|
|
if _, found, err := codes.TakeLoginCode(ctx, "scoped", phone); err != nil || found {
|
|
t.Fatalf("scoped TakeLoginCode found=%v err=%v", found, err)
|
|
}
|
|
if _, found, _ := codes.Get(ctx, "scoped"); !found {
|
|
t.Fatal("login operations deleted a scoped change-phone code")
|
|
}
|
|
})
|
|
|
|
t.Run("wrong code increments atomically and threshold deletes", func(t *testing.T) {
|
|
codes := NewCodeStore()
|
|
record := newRecord()
|
|
if err := codes.Set(ctx, "wrong", record, time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first, err := codes.VerifyLogin(ctx, "wrong", phone, "00000", false, 9)
|
|
if err != nil || first.Status != store.LoginCodeVerifyInvalid || first.Record.Attempts != 1 {
|
|
t.Fatalf("first wrong code = %+v err=%v", first, err)
|
|
}
|
|
stored, found, err := codes.Get(ctx, "wrong")
|
|
if err != nil || !found || stored.Attempts != 1 {
|
|
t.Fatalf("stored after first wrong = %+v found=%v err=%v", stored, found, err)
|
|
}
|
|
second, err := codes.VerifyLogin(ctx, "wrong", phone, "00000", false, 9)
|
|
if err != nil || second.Status != store.LoginCodeVerifyInvalid || second.Record.Attempts != 2 {
|
|
t.Fatalf("threshold wrong code = %+v err=%v", second, err)
|
|
}
|
|
if _, found, _ := codes.Get(ctx, "wrong"); found {
|
|
t.Fatal("threshold-exhausted code remains")
|
|
}
|
|
after, err := codes.VerifyLogin(ctx, "wrong", phone, record.Code, false, 9)
|
|
if err != nil || after.Status != store.LoginCodeVerifyMissing {
|
|
t.Fatalf("verify after exhaustion = %+v err=%v, want Missing", after, err)
|
|
}
|
|
|
|
fallback := newRecord()
|
|
fallback.MaxAttempts = 0
|
|
if err := codes.Set(ctx, "fallback", fallback, time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, err := codes.VerifyLogin(ctx, "fallback", phone, "bad", false, 1); err != nil || got.Status != store.LoginCodeVerifyInvalid {
|
|
t.Fatalf("default threshold verify = %+v err=%v", got, err)
|
|
}
|
|
if _, found, _ := codes.Get(ctx, "fallback"); found {
|
|
t.Fatal("default threshold did not delete code")
|
|
}
|
|
})
|
|
|
|
t.Run("accepted consume and sign-up marker are terminal", func(t *testing.T) {
|
|
codes := NewCodeStore()
|
|
record := newRecord()
|
|
if err := codes.Set(ctx, "consume", record, time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
accepted, err := codes.VerifyLogin(ctx, "consume", phone, record.Code, false, 5)
|
|
if err != nil || accepted.Status != store.LoginCodeVerifyAccepted || accepted.Record.SignUpVerified {
|
|
t.Fatalf("consume verify = %+v err=%v", accepted, err)
|
|
}
|
|
if _, found, _ := codes.Get(ctx, "consume"); found {
|
|
t.Fatal("accepted existing-user code remains")
|
|
}
|
|
|
|
if err := codes.Set(ctx, "issued-existing", record, time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wrongScope, err := codes.VerifyLogin(ctx, "issued-existing", phone, record.Code, true, 5)
|
|
if err != nil || wrongScope.Status != store.LoginCodeVerifyInvalid {
|
|
t.Fatalf("existing-issued keep-for-signup = %+v err=%v, want Invalid", wrongScope, err)
|
|
}
|
|
if _, found, err := codes.ConsumeSignUpVerified(ctx, "issued-existing", phone); err != nil || found {
|
|
t.Fatalf("existing-issued sign-up consume found=%v err=%v", found, err)
|
|
}
|
|
|
|
signUpRecord := record
|
|
signUpRecord.IssuedUserID = 0
|
|
if err := codes.Set(ctx, "signup", signUpRecord, time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expires := codes.m["signup"].expires
|
|
marked, err := codes.VerifyLogin(ctx, "signup", phone, record.Code, true, 5)
|
|
if err != nil || marked.Status != store.LoginCodeVerifyAccepted || !marked.Record.SignUpVerified {
|
|
t.Fatalf("sign-up verify = %+v err=%v", marked, err)
|
|
}
|
|
if got := codes.m["signup"]; !got.code.SignUpVerified || !got.expires.Equal(expires) {
|
|
t.Fatalf("sign-up marker=%+v expiry=%v, want marker with unchanged %v", got.code, got.expires, expires)
|
|
}
|
|
repeated, err := codes.VerifyLogin(ctx, "signup", phone, record.Code, true, 5)
|
|
if err != nil || repeated.Status != store.LoginCodeVerifyMissing {
|
|
t.Fatalf("repeated sign-up verify = %+v err=%v, want terminal Missing", repeated, err)
|
|
}
|
|
if _, found, err := codes.ConsumeSignUpVerified(ctx, "signup", "15550016999"); err != nil || found {
|
|
t.Fatalf("cross-phone sign-up consume found=%v err=%v", found, err)
|
|
}
|
|
if _, found, err := codes.TakeLoginCode(ctx, "signup", phone); err != nil || found {
|
|
t.Fatalf("terminal marker take found=%v err=%v, want false", found, err)
|
|
}
|
|
consumed, found, err := codes.ConsumeSignUpVerified(ctx, "signup", phone)
|
|
if err != nil || !found || !consumed.SignUpVerified || consumed.Code != signUpRecord.Code || consumed.IssuedUserID != 0 {
|
|
t.Fatalf("sign-up consume = %+v found=%v err=%v", consumed, found, err)
|
|
}
|
|
if _, found, err := codes.ConsumeSignUpVerified(ctx, "signup", phone); err != nil || found {
|
|
t.Fatalf("second sign-up consume found=%v err=%v", found, err)
|
|
}
|
|
})
|
|
|
|
t.Run("take returns the removed record exactly once", func(t *testing.T) {
|
|
codes := NewCodeStore()
|
|
record := newRecord()
|
|
if err := codes.Set(ctx, "take", record, time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expected, found, err := codes.Get(ctx, "take")
|
|
if err != nil || !found {
|
|
t.Fatalf("load take record found=%v err=%v", found, err)
|
|
}
|
|
if _, found, err := codes.TakeLoginCode(ctx, "take", "15550016999"); err != nil || found {
|
|
t.Fatalf("cross-phone take found=%v err=%v", found, err)
|
|
}
|
|
taken, found, err := codes.TakeLoginCode(ctx, "take", phone)
|
|
if err != nil || !found || taken != expected {
|
|
t.Fatalf("take = %+v found=%v err=%v, want %+v", taken, found, err, expected)
|
|
}
|
|
if _, found, err := codes.TakeLoginCode(ctx, "take", phone); err != nil || found {
|
|
t.Fatalf("second take found=%v err=%v", found, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCodeStoreAtomicLoginConcurrency(t *testing.T) {
|
|
ctx := context.Background()
|
|
const (
|
|
phone = "15550016002"
|
|
workers = 64
|
|
)
|
|
newRecord := func() store.PhoneCode {
|
|
return store.PhoneCode{
|
|
Version: store.PhoneCodeVersionCurrent,
|
|
Phone: phone,
|
|
Code: "12345",
|
|
Channel: "phone",
|
|
MaxAttempts: 7,
|
|
}
|
|
}
|
|
|
|
t.Run("consume verify has one accepted", func(t *testing.T) {
|
|
codes := NewCodeStore()
|
|
if err := codes.Set(ctx, "verify-race", newRecord(), time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
statuses := concurrentMemoryVerify(t, codes, "verify-race", phone, "12345", false, workers)
|
|
if statuses[store.LoginCodeVerifyAccepted] != 1 || statuses[store.LoginCodeVerifyMissing] != workers-1 || statuses[store.LoginCodeVerifyInvalid] != 0 {
|
|
t.Fatalf("verify race statuses = %+v", statuses)
|
|
}
|
|
})
|
|
|
|
t.Run("mark and consume each have one winner", func(t *testing.T) {
|
|
codes := NewCodeStore()
|
|
if err := codes.Set(ctx, "signup-race", newRecord(), time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
statuses := concurrentMemoryVerify(t, codes, "signup-race", phone, "12345", true, workers)
|
|
if statuses[store.LoginCodeVerifyAccepted] != 1 || statuses[store.LoginCodeVerifyMissing] != workers-1 {
|
|
t.Fatalf("sign-up verify race statuses = %+v", statuses)
|
|
}
|
|
found := concurrentMemoryConsumeSignUp(t, codes, "signup-race", phone, workers)
|
|
if found != 1 {
|
|
t.Fatalf("sign-up consumes = %d, want 1", found)
|
|
}
|
|
})
|
|
|
|
t.Run("take has one winner", func(t *testing.T) {
|
|
codes := NewCodeStore()
|
|
if err := codes.Set(ctx, "take-race", newRecord(), time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found := concurrentMemoryTake(t, codes, "take-race", phone, workers)
|
|
if found != 1 {
|
|
t.Fatalf("takes = %d, want 1", found)
|
|
}
|
|
})
|
|
|
|
t.Run("wrong attempts cannot be lost", func(t *testing.T) {
|
|
codes := NewCodeStore()
|
|
if err := codes.Set(ctx, "wrong-race", newRecord(), time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
statuses := concurrentMemoryVerify(t, codes, "wrong-race", phone, "00000", false, workers)
|
|
if statuses[store.LoginCodeVerifyInvalid] != 7 || statuses[store.LoginCodeVerifyMissing] != workers-7 {
|
|
t.Fatalf("wrong-code race statuses = %+v, want 7 Invalid then Missing", statuses)
|
|
}
|
|
if _, found, _ := codes.Get(ctx, "wrong-race"); found {
|
|
t.Fatal("wrong-code race left an exhausted code")
|
|
}
|
|
})
|
|
|
|
t.Run("verify and cancel-resend take share one winner", func(t *testing.T) {
|
|
codes := NewCodeStore()
|
|
if err := codes.Set(ctx, "mixed-race", newRecord(), time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
results := make(chan bool, workers)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < workers; i++ {
|
|
wg.Add(1)
|
|
go func(take bool) {
|
|
defer wg.Done()
|
|
if take {
|
|
_, found, err := codes.TakeLoginCode(ctx, "mixed-race", phone)
|
|
if err != nil {
|
|
t.Errorf("TakeLoginCode: %v", err)
|
|
}
|
|
results <- found
|
|
return
|
|
}
|
|
verified, err := codes.VerifyLogin(ctx, "mixed-race", phone, "12345", false, 5)
|
|
if err != nil {
|
|
t.Errorf("VerifyLogin: %v", err)
|
|
}
|
|
results <- verified.Status == store.LoginCodeVerifyAccepted
|
|
}(i%2 == 0)
|
|
}
|
|
wg.Wait()
|
|
close(results)
|
|
winners := 0
|
|
for won := range results {
|
|
if won {
|
|
winners++
|
|
}
|
|
}
|
|
if winners != 1 {
|
|
t.Fatalf("mixed verify/take winners = %d, want 1", winners)
|
|
}
|
|
})
|
|
|
|
t.Run("sign-up mark and take share one winner", func(t *testing.T) {
|
|
codes := NewCodeStore()
|
|
if err := codes.Set(ctx, "mixed-signup-race", newRecord(), time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
results := make(chan bool, workers)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < workers; i++ {
|
|
wg.Add(1)
|
|
go func(take bool) {
|
|
defer wg.Done()
|
|
if take {
|
|
_, found, err := codes.TakeLoginCode(ctx, "mixed-signup-race", phone)
|
|
if err != nil {
|
|
t.Errorf("TakeLoginCode: %v", err)
|
|
}
|
|
results <- found
|
|
return
|
|
}
|
|
verified, err := codes.VerifyLogin(ctx, "mixed-signup-race", phone, "12345", true, 5)
|
|
if err != nil {
|
|
t.Errorf("VerifyLogin: %v", err)
|
|
}
|
|
results <- verified.Status == store.LoginCodeVerifyAccepted
|
|
}(i%2 == 0)
|
|
}
|
|
wg.Wait()
|
|
close(results)
|
|
winners := 0
|
|
for won := range results {
|
|
if won {
|
|
winners++
|
|
}
|
|
}
|
|
if winners != 1 {
|
|
t.Fatalf("mixed sign-up/take winners = %d, want 1", winners)
|
|
}
|
|
})
|
|
}
|
|
|
|
func concurrentMemoryVerify(t *testing.T, codes *CodeStore, hash, phone, code string, keep bool, workers int) map[store.LoginCodeVerifyStatus]int {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
results := make(chan store.LoginCodeVerifyStatus, workers)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < workers; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
result, err := codes.VerifyLogin(ctx, hash, phone, code, keep, 5)
|
|
if err != nil {
|
|
t.Errorf("VerifyLogin: %v", err)
|
|
return
|
|
}
|
|
results <- result.Status
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(results)
|
|
counts := make(map[store.LoginCodeVerifyStatus]int)
|
|
for status := range results {
|
|
counts[status]++
|
|
}
|
|
return counts
|
|
}
|
|
|
|
func concurrentMemoryTake(t *testing.T, codes *CodeStore, hash, phone string, workers int) int {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
results := make(chan bool, workers)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < workers; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
_, found, err := codes.TakeLoginCode(ctx, hash, phone)
|
|
if err != nil {
|
|
t.Errorf("TakeLoginCode: %v", err)
|
|
return
|
|
}
|
|
results <- found
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(results)
|
|
foundCount := 0
|
|
for found := range results {
|
|
if found {
|
|
foundCount++
|
|
}
|
|
}
|
|
return foundCount
|
|
}
|
|
|
|
func concurrentMemoryConsumeSignUp(t *testing.T, codes *CodeStore, hash, phone string, workers int) int {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
results := make(chan bool, workers)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < workers; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
_, found, err := codes.ConsumeSignUpVerified(ctx, hash, phone)
|
|
if err != nil {
|
|
t.Errorf("ConsumeSignUpVerified: %v", err)
|
|
return
|
|
}
|
|
results <- found
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(results)
|
|
foundCount := 0
|
|
for found := range results {
|
|
if found {
|
|
foundCount++
|
|
}
|
|
}
|
|
return foundCount
|
|
}
|