Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions tests/relationship-filter-in-join.test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php
/**
* A relationship's filter belongs in the JOIN ON clause, never in WHERE.
*
* `$databaseFieldClassRelationships` entries may carry an optional 4th
* element -- a filter on the joined table. Exactly one exists in the whole
* codebase, and it is load-bearing:
*
* 'MACAddressAssociation' => ['hostID', 'id', 'primac', ['primary' => 1]]
*
* so that `$host->get('primac')` is the host's PRIMARY MAC rather than
* whichever row came back first. `buildQuery()` used to emit that filter into
* `$whereArrayAnd`, and a WHERE predicate on the right-hand table of a LEFT
* OUTER JOIN is not a filter -- it is an INNER JOIN written the long way.
* Rows with nothing to join to are dropped by the WHERE, so a host with no
* `hmPrimary='1'` row stopped existing:
*
* new Host($id)->isValid() -> false
* HostManager->find(...) -> 0 objects
* GET /fog/host/$id -> 404
*
* on a row sitting in the table. Un-loadable, un-editable, un-deletable.
* And `buildQuery()` recurses, so the same predicate reached every class
* whose relationship chain passes through Host -- a task belonging to such a
* host was invisible to TaskManager, which is the half that turns a display
* bug into an operational one.
*
* This branch fixed it in 1cd7446f6 and shipped no test with the fix, which
* is what this file is. It is STRUCTURAL and needs no database: the defect is
* in the SQL text, so the SQL text is what it reads -- and so it runs in CI,
* where there is no server to drive rows through. dev-branch, which got the
* same fix later, carries this file plus a behavioural counterpart that does
* drive real rows.
*
* Usage: php tests/relationship-filter-in-join.test.php
* Exit status 0 = pass, 1 = fail.
*/

require __DIR__ . '/lib/fog-test-harness.php';

FogTestHarness::boot('relfilter');
FogTestHarness::fakeDb();

$t = new FogChecks();

/**
* Builds one class's join text and its WHERE additions.
*
* @param string $class the class to build for
*
* @return array [joins, whereArrayAnd]
*/
function relFilterBuild($class)
{
$obj = FOGCore::getClass($class);
$join = [];
$where = [];
$c = null;
return $obj->buildQuery($join, $where, $c);
}

/*
* 1. The filter is really there. Without this every assertion below would
* pass just as well against a relationship map that had quietly lost it,
* and the test would be measuring nothing.
*/
$relProp = new \ReflectionProperty(
get_class(FOGCore::getClass('Host')),
'databaseFieldClassRelationships'
);
$relProp->setAccessible(true);
$rels = $relProp->getValue(FOGCore::getClass('Host'));
$macRel = $rels['MACAddressAssociation'] ?? null;
$t->check(
'Host still declares a filtered relationship to MACAddressAssociation',
is_array($macRel) && isset($macRel[3]) && is_array($macRel[3])
&& array_key_exists('primary', $macRel[3])
);

/*
* 2. It is emitted inside the ON clause of the hostMAC join, and the join is
* still an outer one. Both halves matter: moving the predicate to ON while
* turning the join inner would drop exactly the same rows.
*/
[$joins, $where] = relFilterBuild('Host');
$t->check(
'the hostMAC join is still a LEFT OUTER JOIN',
false !== strpos($joins, 'LEFT OUTER JOIN `hostMAC` ON ')
);
$t->check(
"hmPrimary is part of the hostMAC ON clause",
false !== strpos(
$joins,
"ON `hostMAC`.`hmHostID`=`hosts`.`hostID` AND `hostMAC`.`hmPrimary` = '1'"
)
);

/*
* 3. And nothing about the optional table reached WHERE -- for Host, and for
* every class that inherits the join transitively. The list is spelled out
* rather than derived so that a class LOSING its path to Host shows up as
* a skipped name here, not as silence.
*/
$classes = [
'Host',
'Task',
'SnapinJob',
'SnapinTask',
'ImagingLog',
'NodeFailure',
'UserTracking',
'OUAssociation',
];
foreach ($classes as $class) {
if (!class_exists($class)) {
$t->check("$class exists, so its join is actually being checked", false);
continue;
}
[$j, $w] = relFilterBuild($class);
$t->check(
"$class puts no hostMAC predicate in WHERE",
!preg_grep('/hostMAC|hmPrimary/', (array)$w)
);
// A class that reaches Host must actually carry the join, or "no
// predicate in WHERE" is true for the boring reason.
if ('Host' !== $class) {
$t->check(
"$class reaches the hostMAC join at all",
false !== strpos($j, 'JOIN `hostMAC` ON ')
);
}
}

$t->finish();