Files
notarius/internal/core/config/redaction_test.go

278 lines
10 KiB
Go

package config
import (
"encoding/json"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestRedactedResolvedPipelinePayloadRedactsEveryBinding(t *testing.T) {
bindings := map[string]pipeline.ModuleBinding{}
for _, name := range []string{
"input", "chunk", "output", "extract", "merge", "normalize",
"resolved-validator", "lane-validator",
} {
bindings[name] = redactionTestBinding(name)
}
resolved := pipeline.ResolvedPipeline{
ID: "redaction-test",
Digest: "sha256:safe-digest",
Input: bindings["input"],
InputExecutionClass: contracts.ExecutionClassDeterministic,
Chunk: bindings["chunk"],
ChunkExecutionClass: contracts.ExecutionClassLLMBacked,
ChunkReferences: redactionTestReferenceTarget(pipeline.StageChunk, "", "chunk-reference-content"),
Steps: []pipeline.ResolvedPipelineStep{{
ID: "default",
ArtifactLanes: []pipeline.ResolvedArtifactLane{{
ID: "safe-lane",
ArtifactKind: "safe/artifact",
Extract: bindings["extract"],
ExtractExecutionClass: contracts.ExecutionClassLLMBacked,
Merge: bindings["merge"],
MergeExecutionClass: contracts.ExecutionClassDeterministic,
Normalize: bindings["normalize"],
NormalizeExecutionClass: contracts.ExecutionClassLLMBacked,
Validators: []pipeline.ModuleBinding{bindings["lane-validator"]},
ExtractReferences: redactionTestReferenceTarget(pipeline.StageExtract, "safe-lane", "extract-reference-content"),
MergeReferences: redactionTestReferenceTarget(pipeline.StageMerge, "safe-lane", "merge-reference-content"),
NormalizeReferences: redactionTestReferenceTarget(pipeline.StageNormalize, "safe-lane", "normalize-reference-content"),
}},
}},
ValidatorChains: []pipeline.ResolvedValidatorChain{{
Stage: pipeline.StageExtract,
LaneID: "safe-lane",
ModuleKey: "safe-extract-owner",
Validators: []pipeline.ResolvedValidator{{
Binding: bindings["resolved-validator"],
ExecutionClass: contracts.ExecutionClassDeterministic,
Target: pipeline.ValidatorTargetTyped,
ArtifactKind: "safe/artifact",
}},
}},
Output: bindings["output"],
OutputExecutionClass: contracts.ExecutionClassDeterministic,
}
effective := EffectiveConfig{
Config: Config{Pipelines: map[string]pipeline.PipelineProfile{
"redaction-test": {Input: bindings["input"]},
}},
PipelineID: "redaction-test",
ResolvedPipeline: resolved,
}
payload := effective.RedactedResolvedPipelinePayload()
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
text := string(encoded)
for name := range bindings {
for _, forbidden := range []string{name + "-secret", name + "-nested-secret"} {
if strings.Contains(text, forbidden) {
t.Fatalf("resolved pipeline summary contains %q: %s", forbidden, text)
}
}
if !strings.Contains(text, name+"-safe") {
t.Fatalf("resolved pipeline summary does not retain safe option for %q: %s", name, text)
}
}
for _, content := range []string{
"chunk-reference-content", "extract-reference-content",
"merge-reference-content", "normalize-reference-content",
} {
if strings.Contains(text, content) {
t.Fatalf("resolved pipeline summary contains materialized reference content %q", content)
}
}
for _, safe := range []string{"[REDACTED]", "safe-reference-path", "safe-binding-source"} {
if !strings.Contains(text, safe) {
t.Fatalf("resolved pipeline summary does not retain %q: %s", safe, text)
}
}
for _, executionClass := range []string{"input_execution_class\":\"deterministic", "chunk_execution_class\":\"llm_backed", "extract_execution_class\":\"llm_backed", "merge_execution_class\":\"deterministic", "normalize_execution_class\":\"llm_backed", "output_execution_class\":\"deterministic"} {
if !strings.Contains(text, executionClass) {
t.Fatalf("resolved pipeline summary does not retain %q: %s", executionClass, text)
}
}
payload.Input.Options["safe"] = "mutated"
nested := payload.Input.Options["nested"].([]any)[0].([]any)[0].(map[string]any)
nested["neighbor"] = "mutated"
payload.ChunkReferences.ReferenceSet.Slots["safe-slot"].Items[0].Content[0] = 'X'
payload.ValidatorChains[0].Validators[0].Binding.Options["safe"] = "mutated"
assertRedactionTestBindingUnchanged(t, effective.ResolvedPipeline.Input, "input")
assertRedactionTestBindingUnchanged(t, effective.Config.Pipelines["redaction-test"].Input, "input")
assertRedactionTestBindingUnchanged(t, effective.ResolvedPipeline.ValidatorChains[0].Validators[0].Binding, "resolved-validator")
if got := string(effective.ResolvedPipeline.ChunkReferences.ReferenceSet.Slots["safe-slot"].Items[0].Content); got != "chunk-reference-content" {
t.Fatalf("source reference content mutated through redacted payload: %q", got)
}
}
func TestRedactedEffectiveConfigPayloadDoesNotAliasSource(t *testing.T) {
binding := redactionTestBinding("effective")
effective := EffectiveConfig{
Config: Config{Pipelines: map[string]pipeline.PipelineProfile{
"redaction-test": {Input: binding},
}},
ResolvedPipeline: pipeline.ResolvedPipeline{Input: binding},
}
payload := effective.RedactedSummaryPayload().(EffectiveConfig)
payload.Config.Pipelines["redaction-test"].Input.Options["safe"] = "mutated"
payload.ResolvedPipeline.Input.Options["safe"] = "mutated"
assertRedactionTestBindingUnchanged(t, effective.Config.Pipelines["redaction-test"].Input, "effective")
assertRedactionTestBindingUnchanged(t, effective.ResolvedPipeline.Input, "effective")
}
func TestRedactedSummaryPayloadsCoverEveryEffectiveConfigBinding(t *testing.T) {
bindings := map[string]pipeline.ModuleBinding{}
for _, name := range []string{"input", "chunk", "output", "extract", "merge", "normalize", "lane-validator"} {
bindings[name] = redactionTestBinding("summary-" + name)
}
effective := EffectiveConfig{
Config: Config{Pipelines: map[string]pipeline.PipelineProfile{
"redaction-test": {
Input: bindings["input"],
Chunk: bindings["chunk"],
Output: bindings["output"],
Artifacts: map[string]pipeline.ArtifactLaneProfile{
"safe-lane": {
Extract: bindings["extract"],
Merge: bindings["merge"],
Normalize: bindings["normalize"],
Validators: []pipeline.ModuleBinding{bindings["lane-validator"]},
},
},
},
}},
ResolvedPipeline: pipeline.ResolvedPipeline{
Input: bindings["input"],
Chunk: bindings["chunk"],
Output: bindings["output"],
Steps: []pipeline.ResolvedPipelineStep{{
ID: "default",
ArtifactLanes: []pipeline.ResolvedArtifactLane{{
ID: "safe-lane",
Extract: bindings["extract"],
Merge: bindings["merge"],
Normalize: bindings["normalize"],
Validators: []pipeline.ModuleBinding{bindings["lane-validator"]},
}},
}},
},
}
payload, ok := effective.RedactedSummaryPayload().(EffectiveConfig)
if !ok {
t.Fatal("RedactedSummaryPayload() returned an unexpected type")
}
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
text := string(encoded)
for name := range bindings {
if strings.Contains(text, "summary-"+name+"-secret") || strings.Contains(text, "summary-"+name+"-nested-secret") {
t.Fatalf("summary payload contains sensitive option for %q: %s", name, text)
}
if !strings.Contains(text, "summary-"+name+"-safe") {
t.Fatalf("summary payload omitted safe option for %q: %s", name, text)
}
}
if !strings.Contains(text, "[REDACTED]") {
t.Fatalf("summary payload contains no redaction marker: %s", text)
}
}
func TestRedactedResolvedPipelinePayloadHandlesTypedOptionContainers(t *testing.T) {
type optionMap map[string]string
type optionList []optionMap
typed := optionList{{
"api_key": "typed-container-secret",
"safe": "typed-container-safe",
}}
effective := EffectiveConfig{ResolvedPipeline: pipeline.ResolvedPipeline{
Input: pipeline.ModuleBinding{Options: map[string]any{"nested": typed}},
}}
payload := effective.RedactedResolvedPipelinePayload()
nested, ok := payload.Input.Options["nested"].([]any)
if !ok || len(nested) != 1 {
t.Fatalf("redacted typed list = %#v", payload.Input.Options["nested"])
}
item, ok := nested[0].(map[string]any)
if !ok {
t.Fatalf("redacted typed map = %#v", nested[0])
}
if got := item["api_key"]; got != "[REDACTED]" {
t.Fatalf("redacted api_key = %v", got)
}
if got := item["safe"]; got != "typed-container-safe" {
t.Fatalf("safe option = %v", got)
}
item["safe"] = "mutated"
if got := typed[0]["safe"]; got != "typed-container-safe" {
t.Fatalf("source typed map mutated through redacted payload: %q", got)
}
}
func redactionTestBinding(name string) pipeline.ModuleBinding {
return pipeline.ModuleBinding{
Module: "safe-" + name,
Options: map[string]any{
"api_key": name + "-secret",
"safe": name + "-safe",
"nested": []any{[]any{map[string]any{
"password": name + "-nested-secret",
"neighbor": name + "-nested-safe",
}}},
},
}
}
func redactionTestReferenceTarget(stage pipeline.ModuleStage, laneID, content string) pipeline.ResolvedReferenceTarget {
return pipeline.ResolvedReferenceTarget{
Stage: stage,
LaneID: laneID,
Module: "safe-reference-module",
Bindings: []pipeline.ReferenceBinding{{
Stage: stage,
LaneID: laneID,
SlotName: "safe-slot",
Source: "safe-reference-path",
BindingSource: "safe-binding-source",
}},
ReferenceSet: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
"safe-slot": {
Slot: contracts.ReferenceSlot{Name: "safe-slot"},
Items: []contracts.ReferenceItem{{
SlotName: "safe-slot",
Content: []byte(content),
Digest: "sha256:safe-reference-digest",
BindingSource: "safe-binding-source",
}},
},
}},
}
}
func assertRedactionTestBindingUnchanged(t *testing.T, binding pipeline.ModuleBinding, name string) {
t.Helper()
if got := binding.Options["safe"]; got != name+"-safe" {
t.Fatalf("source safe option = %v, want %q", got, name+"-safe")
}
nested := binding.Options["nested"].([]any)[0].([]any)[0].(map[string]any)
if got := nested["neighbor"]; got != name+"-nested-safe" {
t.Fatalf("source nested safe option = %v, want %q", got, name+"-nested-safe")
}
}