Scert/backend/internal/localreset/recovery_frames_test.go

216 lines
7.7 KiB
Go

package localreset
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net"
"reflect"
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
)
func longInspectionFixture() deployment.RecoveryInspection {
value := deployment.RecoveryInspection{
TransactionID: strings.Repeat("t", 160), ServerID: "server-1",
Phase: "recovery_required", Status: "recovery_required", ObservedAt: time.Now().UTC(),
}
for _, name := range []string{"certificate", "chain", "key"} {
path := "/" + strings.Repeat(strings.Repeat("<", 199)+"/", 20)
path += strings.Repeat("&", 4096-len(path)-len(name)) + name
for _, suffix := range []string{"", ".scert-tmp-" + value.TransactionID, ".scert-backup-" + value.TransactionID} {
value.Files = append(value.Files, deployment.RecoveryFileObservation{
Path: path + suffix, Kind: "target", Exists: true, Digest: strings.Repeat("a", 64), Comparison: "unproven",
})
}
value.Locks = append(value.Locks, deployment.RecoveryLockObservation{Path: path + ".scert-lock", Exists: true, Ownership: "unproven"})
}
return value
}
func TestRecoveryInspectionLongPathsRoundTrip(t *testing.T) {
inspection := longInspectionFixture()
encoded, err := json.Marshal(inspection)
if err != nil || len(encoded) <= 250<<10 || len(encoded) > maxInspectionBytes {
t.Fatalf("fixture does not exercise escaped path bound: %d %v", len(encoded), err)
}
session := &recoveryTestSession{inspection: &inspection}
_, conn, _ := recoveryConnection(t, session)
client, err := Begin(t.Context(), conn)
if err != nil {
t.Fatal(err)
}
defer client.Close()
before := client.nonce
result, err := client.InspectRecovery(inspection.TransactionID)
if err != nil || !reflect.DeepEqual(result, inspection) {
t.Fatalf("long observation changed or failed: %v", err)
}
if client.nonce == before || session.inspects.Load() != 1 {
t.Fatal("nonce unchanged or observation repeated")
}
if _, err := client.ListRecoveries(""); err != nil || session.lists.Load() != 1 {
t.Fatalf("next request failed: %v", err)
}
}
func TestRecoveryInspectionFrameBounds(t *testing.T) {
inspection := longInspectionFixture()
response := Response{Version: Version, OK: true, Code: "CHALLENGE", Nonce: strings.Repeat("a", 64), Inspection: &inspection}
var output bytes.Buffer
if err := writeRecoveryResponse(t.Context(), &output, response); err != nil {
t.Fatal(err)
}
lines := bytes.Split(bytes.TrimSuffix(output.Bytes(), []byte{'\n'}), []byte{'\n'})
if len(lines) < 2 || len(lines) > maxInspectionParts {
t.Fatalf("unexpected fragment count: %d", len(lines))
}
for index, line := range lines {
var frame Response
if len(line)+1 > MaxFrameSize || json.Unmarshal(line, &frame) != nil || frame.InspectionPart == nil ||
frame.InspectionPart.Index != index || frame.InspectionPart.Total != len(lines) || frame.Nonce != response.Nonce {
t.Fatal("frame exceeds limit or has invalid sequence")
}
}
inspection.Status = strings.Repeat("x", maxInspectionBytes)
output.Reset()
if err := writeRecoveryResponse(t.Context(), &output, response); !errors.Is(err, ErrProtocol) || output.Len() != 0 {
t.Fatal("oversized observation emitted partial frames")
}
}
func TestRecoveryInspectionRejectsMalformedParts(t *testing.T) {
for _, scenario := range []string{"first-index", "many-parts", "zero-total", "negative-total", "large-part", "short-part", "missing-last", "index", "total", "nonce", "mixed", "methods", "capability", "empty-part", "missing-part", "status", "unknown-field", "trailing-json", "partial-observation", "wrong-id"} {
t.Run(scenario, func(t *testing.T) {
serverConn, conn := net.Pipe()
defer serverConn.Close()
defer conn.Close()
_ = serverConn.SetDeadline(time.Now().Add(2 * time.Second))
done := make(chan struct{})
go func() {
defer close(done)
defer serverConn.Close()
nonce := strings.Repeat("a", 64)
if writeFrame(serverConn, Response{Version: Version, OK: true, Code: "CHALLENGE", Nonce: nonce,
Methods: []Method{{ID: "email"}}, RecoveryAvailable: true}) != nil {
return
}
var request Request
if readFrame(bufio.NewReaderSize(serverConn, MaxFrameSize+1), &request) != nil {
return
}
inspection := longInspectionFixture()
if scenario == "partial-observation" {
inspection.Files = inspection.Files[:8]
} else if scenario == "wrong-id" {
inspection.TransactionID = "other"
}
data, _ := json.Marshal(inspection)
if scenario == "unknown-field" {
data = append(data[:len(data)-1], []byte(`,"unexpected":true}`)...)
} else if scenario == "trailing-json" {
data = append(data, []byte(" {}")...)
}
total := (len(data) + inspectionPartSize - 1) / inspectionPartSize
for index := 0; index < total; index++ {
if scenario == "missing-last" && index == total-1 {
return
}
part := &inspectionPart{Index: index, Total: total, Data: data[index*inspectionPartSize : min((index+1)*inspectionPartSize, len(data))]}
response := Response{Version: Version, OK: true, Code: "CHALLENGE", Nonce: strings.Repeat("b", 64), InspectionPart: part}
if index == 0 {
switch scenario {
case "first-index":
part.Index = 1
case "many-parts":
part.Total = maxInspectionParts + 1
case "zero-total":
part.Total = 0
case "negative-total":
part.Total = -1
case "large-part":
part.Data = make([]byte, inspectionPartSize+1)
case "short-part":
part.Data = part.Data[:100]
}
}
if index == 1 {
switch scenario {
case "index":
part.Index = 0
case "total":
part.Total++
case "nonce":
response.Nonce = strings.Repeat("c", 64)
case "mixed":
response.RecoveryPage = &deployment.PendingRecoveryPage{}
case "methods":
response.Methods = []Method{{ID: "email"}}
case "capability":
response.RecoveryAvailable = true
case "empty-part":
part.Data = nil
case "missing-part":
response.InspectionPart = nil
case "status":
response.OK = false
}
}
if writeFrame(serverConn, response) != nil {
return
}
}
}()
client, err := Begin(t.Context(), conn)
if err != nil {
t.Fatal(err)
}
defer client.Close()
result, err := client.InspectRecovery(strings.Repeat("t", 160))
if err == nil || result.TransactionID != "" || len(result.Files) != 0 || client.stage != -1 {
t.Fatal("invalid fragments returned partial observation or reusable connection")
}
<-done
})
}
}
type cancelInspectionWriter struct {
output bytes.Buffer
cancel context.CancelFunc
}
func (w *cancelInspectionWriter) Write(data []byte) (int, error) {
n, err := w.output.Write(data)
w.cancel()
return n, err
}
func TestRecoveryInspectionCancellationStopsFragments(t *testing.T) {
inspection := longInspectionFixture()
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
writer := &cancelInspectionWriter{cancel: cancel}
err := writeRecoveryResponse(ctx, writer, Response{Version: Version, OK: true, Code: "CHALLENGE", Nonce: strings.Repeat("b", 64), Inspection: &inspection})
if !errors.Is(err, context.Canceled) || bytes.Count(writer.output.Bytes(), []byte{'\n'}) != 1 {
t.Fatalf("cancel did not stop after first frame: %v", err)
}
reader := bufio.NewReaderSize(&writer.output, MaxFrameSize+1)
var first Response
if readFrame(reader, &first) != nil {
t.Fatal("first frame unreadable")
}
if result, err := readInspectionParts(reader, first); err == nil || result != nil {
t.Fatal("canceled fragments accepted")
}
if err := writeRecoveryResponse(ctx, io.Discard, Response{Inspection: &inspection}); !errors.Is(err, context.Canceled) {
t.Fatal("pre-canceled operation accepted")
}
}