216 lines
7.7 KiB
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")
|
|
}
|
|
}
|