diff --git a/CHANGELOG.md b/CHANGELOG.md index 805ac27..924ffc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,36 @@ All notable changes to this project are documented here. This project follows [Semantic Versioning](https://semver.org/). +## [1.0.0-preview.2] — Unreleased + +### Added +- **`IgnoreEntity()` and `IgnoreEntities(params Type[])`** on the builder — a class-level + `[AuditIgnore]` for a type the application cannot annotate. The case it exists for is + DotNetBoost.MultiLingual's `Translation`, which shares the application's `DbContext`, is saved + beside every translated entity, and is keyed on four columns: until now it made + `UseAuditing(sp)` unusable beside `UseMultiLingual(sp)`. + - The same rule as the attribute: suppressed and reported as `EntityIgnored`, ahead of + `[Audited]` and `RequireOptIn`, subclasses included, and a write addressed by name — + `audit.For("Translation", id)` — suppressed too. + - Held in the options rather than in `AuditPlan`, which caches what a type's attributes say; + an ignore is configuration, like `RequireOptIn`. + - `Build()` refuses a type that is both declared (`DeclareEntity`, `DeclareEntities`, or an + assembly scan) and ignored, and an ignored type that shares a declared entity's stored name. + So an ignored type never appears in `GET /api/audit/entities`. + - Named `IgnoreEntity` rather than `Ignore` because EF Core's `modelBuilder.Ignore()` means + "do not map this type", and the two would sit a few lines apart. + +### Fixed +- **An excluded entity with a multi-column key no longer throws on save.** The interceptor reported + the skip with the entity's key, and reading a multi-column key throws — so a class-level + `[AuditIgnore]` on such an entity, which the refusal's own message recommends, failed every save + anyway, as did one that `RequireOptIn` left out. A skipped entity is now reported with an empty + key when its key spans several columns: the library never composes one, and empty is what a + report already says for an entity skipped before it was addressed — as it is for a single key + too long for the column, which threw the same way. An entity with a multi-column + key that is *not* excluded is still refused, and the message now names `IgnoreEntity()` + alongside `[AuditIgnore]`. + ## [1.0.0-preview.1] — 2026-10-23 The first published build, and a preview rather than a release: nothing outside this repository has diff --git a/README.md b/README.md index 42bad93..a2df804 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,27 @@ services.AddAuditing(o => .Build(); ``` +**Excluding an entity.** `[AuditIgnore]` on a class keeps it out of the trail. For a type you do not +own and so cannot annotate, say the same thing at registration with `.IgnoreEntity()`. The case +it was made for is DotNetBoost.MultiLingual, whose `Translation` rows share your `DbContext`, are +saved beside every translated entity, and are keyed on four columns, which an audit entry cannot +address: + +```csharp +services.AddDbContext((sp, o) => o.UseNpgsql(cs).UseMultiLingual(sp).UseAuditing(sp)); + +services.AddAuditing() + .UseEntityFrameworkCore() + .DeclareEntitiesFrom(typeof(Product).Assembly) + .IgnoreEntity() // MultiLingual's row: saved, never audited + .Build(); +``` + +It is the attribute's rule exactly: it beats `[Audited]` and `RequireOptIn`, and the skip is +reported to the [observer](docs/observability.md) as `EntityIgnored`. It is not called `Ignore` +because EF Core's `modelBuilder.Ignore()` means "do not map this type"; this one leaves it mapped +and saved. `Build()` refuses a type that is both declared and ignored. + | I want to… | Guide | |---|---| | Record from Dapper or MongoDB, and know when each becomes durable | [Stores](docs/stores.md) | diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index b1f48e9..30d688b 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -20,6 +20,8 @@ reads the declared entities and refuses a configuration with no store. | `.DeclareEntitiesFrom(assembly)` | Core | Declares every public class in `assembly` carrying `[AuditEntity]`, `[Audited]` or `[AuditRetention]` | | `.DeclareEntity()` | Core | Declares one entity, attribute or not | | `.DeclareEntities(params Type[])` | Core | Declares several | +| `.IgnoreEntity()` | Core | Never records `TEntity` — a class-level `[AuditIgnore]` for a type you cannot annotate, such as MultiLingual's `Translation`. Wins over `[Audited]` and `RequireOptIn`. Not `Ignore`, which in EF Core means "do not map" | +| `.IgnoreEntities(params Type[])` | Core | Ignores several | | `.WithRetention(TimeSpan)` | Core | Same as `AddAuditing(o => o.Retention = …)`. Deletes nothing by itself | | `.WithPageSize(defaultSize, maxSize)` | Core | Both page sizes at once | | `.WithMaxValueLength(characters)` | Core | Override the 4000-character value limit | @@ -39,7 +41,8 @@ written to one are invisible to the other, and a trail split across two stores i `Build()` also refuses two combinations the setters cannot catch on their own — `DefaultPageSize` above `MaxPageSize`, and `MaxValueLength` above `AuditingOptions.MaxValueCeiling` -(1 MB of UTF-16) — and two declared entities claiming one stored name. +(1 MB of UTF-16) — two declared entities claiming one stored name, and an `IgnoreEntity` type that +is also declared or shares a declared entity's stored name. ## `AuditingOptions` diff --git a/docs/limitations.md b/docs/limitations.md index 5ff91c6..e1b2bce 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -87,8 +87,14 @@ key if its history matters. ## Composite keys are refused An entry records a single key. An entity with a composite primary key throws when it is saved, with -the three ways out in the message: a surrogate key, `[AuditIgnore]`, or recording by hand with -`audit.For(name, id)` and a key you compose yourself. +the ways out in the message: a surrogate key, `[AuditIgnore]` — or `.IgnoreEntity()` at +registration for a type you cannot annotate — or recording by hand with `audit.For(name, id)` and a +key you compose yourself. + +An excluded entity's key is never read, so excluding it is enough: DotNetBoost.MultiLingual's own +`Translation` row is keyed on four columns, and `.IgnoreEntity()` is what lets the +interceptor run in a context that saves one. Its skip is reported to the observer with an empty +`entityId`, because the library does not compose a multi-column key even for a report. `DotNetBoost.Media` and `DotNetBoost.MultiLingual` refuse composite keys for the same reason, so an entity that cannot be audited cannot be given media or translations either. diff --git a/docs/scope.md b/docs/scope.md index c7f65fc..e8c5aa6 100644 --- a/docs/scope.md +++ b/docs/scope.md @@ -175,7 +175,8 @@ plain text, forever, in a table nobody thinks to look at. - `[AuditIgnore]` on a property — never recorded, not even its name. - `[AuditRedact]` on a property — recorded as changed, both values `null`, `IsRedacted` set. -- `[AuditIgnore]` on a class — the entity is not audited at all. +- `[AuditIgnore]` on a class — the entity is not audited at all. `.IgnoreEntity()` at + registration is the same rule for a type the application cannot annotate. - Redaction follows **the attribute**, never whether the value happened to be encrypted at rest, so it holds with no encryptor configured. This is the rule Settings' observer got right. - An opt-in **allow-list mode** (`AuditOnly`) for the entities that want it, because the sane diff --git a/src/DotNetBoost.Auditing.Core/AuditingInfrastructure.cs b/src/DotNetBoost.Auditing.Core/AuditingInfrastructure.cs index eafa8f1..b7701b1 100644 --- a/src/DotNetBoost.Auditing.Core/AuditingInfrastructure.cs +++ b/src/DotNetBoost.Auditing.Core/AuditingInfrastructure.cs @@ -46,8 +46,8 @@ public AuditingBuilder(IServiceCollection services) /// /// The configured services, so a caller can carry on with them. /// - /// No store is configured, the options contradict each other, or two declared entities claim - /// the same stored name. + /// No store is configured, the options contradict each other, two declared entities claim + /// the same stored name, or an entity excluded with IgnoreEntity is also declared. /// public IServiceCollection Build() { @@ -95,9 +95,59 @@ private AuditModelRegistry BuildRegistry() declared[name] = type; } + RefuseIgnoredDeclarations(declared); + return declared.Count == 0 ? AuditModelRegistry.Empty : new AuditModelRegistry(declared); } + /// + /// Refuses an IgnoreEntity<T>() that contradicts a declaration: the same type both + /// declared and ignored, or an ignored type sharing a declared entity's stored name. + /// + /// + /// The first is two instructions that cannot both be followed — declared says "apply this type's + /// rules", ignored says "never record it" — and either reading would surprise someone, so the + /// library asks rather than picks. The second matters for a write addressed by name: with an + /// ignored and a declared type behind one name, audit.For(name, id) would be recorded + /// under one type's rules or suppressed under the other's ignore, chosen by nothing the caller + /// can see. It is the same refusal as two declared types under one name. + /// + /// Also what keeps an ignored type out of the declared-entities listing + /// (GET /api/audit/entities): the listing is this registry, and an ignored type cannot be + /// in it. + /// + /// + /// An ignored type is declared, or shares a declared name. + private void RefuseIgnoredDeclarations(Dictionary declared) + { + foreach (var ignored in Options.IgnoredEntities) + { + foreach (var (name, type) in declared) + { + // IsAssignableFrom rather than ==, because a subclass of an ignored type is ignored + // too, and declaring one would be the same contradiction one level down. + if (ignored.IsAssignableFrom(type)) + { + throw new InvalidOperationException( + $"'{type.FullName}' is both declared and excluded with IgnoreEntity" + + (type == ignored ? "" : $"<{ignored.Name}>, its base") + + ". Declaring an entity applies its rules and ignoring it records nothing, so " + + "one of the two is a mistake: remove the IgnoreEntity call, or stop declaring " + + "the type (an assembly scan declares every class carrying [AuditEntity], " + + "[Audited] or [AuditRetention])."); + } + + if (string.Equals(AuditEntityNames.For(ignored), name, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"'{ignored.FullName}' is excluded with IgnoreEntity, and '{type.FullName}' is declared " + + $"under the same entity name '{name}'. A write addressed by that name could be " + + "either. Give the declared one a distinct [AuditEntity(\"…\")] name."); + } + } + } + } + /// /// The types in worth declaring: a concrete, public class that /// carries any of the library's attributes. Scanning every class would put every DTO and diff --git a/src/DotNetBoost.Auditing.Core/AuditingOptions.cs b/src/DotNetBoost.Auditing.Core/AuditingOptions.cs index 2ed4c2f..21c5e4c 100644 --- a/src/DotNetBoost.Auditing.Core/AuditingOptions.cs +++ b/src/DotNetBoost.Auditing.Core/AuditingOptions.cs @@ -148,6 +148,61 @@ public TimeSpan? Retention } } + private readonly List _ignoredEntities = []; + + /// + /// The entity types excluded by IgnoreEntity<T>() — a class-level + /// [AuditIgnore] for a type the application cannot annotate. + /// + /// Here rather than in , which is a static cache of what a type's + /// attributes say: an ignore is configuration, and two hosts reading the same assembly can + /// differ about it, exactly as they can about . + /// + /// + internal IReadOnlyList IgnoredEntities => _ignoredEntities; + + /// Adds to , once. + internal void Ignore(Type entityType) + { + if (!_ignoredEntities.Contains(entityType)) + _ignoredEntities.Add(entityType); + } + + /// + /// Whether is excluded by IgnoreEntity<T>(). A + /// subclass of an ignored type is ignored with it, as it would be by an inherited + /// [AuditIgnore] — which is also what keeps an EF Core proxy of an ignored entity out. + /// + internal bool IsIgnored(Type entityType) + { + // A list rather than a set, and a loop rather than a lookup: the subclass rule needs + // IsAssignableFrom, and an application ignores one or two types, not hundreds. + foreach (var ignored in _ignoredEntities) + { + if (ignored.IsAssignableFrom(entityType)) + return true; + } + + return false; + } + + /// + /// Whether the entity stored under is excluded by + /// IgnoreEntity<T>(), for a write addressed by name — the case where there is no + /// type to ask about. Build() refuses an ignored type whose name a declared entity also + /// uses, so the name is unambiguous here. + /// + internal bool IsIgnored(string entityName) + { + foreach (var ignored in _ignoredEntities) + { + if (string.Equals(AuditEntityNames.For(ignored), entityName, StringComparison.Ordinal)) + return true; + } + + return false; + } + /// /// Validates the combination. Called by Build(); the individual setters have already /// rejected their own bad values, so this is only for the pairs that are each valid alone. diff --git a/src/DotNetBoost.Auditing.Core/AuditingServiceCollectionExtensions.cs b/src/DotNetBoost.Auditing.Core/AuditingServiceCollectionExtensions.cs index 9e0865f..e5efdd1 100644 --- a/src/DotNetBoost.Auditing.Core/AuditingServiceCollectionExtensions.cs +++ b/src/DotNetBoost.Auditing.Core/AuditingServiceCollectionExtensions.cs @@ -132,6 +132,78 @@ public static AuditingBuilder DeclareEntities(this AuditingBuilder builder, para return builder; } + /// + /// Never records — a class-level [AuditIgnore], for a + /// type the application cannot annotate because it does not own it. + /// + /// services.AddAuditing() + /// .UseEntityFrameworkCore<AppDbContext>() + /// .DeclareEntitiesFrom(typeof(Product).Assembly) + /// .IgnoreEntity<Translation>() // DotNetBoost.MultiLingual's row + /// .Build(); + /// + /// + /// The case it exists for is a sibling library's entity in the application's own + /// DbContext: DotNetBoost.MultiLingual's Translation is saved beside every + /// translated entity, is keyed on four columns, and comes from a package the application + /// cannot add an attribute to. A trail of it would also repeat what the translated entity's + /// own history should say. + /// + /// + /// The same rule as the attribute, in every respect: the change is not recorded and is + /// reported to the observer as EntityIgnored; it wins over [Audited] and over + /// RequireOptIn; a subclass is ignored with its base; and a write addressed by name, + /// audit.For("Translation", id), is suppressed as well, because the ignore names the + /// type as surely as a declaration would. + /// + /// + /// Named IgnoreEntity rather than Ignore because EF Core's + /// modelBuilder.Ignore<T>() already means something else — "this type is not + /// mapped" — and the two would sit a few lines apart in the same application. This one + /// leaves the type mapped and saved, and only keeps it out of the trail. + /// + /// + /// The entity type. + /// The builder. + /// The builder, for chaining. + /// is null. + /// + /// Build() refuses a type that is also declared — by DeclareEntity, + /// DeclareEntities or an assembly scan — and one whose stored name a declared entity + /// also uses. Declared means "apply this type's rules", ignored means "never record it", and + /// the library will not guess which was meant. + /// + public static AuditingBuilder IgnoreEntity(this AuditingBuilder builder) + where TEntity : class + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Options.Ignore(typeof(TEntity)); + return builder; + } + + /// + /// Never records any of . See + /// . + /// + /// The builder. + /// The entity types. + /// The builder, for chaining. + /// Either argument, or one of the types, is null. + public static AuditingBuilder IgnoreEntities(this AuditingBuilder builder, params Type[] entityTypes) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(entityTypes); + + foreach (var type in entityTypes) + { + ArgumentNullException.ThrowIfNull(type); + builder.Options.Ignore(type); + } + + return builder; + } + /// /// Keeps entries for , for entities with no /// [AuditRetention] of their own. Nothing is deleted until a prune runs. diff --git a/src/DotNetBoost.Auditing.Core/PublicApi.txt b/src/DotNetBoost.Auditing.Core/PublicApi.txt index cf153dd..a2f8ff8 100644 --- a/src/DotNetBoost.Auditing.Core/PublicApi.txt +++ b/src/DotNetBoost.Auditing.Core/PublicApi.txt @@ -378,6 +378,8 @@ namespace Microsoft.Extensions.DependencyInjection public static DotNetBoost.Auditing.Core.AuditingBuilder DeclareEntitiesFrom(DotNetBoost.Auditing.Core.AuditingBuilder builder, System.Reflection.Assembly assembly); public static DotNetBoost.Auditing.Core.AuditingBuilder DeclareEntity(DotNetBoost.Auditing.Core.AuditingBuilder builder); public static DotNetBoost.Auditing.Core.AuditingBuilder DisableRecording(DotNetBoost.Auditing.Core.AuditingBuilder builder); + public static DotNetBoost.Auditing.Core.AuditingBuilder IgnoreEntities(DotNetBoost.Auditing.Core.AuditingBuilder builder, System.Type[] entityTypes); + public static DotNetBoost.Auditing.Core.AuditingBuilder IgnoreEntity(DotNetBoost.Auditing.Core.AuditingBuilder builder); public static DotNetBoost.Auditing.Core.AuditingBuilder RequireOptIn(DotNetBoost.Auditing.Core.AuditingBuilder builder); public static DotNetBoost.Auditing.Core.AuditingBuilder UseActorAccessor(DotNetBoost.Auditing.Core.AuditingBuilder builder); public static DotNetBoost.Auditing.Core.AuditingBuilder UseCustomStore(DotNetBoost.Auditing.Core.AuditingBuilder builder, System.Func store); diff --git a/src/DotNetBoost.Auditing.Core/Services/AuditRecorder.cs b/src/DotNetBoost.Auditing.Core/Services/AuditRecorder.cs index afc71f7..ff86e0a 100644 --- a/src/DotNetBoost.Auditing.Core/Services/AuditRecorder.cs +++ b/src/DotNetBoost.Auditing.Core/Services/AuditRecorder.cs @@ -59,7 +59,7 @@ internal sealed class AuditRecorder( /// Why is not audited, or null when it is. /// /// - /// The same three rules applies, in one place so a capture source + /// The same rules applies, in one place so a capture source /// that skips an entity early and the engine that would have skipped it late cannot disagree /// about why. Reporting is the caller's, because only the caller knows whether it is about to /// skip the entity or merely asking about it. @@ -73,7 +73,9 @@ internal sealed class AuditRecorder( var plan = AuditPlan.For(entityType); - if (plan.Ignored) + // The attribute and IgnoreEntity() are one rule from two sources: the plan caches what + // the type says, the options hold what this host was configured with. + if (plan.Ignored || _options.IsIgnored(entityType)) return AuditSuppression.EntityIgnored; if (_options.RequireOptIn && !plan.OptedIn) @@ -115,6 +117,14 @@ internal void ReportSuppressed( // neither there are no model rules to apply — see AuditWrite.EntityType. var plan = write.EntityType is { } type ? AuditPlan.For(type) : _registry.PlanFor(entityName); + // IgnoreEntity(), ahead of everything that depends on a plan: an ignored type is never + // declared (Build() refuses both), so a write addressed by name has no plan to find, and + // without this it would be recorded — or, under opt-in, reported as undeclared — when the + // application said plainly not to. By type when there is one, so a subclass is caught; by + // name otherwise, which Build() has made unambiguous. + if (write.EntityType is { } written ? _options.IsIgnored(written) : _options.IsIgnored(entityName)) + return Suppressed(AuditSuppression.EntityIgnored, entityName, entityId, write.Action); + if (plan is not null) { if (plan.Ignored) diff --git a/src/DotNetBoost.Auditing.EntityFrameworkCore/AuditingSaveChangesInterceptor.cs b/src/DotNetBoost.Auditing.EntityFrameworkCore/AuditingSaveChangesInterceptor.cs index a6814f7..793ef11 100644 --- a/src/DotNetBoost.Auditing.EntityFrameworkCore/AuditingSaveChangesInterceptor.cs +++ b/src/DotNetBoost.Auditing.EntityFrameworkCore/AuditingSaveChangesInterceptor.cs @@ -294,9 +294,11 @@ private void Capture(DbContext context, SaveState state) Recorder.ReportSuppressed( suppression, AuditEntityNames.For(entry.Metadata.ClrType), - // Empty when the key arrives with the INSERT: this entity was skipped before it - // was ever addressed, and inventing a placeholder would read as a real key. - TrackedKeys.TryGetKey(entry) ?? string.Empty, + // Empty when the key arrives with the INSERT, or spans several columns: this + // entity was skipped before it was ever addressed, and inventing a placeholder + // would read as a real key. Never a throw — a multi-column key is exactly what + // an application is told to exclude, so the exclusion cannot trip over it. + TrackedKeys.KeyForReport(entry), action); continue; @@ -306,7 +308,9 @@ private void Capture(DbContext context, SaveState state) // Nothing moved. Left to the engine this would return null anyway, and with the same // report — but caught here it also saves deferring an entry that will turn out to be - // empty, so the report is made here too rather than lost to the shortcut. + // empty, so the report is made here too rather than lost to the shortcut. TryGetKey and + // not KeyForReport: only an entity that was NOT excluded gets this far, and for one of + // those a multi-column key is still refused, saved with a change or without one. if (action == AuditAction.Updated && changes.Count == 0) { Recorder.ReportSuppressed( @@ -351,7 +355,8 @@ private void RecordDeferred(SaveState state) var entityId = TrackedKeys.TryGetKey(entry) ?? throw new InvalidOperationException( $"{entry.Metadata.DisplayName()} still has no key after the save, so its creation " + - "cannot be recorded. Assign the key in code, or exclude the entity with [AuditIgnore]."); + "cannot be recorded. Assign the key in code, or exclude the entity with [AuditIgnore] " + + "or IgnoreEntity()."); Record(write with { EntityId = entityId }); } diff --git a/src/DotNetBoost.Auditing.EntityFrameworkCore/EntityDiff.cs b/src/DotNetBoost.Auditing.EntityFrameworkCore/EntityDiff.cs index 5035fbf..e7e85dd 100644 --- a/src/DotNetBoost.Auditing.EntityFrameworkCore/EntityDiff.cs +++ b/src/DotNetBoost.Auditing.EntityFrameworkCore/EntityDiff.cs @@ -196,8 +196,9 @@ internal static class TrackedKeys throw new InvalidOperationException( $"{entry.Metadata.DisplayName()} has a composite primary key " + $"({string.Join(", ", key.Properties.Select(p => p.Name))}). An audit entry records a " + - "single key. Give the entity a surrogate key, exclude it with [AuditIgnore], or record " + - "against it by hand with audit.For(name, id)."); + "single key. Give the entity a surrogate key, exclude it with [AuditIgnore] — or with " + + $".IgnoreEntity<{entry.Metadata.ClrType.Name}>() at registration, for a type you cannot " + + "annotate — or record against it by hand with audit.For(name, id)."); } var property = entry.Property(key.Properties[0].Name); @@ -215,4 +216,39 @@ internal static class TrackedKeys return EntityKey.ToKeyString(property.CurrentValue!); } + + /// + /// The key to put in a report about an entry that is not being recorded: its stored + /// string where it has one, and empty otherwise — never an exception. + /// + /// + /// Empty rather than the parts joined together for a multi-column key, for the same reason + /// and both key accessors refuse to compose one: an + /// is an address, Entity Addressing v1 has no composite form, + /// and a joined string would read as one this library chose — the separator, the order — for a + /// key the application never asked it to address. Empty is what a report already says for a + /// creation skipped before its key existed: this entity was skipped before it was addressed. + /// + /// Only for a suppression. An entity that is about to be recorded still goes through + /// and still refuses a multi-column key, because there the key becomes + /// the entry's address. + /// + /// + internal static string KeyForReport(EntityEntry entry) + { + if (entry.Metadata.FindPrimaryKey() is not { Properties.Count: 1 }) + return string.Empty; + + try + { + return TryGetKey(entry) ?? string.Empty; + } + catch (InvalidOperationException) + { + // A single key this library could not address either — longer than EntityKey.MaxLength, + // or blank. The entity is excluded, so its key never had to fit the column; the report + // must not be what fails the application's save. + return string.Empty; + } + } } diff --git a/tests/DotNetBoost.Auditing.ProviderTests/EfCoreCaptureTests.cs b/tests/DotNetBoost.Auditing.ProviderTests/EfCoreCaptureTests.cs index 56de770..c0c155b 100644 --- a/tests/DotNetBoost.Auditing.ProviderTests/EfCoreCaptureTests.cs +++ b/tests/DotNetBoost.Auditing.ProviderTests/EfCoreCaptureTests.cs @@ -399,9 +399,129 @@ public async Task A_composite_key_is_refused_with_the_workarounds_in_the_message })); Assert.Contains("[AuditIgnore]", error.Message, StringComparison.Ordinal); + Assert.Contains("IgnoreEntity<", error.Message, StringComparison.Ordinal); Assert.Contains("audit.For(name, id)", error.Message, StringComparison.Ordinal); } + // ── excluding a multi-column key ─────────────────────────────────────── + // + // The refusal above tells the application to exclude the entity. These are the tests that doing + // so works: the skip is reported before anything tries to read the key, so an excluded entity's + // key shape is no longer the interceptor's business. + + [Fact] + public async Task An_ignored_entity_with_a_multi_column_key_saves_beside_an_audited_one() + { + using var harness = new EfCoreHarness(build: b => b.IgnoreEntity()); + + await harness.InScopeAsync(async (db, _) => + { + // The shape MultiLingual gives a catalogue save: the entity, and a translation row for it. + db.Products.Add(new Product { Name = "Widget", Price = 1m }); + db.Translations.Add(new Translation + { + EntityName = "Product", EntityId = "1", PropertyName = "Name", LanguageCode = "ar", Value = "ودجت" + }); + await db.SaveChangesAsync(); + }); + + await harness.InScopeAsync(async (db, _) => + { + var product = await db.Products.SingleAsync(); + var translation = await db.Translations.SingleAsync(); + product.Price = 2m; + translation.Value = "أداة"; + await db.SaveChangesAsync(); + }); + + var entries = await harness.EntriesAsync(); + Assert.All(entries, e => Assert.Equal("Product", e.EntityName)); + Assert.Equal([AuditAction.Updated, AuditAction.Created], entries.Select(e => e.Action)); + + Assert.Equal( + [(AuditSuppression.EntityIgnored, "Translation", "", AuditAction.Created), + (AuditSuppression.EntityIgnored, "Translation", "", AuditAction.Updated)], + harness.Observer.Suppressed); + } + + [Fact] + public async Task A_class_level_AuditIgnore_on_a_multi_column_key_no_longer_throws() + { + // The regression: the refusal's own advice was to add [AuditIgnore], and the skip that + // attribute causes then tried to read the multi-column key for its report and threw anyway. + using var harness = new EfCoreHarness(); + + await harness.InScopeAsync(async (db, _) => + { + db.IgnoredCompositeKeyeds.Add(new IgnoredCompositeKeyed { TenantId = 1, LocalId = 2, Label = "x" }); + await db.SaveChangesAsync(); + }); + + await harness.InScopeAsync(async (db, _) => + { + db.IgnoredCompositeKeyeds.Remove(await db.IgnoredCompositeKeyeds.SingleAsync()); + await db.SaveChangesAsync(); + }); + + Assert.Empty(await harness.EntriesAsync()); + Assert.Equal( + [(AuditSuppression.EntityIgnored, nameof(IgnoredCompositeKeyed), "", AuditAction.Created), + (AuditSuppression.EntityIgnored, nameof(IgnoredCompositeKeyed), "", AuditAction.Deleted)], + harness.Observer.Suppressed); + } + + [Fact] + public async Task An_ignored_entity_with_a_key_too_long_to_address_is_reported_not_thrown() + { + // Not a multi-column key this time: a single one longer than the column holds. Excluded, so + // it never has to fit, and the report must not be what fails the save. + using var harness = new EfCoreHarness(build: b => b.IgnoreEntity()); + + await harness.InScopeAsync(async (db, _) => + { + db.LongKeyeds.Add(new LongKeyed { Code = new string('k', 100) }); + await db.SaveChangesAsync(); + }); + + Assert.Empty(await harness.EntriesAsync()); + Assert.Equal( + (AuditSuppression.EntityIgnored, nameof(LongKeyed), "", AuditAction.Created), + Assert.Single(harness.Observer.Suppressed)); + } + + [Fact] + public async Task An_ignored_entity_is_reported_as_ignored_rather_than_as_not_opted_in() + { + using var harness = new EfCoreHarness(o => o.RequireOptIn = true, build: b => b.IgnoreEntity()); + + await harness.InScopeAsync(async (db, _) => + { + db.Translations.Add(new Translation + { + EntityName = "Product", EntityId = "1", PropertyName = "Name", LanguageCode = "ar", Value = "x" + }); + await db.SaveChangesAsync(); + }); + + Assert.Empty(await harness.EntriesAsync()); + Assert.Equal(AuditSuppression.EntityIgnored, Assert.Single(harness.Observer.Suppressed).Reason); + } + + [Fact] + public void Ignoring_an_entity_an_assembly_scan_declares_is_refused_at_build() + { + // Product carries [AuditEntity], so the scan declares it — and a type both declared and + // ignored is two instructions that cannot both be followed. + var builder = new ServiceCollection() + .AddAuditing() + .UseEntityFrameworkCore() + .DeclareEntitiesFrom(typeof(Product).Assembly) + .IgnoreEntity(); + + var error = Assert.Throws(() => builder.Build()); + Assert.Contains(typeof(Product).FullName!, error.Message, StringComparison.Ordinal); + } + [Fact] public async Task Recording_off_captures_nothing_and_the_save_still_works() { diff --git a/tests/DotNetBoost.Auditing.ProviderTests/EfCoreHarness.cs b/tests/DotNetBoost.Auditing.ProviderTests/EfCoreHarness.cs index 1abf76a..5a04981 100644 --- a/tests/DotNetBoost.Auditing.ProviderTests/EfCoreHarness.cs +++ b/tests/DotNetBoost.Auditing.ProviderTests/EfCoreHarness.cs @@ -19,6 +19,9 @@ public class TestDbContext(DbContextOptions options) : DbContext( public DbSet FluentlyKeyeds => Set(); public DbSet CompositeKeyeds => Set(); public DbSet Orders => Set(); + public DbSet Translations => Set(); + public DbSet IgnoredCompositeKeyeds => Set(); + public DbSet LongKeyeds => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -29,6 +32,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity().Property(f => f.Reference).ValueGeneratedNever(); modelBuilder.Entity().HasKey(c => new { c.TenantId, c.LocalId }); modelBuilder.Entity().OwnsOne(o => o.Address); + modelBuilder.Entity().HasKey(t => new { t.EntityName, t.EntityId, t.PropertyName, t.LanguageCode }); + modelBuilder.Entity().HasKey(c => new { c.TenantId, c.LocalId }); + modelBuilder.Entity().HasKey(l => l.Code); modelBuilder.ApplyAuditing(); @@ -73,7 +79,13 @@ internal sealed class EfCoreHarness : IDisposable private readonly SqliteConnection _connection; private readonly ServiceProvider _provider; - public EfCoreHarness(Action? configure = null, bool capture = true) + /// Applied to the options. + /// Whether the context records itself through the interceptor. + /// Applied to the builder after the declarations, just before Build(). + public EfCoreHarness( + Action? configure = null, + bool capture = true, + Action? build = null) { _connection = new SqliteConnection("DataSource=:memory:"); _connection.Open(); @@ -91,10 +103,12 @@ public EfCoreHarness(Action? configure = null, bool capture = t o.UseAuditing(sp); }); - services.AddAuditing(o => configure?.Invoke(o)) - .UseEntityFrameworkCore() - .DeclareEntitiesFrom(typeof(Product).Assembly) - .Build(); + var builder = services.AddAuditing(o => configure?.Invoke(o)) + .UseEntityFrameworkCore() + .DeclareEntitiesFrom(typeof(Product).Assembly); + + build?.Invoke(builder); + builder.Build(); services.Replace(ServiceDescriptor.Scoped(_ => Actors)); diff --git a/tests/DotNetBoost.Auditing.ProviderTests/Entities.cs b/tests/DotNetBoost.Auditing.ProviderTests/Entities.cs index b978e41..3643963 100644 --- a/tests/DotNetBoost.Auditing.ProviderTests/Entities.cs +++ b/tests/DotNetBoost.Auditing.ProviderTests/Entities.cs @@ -60,6 +60,34 @@ public class CompositeKeyed public string Label { get; set; } = ""; } +/// +/// The shape of DotNetBoost.MultiLingual's translation row: a type an application shares its context +/// with and cannot annotate, keyed on four columns. IgnoreEntity<T>() is for this. +/// +public class Translation +{ + public string EntityName { get; set; } = ""; + public string EntityId { get; set; } = ""; + public string PropertyName { get; set; } = ""; + public string LanguageCode { get; set; } = ""; + public string Value { get; set; } = ""; +} + +/// A multi-column key the application does own, and so excludes with the attribute. +[AuditIgnore] +public class IgnoredCompositeKeyed +{ + public int TenantId { get; set; } + public int LocalId { get; set; } + public string Label { get; set; } = ""; +} + +/// A single key longer than EntityKey.MaxLength: addressable by EF, not by an entry. +public class LongKeyed +{ + public string Code { get; set; } = ""; +} + public class Order { public int Id { get; set; } diff --git a/tests/DotNetBoost.Auditing.UnitTests/RegistrationTests.cs b/tests/DotNetBoost.Auditing.UnitTests/RegistrationTests.cs index caf3e27..13d0263 100644 --- a/tests/DotNetBoost.Auditing.UnitTests/RegistrationTests.cs +++ b/tests/DotNetBoost.Auditing.UnitTests/RegistrationTests.cs @@ -154,6 +154,162 @@ public void Declaring_the_same_type_twice_is_harmless() .Build(); } + // ── IgnoreEntity ─────────────────────────────────────────────────────── + + /// + /// A scope over an engine built with , and the observer it reports + /// to — the only place an ignored entity leaves a trace. + /// + private static (IServiceScope Scope, IAuditManager Audit, RecordingAuditStore Store, RecordingAuditObserver Observer) + Ignoring(Func configure, Action? options = null) + { + var store = new RecordingAuditStore(); + + var provider = configure(new ServiceCollection() + .AddAuditing(o => options?.Invoke(o)) + .UseCustomStore(_ => store) + .UseObserver()) + .Build() + .BuildServiceProvider(); + + var scope = provider.CreateScope(); + + return ( + scope, + scope.ServiceProvider.GetRequiredService(), + store, + (RecordingAuditObserver)provider.GetRequiredService()); + } + + [Fact] + public async Task An_ignored_entity_is_suppressed_as_ignored_whichever_way_it_is_addressed() + { + var (scope, audit, store, observer) = Ignoring(b => b.IgnoreEntity()); + using var lifetime = scope; + + Assert.False(audit.IsAudited(typeof(Product))); + + Assert.Null(await audit.For(new Product { Id = 1 }) + .UpdatedAsync([new AuditChange(nameof(Product.Price), 1m, 2m)])); + + // By name too: the ignore names the type, so the engine knows the name as surely as it + // would for a declared [AuditIgnore] class — and the two must not differ. + Assert.Null(await audit.For("Product", "1").DeletedAsync()); + + Assert.Empty(store.Entries); + Assert.Equal( + [(AuditSuppression.EntityIgnored, "Product", "1", AuditAction.Updated), + (AuditSuppression.EntityIgnored, "Product", "1", AuditAction.Deleted)], + observer.Suppressed); + } + + [Fact] + public async Task IgnoreEntity_matches_a_declared_class_level_AuditIgnore() + { + var (attributeScope, byAttribute, _, attributeObserver) = Ignoring(b => b.DeclareEntity()); + var (configScope, byConfig, _, configObserver) = Ignoring(b => b.IgnoreEntity()); + using var a = attributeScope; + using var c = configScope; + + await byAttribute.For(new Heartbeat { Id = 1 }).CreatedAsync(); + await byAttribute.For("Heartbeat", "1").DeletedAsync(); + await byConfig.For(new Product { Id = 1 }).CreatedAsync(); + await byConfig.For("Product", "1").DeletedAsync(); + + Assert.Equal(attributeObserver.Reasons, configObserver.Reasons); + Assert.Equal(byAttribute.IsAudited(typeof(Heartbeat)), byConfig.IsAudited(typeof(Product))); + } + + [Fact] + public async Task IgnoreEntity_wins_over_Audited_and_over_RequireOptIn() + { + var (scope, audit, store, observer) = Ignoring( + b => b.IgnoreEntities(typeof(Invoice)), + o => o.RequireOptIn = true); + using var lifetime = scope; + + Assert.False(audit.IsAudited(typeof(Invoice))); + Assert.Null(await audit.For(new Invoice { Id = 1 }) + .UpdatedAsync([new AuditChange(nameof(Invoice.Total), 1m, 2m)])); + + Assert.Empty(store.Entries); + + // Ignored, not NotOptedIn: the entity is [Audited], so "not opted in" would be false. + Assert.Equal(AuditSuppression.EntityIgnored, Assert.Single(observer.Suppressed).Reason); + } + + [Fact] + public async Task Ignoring_one_entity_leaves_the_others_recorded() + { + var (scope, audit, store, _) = Ignoring(b => b.IgnoreEntity()); + using var lifetime = scope; + + Assert.NotNull(await audit.For(new Invoice { Id = 1 }) + .UpdatedAsync([new AuditChange(nameof(Invoice.Total), 1m, 2m)])); + + Assert.Equal("Invoice", Assert.Single(store.Entries).EntityName); + } + + [Fact] + public void Ignoring_the_same_type_twice_is_harmless() + { + new ServiceCollection() + .AddAuditing() + .UseCustomStore() + .IgnoreEntity() + .IgnoreEntities(typeof(Product), typeof(Product)) + .Build(); + } + + [Fact] + public void An_entity_both_declared_and_ignored_is_refused_at_build() + { + var declaredFirst = new ServiceCollection() + .AddAuditing() + .UseCustomStore() + .DeclareEntity() + .IgnoreEntity(); + + var ignoredFirst = new ServiceCollection() + .AddAuditing() + .UseCustomStore() + .IgnoreEntities(typeof(Invoice)) + .DeclareEntities(typeof(Invoice)); + + foreach (var builder in new[] { declaredFirst, ignoredFirst }) + { + var error = Assert.Throws(() => builder.Build()); + Assert.Contains(typeof(Invoice).FullName!, error.Message, StringComparison.Ordinal); + Assert.Contains("IgnoreEntity", error.Message, StringComparison.Ordinal); + } + } + + [Fact] + public void An_ignored_entity_sharing_a_declared_entitys_stored_name_is_refused_at_build() + { + // Two types, one name: a write addressed as For("Person", id) would be both recorded under + // one type's rules and suppressed under the other's ignore. + var builder = new ServiceCollection() + .AddAuditing() + .UseCustomStore() + .DeclareEntity() + .IgnoreEntity(); + + var error = Assert.Throws(() => builder.Build()); + Assert.Contains("'Person'", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void IgnoreEntity_validates_its_arguments() + { + var builder = new ServiceCollection().AddAuditing(); + + Assert.Throws(() => AuditingBuilderExtensions.IgnoreEntity(null!)); + Assert.Throws(() => AuditingBuilderExtensions.IgnoreEntities(null!, typeof(Product))); + Assert.Throws(() => builder.IgnoreEntities(null!)); + Assert.Throws(() => builder.IgnoreEntities(typeof(Product), null!)); + } + [Fact] public void A_default_page_size_above_the_maximum_is_refused_at_build() {