Scert/backend/internal/localreset/recovery_disposition_test.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")
}
}