Skip to content

Commit f01d5e6

Browse files
cemililikclaude
andcommitted
test(tenancy): prove the policy with no EF in the path
The unfiltered customization read asked EF to drop its query filter. Both that and raw SQL remove the filter; only one removes it by construction, and the difference is what the case is worth: a call that silently stopped applying would leave it green while proving nothing. So it reads the three tables over the request's own connection through IUnitOfWork. There is no filter to reacquire, and what answers is the policy plus the SET LOCAL the pipeline issued — the half the schema suite cannot reach, because it announces the tenant itself. It is also the idiom No_IgnoreQueryFilters_Outside_PlatformAdminScope asks for. That rule scans backend/src and not this file, so the call was legal; a test is still where the next person reads the pattern from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b16a2c5 commit f01d5e6

1 file changed

Lines changed: 61 additions & 24 deletions

File tree

‎backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs‎

Lines changed: 61 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -236,13 +236,11 @@ public async Task The_policy_holds_the_customization_tables_without_the_filter()
236236
// announcement `TransactionBehavior` made — which is the half the schema
237237
// suite cannot reach, because it sets `app.tenant_id` itself.
238238
//
239-
// <b>What it does not discriminate, stated rather than implied.</b> The
240-
// filter and the policy scope on the same column with the same value, so no
241-
// in-process read can tell "the filter answered" from "the policy answered":
242-
// if `IgnoreQueryFilters()` ever stopped applying, this case would keep
243-
// passing. The layer that proves the policy alone is the schema suite, which
244-
// runs raw SQL as `learnstack_app` with no EF in the picture at all
245-
// (`CustomizationSchemaTests`). This one adds the request path to it.
239+
// The read is raw SQL on the request's own connection, so there is no filter
240+
// to reacquire and nothing to trust: what answers is the policy plus the
241+
// announcement. `CustomizationSchemaTests` asks the policy the same question
242+
// in isolation; what only this case can say is that the announcement a real
243+
// request produced is the one the policy read.
246244
var english = await GetAsync(SeedData.English.Host, "customizations-unfiltered");
247245
var yoga = await GetAsync(SeedData.Yoga.Host, "customizations-unfiltered");
248246

@@ -544,13 +542,12 @@ public enum ProbeSubject
544542
Settings,
545543
Customizations,
546544

547-
/// <summary>The same read with the EF query filter removed.</summary>
545+
/// <summary>The same read as raw SQL, with no EF in the path.</summary>
548546
/// <remarks>
549547
/// One of the four isolation layers taken away on purpose, so the one behind it
550-
/// answers alone. <c>No_IgnoreQueryFilters_Outside_PlatformAdminScope</c> scans
551-
/// <c>backend/src</c> and not this file — the rule exists so a production call
552-
/// site is a deliberate edit, and a test that removes a layer to prove the next
553-
/// one holds is the claim defence in depth makes.
548+
/// answers alone — and taken away by construction rather than by asking EF to
549+
/// drop it, which is the difference between a case that proves something and a
550+
/// case that trusts a call.
554551
/// </remarks>
555552
CustomizationsUnfiltered,
556553
}
@@ -611,7 +608,8 @@ public sealed record ForeignWriteCommand(Guid TenantId)
611608
/// </remarks>
612609
public sealed class ProbeQueryHandler(
613610
TenancyDbContext db,
614-
LearnStack.Modules.Customization.Infrastructure.Persistence.CustomizationDbContext customization)
611+
LearnStack.Modules.Customization.Infrastructure.Persistence.CustomizationDbContext customization,
612+
SharedKernel.Persistence.IUnitOfWork unitOfWork)
615613
: MediatR.IRequestHandler<ProbeQuery, SharedKernel.Results.Result<List<string>>>,
616614
MediatR.IRequestHandler<UnresolvedProbeQuery, SharedKernel.Results.Result<List<string>>>,
617615
MediatR.IRequestHandler<UnresolvedCustomizationProbeQuery,
@@ -640,7 +638,7 @@ await ReadSettingsAsync(db, cancellationToken)),
640638
await ReadCustomizationsAsync(customization, cancellationToken)),
641639

642640
ProbeSubject.CustomizationsUnfiltered => SharedKernel.Results.Result.Ok(
643-
await ReadCustomizationsAsync(customization, cancellationToken, filtered: false)),
641+
await ReadCustomizationsWithoutEfAsync(unitOfWork, cancellationToken)),
644642

645643
// Exhaustive by construction, fail-closed on a member added without deciding
646644
// what it reads — the house style, and the opposite of falling through to
@@ -680,29 +678,68 @@ await ReadCustomizationsAsync(customization, cancellationToken, filtered: false)
680678
/// </remarks>
681679
private static async Task<List<string>> ReadCustomizationsAsync(
682680
LearnStack.Modules.Customization.Infrastructure.Persistence.CustomizationDbContext db,
683-
CancellationToken cancellationToken,
684-
bool filtered = true)
681+
CancellationToken cancellationToken)
685682
{
686-
var contentTypes = await Rows(db.TenantContentTypes, filtered)
683+
var contentTypes = await db.TenantContentTypes
687684
.Select(contentType => "content-type:" + contentType.Key + "@" + contentType.TenantId)
688685
.ToListAsync(cancellationToken);
689686

690-
var taxonomies = await Rows(db.TenantLevelTaxonomies, filtered)
687+
var taxonomies = await db.TenantLevelTaxonomies
691688
.Select(taxonomy => "taxonomy:" + taxonomy.Key + "@" + taxonomy.TenantId)
692689
.ToListAsync(cancellationToken);
693690

694-
var bands = await Rows(
695-
db.Set<LearnStack.Modules.Customization.Domain.TenantLevelTaxonomyItem>(), filtered)
691+
var bands = await db
692+
.Set<LearnStack.Modules.Customization.Domain.TenantLevelTaxonomyItem>()
696693
.Select(item => "band:" + item.Key + "@" + item.TenantId)
697694
.ToListAsync(cancellationToken);
698695

699696
return [.. contentTypes.Concat(taxonomies).Concat(bands).Order(StringComparer.Ordinal)];
700697
}
701698

702-
/// <summary>The same query, with the EF filter kept or taken away.</summary>
703-
private static IQueryable<TEntity> Rows<TEntity>(IQueryable<TEntity> rows, bool filtered)
704-
where TEntity : class =>
705-
filtered ? rows : rows.IgnoreQueryFilters();
699+
/// <summary>
700+
/// The same three tables, read with no EF in the path at all.
701+
/// </summary>
702+
/// <remarks>
703+
/// <para>
704+
/// <b>Raw SQL on the request's own connection, not <c>IgnoreQueryFilters()</c>.</b>
705+
/// Both remove the query filter, and only one of them removes it by construction:
706+
/// asking EF to drop a filter leaves EF deciding, so a call that silently stopped
707+
/// applying would leave this case green while proving nothing. There is no filter
708+
/// to reacquire here, and what answers is the policy plus the <c>SET LOCAL
709+
/// app.tenant_id</c> that <c>TransactionBehavior</c> issued — which is the half
710+
/// the schema suite cannot reach, because it announces the tenant itself.
711+
/// </para>
712+
/// <para>
713+
/// It is also the pattern <c>No_IgnoreQueryFilters_Outside_PlatformAdminScope</c>
714+
/// asks for. That rule scans <c>backend/src</c> and not this file, so the call was
715+
/// legal — but a test is where the next person reads the idiom from.
716+
/// </para>
717+
/// </remarks>
718+
private static async Task<List<string>> ReadCustomizationsWithoutEfAsync(
719+
SharedKernel.Persistence.IUnitOfWork unitOfWork, CancellationToken cancellationToken)
720+
{
721+
await using var command = new NpgsqlCommand(
722+
"""
723+
SELECT 'content-type:' || key || '@' || tenant_id FROM tenant_content_types
724+
UNION ALL
725+
SELECT 'taxonomy:' || key || '@' || tenant_id FROM tenant_level_taxonomies
726+
UNION ALL
727+
SELECT 'band:' || key || '@' || tenant_id FROM tenant_level_taxonomy_items
728+
ORDER BY 1
729+
""",
730+
(NpgsqlConnection)unitOfWork.Connection,
731+
(NpgsqlTransaction?)unitOfWork.Transaction);
732+
733+
var rows = new List<string>();
734+
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
735+
736+
while (await reader.ReadAsync(cancellationToken))
737+
{
738+
rows.Add(reader.GetString(0));
739+
}
740+
741+
return rows;
742+
}
706743

707744
/// <summary>
708745
/// Every setting the request can see, as <c>key=scope</c>.

0 commit comments

Comments
 (0)