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.
119 lines
5.2 KiB
Python
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
|