Scert/backend/internal/localreset/recovery_test.go

207 lines
6.4 KiB
Go

package localreset
import (
"bufio"
"context"
"errors"
"net"
"strings"
"sync/atomic"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
)
type recoveryTestSession struct {
testSession
lists atomic.Int32
inspects atomic.Int32
fail bool
oversized bool
inspection *deployment.RecoveryInspection
}
func (*recoveryTestSession) RecoveryAvailable() bool { return true }
func (s *recoveryTestSession) ListRecoveries(context.Context, string) (deployment.PendingRecoveryPage, error) {
s.lists.Add(1)
return deployment.PendingRecoveryPage{Items: []deployment.PendingRecovery{{ID: "tx-1", ServerID: "s1"}}}, nil
}
func (s *recoveryTestSession) InspectRecovery(_ context.Context, id string) (deployment.RecoveryInspection, error) {
s.inspects.Add(1)
if s.inspection != nil {
return *s.inspection, nil
}
result := deployment.RecoveryInspection{TransactionID: id, ServerID: "s1", ObservedAt: time.Now(),
Files: make([]deployment.RecoveryFileObservation, 9), Locks: make([]deployment.RecoveryLockObservation, 3)}
if s.fail {
return result, errors.New("secret-error")
}
if s.oversized {
result.Files[0].Path = strings.Repeat("x", MaxFrameSize)
}
return result, nil
}
func recoveryConnection(t *testing.T, session Session) (*Server, net.Conn, <-chan struct{}) {
t.Helper()
serverConn, clientConn := net.Pipe()
server := newServer(nil, func(net.Conn) (Peer, error) { return Peer{Identity: "test"}, nil }, nil)
server.timeout = 5 * time.Second
done := make(chan struct{})
ctx, cancel := context.WithCancel(t.Context())
go func() {
defer close(done)
defer serverConn.Close()
server.handle(ctx, serverConn, func(context.Context, Peer) (Session, error) { return session, nil })
}()
t.Cleanup(func() {
cancel()
_ = clientConn.Close()
_ = serverConn.Close()
select {
case <-done:
case <-time.After(time.Second):
t.Error("recovery session did not exit")
}
})
return server, clientConn, done
}
func TestRecoveryProtocolRoundTrip(t *testing.T) {
session := &recoveryTestSession{}
server, conn, done := recoveryConnection(t, session)
client, err := Begin(t.Context(), conn)
if err != nil || !client.RecoveryAvailable() {
t.Fatalf("begin: %v", err)
}
page, err := client.ListRecoveries("")
if err != nil || len(page.Items) != 1 {
t.Fatal(err)
}
result, err := client.InspectRecovery(page.Items[0].ID)
if err != nil || result.HistoricalEvidence || result.TransactionID != "tx-1" {
t.Fatalf("inspection: %+v, %v", result, err)
}
_ = client.Close()
<-done
if session.resets.Load() != 0 || session.selected.Load() != 0 || session.verified.Load() != 0 ||
session.closed.Load() != 1 || session.lists.Load() != 1 || session.inspects.Load() != 1 || len(server.failures) != 0 {
t.Fatal("read-only flow mutated reset state, leaked session, or penalized clean close")
}
}
func TestRecoveryProtocolRejectsReplayAndCrossFlow(t *testing.T) {
for _, scenario := range []string{"replay", "select", "verify", "reset", "mixed", "bad-id"} {
t.Run(scenario, func(t *testing.T) {
session := &recoveryTestSession{}
_, conn, done := recoveryConnection(t, session)
reader := bufio.NewReaderSize(conn, MaxFrameSize+1)
var response Response
if err := readFrame(reader, &response); err != nil {
t.Fatal(err)
}
oldNonce := response.Nonce
if err := writeFrame(conn, Request{Version: Version, Nonce: oldNonce, Action: "recovery_list"}); err != nil {
t.Fatal(err)
}
if err := readFrame(reader, &response); err != nil {
t.Fatal(err)
}
request := Request{Version: Version, Nonce: response.Nonce, Action: "recovery_inspect", TransactionID: "tx-1"}
switch scenario {
case "replay":
request.Nonce = oldNonce
case "select":
request = Request{Version: Version, Nonce: response.Nonce, Action: "select", Method: "recovery"}
case "verify":
request = Request{Version: Version, Nonce: response.Nonce, Action: "verify", Code: "test-code"}
case "reset":
request = Request{Version: Version, Nonce: response.Nonce, Action: "reset", Password: "Example123!"}
case "mixed":
request.Password = "secret-password"
case "bad-id":
request.TransactionID = "../bad"
}
if err := writeFrame(conn, request); err != nil {
t.Fatal(err)
}
response = Response{}
if err := readFrame(reader, &response); err != nil || response.OK || response.Inspection != nil {
t.Fatalf("accepted: %+v %v", response, err)
}
<-done
if session.inspects.Load() != 0 || session.resets.Load() != 0 || session.selected.Load() != 0 || session.verified.Load() != 0 {
t.Fatal("invalid request reached business operation")
}
})
}
}
func TestRecoveryClientAndResetMutuallyExclusive(t *testing.T) {
for _, first := range []string{"reset", "recovery"} {
t.Run(first, func(t *testing.T) {
session := &recoveryTestSession{}
_, conn, _ := recoveryConnection(t, session)
client, err := Begin(t.Context(), conn)
if err != nil {
t.Fatal(err)
}
defer client.Close()
if first == "reset" {
if err := client.Select("recovery"); err != nil {
t.Fatal(err)
}
if _, err := client.ListRecoveries(""); err == nil {
t.Fatal("cross-flow accepted")
}
} else {
if _, err := client.ListRecoveries(""); err != nil {
t.Fatal(err)
}
if err := client.Select("recovery"); err == nil {
t.Fatal("cross-flow accepted")
}
}
})
}
}
func TestRecoveryProtocolFailureReturnsNoPartialData(t *testing.T) {
for _, oversized := range []bool{false, true} {
session := &recoveryTestSession{fail: !oversized, oversized: oversized}
_, conn, _ := recoveryConnection(t, session)
client, err := Begin(t.Context(), conn)
if err != nil {
t.Fatal(err)
}
result, err := client.InspectRecovery("tx-1")
if err == nil || result.TransactionID != "" || len(result.Files) != 0 || strings.Contains(err.Error(), "secret") {
t.Fatal("failure leaked partial data")
}
_ = client.Close()
}
}
func TestRecoveryProtocolRequestLimit(t *testing.T) {
session := &recoveryTestSession{}
_, conn, done := recoveryConnection(t, session)
client, err := Begin(t.Context(), conn)
if err != nil {
t.Fatal(err)
}
defer client.Close()
for i := 0; i < MaxRecoveryRequests; i++ {
if _, err := client.ListRecoveries(""); err != nil {
t.Fatal(err)
}
}
if _, err := client.ListRecoveries(""); err == nil {
t.Fatal("request limit ignored")
}
<-done
if session.lists.Load() != MaxRecoveryRequests {
t.Fatal("extra list reached service")
}
}