@@ -234,6 +234,180 @@ func TestConsistency(t *testing.T) {
234234 })
235235}
236236
237+ // TestQueryPlanConsistencyGRPC verifies that the experimental query plan engine
238+ // produces consistent results for LookupResources and LookupSubjects when those
239+ // operations are routed through the permissionServer gRPC handlers
240+ // (lookupResourcesWithQueryPlan / lookupSubjectsWithQueryPlan).
241+ //
242+ // It iterates the same validation files as TestConsistency, spins up a server
243+ // with both "lr" and "ls" query-plan flags enabled, and then verifies that the
244+ // set of returned resource/subject IDs matches the expected accessibility set.
245+ //
246+ // Note: the query plan handlers do not yet support cursor pagination or wildcard
247+ // exclusions, so this test intentionally uses unpaginated single-shot calls and
248+ // only checks set membership rather than running the full validateLookupResources
249+ // / validateLookupSubjects suite.
250+ func TestQueryPlanConsistencyGRPC (t * testing.T ) { // nolint:tparallel
251+ t .Parallel ()
252+
253+ consistencyTestFiles , err := consistencytestutil .ListTestConfigs ()
254+ require .NoError (t , err )
255+
256+ for _ , filePath := range consistencyTestFiles {
257+ t .Run (path .Base (filePath ), func (t * testing.T ) {
258+ t .Parallel ()
259+ runQueryPlanConsistencyGRPCForFile (t , filePath )
260+ })
261+ }
262+ }
263+
264+ // runQueryPlanConsistencyGRPCForFile spins up a full gRPC server with both the
265+ // "lr" and "ls" experimental query plan flags enabled, then validates that
266+ // LookupResources and LookupSubjects return the correct set of IDs for every
267+ // resource-type × subject combination in the validation file.
268+ func runQueryPlanConsistencyGRPCForFile (t * testing.T , filePath string ) {
269+ options := []server.ConfigOption {
270+ server .WithDispatchChunkSize (10 ),
271+ server .WithExperimentalQueryPlan ("lr" ),
272+ server .WithExperimentalQueryPlan ("ls" ),
273+ }
274+
275+ cad := consistencytestutil .LoadDataAndCreateClusterForTesting (t , filePath , testTimedelta , options ... )
276+
277+ headRevision , err := cad .DataStore .HeadRevision (cad .Ctx )
278+ require .NoError (t , err )
279+
280+ accessibilitySet := consistencytestutil .BuildAccessibilitySet (t , cad .Ctx , cad .Populated , cad .DataStore )
281+
282+ testers := consistencytestutil .ServiceTesters (cad .Conn )
283+ for _ , tester := range testers {
284+ t .Run (tester .Name (), func (t * testing.T ) {
285+ validateQueryPlanLookupResources (t , cad , tester , headRevision , accessibilitySet )
286+ validateQueryPlanLookupSubjects (t , cad , tester , headRevision , accessibilitySet )
287+ })
288+ }
289+ }
290+
291+ // validateQueryPlanLookupResources checks that for each resource-type × subject
292+ // pair the query plan handler returns exactly the same resource IDs as the
293+ // accessibility set expects. Pagination is not tested here because the query
294+ // plan handlers do not yet implement cursor-based pagination.
295+ func validateQueryPlanLookupResources (
296+ t * testing.T ,
297+ cad consistencytestutil.ConsistencyClusterAndData ,
298+ tester consistencytestutil.ServiceTester ,
299+ revision datastore.Revision ,
300+ accessibilitySet * consistencytestutil.AccessibilitySet ,
301+ ) {
302+ testForEachResourceTypeInPopulated (t , cad .Populated , "qp_lookup_resources" ,
303+ func (t * testing.T , resourceRelation tuple.RelationReference ) {
304+ for _ , subject := range accessibilitySet .AllSubjectsNoWildcards () {
305+ t .Run (tuple .StringONR (subject ), func (t * testing.T ) {
306+ accessibleResources := accessibilitySet .LookupAccessibleResources (resourceRelation , subject )
307+
308+ // Single unpaginated call (limit=0 means no limit).
309+ foundResources , _ , err := tester .LookupResources (t .Context (), resourceRelation , subject , revision , nil , 0 , nil )
310+ require .NoError (t , err )
311+
312+ resolvedIDs := make ([]string , 0 , len (foundResources ))
313+ for _ , r := range foundResources {
314+ resolvedIDs = append (resolvedIDs , r .ResourceObjectId )
315+ }
316+
317+ require .ElementsMatch (t ,
318+ slices .Collect (maps .Keys (accessibleResources )),
319+ resolvedIDs ,
320+ "query plan LookupResources mismatch for %s#%s / subject %s" ,
321+ resourceRelation .ObjectType , resourceRelation .Relation , tuple .StringONR (subject ),
322+ )
323+ })
324+ }
325+ })
326+ }
327+
328+ // validateQueryPlanLookupSubjects checks that for each resource × subject-type
329+ // pair the query plan handler returns at least the subjects the accessibility
330+ // set defines as directly accessible. Wildcard exclusion checking is omitted
331+ // because the query plan handler does not yet populate ExcludedSubjects.
332+ func validateQueryPlanLookupSubjects (
333+ t * testing.T ,
334+ cad consistencytestutil.ConsistencyClusterAndData ,
335+ tester consistencytestutil.ServiceTester ,
336+ revision datastore.Revision ,
337+ accessibilitySet * consistencytestutil.AccessibilitySet ,
338+ ) {
339+ testForEachResourceInPopulated (t , cad .Populated , accessibilitySet , "qp_lookup_subjects" ,
340+ func (t * testing.T , resource tuple.ObjectAndRelation ) {
341+ for _ , subjectType := range accessibilitySet .SubjectTypes () {
342+ t .Run (fmt .Sprintf ("%s#%s" , subjectType .ObjectType , subjectType .Relation ),
343+ func (t * testing.T ) {
344+ resolvedSubjects , err := tester .LookupSubjects (t .Context (), resource , subjectType , revision , nil )
345+ require .NoError (t , err )
346+
347+ // The accessibility set only covers directly-accessible defined subjects;
348+ // it does not include inferred subjects or wildcards, so we check subset.
349+ expectedDefinedSubjects := accessibilitySet .DirectlyAccessibleDefinedSubjectsOfType (resource , subjectType )
350+ requireSubsetOf (t ,
351+ slices .Collect (maps .Keys (resolvedSubjects )),
352+ slices .Collect (maps .Keys (expectedDefinedSubjects )),
353+ )
354+ })
355+ }
356+ })
357+ }
358+
359+ // testForEachResourceTypeInPopulated runs a subtest for every relation on every
360+ // namespace defined in the populated validation file.
361+ func testForEachResourceTypeInPopulated (
362+ t * testing.T ,
363+ populated * validationfile.PopulatedValidationFile ,
364+ prefix string ,
365+ handler func (t * testing.T , resourceRelation tuple.RelationReference ),
366+ ) {
367+ t .Helper ()
368+ for _ , resourceType := range populated .NamespaceDefinitions {
369+ for _ , relation := range resourceType .Relation {
370+ t .Run (fmt .Sprintf ("%s_%s_%s" , prefix , resourceType .Name , relation .Name ),
371+ func (t * testing.T ) {
372+ handler (t , tuple.RelationReference {
373+ ObjectType : resourceType .Name ,
374+ Relation : relation .Name ,
375+ })
376+ })
377+ }
378+ }
379+ }
380+
381+ // testForEachResourceInPopulated runs a subtest for every resource instance
382+ // present in the accessibility set, filtered by namespace definitions.
383+ func testForEachResourceInPopulated (
384+ t * testing.T ,
385+ populated * validationfile.PopulatedValidationFile ,
386+ accessibilitySet * consistencytestutil.AccessibilitySet ,
387+ prefix string ,
388+ handler func (t * testing.T , resource tuple.ObjectAndRelation ),
389+ ) {
390+ t .Helper ()
391+ for _ , resourceType := range populated .NamespaceDefinitions {
392+ resources , ok := accessibilitySet .ResourcesByNamespace .Get (resourceType .Name )
393+ if ! ok {
394+ continue
395+ }
396+ for _ , relation := range resourceType .Relation {
397+ for _ , resource := range resources {
398+ t .Run (fmt .Sprintf ("%s_%s_%s_%s" , prefix , resourceType .Name , resource .ObjectID , relation .Name ),
399+ func (t * testing.T ) {
400+ handler (t , tuple.ObjectAndRelation {
401+ ObjectType : resourceType .Name ,
402+ ObjectID : resource .ObjectID ,
403+ Relation : relation .Name ,
404+ })
405+ })
406+ }
407+ }
408+ }
409+ }
410+
237411func runConsistencyTestSuiteForFile (t * testing.T , filePath string , useCachingDispatcher bool , chunkSize uint16 ) {
238412 options := []server.ConfigOption {
239413 server .WithDispatchChunkSize (chunkSize ),
0 commit comments