package workspace import ( "errors" "fmt" "strings" "sync" "testing" "go.uber.org/zap" "go.uber.org/zap/zaptest/observer" iop "iop/proto/gen/iop" ) func TestWorkspaceObservation(t *testing.T) { root := t.TempDir() core, logs := observer.New(zap.InfoLevel) runtime, err := NewRuntime([]*iop.WorkspaceConfig{testWorkspaceConfig(root)}, "darwin", zap.New(core)) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = runtime.Close() }) if _, err := runtime.Open(testRequestAuthority("request-observation")); err != nil { t.Fatal(err) } const sentinel = "SECRET_PATH_COMMAND_OUTPUT_BEARER" if result := runtime.Write("request-observation", sentinel, []byte(sentinel)); result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS { t.Fatalf("write result = %+v", result) } cleanup := runtime.Cleanup(t.Context(), "request-observation") if cleanup.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS { t.Fatalf("cleanup result = %+v", cleanup) } allowed := map[string]bool{ "correlation": true, "event": true, "operation": true, "outcome": true, "error_code": true, "duration_ms": true, "truncated": true, "process_count": true, "artifact_count": true, } entries := logs.All() if len(entries) != 2 { t.Fatalf("workspace logs = %d, want 2", len(entries)) } for _, entry := range entries { if entry.Message != workspaceObservationLogKey { t.Fatalf("log message = %q", entry.Message) } if strings.Contains(strings.ToLower(fmt.Sprint(entry.ContextMap()["correlation"])), "secret") { t.Fatalf("secret correlation leaked: %+v", entry) } if len(entry.Context) != len(allowed) { t.Fatalf("log field count = %d, want %d", len(entry.Context), len(allowed)) } for _, field := range entry.Context { if !allowed[field.Key] { t.Fatalf("unexpected log key %q", field.Key) } if strings.Contains(strings.ToLower(field.String), "secret") { t.Fatalf("secret sentinel leaked in %q", field.Key) } } } } func TestWorkspaceObservationFailureIsolation(t *testing.T) { runtime, err := NewRuntime([]*iop.WorkspaceConfig{testWorkspaceConfig(t.TempDir())}, "darwin", zap.NewNop()) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = runtime.Close() }) if _, err := runtime.Open(testRequestAuthority("request-failure-isolation")); err != nil { t.Fatal(err) } runtime.observer = &workspaceSafeObserver{inner: workspaceObserverFunc(func(workspaceObservation) error { panic("observer panic") })} if result := runtime.Write("request-failure-isolation", "result.txt", []byte("kept")); result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS { t.Fatalf("write changed by observer panic: %+v", result) } if cleanup := runtime.Cleanup(t.Context(), "request-failure-isolation"); cleanup.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS { t.Fatalf("cleanup changed by observer panic: %+v", cleanup) } if got := runtime.observer.failureCount(); got != 2 { t.Fatalf("isolated panic failures = %d, want 2", got) } runtime.observer = &workspaceSafeObserver{inner: workspaceObserverFunc(func(workspaceObservation) error { return errors.New("observer error") })} if result := runtime.Write("unknown-request", "result.txt", nil); result.Code != iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_INVALID_REQUEST { t.Fatalf("expected normal invalid result, got %+v", result) } if got := runtime.observer.failureCount(); got != 1 { t.Fatalf("isolated error failures = %d, want 1", got) } } type workspaceObserverFunc func(workspaceObservation) error func (fn workspaceObserverFunc) Emit(observation workspaceObservation) error { return fn(observation) } type capturingObserver struct { mu sync.Mutex observations []workspaceObservation } func (c *capturingObserver) Emit(obs workspaceObservation) error { c.mu.Lock() defer c.mu.Unlock() c.observations = append(c.observations, obs) return nil } func TestWorkspaceObservationCorrelationSurvivesCleanupOverlap(t *testing.T) { root := t.TempDir() runtime, err := NewRuntime([]*iop.WorkspaceConfig{testWorkspaceConfig(root)}, "darwin", zap.NewNop()) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = runtime.Close() }) if _, err := runtime.Open(testRequestAuthority("request-cleanup-overlap")); err != nil { t.Fatal(err) } request, err := runtime.Request("request-cleanup-overlap") if err != nil { t.Fatal(err) } expectedCorrelation := request.correlation if expectedCorrelation == "" { t.Fatalf("expected non-empty correlation at open, got empty") } capturer := &capturingObserver{} runtime.observer = &workspaceSafeObserver{inner: capturer} entered := make(chan struct{}) release := make(chan struct{}) request.entry.beforeRename = func() error { close(entered) <-release return nil } writeDone := make(chan Result, 1) go func() { writeDone <- runtime.Write("request-cleanup-overlap", "result.txt", []byte("overlap")) }() <-entered cleanup := runtime.Cleanup(t.Context(), "request-cleanup-overlap") if cleanup.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS { t.Fatalf("cleanup result = %+v, want success", cleanup) } close(release) writeResult := <-writeDone if writeResult.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS { t.Fatalf("write after cleanup overlap = %+v, want success", writeResult) } capturer.mu.Lock() defer capturer.mu.Unlock() if len(capturer.observations) != 2 { t.Fatalf("observations count = %d, want 2 (1 cleanup + 1 tool)", len(capturer.observations)) } var cleanupCount, toolCount int var cleanupCorrelation, toolCorrelation string for _, obs := range capturer.observations { switch obs.event { case workspaceObservationCleanup: cleanupCount++ cleanupCorrelation = obs.correlation case workspaceObservationTool: toolCount++ toolCorrelation = obs.correlation } } if cleanupCount != 1 { t.Fatalf("cleanup observation count = %d, want 1", cleanupCount) } if toolCount != 1 { t.Fatalf("tool observation count = %d, want 1", toolCount) } if cleanupCorrelation == "" { t.Fatalf("cleanup correlation is empty") } if toolCorrelation == "" { t.Fatalf("tool correlation is empty after cleanup overlap") } if cleanupCorrelation != toolCorrelation { t.Fatalf("cleanup correlation=%q != tool correlation=%q, want shared correlation", cleanupCorrelation, toolCorrelation) } if cleanupCorrelation != expectedCorrelation { t.Fatalf("correlation=%q != expected=%q, want immutable request-local correlation", cleanupCorrelation, expectedCorrelation) } }