1515use Cycle \ORM \Tests \Fixtures \Comment ;
1616use Cycle \ORM \Tests \Fixtures \SortByIDScope ;
1717use Cycle \ORM \Tests \Fixtures \User ;
18+ use Cycle \ORM \Tests \Fixtures \WrappedQueryScope ;
1819use Cycle \ORM \Tests \Traits \TableTrait ;
1920use Cycle \Database \Exception \StatementException ;
2021
@@ -442,6 +443,109 @@ public function testInloadWithScopeOrderedAndWhere(): void
442443 $ this ->assertSame ('msg 2.3 ' , $ res [0 ]->comments [0 ]->message );
443444 }
444445
446+ /**
447+ * Documents the historical bug: when a joined-loader scope adds a plain top-level
448+ * condition (no wrapWhere), an adversarial `orWhere` injected via the 'load' option
449+ * bypasses the scope due to AND-over-OR precedence inside the JOIN's ON clause.
450+ */
451+ public function testInloadJoinedScopeWithoutWrapWhereIsBypassedByAdversarialOrLoad (): void
452+ {
453+ $ this ->orm = $ this ->withCommentsSchema ([
454+ // Plain QueryScope — adds `level >= 2` as a flat AND token, no wrap.
455+ Schema::SCOPE => new Select \QueryScope (['@.level ' => ['>= ' => 2 ]]),
456+ ]);
457+
458+ $ res = (new Select ($ this ->orm , User::class))
459+ ->load ('comments ' , [
460+ 'method ' => JoinableLoader::INLOAD ,
461+ 'load ' => static function (Select \QueryBuilder $ q ): void {
462+ // top-level ON-condition WHERE + OR — the scope's AND lands at the
463+ // tail of the same flat token list:
464+ // ON join_key AND level = 1 OR message = 'msg 4' AND level >= 2
465+ // ≡ (join_key AND level = 1) OR (message = 'msg 4' AND level >= 2)
466+ // The first OR-arm ignores `level >= 2` — scope bypassed.
467+ $ q ->where ('@.level ' , 1 )
468+ ->orWhere ('@.message ' , 'msg 4 ' );
469+ },
470+ ])
471+ ->orderBy ('user.id ' )
472+ ->fetchAll ();
473+
474+ [$ userA , $ userB ] = $ res ;
475+
476+ // User A: msg 1 (level=1) sneaks through the first OR-arm; msg 4 matches the
477+ // second arm legitimately.
478+ $ this ->assertCount (2 , $ userA ->comments );
479+ $ aMessages = $ this ->extractMessages ($ userA ->comments );
480+ $ this ->assertSame (['msg 1 ' , 'msg 4 ' ], $ aMessages );
481+
482+ // User B: msg 2.1 (level=1) sneaks through the first arm — scope leaks again.
483+ $ this ->assertCount (1 , $ userB ->comments );
484+ $ this ->assertSame ('msg 2.1 ' , $ userB ->comments [0 ]->message );
485+ }
486+
487+ /**
488+ * Companion to the test above: with {@see WrappedQueryScope} (calls wrapWhere() first),
489+ * QueryBuilder::targetFunc() forwards the wrap to `wrapOnWhere` on the JOIN's ON tokens,
490+ * enclosing the user's OR group. The scope's `level >= 2` condition stays effective.
491+ */
492+ public function testInloadJoinedScopeWithWrapWhereProtectsAgainstAdversarialOrLoad (): void
493+ {
494+ $ this ->orm = $ this ->withCommentsSchema ([
495+ Schema::SCOPE => new WrappedQueryScope (['@.level ' => ['>= ' => 2 ]]),
496+ ]);
497+
498+ $ res = (new Select ($ this ->orm , User::class))
499+ ->load ('comments ' , [
500+ 'method ' => JoinableLoader::INLOAD ,
501+ 'load ' => static function (Select \QueryBuilder $ q ): void {
502+ $ q ->where ('@.level ' , 1 )
503+ ->orWhere ('@.message ' , 'msg 4 ' );
504+ },
505+ ])
506+ ->orderBy ('user.id ' )
507+ ->fetchAll ();
508+
509+ [$ userA , $ userB ] = $ res ;
510+
511+ // ON ((join_key AND level = 1) OR message = 'msg 4') AND level >= 2
512+ // User A: only msg 4 (level = 4) matches the OR group AND scope.
513+ $ this ->assertCount (1 , $ userA ->comments );
514+ $ this ->assertSame ('msg 4 ' , $ userA ->comments [0 ]->message );
515+
516+ // User B: no comment satisfies both the OR group and `level >= 2`.
517+ $ this ->assertCount (0 , $ userB ->comments );
518+ }
519+
520+ /**
521+ * Sanity check: a wrap-aware scope works correctly in plain INLOAD joining without
522+ * any adversarial conditions — wrapWhere() on empty ON tokens is a no-op and the
523+ * scope adds its condition at the top level of the JOIN's ON clause.
524+ */
525+ public function testInloadJoinedScopeWithWrapWhereWithoutAdversarialLoad (): void
526+ {
527+ $ this ->orm = $ this ->withCommentsSchema ([
528+ Schema::SCOPE => new WrappedQueryScope (['@.level ' => ['>= ' => 3 ]]),
529+ ]);
530+
531+ $ res = (new Select ($ this ->orm , User::class))
532+ ->load ('comments ' , [
533+ 'method ' => JoinableLoader::INLOAD ,
534+ ])
535+ ->orderBy ('user.id ' )
536+ ->fetchAll ();
537+
538+ [$ userA , $ userB ] = $ res ;
539+
540+ // Only level >= 3 comments survive: msg 3, msg 4, msg 2.3
541+ $ this ->assertCount (2 , $ userA ->comments );
542+ $ aMessages = $ this ->extractMessages ($ userA ->comments );
543+ $ this ->assertSame (['msg 3 ' , 'msg 4 ' ], $ aMessages );
544+
545+ $ this ->assertCount (1 , $ userB ->comments );
546+ $ this ->assertSame ('msg 2.3 ' , $ userB ->comments [0 ]->message );
547+ }
548+
445549 public function testInvalidOrderBy (): void
446550 {
447551 $ this ->expectException (StatementException::class);
@@ -457,6 +561,20 @@ public function testInvalidOrderBy(): void
457561 ])->orderBy ('user.id ' , 'DESC ' )->fetchAll ();
458562 }
459563
564+ /**
565+ * @return list<string>
566+ */
567+ private function extractMessages (iterable $ comments ): array
568+ {
569+ $ messages = [];
570+ foreach ($ comments as $ comment ) {
571+ $ messages [] = $ comment ->message ;
572+ }
573+ \sort ($ messages );
574+
575+ return $ messages ;
576+ }
577+
460578 public function setUp (): void
461579 {
462580 parent ::setUp ();
0 commit comments