Files
shopdb-flask/tests/test_plugins/test_knowledgebase_visibility.py
cproudlock afce24379c
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 39s
CI / backend (push) Successful in 3m42s
Reading anything needs a permission
A sweep of the URL map found 165 GET routes that answered anybody, with no
token at all: the whole asset inventory, every machine, PC, printer and network
device, the USB checkout history and who is holding which drive, the staff
directory with its photographs, every core report. Guest access had been argued
about one feature-flag at a time while the front door stood open.

Each of those now carries the read permission it belongs to. That also makes
the guest role mean something: a site can open exactly the reads it chooses,
and nothing follows by accident.

Thirty-three endpoints stay public, and each is named with its reason rather
than left to be inferred: the app shell and its bootstrap, the settings
allowlist (the handler already filters an anonymous caller down to branding and
a few keys), images fetched by <img src> which carries no Authorization header,
what a display kiosk renders before anybody signs in, the parts and USB kiosks,
the client scripts that run as SYSTEM before a user session exists, and a
secure-send link whose unguessable token IS the authorisation.

tests/test_core/test_public_read_surface.py is the standing gate, the companion
to the mutating-route sweep that has guarded writes for months. A new public
read fails CI until somebody adds it to that list in front of a reviewer. Four
further reads need a login but no permission, because the handler decides from
who you are - your own user record, your own API tokens, the role catalogue the
grid renders - and the same test proves each still refuses an anonymous caller.
Verified not vacuous by ungating one route and watching the sweep name it.

The employees plugin gained employees.view; the directory is names, SSOs,
photographs and reporting lines, and it had no permission of any kind.

Around ninety tests asserted payload shapes against endpoints that answered
anybody. They are about what the answer says, not about who may ask, so they
are signed in now rather than the gate being loosened. The ones that assert an
anonymous caller is refused were left exactly as they were.
2026-08-27 14:26:06 -04:00

119 lines
5.2 KiB
Python

"""What a knowledge base article's topic decides about seeing the article.
An article's topic is an Application. When that application is retired the
article describes something no longer in service, and showing it beside live
documentation reads as though it were current - so it does not show at all.
`isactive` is the ONLY property of an application that decides this. In
particular `ishidden`, which governs whether an application appears on the tiles
page, says nothing about whether it can be the subject of an article.
"""
import pytest
from shopdb.core.models import Application
from plugins.knowledgebase.models import KnowledgeBase
@pytest.fixture(autouse=True)
def _signed_in(client, auth_headers):
"""Reading needs a permission now, and these tests are about what the
payload says rather than about who may see it. Signing the client in once
beats threading headers through every helper in the module."""
client.environ_base['HTTP_AUTHORIZATION'] = auth_headers['Authorization']
yield
client.environ_base.pop('HTTP_AUTHORIZATION', None)
@pytest.fixture
def library(db):
"""One application of each interesting shape, with an article each."""
applications = {
'live': Application(appname='Live App', isinstallable=True,
ishidden=False, isactive=True),
'notinstallable': Application(appname='Manual App', isinstallable=False,
ishidden=False, isactive=True),
'hidden': Application(appname='Hidden App', isinstallable=False,
ishidden=True, isactive=True),
'retired': Application(appname='Retired App', isinstallable=True,
ishidden=False, isactive=False),
}
db.session.add_all(applications.values())
db.session.flush()
articles = {
'live': KnowledgeBase(shortdescription='Live runbook', linkurl='u',
appid=applications['live'].appid, clicks=3,
isactive=True),
'hidden': KnowledgeBase(shortdescription='Hidden runbook', linkurl='u',
appid=applications['hidden'].appid, clicks=2,
isactive=True),
'retired': KnowledgeBase(shortdescription='Retired runbook', linkurl='u',
appid=applications['retired'].appid, clicks=5,
isactive=True),
'topicless': KnowledgeBase(shortdescription='General note', linkurl='u',
appid=None, clicks=1, isactive=True),
}
db.session.add_all(articles.values())
db.session.commit()
return {'applications': applications, 'articles': articles}
def titles(client, url='/api/knowledgebase'):
return sorted(row['shortdescription']
for row in client.get(url).get_json()['data'])
def test_an_article_about_a_retired_application_does_not_show(client, library):
assert 'Retired runbook' not in titles(client)
def test_an_article_with_no_topic_still_shows(client, library):
"""A null topic is not a retired one. Not every article is about an
application, and those must not be collateral damage."""
assert 'General note' in titles(client)
def test_a_hidden_application_is_still_a_valid_topic(client, library):
"""ishidden keeps an application off the tiles page. It says nothing about
documentation, and isactive is the only filter that applies here."""
assert 'Hidden runbook' in titles(client)
def test_searching_a_retired_application_name_finds_nothing(client, library):
"""The topic search matched on application name without checking isactive,
which surfaced retired articles and printed the retired application as their
subject."""
assert titles(client, '/api/knowledgebase?search=Retired App') == []
def test_searching_a_title_does_not_resurrect_a_retired_article(client, library):
"""Hiding it from the list and finding it by title would be no rule at all."""
found = titles(client, '/api/knowledgebase?search=runbook')
assert 'Retired runbook' not in found
assert 'Live runbook' in found
def test_the_counts_agree_with_the_list(client, library, auth_headers):
"""A total that includes articles nobody can see is a total nobody can
reconcile against the page."""
visible = titles(client)
stats = client.get('/api/knowledgebase/stats').get_json()['data']
total = stats.get('total_articles', stats.get('totalarticles'))
clicks = stats.get('total_clicks', stats.get('totalclicks'))
assert total == len(visible) == 3
# 3 + 2 + 1 from the live, hidden-topic and topicless articles; the retired
# article's 5 clicks are excluded along with the article.
assert clicks == 6
def test_the_topic_list_offers_every_active_application(client, library, auth_headers):
"""What the article form's topic dropdown fetches. Installable or not,
hidden or not - only retired is excluded."""
response = client.get('/api/applications?perpage=100&showhidden=true',
headers=auth_headers)
offered = sorted(row['appname'] for row in response.get_json()['data'])
assert offered == ['Hidden App', 'Live App', 'Manual App']
assert 'Retired App' not in offered