Files
go-sip/internal/agent/upload_failure_report_test.go
T

155 lines
6.8 KiB
Go

package agent
import (
"context"
"errors"
"net/http"
"os"
"path/filepath"
"testing"
"time"
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
"github.com/google/uuid"
"google.golang.org/protobuf/proto"
)
type mockFailureRPC struct {
requests []*agentv1.ReportExecutionEventRequest
unavailable bool
wrongReceipt bool
}
func (m *mockFailureRPC) ReportExecutionEvent(_ context.Context, request *agentv1.ReportExecutionEventRequest) (*agentv1.ReportExecutionEventResponse, error) {
m.requests = append(m.requests, proto.Clone(request).(*agentv1.ReportExecutionEventRequest))
if m.unavailable {
return nil, errors.New("Dispatcher unavailable before receipt")
}
factID := request.Fact.FactId
if m.wrongReceipt {
factID = uuid.NewString()
}
return &agentv1.ReportExecutionEventResponse{Receipt: &agentv1.OperationReceipt{
Result: agentv1.ResultCode_RESULT_CODE_ACCEPTED, FactId: factID, ContentSha256: request.Fact.ContentSha256,
}}, nil
}
func TestMockUploadFailureReportReusesDurableFactAcrossActiveBoots(t *testing.T) {
root := t.TempDir()
observed := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC)
spool, err := NewSpool(root, func() time.Time { return observed })
if err != nil {
t.Fatal(err)
}
binding := &agentv1.ExecutionBinding{TenantId: "tenant-a", TenantKey: "tenant-a", ExecutionId: "execution-a"}
if err := spool.ClaimUpload(UploadAttempt{
UploadID: "upload-a", Identity: "identity-a", State: "attempted", RequestID: uuid.NewString(),
Binding: binding, Asset: &agentv1.AssetDescriptor{AssetId: "recording-a"},
}); err != nil {
t.Fatal(err)
}
active := &agentv1.RequestMeta{
ProtocolVersion: "agent.v1", AgentId: "agent-a", CellId: "cell-a", BootId: "boot-old",
DispatcherEpoch: "epoch-a", SessionGeneration: 1,
}
remote := &mockFailureRPC{unavailable: true}
known, err := spool.ReportMockUploadFailure(context.Background(), remote, active, "upload-a", &UploadHTTPError{StatusCode: http.StatusForbidden})
if !known || err == nil || len(remote.requests) != 1 {
t.Fatalf("explicit 403 failure was not durably sent: known=%t requests=%d err=%v", known, len(remote.requests), err)
}
record, err := spool.LoadUploadAttempt("upload-a")
if err != nil || record.FailureFact == nil || record.FailureDelivered {
t.Fatalf("fact lost before receipt: record=%+v err=%v", record, err)
}
factID, digest, sourceBoot := record.FailureFact.FactId, record.FailureFact.ContentSha256, record.FailureFact.SourceBootId
if sourceBoot != active.BootId || record.FailureFact.ObservedAtUnixMs != observed.UnixMilli() {
t.Fatalf("fact lost source boot or observation clock: boot=%q observed=%d", sourceBoot, record.FailureFact.ObservedAtUnixMs)
}
restarted, err := NewSpool(root, nil)
if err != nil {
t.Fatal(err)
}
activeNew := proto.Clone(active).(*agentv1.RequestMeta)
activeNew.BootId = "boot-new"
activeNew.SessionGeneration = 2
remote.unavailable, remote.wrongReceipt = false, true
if err := restarted.RecoverMockUploadFailures(context.Background(), remote, activeNew); err == nil {
t.Fatal("unrelated accepted receipt closed a durable failure fact")
}
record, err = restarted.LoadUploadAttempt("upload-a")
if err != nil || record.FailureDelivered {
t.Fatalf("false receipt advanced failure state: record=%+v err=%v", record, err)
}
remote.wrongReceipt = false
if err := restarted.RecoverMockUploadFailures(context.Background(), remote, activeNew); err != nil {
t.Fatalf("failure fact not recovered after boot change: %v", err)
}
record, err = restarted.LoadUploadAttempt("upload-a")
if err != nil || !record.FailureDelivered || record.State != "attempted" {
t.Fatalf("accepted receipt did not close only the fact: record=%+v err=%v", record, err)
}
if len(remote.requests) != 3 {
t.Fatalf("expected original, false receipt and recovery; got %d reports", len(remote.requests))
}
for _, request := range remote.requests {
if request.Fact.FactId != factID || request.Fact.ContentSha256 != digest || request.Fact.SourceBootId != sourceBoot {
t.Fatalf("recovery generated a new failure identity: %+v", request.Fact)
}
}
if remote.requests[2].Meta.BootId != activeNew.BootId || remote.requests[2].Meta.SessionGeneration != activeNew.SessionGeneration {
t.Fatalf("recovery did not use the current active session: %+v", remote.requests[2].Meta)
}
if err := restarted.RecoverMockUploadFailures(context.Background(), remote, activeNew); err != nil || len(remote.requests) != 3 {
t.Fatalf("acknowledged failure re-reported: requests=%d err=%v", len(remote.requests), err)
}
}
func TestMockUploadFailureClassificationKeepsUncertainOutcomesUnknown(t *testing.T) {
for _, tc := range []struct {
name string
err error
code string
}{
{"expired grant", ErrUploadGrantExpired, "upload_authorization_expired"},
{"invalid grant", ErrUploadGrantInvalid, "upload_authorization_failed"},
{"checksum mismatch", ErrUploadChecksumMismatch, "checksum_mismatch"},
{"explicit 400", &UploadHTTPError{StatusCode: http.StatusBadRequest}, "upload_failed"},
{"explicit 403", &UploadHTTPError{StatusCode: http.StatusForbidden}, "upload_authorization_failed"},
{"missing recording", os.ErrNotExist, "upload_failed"},
{"request timeout", &UploadHTTPError{StatusCode: http.StatusRequestTimeout}, ""},
{"throttled", &UploadHTTPError{StatusCode: http.StatusTooManyRequests}, ""},
{"server uncertain", &UploadHTTPError{StatusCode: http.StatusInternalServerError}, ""},
{"transport uncertain", errors.New("connection reset"), ""},
} {
t.Run(tc.name, func(t *testing.T) {
if got := MockUploadFailureCode(tc.err); got != tc.code {
t.Fatalf("classification %q, want %q", got, tc.code)
}
})
}
spool, err := NewSpool(t.TempDir(), nil)
if err != nil {
t.Fatal(err)
}
if err := spool.ClaimUpload(UploadAttempt{
UploadID: "unknown-a", Identity: "identity-a", State: "attempted", RequestID: uuid.NewString(),
Binding: &agentv1.ExecutionBinding{TenantId: "tenant-a", TenantKey: "tenant-a", ExecutionId: "execution-a"},
Asset: &agentv1.AssetDescriptor{AssetId: "recording-a"},
}); err != nil {
t.Fatal(err)
}
active := &agentv1.RequestMeta{ProtocolVersion: "agent.v1", AgentId: "agent-a", CellId: "cell-a", BootId: "boot-a", DispatcherEpoch: "epoch-a", SessionGeneration: 1}
remote := &mockFailureRPC{}
known, err := spool.ReportMockUploadFailure(context.Background(), remote, active, "unknown-a", &UploadHTTPError{StatusCode: 500})
if err != nil || known || len(remote.requests) != 0 {
t.Fatalf("unknown PUT was reported as failed: known=%t reports=%d err=%v", known, len(remote.requests), err)
}
pending, err := spool.PendingUploadFailures()
if err != nil || len(pending) != 0 {
t.Fatalf("unknown PUT produced a terminal fact: pending=%d err=%v", len(pending), err)
}
if _, err := os.Stat(filepath.Join(spool.root, ".uploads", "unknown-a", "state.json")); err != nil {
t.Fatal(err)
}
}