307 lines
12 KiB
Go
307 lines
12 KiB
Go
package localreset
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
|
)
|
|
|
|
type dispositionTestSession struct {
|
|
recoveryTestSession
|
|
prepares atomic.Int32
|
|
executes atomic.Int32
|
|
queries atomic.Int32
|
|
prepared RecoveryPreparation
|
|
result deployment.RecoveryDispositionSummary
|
|
fail bool
|
|
}
|
|
|
|
func (*dispositionTestSession) DispositionAvailable() bool { return true }
|
|
func (s *dispositionTestSession) PrepareRecovery(_ context.Context, id string, action deployment.RecoveryAction) (RecoveryPreparation, error) {
|
|
s.prepares.Add(1)
|
|
now := time.Now().UTC()
|
|
s.prepared = RecoveryPreparation{DispositionID: strings.Repeat("b", 64), Token: strings.Repeat("c", 64),
|
|
Summary: deployment.RecoveryReviewSummary{TransactionID: id, ServerID: "server-1", Phase: "reload_attempted", Action: action, HistoricalEvidence: true, ObservedAt: now, ExpiresAt: now.Add(time.Minute)}}
|
|
return s.prepared, nil
|
|
}
|
|
func (s *dispositionTestSession) ExecuteRecovery(_ context.Context, id, token string) (deployment.RecoveryDispositionSummary, error) {
|
|
s.executes.Add(1)
|
|
if id != s.prepared.DispositionID || token != s.prepared.Token || s.fail {
|
|
return deployment.RecoveryDispositionSummary{}, errors.New("secret-executor-error")
|
|
}
|
|
now := time.Now().UTC()
|
|
results := map[deployment.RecoveryAction]string{deployment.RecoveryActionRecord: "observed", deployment.RecoveryActionCommit: "committed", deployment.RecoveryActionRollback: "rolled_back"}
|
|
s.result = deployment.RecoveryDispositionSummary{ID: id, TransactionID: s.prepared.Summary.TransactionID, ServerID: "server-1", Action: s.prepared.Summary.Action, Status: "succeeded", ResultCode: results[s.prepared.Summary.Action], CreatedAt: now, UpdatedAt: now}
|
|
s.prepared = RecoveryPreparation{}
|
|
return s.result, nil
|
|
}
|
|
func (s *dispositionTestSession) GetRecoveryDisposition(context.Context, string) (deployment.RecoveryDispositionSummary, error) {
|
|
s.queries.Add(1)
|
|
return s.result, nil
|
|
}
|
|
|
|
func TestDispositionProtocolThreeActionsAndQuery(t *testing.T) {
|
|
for _, action := range []deployment.RecoveryAction{deployment.RecoveryActionRecord, deployment.RecoveryActionCommit, deployment.RecoveryActionRollback} {
|
|
t.Run(string(action), func(t *testing.T) {
|
|
session := &dispositionTestSession{}
|
|
_, conn, done := recoveryConnection(t, session)
|
|
client, err := Begin(t.Context(), conn)
|
|
if err != nil || !client.DispositionAvailable() {
|
|
t.Fatalf("capability: %v", err)
|
|
}
|
|
prepared, err := client.PrepareRecovery("tx-1", action)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := client.ExecuteRecovery(prepared.DispositionID, prepared.Token)
|
|
if err != nil || result.Action != action || result.Status != "succeeded" {
|
|
t.Fatalf("execute: %+v %v", result, err)
|
|
}
|
|
query, err := client.GetRecoveryDisposition(result.ID)
|
|
if err != nil || query != result {
|
|
t.Fatalf("query: %+v %v", query, err)
|
|
}
|
|
if _, err := client.ExecuteRecovery(prepared.DispositionID, prepared.Token); err == nil {
|
|
t.Fatal("replay accepted")
|
|
}
|
|
_ = client.Close()
|
|
<-done
|
|
if session.prepares.Load() != 1 || session.executes.Load() != 2 || session.queries.Load() != 1 || session.resets.Load() != 0 {
|
|
t.Fatal("unexpected business calls")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDispositionRequestFieldIsolation(t *testing.T) {
|
|
nonce := strings.Repeat("a", 64)
|
|
requests := []Request{
|
|
{Action: "recovery_list"}, {Action: "recovery_inspect", TransactionID: "tx-1"},
|
|
{Action: "recovery_prepare", TransactionID: "tx-1", RecoveryAction: deployment.RecoveryActionRecord},
|
|
{Action: "recovery_execute", DispositionID: strings.Repeat("b", 64), ConfirmationToken: strings.Repeat("c", 64)},
|
|
{Action: "recovery_result", DispositionID: strings.Repeat("b", 64)},
|
|
{Action: "recovery_reconcile_prepare", DispositionID: strings.Repeat("b", 64)},
|
|
}
|
|
fields := []struct {
|
|
name string
|
|
set func(*Request)
|
|
allowed map[string]bool
|
|
}{
|
|
{"method", func(r *Request) { r.Method = "email" }, nil},
|
|
{"password", func(r *Request) { r.Password = "secret" }, nil},
|
|
{"code", func(r *Request) { r.Code = "12345678" }, nil},
|
|
{"cursor", func(r *Request) { r.Cursor = "tx-0" }, map[string]bool{"recovery_list": true}},
|
|
{"transaction", func(r *Request) { r.TransactionID = "tx-1" }, map[string]bool{"recovery_inspect": true, "recovery_prepare": true}},
|
|
{"action", func(r *Request) { r.RecoveryAction = deployment.RecoveryActionRecord }, map[string]bool{"recovery_prepare": true}},
|
|
{"id", func(r *Request) { r.DispositionID = strings.Repeat("b", 64) }, map[string]bool{"recovery_execute": true, "recovery_result": true, "recovery_reconcile_prepare": true}},
|
|
{"token", func(r *Request) { r.ConfirmationToken = strings.Repeat("c", 64) }, map[string]bool{"recovery_execute": true}},
|
|
}
|
|
for _, request := range requests {
|
|
request.Version, request.Nonce = Version, nonce
|
|
if !validRecoveryRequest(request, nonce) {
|
|
t.Fatalf("valid request rejected: %s", request.Action)
|
|
}
|
|
for _, field := range fields {
|
|
if field.allowed[request.Action] {
|
|
continue
|
|
}
|
|
mixed := request
|
|
field.set(&mixed)
|
|
if validRecoveryRequest(mixed, nonce) {
|
|
t.Fatalf("mixed %s/%s accepted", request.Action, field.name)
|
|
}
|
|
}
|
|
if validRecoveryRequest(request, strings.Repeat("d", 64)) {
|
|
t.Fatal("old nonce accepted")
|
|
}
|
|
}
|
|
for _, field := range fields[5:] {
|
|
request := Request{Version: Version, Nonce: nonce, Action: "select", Method: "email"}
|
|
field.set(&request)
|
|
if validRequest(request, nonce, "select") {
|
|
t.Fatalf("reset accepts %s", field.name)
|
|
}
|
|
}
|
|
for _, bad := range []string{"", ".", "..", strings.Repeat("0", 64), strings.Repeat("A", 64), strings.Repeat("b", 65)} {
|
|
if validRecoverySecret(bad) {
|
|
t.Fatal("bad secret accepted")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDispositionReplayNonceAndMixedPayloadNeverExecute(t *testing.T) {
|
|
for _, mode := range []string{"old-nonce", "password", "cursor", "transaction", "action", "prepare-token", "query-token"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
session := &dispositionTestSession{}
|
|
_, conn, done := recoveryConnection(t, session)
|
|
reader := bufio.NewReaderSize(conn, MaxFrameSize+1)
|
|
var first Response
|
|
if err := readFrame(reader, &first); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writeFrame(conn, Request{Version: Version, Nonce: first.Nonce, Action: "recovery_prepare", TransactionID: "tx-1", RecoveryAction: deployment.RecoveryActionRecord}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var prepared Response
|
|
if err := readFrame(reader, &prepared); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request := Request{Version: Version, Nonce: prepared.Nonce, Action: "recovery_execute", DispositionID: prepared.Preparation.DispositionID, ConfirmationToken: prepared.Preparation.Token}
|
|
switch mode {
|
|
case "old-nonce":
|
|
request.Nonce = first.Nonce
|
|
case "password":
|
|
request.Password = "secret"
|
|
case "cursor":
|
|
request.Cursor = "tx-0"
|
|
case "transaction":
|
|
request.TransactionID = "tx-1"
|
|
case "action":
|
|
request.RecoveryAction = deployment.RecoveryActionRollback
|
|
case "prepare-token":
|
|
request.Action, request.TransactionID, request.RecoveryAction = "recovery_prepare", "tx-1", deployment.RecoveryActionRecord
|
|
case "query-token":
|
|
request.Action = "recovery_result"
|
|
}
|
|
if err := writeFrame(conn, request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var response Response
|
|
if err := readFrame(reader, &response); err != nil || response.OK || response.Code != "INVALID_REQUEST" {
|
|
t.Fatalf("response: %+v %v", response, err)
|
|
}
|
|
<-done
|
|
if session.executes.Load() != 0 || session.queries.Load() != 0 || session.prepares.Load() != 1 {
|
|
t.Fatal("invalid request reached service")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDispositionClientRejectsMalformedPrepare(t *testing.T) {
|
|
for _, mode := range []string{"token", "id", "transaction", "action", "lifetime", "missing-evidence", "mixed-result", "mixed-page", "capability"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
server, conn := net.Pipe()
|
|
defer server.Close()
|
|
_ = server.SetDeadline(time.Now().Add(2 * time.Second))
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
defer server.Close()
|
|
_ = writeFrame(server, Response{Version: Version, OK: true, Code: "CHALLENGE", Nonce: strings.Repeat("a", 64), Methods: []Method{{ID: "email"}}, RecoveryAvailable: true, DispositionAvailable: true})
|
|
var request Request
|
|
if readFrame(bufio.NewReaderSize(server, MaxFrameSize+1), &request) != nil {
|
|
return
|
|
}
|
|
session := &dispositionTestSession{}
|
|
value, _ := session.PrepareRecovery(t.Context(), "tx-1", deployment.RecoveryActionCommit)
|
|
response := Response{Version: Version, OK: true, Code: "CHALLENGE", Nonce: strings.Repeat("b", 64), Preparation: &value}
|
|
switch mode {
|
|
case "token":
|
|
value.Token = "bad"
|
|
case "id":
|
|
value.DispositionID = strings.Repeat("0", 64)
|
|
case "transaction":
|
|
value.Summary.TransactionID = "tx-2"
|
|
case "action":
|
|
value.Summary.Action = deployment.RecoveryActionRollback
|
|
case "lifetime":
|
|
value.Summary.ExpiresAt = value.Summary.ObservedAt.Add(time.Hour)
|
|
case "missing-evidence":
|
|
value.Summary.HistoricalEvidence = false
|
|
case "mixed-result":
|
|
response.Disposition = &deployment.RecoveryDispositionSummary{}
|
|
case "mixed-page":
|
|
response.RecoveryPage = &deployment.PendingRecoveryPage{}
|
|
case "capability":
|
|
response.DispositionAvailable = true
|
|
}
|
|
_ = writeFrame(server, response)
|
|
}()
|
|
client, err := Begin(t.Context(), conn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if value, err := client.PrepareRecovery("tx-1", deployment.RecoveryActionCommit); err == nil || value.Token != "" {
|
|
t.Fatal("malformed preparation returned")
|
|
}
|
|
_ = client.Close()
|
|
<-done
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDispositionFieldsRejectedInInspectionParts(t *testing.T) {
|
|
inspection := longInspectionFixture()
|
|
var raw bytes.Buffer
|
|
if err := writeRecoveryResponse(t.Context(), &raw, Response{Version: Version, OK: true, Code: "CHALLENGE", Nonce: strings.Repeat("a", 64), Inspection: &inspection}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, field := range []string{"preparation", "disposition", "capability"} {
|
|
for _, index := range []int{0, 1} {
|
|
lines := bytes.Split(bytes.TrimSuffix(raw.Bytes(), []byte{'\n'}), []byte{'\n'})
|
|
var changed Response
|
|
if err := json.Unmarshal(lines[index], &changed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
switch field {
|
|
case "preparation":
|
|
changed.Preparation = &RecoveryPreparation{}
|
|
case "disposition":
|
|
changed.Disposition = &deployment.RecoveryDispositionSummary{}
|
|
case "capability":
|
|
changed.DispositionAvailable = true
|
|
}
|
|
lines[index], _ = json.Marshal(changed)
|
|
reader := bufio.NewReaderSize(bytes.NewReader(append(bytes.Join(lines, []byte{'\n'}), '\n')), MaxFrameSize+1)
|
|
var first Response
|
|
if err := readFrame(reader, &first); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if value, err := readInspectionParts(reader, first); err == nil || value != nil {
|
|
t.Fatalf("mixed fragment %s/%d accepted", field, index)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDispositionQueryAfterReconnectDoesNotExecute(t *testing.T) {
|
|
session := &dispositionTestSession{}
|
|
_, conn, done := recoveryConnection(t, session)
|
|
client, err := Begin(t.Context(), conn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
prepared, err := client.PrepareRecovery("tx-1", deployment.RecoveryActionRecord)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := client.ExecuteRecovery(prepared.DispositionID, prepared.Token); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = client.Close()
|
|
<-done
|
|
next := &dispositionTestSession{result: session.result}
|
|
_, conn, _ = recoveryConnection(t, next)
|
|
client, err = Begin(t.Context(), conn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer client.Close()
|
|
if result, err := client.GetRecoveryDisposition(prepared.DispositionID); err != nil || result.Status != "succeeded" {
|
|
t.Fatalf("query: %+v %v", result, err)
|
|
}
|
|
if next.prepares.Load() != 0 || next.executes.Load() != 0 || next.queries.Load() != 1 {
|
|
t.Fatal("query replayed operation")
|
|
}
|
|
}
|