BE+FE: Plugins -> Sources DevDetails tab

This commit is contained in:
jokob-sk committed 2026-10-09 09:18:04 +11:00
1 parent 2e2462c280
commit f37f2c7df7
58 files changed
+2008 -200

No files matched your search

+210
View File
@@ -0,0 +1,210 @@
"""
Tests for the Sources tab's Field View backend (server/db/plugin_field_views.py).
Four things are tested:
1. get_plugin_columns_for_field() correctness against a fabricated plugin list -
every column role, both exclusion reasons, and that matching is on
mapped_to_column, not the advisory type tag.
2. The MAC-identity invariant holds for every real, participating plugin.
3. The IP-discovery structural check holds against the real plugin inventory,
and dockerfdisc/wificanary are excluded for their documented reasons.
4. get_all_device_field_views()'s identity-based cache.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "server"))
from db.plugin_field_views import ( # noqa: E402
DEVICE_FIELD_VIEWS,
build_field_views_payload,
get_all_device_field_views,
get_plugin_columns_for_field,
)
from utils.plugin_utils import get_plugins_configs # noqa: E402
def _plugin(prefix, primary_mapped_to="scanMac", extra_columns=None):
"""Build a fake CurrentScan-mapped plugin dict for get_plugin_columns_for_field() tests."""
columns = [{"column": "objectPrimaryId", "mapped_to_column": primary_mapped_to}]
if extra_columns:
columns.extend(extra_columns)
return {
"unique_prefix": prefix,
"mapped_to_table": "CurrentScan",
"database_column_definitions": columns,
}
class TestGetPluginColumnsForFieldCorrectness:
def test_matches_objectSecondaryId(self):
plugins = [_plugin("A", extra_columns=[
{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP", "type": "device_ip"},
])]
assert get_plugin_columns_for_field(plugins, "ip") == [("A", "objectSecondaryId")]
def test_matches_watchedValue_role_regardless_of_type_tag(self):
"""Matching is on mapped_to_column, not the advisory type tag - a mislabeled
column (type: 'label' on an actual scanLastIP mapping) must still be included."""
plugins = [_plugin("B", extra_columns=[
{"column": "watchedValue2", "mapped_to_column": "scanLastIP", "type": "label"},
])]
assert get_plugin_columns_for_field(plugins, "ip") == [("B", "watchedValue2")]
def test_excludes_extra_mapped_column(self):
"""scanLastIP mapped to 'extra' (not an identity/watched column) must be excluded -
there's no column role get_plugin_columns_for_field() can point at."""
plugins = [_plugin("C", extra_columns=[
{"column": "extra", "mapped_to_column": "scanLastIP"},
])]
assert get_plugin_columns_for_field(plugins, "ip") == []
def test_excludes_non_scanMac_objectPrimaryId(self):
"""A plugin whose objectPrimaryId maps to scanParentMAC (not scanMac) is excluded,
even if it maps scanLastIP correctly - the MAC-identity invariant is checked first."""
plugins = [_plugin("D", primary_mapped_to="scanParentMAC", extra_columns=[
{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP"},
])]
assert get_plugin_columns_for_field(plugins, "ip") == []
def test_excludes_non_CurrentScan_mapped_plugin(self):
plugin = _plugin("E", extra_columns=[
{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP"},
])
plugin["mapped_to_table"] = "Plugins_Objects"
assert get_plugin_columns_for_field([plugin], "ip") == []
def test_multiple_plugins_mixed(self):
plugins = [
_plugin("A", extra_columns=[{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP"}]),
_plugin("B", primary_mapped_to="scanParentMAC", extra_columns=[
{"column": "extra", "mapped_to_column": "scanLastIP"},
]),
_plugin("F", extra_columns=[{"column": "watchedValue1", "mapped_to_column": "scanLastIP"}]),
]
assert get_plugin_columns_for_field(plugins, "ip") == [
("A", "objectSecondaryId"),
("F", "watchedValue1"),
]
class TestRealPluginInventory:
"""Checked against the real plugin inventory (get_plugins_configs(True)), not a
fabricated list - these are the two tests that would catch a real plugin's config.json
silently breaking the invariant get_plugin_columns_for_field() enforces."""
def test_mac_identity_invariant_holds_for_participants(self):
all_plugins = get_plugins_configs(True)
participants = get_plugin_columns_for_field(all_plugins, "ip")
by_prefix = {p.get("unique_prefix"): p for p in all_plugins}
for prefix, _column in participants:
plugin = by_prefix[prefix]
primary_id_col = next(
c for c in plugin["database_column_definitions"]
if c.get("column") == "objectPrimaryId"
)
assert primary_id_col.get("mapped_to_column") == "scanMac", (
f"{prefix} participates in the 'ip' field view but its objectPrimaryId "
f"does not map to scanMac - this should have been excluded"
)
def test_ip_discovery_structural_check(self):
all_plugins = get_plugins_configs(True)
participants = get_plugin_columns_for_field(all_plugins, "ip")
participant_prefixes = {prefix for prefix, _ in participants}
assert "dockerdisc" not in participant_prefixes, (
"dockerdisc maps its IP to 'extra' and objectPrimaryId to scanParentMAC - "
"excluded for both reasons, should never appear here"
)
assert "wificanary" not in participant_prefixes, (
"wificanary has no MAC-based objectPrimaryId at all - should never appear here"
)
valid_roles = {"objectPrimaryId", "objectSecondaryId", "watchedValue1", "watchedValue2", "watchedValue3", "watchedValue4"}
for prefix, column in participants:
assert column in valid_roles, f"{prefix} returned an invalid column role: {column}"
# Incidental today - not frozen. A future plugin addition legitimately changes this.
assert len(participants) == 23
class TestGetAllDeviceFieldViewsCache:
def test_same_object_returns_cached_result(self):
plugins = [_plugin("A", extra_columns=[
{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP"},
])]
first = get_all_device_field_views(plugins)
second = get_all_device_field_views(plugins)
assert first is second
def test_different_object_recomputes(self):
plugins_v1 = [_plugin("A", extra_columns=[
{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP"},
])]
plugins_v2 = [_plugin("A", extra_columns=[
{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP"},
]), _plugin("G", extra_columns=[
{"column": "watchedValue3", "mapped_to_column": "scanLastIP"},
])]
result_v1 = get_all_device_field_views(plugins_v1)
result_v2 = get_all_device_field_views(plugins_v2)
assert result_v1 is not result_v2
assert result_v1["ip"] == [("A", "objectSecondaryId")]
assert result_v2["ip"] == [("A", "objectSecondaryId"), ("G", "watchedValue3")]
def test_covers_every_registered_field(self):
plugins = [_plugin("A", extra_columns=[
{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP"},
])]
result = get_all_device_field_views(plugins)
assert set(result.keys()) == set(DEVICE_FIELD_VIEWS.keys())
class TestBuildFieldViewsPayload:
"""The exact shape embedded into plugins.json's field_views block - the frontend
contract, not just producer/helper equality. Each field_key maps to an object
carrying its own label_key/value_type alongside its columns, so the frontend
reads DEVICE_FIELD_VIEWS' declared label/rendering type instead of re-deriving
them per field_key."""
def test_matches_get_all_device_field_views_content(self):
plugins = [
_plugin("A", extra_columns=[{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP"}]),
_plugin("F", extra_columns=[{"column": "watchedValue2", "mapped_to_column": "scanLastIP"}]),
]
payload = build_field_views_payload(plugins)
expected = get_all_device_field_views(plugins)
assert payload["ip"]["columns"] == [
{"plugin": prefix, "column": column} for prefix, column in expected["ip"]
]
def test_entry_shape_has_label_key_value_type_and_columns(self):
plugins = [_plugin("A", extra_columns=[
{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP"},
])]
payload = build_field_views_payload(plugins)
assert set(payload["ip"].keys()) == {"label_key", "value_type", "columns"}
assert payload["ip"]["label_key"] == DEVICE_FIELD_VIEWS["ip"]["label_key"]
assert payload["ip"]["value_type"] == DEVICE_FIELD_VIEWS["ip"]["value_type"]
assert len(payload["ip"]["columns"]) == 1
column_entry = payload["ip"]["columns"][0]
assert set(column_entry.keys()) == {"plugin", "column"}
assert column_entry == {"plugin": "A", "column": "objectSecondaryId"}
def test_covers_every_registered_field_with_its_own_label_key(self):
plugins = [_plugin("A", extra_columns=[
{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP"},
])]
payload = build_field_views_payload(plugins)
assert set(payload.keys()) == set(DEVICE_FIELD_VIEWS.keys())
for field_key, field_def in DEVICE_FIELD_VIEWS.items():
assert payload[field_key]["label_key"] == field_def["label_key"]
assert payload[field_key]["value_type"] == field_def["value_type"]
+29
View File
@@ -0,0 +1,29 @@
# Sources / Session Info characterization tests
Playwright specs for the Sources tab (Plugin View + Field View) and the
Session Info tab. These are characterization tests, not unit tests of
internal functions: each one mocks the relevant GraphQL/REST calls via
`page.route()` with fixed fixture data (no real DB, no seeded scan data)
and asserts on rendered, observable DOM - the same assertions should hold
against a future frontend rewrite, unchanged.
Run against a live NetAlertX instance (defaults to `http://localhost:20211`,
override with `NAX_BASE_URL`):
```
npm install
npx playwright install chromium
npm run test:e2e
```
Scope is deliberately narrow - four specs covering the Sources/Session Info
PRD's acceptance criteria, not a general frontend test suite. Do not add
unrelated specs here without a separate, deliberate decision to broaden this
setup.
**Status as of this implementation pass:** written and manually reviewed for
correctness against the actual frontend code (GraphQL shapes, route URLs,
DOM selectors), but not yet run against a live instance - no running
NetAlertX server was available in the environment these were written in.
Run them against a real devcontainer before considering this PRD's Playwright
work complete.
+32
View File
@@ -0,0 +1,32 @@
// Test #8 (PRD): ?tab=<plugin_prefix> (existing deep-link) and ?field=<key>
// (new) must not interfere with each other's resolution. Note: the
// top-level Sources tab itself isn't auto-activated by either param today
// (initializeTabs() on the device-details page only reads cache, not the
// URL) - that's pre-existing behavior, unchanged by this PRD, so both tests
// click the Sources tab explicitly before asserting, matching reality.
const { test, expect } = require('@playwright/test');
const { TEST_MAC, mockSourcesBackend } = require('./fixtures');
test('?tab=<plugin> deep-link resolves to Plugin View with that plugin active', async ({ page }) => {
await mockSourcesBackend(page);
await page.goto(`/deviceDetails.php?mac=${encodeURIComponent(TEST_MAC)}&tab=FREEBOX`);
await page.click('#tabSources');
await expect(page.locator('#sourcesPluginView')).toBeVisible();
await expect(page.locator('#sourcesFieldView')).toBeHidden();
await expect(page.locator('#sourcesViewToggle')).toHaveAttribute('data-view', 'plugin');
await expect(page.locator('a#FREEBOX_id')).toHaveClass(/active/);
});
test('?field=<key> deep-link resolves to Field View with that field active', async ({ page }) => {
await mockSourcesBackend(page);
await page.goto(`/deviceDetails.php?mac=${encodeURIComponent(TEST_MAC)}&field=ip`);
await page.click('#tabSources');
await expect(page.locator('#sourcesFieldView')).toBeVisible();
await expect(page.locator('#sourcesPluginView')).toBeHidden();
await expect(page.locator('#sourcesViewToggle')).toHaveAttribute('data-view', 'field');
await expect(page.locator('#fieldPane_ip')).toHaveClass(/active/);
});
+32
View File
@@ -0,0 +1,32 @@
// Test #4 (PRD): Field View's join logic - picks each plugin's value from
// whichever Plugins_Objects column field_views.ip names for it, not a
// hardcoded column role, and status renders as plugin-observation wording.
const { test, expect } = require('@playwright/test');
const { TEST_MAC, mockSourcesBackend } = require('./fixtures');
test('Field View renders Plugin/Value/Status/Last-Changed from the correct column per plugin', async ({ page }) => {
await mockSourcesBackend(page);
await page.goto(`/deviceDetails.php?mac=${encodeURIComponent(TEST_MAC)}`);
await page.click('#tabSources');
await page.click('#sourcesViewToggle');
await page.click('#sourcesViewMenu a[data-view="field"]');
const rows = page.locator('#fieldPane_ip tbody tr');
await expect(rows).toHaveCount(3);
// FREEBOX's value comes from objectSecondaryId.
await expect(rows.filter({ hasText: 'FREEBOX' })).toContainText('fe80::1');
// ARPSCAN's value comes from watchedValue1 - not objectSecondaryId.
await expect(rows.filter({ hasText: 'ARPSCAN' })).toContainText('192.168.1.50');
// SNMPDSC's value comes from watchedValue2 - proves the join isn't
// hardcoded to either of the other two roles.
await expect(rows.filter({ hasText: 'SNMPDSC' })).toContainText('fe80::2');
// missing-in-last-scan renders as plugin-observation wording, not a
// liveness claim.
const snmpRow = rows.filter({ hasText: 'SNMPDSC' });
await expect(snmpRow).toContainText('Not reported last run');
await expect(snmpRow).not.toContainText('offline');
await expect(snmpRow).not.toContainText('unreachable');
});
+98
View File
@@ -0,0 +1,98 @@
// Shared fixture data and route-mocking helpers for the Sources/Session Info
// characterization tests. Every spec uses these instead of hand-rolling its
// own mock shapes, so a GraphQL/REST contract change only needs updating here.
const TEST_MAC = 'aa:bb:cc:dd:ee:01';
const PLUGINS_JSON_FIXTURE = {
data: [
{ unique_prefix: 'FREEBOX', show_ui: true, mapped_to_table: 'CurrentScan' },
{ unique_prefix: 'ARPSCAN', show_ui: true, mapped_to_table: 'CurrentScan' },
{ unique_prefix: 'SNMPDSC', show_ui: true, mapped_to_table: 'CurrentScan' },
],
field_views: {
ip: {
label_key: 'DevDetail_SessionTable_IP',
value_type: 'device_ip',
columns: [
{ plugin: 'FREEBOX', column: 'objectSecondaryId' },
{ plugin: 'ARPSCAN', column: 'watchedValue1' },
{ plugin: 'SNMPDSC', column: 'watchedValue2' },
],
},
},
};
const PLUGINS_OBJECTS_FIXTURE = [
{
plugin: 'FREEBOX', objectSecondaryId: 'fe80::1', watchedValue1: null, watchedValue2: null,
watchedValue3: null, watchedValue4: null, status: 'watched-not-changed', dateTimeChanged: '2026-10-05 10:00:00',
},
{
plugin: 'ARPSCAN', objectSecondaryId: null, watchedValue1: '192.168.1.50', watchedValue2: null,
watchedValue3: null, watchedValue4: null, status: 'new', dateTimeChanged: '2026-10-05 10:01:00',
},
{
plugin: 'SNMPDSC', objectSecondaryId: null, watchedValue1: null, watchedValue2: 'fe80::2',
watchedValue3: null, watchedValue4: null, status: 'missing-in-last-scan', dateTimeChanged: '2026-10-05 09:50:00',
},
];
const DEVICE_DATA_FIXTURE = {
devMac: TEST_MAC,
devName: 'Test Device',
devStatus: 'Online',
devPresentLastScan: 1,
devAlertDown: 1,
devFlapping: 0,
devIsSleeping: 0,
devIsArchived: 0,
devIsNew: 0,
devPrimaryIPv4: '192.168.1.50',
devPrimaryIPv6: 'fe80::1',
devFQDN: 'test-device.lan',
devLastConnection: '2026-10-05 10:00:00',
devFirstConnection: '2025-01-01 00:00:00',
};
// Routes every GraphQL call through to the right fixture based on the query
// text in the POST body, and plugins.json/device endpoint by URL.
async function mockSourcesBackend(page, { mac = TEST_MAC, pluginsObjects = PLUGINS_OBJECTS_FIXTURE } = {}) {
let pluginsObjectsCallCount = 0;
await page.route('**/php/server/query_json.php?file=plugins.json*', route => {
route.fulfill({ json: PLUGINS_JSON_FIXTURE });
});
await page.route('**/server/graphql', route => {
const body = JSON.parse(route.request().postData());
if (body.query.includes('pluginsObjects')) {
pluginsObjectsCallCount += 1;
route.fulfill({
json: { data: { pluginsObjects: { entries: pluginsObjects } } },
});
return;
}
// Any other GraphQL query (settings, etc.) this flow happens to fire -
// return an empty-but-valid shape rather than failing the request.
route.fulfill({ json: { data: {} } });
});
await page.route(`**/server/device/${encodeURIComponent(mac)}*`, route => {
route.fulfill({ json: DEVICE_DATA_FIXTURE });
});
return {
getPluginsObjectsCallCount: () => pluginsObjectsCallCount,
};
}
module.exports = {
TEST_MAC,
PLUGINS_JSON_FIXTURE,
PLUGINS_OBJECTS_FIXTURE,
DEVICE_DATA_FIXTURE,
mockSourcesBackend,
};
+25
View File
@@ -0,0 +1,25 @@
// Test #5 (PRD): Session Info renders without a settings lookup - all 6
// migrated fields display, including devStatus (the one that's never
// actually worked before this PRD), and there's no save/submit control.
const { test, expect } = require('@playwright/test');
const { TEST_MAC, mockSourcesBackend } = require('./fixtures');
test('Session Info displays all 6 migrated fields including devStatus, with no save control', async ({ page }) => {
await mockSourcesBackend(page);
await page.goto(`/deviceDetails.php?mac=${encodeURIComponent(TEST_MAC)}`);
await page.click('#tabSessionInfo');
await expect(page.locator('#sessionInfo_devPrimaryIPv4')).toHaveText('192.168.1.50');
await expect(page.locator('#sessionInfo_devPrimaryIPv6')).toHaveText('fe80::1');
await expect(page.locator('#sessionInfo_devFQDN')).toHaveText('test-device.lan');
await expect(page.locator('#sessionInfo_devLastConnection')).not.toBeEmpty();
await expect(page.locator('#sessionInfo_devFirstConnection')).not.toBeEmpty();
// devStatus: the dead field this PRD fixes - must actually render something,
// not be silently blank the way it always has been before this change.
await expect(page.locator('#sessionInfo_devStatus')).not.toBeEmpty();
await expect(page.locator('#panSessionInfo button[type="submit"]')).toHaveCount(0);
await expect(page.locator('#panSessionInfo .pa-btn-save, #panSessionInfo #btnSave')).toHaveCount(0);
});
+30
View File
@@ -0,0 +1,30 @@
// Test #6 (PRD): Known IPs (Session Info) and Field View read the same
// pluginsObjects data via exactly one fetch - the data assertion alone
// wouldn't catch two independently-issued calls that happen to agree, so
// this also asserts the call count.
const { test, expect } = require('@playwright/test');
const { TEST_MAC, mockSourcesBackend } = require('./fixtures');
test('Known IPs and Field View show consistent filtered data via one shared fetch', async ({ page }) => {
const mocks = await mockSourcesBackend(page);
await page.goto(`/deviceDetails.php?mac=${encodeURIComponent(TEST_MAC)}`);
// Open Session Info first.
await page.click('#tabSessionInfo');
await expect(page.locator('#sessionInfo_knownIps')).toContainText('fe80::1');
await expect(page.locator('#sessionInfo_knownIps')).toContainText('192.168.1.50');
// SNMPDSC's fe80::2 is missing-in-last-scan - excluded from Known IPs.
await expect(page.locator('#sessionInfo_knownIps')).not.toContainText('fe80::2');
// Then open Sources -> Field View, which shows the full, unfiltered set.
await page.click('#tabSources');
await page.click('#sourcesViewToggle');
await page.click('#sourcesViewMenu a[data-view="field"]');
const rows = page.locator('#fieldPane_ip tbody tr');
await expect(rows).toHaveCount(3); // includes the missing-in-last-scan row
// The actual proof of the shared-fetch mechanism: exactly one network
// call for pluginsObjects across both views, not one per view.
expect(mocks.getPluginsObjectsCallCount()).toBe(1);
});