package audit import ( "context" "sync" "testing" "time" packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/ui" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type mockSink struct { mu sync.Mutex events []AuditEvent closed bool } func (m *mockSink) Handle(_ context.Context, event AuditEvent) error { m.mu.Lock() defer m.mu.Unlock() m.events = append(m.events, event) return nil } func (m *mockSink) Close() error { m.mu.Lock() defer m.mu.Unlock() m.closed = true return nil } func (m *mockSink) getEvents() []AuditEvent { m.mu.Lock() defer m.mu.Unlock() cp := make([]AuditEvent, len(m.events)) copy(cp, m.events) return cp } func testPackageVersion(name, version, ecosystem string) *packagev1.PackageVersion { eco := packagev1.Ecosystem_ECOSYSTEM_UNSPECIFIED switch ecosystem { case "npm": eco = packagev1.Ecosystem_ECOSYSTEM_NPM case "pypi": eco = packagev1.Ecosystem_ECOSYSTEM_PYPI } return &packagev1.PackageVersion{ Package: &packagev1.Package{ Name: name, Ecosystem: eco, }, Version: version, } } func TestAuditorDispatchesToAllSinks(t *testing.T) { s1 := &mockSink{} s2 := &mockSink{} a := newAuditor(s1, s2) event := AuditEvent{Type: EventTypeMalwareBlocked, Message: "test"} a.dispatch(context.Background(), event) assert.Len(t, s1.getEvents(), 1) assert.Len(t, s2.getEvents(), 1) assert.Equal(t, EventTypeMalwareBlocked, s1.getEvents()[0].Type) assert.Equal(t, EventTypeMalwareBlocked, s2.getEvents()[0].Type) } func TestAuditorSetsTimestamp(t *testing.T) { s := &mockSink{} a := newAuditor(s) before := time.Now() a.dispatch(context.Background(), AuditEvent{Type: EventTypeError}) after := time.Now() events := s.getEvents() require.Len(t, events, 1) assert.False(t, events[0].Timestamp.IsZero()) assert.True(t, !events[0].Timestamp.Before(before)) assert.True(t, !events[0].Timestamp.After(after)) } func TestAuditorCloseClosesAllSinks(t *testing.T) { s1 := &mockSink{} s2 := &mockSink{} a := newAuditor(s1, s2) err := a.close() require.NoError(t, err) assert.True(t, s1.closed) assert.True(t, s2.closed) } func TestAuditorSessionTracking(t *testing.T) { a := newAuditor() // No session yet — record calls are no-ops a.recordAllowed() assert.Nil(t, a.getSession()) a.startSession("npm", []string{"install", "lodash"}) s := a.getSession() require.NotNil(t, s) assert.Equal(t, "npm", s.packageManager) assert.Equal(t, []string{"install", "lodash"}, s.args) a.recordAllowed() a.recordBlocked() a.recordConfirmed() a.recordTrustedSkipped() s.mu.Lock() defer s.mu.Unlock() assert.Equal(t, uint32(3), s.totalAnalyzed) // confirmed doesn't count — LogInstallAllowed does assert.Equal(t, uint32(1), s.allowedCount) assert.Equal(t, uint32(1), s.blockedCount) assert.Equal(t, uint32(1), s.confirmedCount) assert.Equal(t, uint32(1), s.trustedSkipped) } func TestPublicAPIDispatchesToSinks(t *testing.T) { s := &mockSink{} a := newAuditor(s) setGlobal(a) defer resetGlobal() pv := testPackageVersion("evil", "1.0.0", "npm") LogMalwareBlocked(pv, "malware", "analysis-1", "https://ref", true, false) events := s.getEvents() require.Len(t, events, 1) assert.Equal(t, EventTypeMalwareBlocked, events[0].Type) assert.Equal(t, pv, events[0].PackageVersion) assert.Equal(t, "malware", events[0].Details["reason"]) assert.Equal(t, "analysis-1", events[0].AnalysisID) assert.Equal(t, true, events[0].IsMalware) } func TestPublicAPISilentWhenNotInitialized(t *testing.T) { resetGlobal() // None of these should panic LogMalwareBlocked(nil, "reason", "", "", false, false) LogMalwareConfirmed(nil, "", false, false) LogInstallAllowed(nil, 5) LogInstallTrustedAllowed(nil) LogInstallInsecureBypass(nil) LogInstallStarted("npm", []string{"install"}) LogProxyHostObserved("host", "GET", "reason", nil) LogSandboxOverride("profile", nil) LogError("err", nil) } func TestLogInstallStartedInitializesSession(t *testing.T) { s := &mockSink{} a := newAuditor(s) setGlobal(a) defer resetGlobal() LogInstallStarted("pip", []string{"install", "requests"}) sess := a.getSession() require.NotNil(t, sess) assert.Equal(t, "pip", sess.packageManager) assert.Equal(t, []string{"install", "requests"}, sess.args) } func TestLogInstallAllowedIncrementsSession(t *testing.T) { s := &mockSink{} a := newAuditor(s) setGlobal(a) defer resetGlobal() a.startSession("npm", nil) LogInstallAllowed(testPackageVersion("pkg", "1.0", "npm"), 3) sess := a.getSession() require.NotNil(t, sess) assert.Equal(t, uint32(1), sess.allowedCount) assert.Equal(t, uint32(1), sess.totalAnalyzed) } func TestLogMalwareBlockedIncrementsSession(t *testing.T) { s := &mockSink{} a := newAuditor(s) setGlobal(a) defer resetGlobal() a.startSession("npm", nil) LogMalwareBlocked(testPackageVersion("evil", "1.0", "npm"), "bad", "", "", true, false) sess := a.getSession() require.NotNil(t, sess) assert.Equal(t, uint32(1), sess.blockedCount) assert.Equal(t, uint32(1), sess.totalAnalyzed) } func TestLogMalwareConfirmedIncrementsSession(t *testing.T) { s := &mockSink{} a := newAuditor(s) setGlobal(a) defer resetGlobal() a.startSession("npm", nil) LogMalwareConfirmed(testPackageVersion("pkg", "1.0", "npm"), "a-1", true, false) sess := a.getSession() require.NotNil(t, sess) assert.Equal(t, uint32(1), sess.confirmedCount) assert.Equal(t, uint32(0), sess.totalAnalyzed) // confirmed doesn't increment — LogInstallAllowed does } func TestLogInstallTrustedAllowedIncrementsSession(t *testing.T) { s := &mockSink{} a := newAuditor(s) setGlobal(a) defer resetGlobal() a.startSession("npm", nil) LogInstallTrustedAllowed(testPackageVersion("pkg", "1.0", "npm")) sess := a.getSession() require.NotNil(t, sess) assert.Equal(t, uint32(1), sess.trustedSkipped) assert.Equal(t, uint32(1), sess.totalAnalyzed) } func TestLogCooldownSkippedEmitsEventWithReason(t *testing.T) { s := &mockSink{} a := newAuditor(s) setGlobal(a) defer resetGlobal() LogCooldownSkipped(testPackageVersion("pkg", "1.0", "npm")) events := s.getEvents() require.Len(t, events, 1) assert.Equal(t, EventTypeCooldownSkipped, events[0].Type) assert.Equal(t, "dependency_cooldown.skip", events[0].Reason) assert.Equal(t, "dependency_cooldown.skip", events[0].Details["reason"]) } func TestLogSessionCompleteDispatchesEvent(t *testing.T) { s := &mockSink{} a := newAuditor(s) setGlobal(a) defer resetGlobal() a.startSession("npm", []string{"install", "express"}) LogInstallAllowed(testPackageVersion("express", "4.0.0", "npm"), 1) LogSessionComplete(OutcomeSuccess, FlowTypeGuard) events := s.getEvents() require.Len(t, events, 2) assert.Equal(t, EventTypeSessionComplete, events[1].Type) require.NotNil(t, events[1].SessionData) assert.Equal(t, "npm", events[1].SessionData.PackageManager) assert.Equal(t, FlowTypeGuard, events[1].SessionData.FlowType) assert.Equal(t, OutcomeSuccess, events[1].SessionData.Outcome) assert.Equal(t, uint32(1), events[1].SessionData.AllowedCount) } func TestLogSessionCompleteSilentWhenNotInitialized(t *testing.T) { resetGlobal() // Should not panic LogSessionComplete(OutcomeSuccess, FlowTypeGuard) } func TestLogSessionSummaryDispatchesEvent(t *testing.T) { s := &mockSink{} setGlobal(newAuditor(s)) defer resetGlobal() // The persistent proxy daemon serves every package manager, so the summary // carries no single one. LogSessionSummary(SessionData{ FlowType: FlowTypeProxy, Outcome: OutcomeBlocked, TotalAnalyzed: 3, BlockedCount: 1, AllowedCount: 2, }) events := s.getEvents() require.Len(t, events, 1) assert.Equal(t, EventTypeSessionComplete, events[0].Type) require.NotNil(t, events[0].SessionData) assert.Empty(t, events[0].SessionData.PackageManager) assert.Equal(t, FlowTypeProxy, events[0].SessionData.FlowType) assert.Equal(t, OutcomeBlocked, events[0].SessionData.Outcome) assert.Equal(t, uint32(1), events[0].SessionData.BlockedCount) } func TestLogSessionSummarySilentWhenNotInitialized(t *testing.T) { resetGlobal() // Should not panic LogSessionSummary(SessionData{Outcome: OutcomeSuccess}) } // TestUIOutcomesMappToAuditOutcomes ensures every ui.ExecutionOutcome has a // corresponding audit.Outcome constant. If someone adds a new outcome to the // UI layer without updating the audit package, this test will fail. // // Both lists must be kept in sync manually. If a new ui.ExecutionOutcome is // added, add it to uiOutcomes below AND add a matching audit.Outcome constant. // The length check catches the case where one list is updated but not the other. func TestUIOutcomesMappToAuditOutcomes(t *testing.T) { auditOutcomes := []Outcome{ OutcomeSuccess, OutcomeBlocked, OutcomeUserCancelled, OutcomeDryRun, OutcomeError, OutcomeInsecureBypass, } uiOutcomes := []ui.ExecutionOutcome{ ui.OutcomeSuccess, ui.OutcomeBlocked, ui.OutcomeUserCancelled, ui.OutcomeDryRun, ui.OutcomeError, ui.OutcomeInsecureBypass, } require.Equal(t, len(uiOutcomes), len(auditOutcomes), "ui.ExecutionOutcome and audit.Outcome count mismatch — a new outcome was added to one but not the other") knownOutcomes := make(map[Outcome]bool, len(auditOutcomes)) for _, o := range auditOutcomes { knownOutcomes[o] = true } for _, uiOutcome := range uiOutcomes { auditOutcome := Outcome(uiOutcome.String()) assert.True(t, knownOutcomes[auditOutcome], "ui.ExecutionOutcome %q (String()=%q) has no matching audit.Outcome constant — add it to audit/event.go", uiOutcome, uiOutcome.String()) } } func TestInitializeWithCloudDisabled(t *testing.T) { resetGlobal() defer resetGlobal() cfg := config.Get() cfg.Config.Cloud.Enabled = false err := Initialize(cfg) require.NoError(t, err) require.NotNil(t, global) // Should have exactly one sink (eventlog) assert.Len(t, global.sinks, 1) }