273 lines
9.5 KiB
Go
273 lines
9.5 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
|
)
|
|
|
|
func TestRunnerHandlesRetryableNormalizeFallbacks(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
retries int
|
|
operation func(int) erasedTypedResult
|
|
validator *preparedValidator
|
|
wantCalls int
|
|
wantItem string
|
|
wantWarnings []string
|
|
wantRejected int
|
|
wantDebug []string
|
|
wantCheckpoint int
|
|
}{
|
|
{
|
|
name: "accepts zero-retry fallback",
|
|
retries: 0,
|
|
operation: func(int) erasedTypedResult {
|
|
return retryableNormalizeResult("fallback", "ordinary", "fallback-warning")
|
|
},
|
|
wantCalls: 1,
|
|
wantItem: "fallback",
|
|
wantWarnings: []string{"ordinary", "fallback-warning"},
|
|
wantDebug: []string{`"another_attempt":false`, `"fallback_accepted":true`},
|
|
wantCheckpoint: 1,
|
|
},
|
|
{
|
|
name: "retries before accepting ordinary result",
|
|
retries: 1,
|
|
operation: func(attempt int) erasedTypedResult {
|
|
if attempt == 1 {
|
|
return retryableNormalizeResult("discarded", "discarded-ordinary", "discarded-fallback")
|
|
}
|
|
return erasedTypedResult{Value: codecNotes{Items: []string{"accepted"}}, Warnings: []contracts.Warning{{Scope: "accepted", ReasonCode: "ordinary", Message: "accepted-warning"}}}
|
|
},
|
|
wantCalls: 2,
|
|
wantItem: "accepted",
|
|
wantWarnings: []string{"accepted-warning"},
|
|
wantDebug: []string{`"another_attempt":true`, `"fallback_accepted":false`},
|
|
wantCheckpoint: 1,
|
|
},
|
|
{
|
|
name: "accepts final fallback after exhaustion",
|
|
retries: 1,
|
|
operation: func(attempt int) erasedTypedResult {
|
|
return retryableNormalizeResult(fmt.Sprintf("fallback-%d", attempt), fmt.Sprintf("ordinary-%d", attempt), fmt.Sprintf("fallback-warning-%d", attempt))
|
|
},
|
|
wantCalls: 2,
|
|
wantItem: "fallback-2",
|
|
wantWarnings: []string{"ordinary-2", "fallback-warning-2"},
|
|
wantDebug: []string{`"another_attempt":false`, `"fallback_accepted":true`},
|
|
wantCheckpoint: 1,
|
|
},
|
|
{
|
|
name: "keeps final fallback rejection terminal",
|
|
retries: 1,
|
|
operation: func(int) erasedTypedResult {
|
|
return retryableNormalizeResult("rejected", "ordinary", "fallback-warning")
|
|
},
|
|
validator: &preparedValidator{
|
|
resolved: ResolvedValidator{Binding: Binding("reject-final-fallback"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"},
|
|
typedValidate: func(context.Context, any, typedValidationTarget) (contracts.ValidationResult, error) {
|
|
return contracts.ValidationResult{Approved: false, ReasonCode: "rejected", Message: "reject fallback"}, nil
|
|
},
|
|
},
|
|
wantCalls: 2,
|
|
wantRejected: 1,
|
|
wantDebug: []string{`"fallback_accepted":true`, `"rejection"`},
|
|
wantCheckpoint: 0,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
prepared := preparedAttemptDebugPipeline(t)
|
|
lane := &prepared.Steps[0].lanes[0]
|
|
lane.resolved.Normalize.Retries = tc.retries
|
|
if tc.validator != nil {
|
|
lane.normalizeValidators.validators = []preparedValidator{*tc.validator}
|
|
}
|
|
calls := 0
|
|
lane.typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) {
|
|
calls++
|
|
return tc.operation(calls), nil
|
|
}
|
|
debug := newCapturedDebugRecorder()
|
|
checkpoints := &candidateCheckpointRecorder{CheckpointRecorder: NoopCheckpointRecorder()}
|
|
output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Debug: debug, Checkpoints: checkpoints})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
if calls != tc.wantCalls {
|
|
t.Fatalf("normalize calls = %d, want %d", calls, tc.wantCalls)
|
|
}
|
|
if checkpoints.normalizeSucceeded != tc.wantCheckpoint {
|
|
t.Fatalf("normalize checkpoints = %d, want %d", checkpoints.normalizeSucceeded, tc.wantCheckpoint)
|
|
}
|
|
if len(output.Rejected) != tc.wantRejected {
|
|
t.Fatalf("rejected outputs = %#v, want %d", output.Rejected, tc.wantRejected)
|
|
}
|
|
var retryDebug strings.Builder
|
|
for _, name := range debug.names() {
|
|
if strings.HasPrefix(name, "normalize/notes/attempt-") && strings.HasSuffix(name, ".json") {
|
|
retryDebug.Write(debug.json[name])
|
|
}
|
|
}
|
|
for _, fragment := range tc.wantDebug {
|
|
if !strings.Contains(retryDebug.String(), fragment) {
|
|
t.Fatalf("retry debug = %s, want %q", retryDebug.String(), fragment)
|
|
}
|
|
}
|
|
if tc.wantRejected != 0 {
|
|
if output.Rejected[0].AttemptCount != tc.wantCalls {
|
|
t.Fatalf("rejection attempt count = %d, want %d", output.Rejected[0].AttemptCount, tc.wantCalls)
|
|
}
|
|
return
|
|
}
|
|
if len(output.NormalizeOutputs) != 1 {
|
|
t.Fatalf("normalize outputs = %#v, want one", output.NormalizeOutputs)
|
|
}
|
|
decoded, err := lane.typed.codec.decode(output.NormalizeOutputs[0].Artifact.Content)
|
|
if err != nil {
|
|
t.Fatalf("decode normalized output: %v", err)
|
|
}
|
|
normalized, ok := decoded.(codecNotes)
|
|
if !ok {
|
|
t.Fatalf("decoded normalized output = %T, want codecNotes", decoded)
|
|
}
|
|
if got := firstNote(normalized); got != tc.wantItem {
|
|
t.Fatalf("normalized item = %q, want %q", got, tc.wantItem)
|
|
}
|
|
gotWarnings := make([]string, len(output.Warnings))
|
|
for index, warning := range output.Warnings {
|
|
gotWarnings[index] = warning.Message
|
|
}
|
|
if strings.Join(gotWarnings, "|") != strings.Join(tc.wantWarnings, "|") {
|
|
t.Fatalf("durable warnings = %#v, want %#v", gotWarnings, tc.wantWarnings)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunnerValidatesNormalizeRetryDiagnostics(t *testing.T) {
|
|
const (
|
|
reasonSentinel = "reason-diagnostic-sentinel"
|
|
messageSentinel = "message-diagnostic-sentinel"
|
|
)
|
|
reasonOverLimit := strings.Repeat("r", contracts.MaxNormalizeRetryReasonCodeBytes-len(reasonSentinel)) + reasonSentinel + "x"
|
|
messageOverLimit := strings.Repeat("m", contracts.MaxNormalizeRetryMessageBytes-len(messageSentinel)) + messageSentinel + "x"
|
|
tests := []struct {
|
|
name string
|
|
retry contracts.NormalizeRetry
|
|
wantError string
|
|
hiddenValues []string
|
|
}{
|
|
{
|
|
name: "accepts byte limits",
|
|
retry: contracts.NormalizeRetry{
|
|
ReasonCode: strings.Repeat("r", contracts.MaxNormalizeRetryReasonCodeBytes),
|
|
Message: strings.Repeat("m", contracts.MaxNormalizeRetryMessageBytes),
|
|
},
|
|
},
|
|
{
|
|
name: "rejects oversized reason code",
|
|
retry: contracts.NormalizeRetry{
|
|
ReasonCode: reasonOverLimit,
|
|
Message: messageSentinel,
|
|
},
|
|
wantError: "reason code exceeds maximum length",
|
|
hiddenValues: []string{reasonSentinel, messageSentinel},
|
|
},
|
|
{
|
|
name: "rejects oversized message",
|
|
retry: contracts.NormalizeRetry{
|
|
ReasonCode: reasonSentinel,
|
|
Message: messageOverLimit,
|
|
},
|
|
wantError: "message exceeds maximum length",
|
|
hiddenValues: []string{reasonSentinel, messageSentinel},
|
|
},
|
|
{
|
|
name: "rejects invalid reason code UTF-8",
|
|
retry: contracts.NormalizeRetry{
|
|
ReasonCode: reasonSentinel + string([]byte{0xff}),
|
|
Message: messageSentinel,
|
|
},
|
|
wantError: "reason code has invalid UTF-8",
|
|
hiddenValues: []string{reasonSentinel, messageSentinel},
|
|
},
|
|
{
|
|
name: "rejects invalid message UTF-8",
|
|
retry: contracts.NormalizeRetry{
|
|
ReasonCode: reasonSentinel,
|
|
Message: messageSentinel + string([]byte{0xff}),
|
|
},
|
|
wantError: "message has invalid UTF-8",
|
|
hiddenValues: []string{reasonSentinel, messageSentinel},
|
|
},
|
|
{
|
|
name: "rejects blank reason code",
|
|
retry: contracts.NormalizeRetry{
|
|
ReasonCode: " \t\n ",
|
|
Message: messageSentinel,
|
|
},
|
|
wantError: "reason code is blank",
|
|
hiddenValues: []string{messageSentinel},
|
|
},
|
|
{
|
|
name: "rejects blank message",
|
|
retry: contracts.NormalizeRetry{
|
|
ReasonCode: reasonSentinel,
|
|
Message: " \t\n ",
|
|
},
|
|
wantError: "message is blank",
|
|
hiddenValues: []string{reasonSentinel},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
prepared := preparedAttemptDebugPipeline(t)
|
|
prepared.Steps[0].lanes[0].typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) {
|
|
return erasedTypedResult{Value: codecNotes{Items: []string{"safe"}}, Retry: &tc.retry}, nil
|
|
}
|
|
debug := newCapturedDebugRecorder()
|
|
output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Debug: debug})
|
|
if tc.wantError == "" {
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want accepted retry fallback", err)
|
|
}
|
|
if len(output.NormalizeOutputs) != 1 {
|
|
t.Fatalf("normalize outputs = %#v, want one accepted fallback", output.NormalizeOutputs)
|
|
}
|
|
return
|
|
}
|
|
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
|
|
t.Fatalf("Run() error = %v, want fixed %q error", err, tc.wantError)
|
|
}
|
|
var debugOutput strings.Builder
|
|
for _, name := range debug.names() {
|
|
debugOutput.Write(debug.json[name])
|
|
}
|
|
for _, value := range tc.hiddenValues {
|
|
if strings.Contains(err.Error(), value) || strings.Contains(debugOutput.String(), value) {
|
|
t.Fatalf("retry diagnostic leaked %q", value)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func retryableNormalizeResult(item, ordinary, fallback string) erasedTypedResult {
|
|
return erasedTypedResult{
|
|
Value: codecNotes{Items: []string{item}},
|
|
Warnings: []contracts.Warning{{Scope: "attempt", ReasonCode: "ordinary", Message: ordinary}},
|
|
Retry: &contracts.NormalizeRetry{
|
|
ReasonCode: "retryable_normalization",
|
|
Message: "safe fallback is available",
|
|
FallbackWarnings: []contracts.Warning{{Scope: "fallback", ReasonCode: "fallback", Message: fallback}},
|
|
},
|
|
}
|
|
}
|