featkpr

BookStack: 304 tests pass, and 269 fail as they must when sent as the wrong user

featkpr ran on a throwaway copy of BookStack at the head of one of its pull requests. It read the code, walked the screens, wrote tests per route and role, and sent each test again as the wrong user.

Ask for a private demoTim opens this run in featkpr with you: any test, both of its runs.

218 expect a refusal: sent as the Admin, who got through, so each failed as it must 51 expect to get through: sent as a user without the permission, refused, so each failed as it must 35 unchecked: none of our seed's users lacks the permission they need
269 of 304 proven by the wrong-user check · run 26 Sep 2026, 17:42 UTC, all 304 passed · wrong-user run 17:57 UTC · 0 got through when they should not have · 0 failed · commit 0f5164ec
What worked
All 304 tests that ran passed. Sent again as the wrong user, 269 of them failed, as they must: 51 refused to a user without the permission, 218 let through for the Admin. So each depends on a permission check BookStack makes.
What is open
35 tests are unchecked: none of our seed's users lacks the permission they need. 161 drafted flows wait for a person. No bug was caught.
What it missed
A separate missed-items check, from a fresh start: of 2,372 things, 605 missed at their own step, counted by step and by reason.

The code: BookStack pull request #6213, the head of the pull request on 24 Sep 2026, commit 0f5164ec, 73 commits after v26.05.5. Codeberg, read 27 Sep 2026

How it was run #

featkpr worked on a throwaway copy of BookStack at commit 0f5164ec: the head of BookStack pull request #6213, “Hide image upload options in Image Manager when user lacks permission”, merged with the development branch on 24 Sep 2026, 73 commits after the v26.05.5 release. The copy runs on PHP’s built-in server with MySQL 8.4 and a mail catcher, started empty on our own machines and thrown away after. It is not a production-like setup: no queue worker and no scheduler run. Nothing ran against BookStack’s servers or anyone’s data.

BookStack ships four roles: Admin, Editor, Viewer and Public. Our seed added four more, with one user in every role that signs in, so that for most permissions some user holds it and some user does not.

The six steps below ran on different days, and each prints its own date. The features, the tests, the run and the missed-items check are featkpr’s records, exported 28 Sep 2026. The crawl was read from the app on 25 Sep 2026, and the flows from its Flows board on 26 Sep 2026.

BookStack's code the head of pull request #6213, 24 Sep 2026 · commit 0f5164ec PHP · Laravel, read as files: nothing runs yet
  1. reads routes, handlers, permission checks 1 · code read343 routes
  2. cites the code each name came from 2 · features named307 features
  3. 3 · screens crawled167 screens signs in as the Admin, opens what it can reach, sends 8 create forms
  4. 4 · flows drafted163 flows
  5. 5 · tests run304 pass sends each test as the user it was written for
  6. 6 · wrong-user check269 proven sends it again as a user who should get the opposite answer
A throwaway copy of BookStack PHP's built-in server · MySQL 8.4 · a mail catcher, on our machines; no queue worker, no scheduler
  • Admin
  • Editor
  • Viewer
  • Public
  • M6 Updater
  • M6 Deleter
  • M6 Reader
  • M6 Content-only reader
one user per role; outlined thin: added by our seed
featkpr's steps in the middle; what each one reads or signs in to, at the side. Source: featkpr's setup for BookStack: its roles, its users and the images of the copy.
RoleSigns in asMay
AdminBookStack's ownM6 Admineverything, settings included
EditorBookStack's ownM6 Editorcreate and edit books, chapters and pages
ViewerBookStack's ownM6 Viewerread books and their content
PublicBookStack's owna signed-out visitorwhat public access allows; it is off in this copy
M6 Updateradded by our seedM6 Updaterread pages and update them, no revisions
M6 Deleteradded by our seedM6 Deleterread pages and delete them, no revisions
M6 Readeradded by our seedM6 Readerread pages, no revisions
M6 Content-only readeradded by our seedM6 Content Onlyread one book through a permission set on that book, nothing at role level

BookStack ships four roles: Admin, Editor, Viewer and Public. Our seed added four more, and one user in every role that can sign in, so that for most permissions some user holds it and some user does not. “M6” is only our seed's prefix for what it made.

BookStack
the head of pull request #6213, commit 0f5164ec, on PHP's built-in serverthe code as of 24 Sep 2026, 73 commits after v26.05.5
Its database
MySQL 8.4, empty at the start and thrown away afterthe official MySQL 8.4 image
Its mail
a mail catcher, so nothing leaves the copyMailpit
What it is not
a production-like setup: no queue worker and no scheduler run, so anything BookStack does later, in the background, was not exerciseda throwaway copy for tests, not a staging server
Where
our own machines, one copy per commit; never BookStack's servers or anyone's datafeatkpr's setup for BookStack
GET /books/{bookSlug}/permissions: the book's own permissions: the Content-only reader may view this one book and nothing more
GET /books/{bookSlug}/permissions · the book's own permissions: the Content-only reader may view this one book and nothing more · BookStack, as the crawl captured it

1. Reading the code #

featkpr read BookStack’s routes, their handlers and the permission checks written in them, at one commit: 343 routes. Nothing runs at this step. The missed-items section checks the reading: featkpr does not read BookStack’s console commands yet.

Map, level 1: routes read from the code, grouped by what they do, before a feature names them
Map, level 1 · routes read from the code, grouped by what they do, before a feature names them

Screens of featkpr's app on this page run on sample data, so the numbers inside them are examples, not BookStack's. Screens of BookStack are the crawl's own captures.

2. Naming the features #

From the routes and what their pages show, featkpr named 307 features in 12 modules (featkpr’s records, exported 28 Sep 2026). Each feature cites the routes and screens it was named from, so a name can always be traced back to code.

Features: named features by module, from only known in the code to tests drafted
Features · named features by module, from only known in the code to tests drafted

3. Walking the screens #

On 25 Sep 2026, featkpr signed in to the copy as the Admin and walked it: 167 screens opened, each kept as a picture, and 8 forms sent (read from the app on 25 Sep 2026). The crawl sends only forms that create something, so most edit and delete goals are not reached yet. It did not walk as a signed-out visitor.

GET /: BookStack's home page
GET / · BookStack's home page · BookStack, as the crawl captured it
GET /create-book: the Create New Book form
GET /create-book · the Create New Book form · BookStack, as the crawl captured it
GET /shelves: BookStack's shelves
GET /shelves · BookStack's shelves · BookStack, as the crawl captured it
GET /books/{bookSlug}/page/{pageSlug}/revisions: a page's revisions
GET /books/{bookSlug}/page/{pageSlug}/revisions · a page's revisions · BookStack, as the crawl captured it

Four of the 167 screens, as the crawl captured them signed in as the Admin on the copy. The books and pages in them are the copy's own seed. Each opens full size.

4. Drafting the flows #

From the crawl, featkpr drafted 163 flows a person can take to reach 69 goals, screen by screen, with what a person does on each screen and what a test would check (the Flows board, read on 26 Sep 2026). A person keeps or drops each one. 161 are still waiting, so no test has been written from a flow yet.

Create a booka goal featkpr drafted, starting from HomeBookStack · commit 0f5164ec · crawl of 25 Sep 2026, read from the app · signed in as Admin
  1. 1 · GET /

    BookStack's home page as the crawl captured it

    Start on the home page

    • Follow “Books”
    • lands on /books
    • its heading reads “Books”

    screen captured by the crawl

  2. 2 · GET /books

    BookStack's books list

    Open the new-book form

    • Follow “Create New Book”
    • lands on /create-book
    • its heading reads “Create New Book”

    screen captured by the crawl

  3. 3 · GET /create-book

    BookStack's Create New Book form

    Fill the form and save

    • Type the Namerequired
    • Descriptionoptional
    • Cover imageoptional
    • Book Tagsoptional
    • Press “Save Book”
    • lands on /books/{slug}
    • its heading reads the Name typed
    • it shows the Name typed
    • without a Name it is refused: The name field is required.

    form filled and sent by the crawl

  4. 4 · GET /books/{slug}

    A BookStack book page

    The new book's page

    • reaches /books/{slug}
    • the flow ends here, not before

    a book's page, captured by the crawl

  5. then · a person

    Waiting on a person

    Keep it and its 10 checks become the flow's test. Drop it and it is never offered again.

    tests from this flow: none yet

5. Writing and running the tests #

featkpr writes its tests from the map, per route and per role: 481 written so far (27 Sep), none by hand. Each is a pytest file that calls BookStack through Playwright’s request API, with no browser. The run of 26 Sep 2026, 17:42 UTC ran the 304 that were ready then, each signed in as the user it was written for: someone who should get through, or someone who should be refused. All 304 passed.

a feature's outcomes: who gets what on one feature, with and without each permission; each line becomes a test
a feature's outcomes · who gets what on one feature, with and without each permission; each line becomes a test

Each line is one outcome of one feature, with and without a permission; each outcome becomes one test. The app on sample data.

6. The wrong-user check #

This is the access-control part of a penetration test, the part that finds broken access control, OWASP’s number one risk on the web. It runs per route and role, which makes it narrower than a full penetration test.

A passing test shows a goal works, but a route that checks no permission at all passes too. So featkpr sends each test a second time as the wrong user: someone who should get the opposite answer. A test that expects to get through is sent as a user without the permission, and BookStack must refuse. A test that expects a refusal is sent as the Admin, who must get through. Either way the test must now fail. A test that still passes is not checking the permission.

In the wrong-user run of 26 Sep 2026, 17:57 UTC, 269 of the 304 tests failed as the wrong user, as they must: 51 were refused to a user without the permission, and 218 were let through for the Admin. None got through as the wrong user when it should not have. 35 are unchecked: they all expect access, and the wrong-user run signs in only as users our seed made; none of them lacks the permission.

One test · Entities

Convert a book into a shelf

POST /books/m6-revisions/convert-to-shelf

  1. Sign in as the test's own user.
  2. Convert a book of the test's own into a shelf: send the form straight to the route, with the page's CSRF token.
  3. Check: BookStack answers 302 and moves on to /shelves/{slug}.

Sent twice, the same request. Only the signed-in user changes.

The test's own user · the run

M6 Editor, role Editor

book-create-allbook-deletebook-updatebook-viewbookshelf-create-all

BookStack answers 302 → /shelves/m6-revisions

passed: the goal works for someone allowed to reach it

The wrong user · the wrong-user run

M6 Content Only, role M6 Content-only reader

content-export and nothing more: no book-update, bookshelf-create-all

BookStack sent to / (the home page) instead of to the new shelf

refused, so the test fails, as it must: the test's pass depends on BookStack's permission check

the test's own wordsREACHED redirects to /shelves/m6-revisions; this went to 'http://localhost:8099'localhost:8099 is the throwaway copy's own address, so this is its home page

This test expects to get through, so its wrong user is someone without the permission. It is one of 51 like it in the run. The other 218 proven tests expect a refusal, so their wrong user is the Admin, who must get through, and the test fails the same way.

One test as featkpr's app shows it, on sample data recorded at commit 0f5164ec, not the run of 26 Sep · Entities · POST /books/{bookSlug}/convert-to-shelf

Every test of the run, accounted for #

as the wrong user it failed, as it must as the wrong user it still passed there is no wrong user to send yet passed for its own user
failed as the wrong user, as it must269proven: 218 let through for the Admin when they expect a refusal, 51 refused to a user without the permission when they expect to get through
still passed as the wrong user0would mean a missing permission check, or a test that cannot tell
no wrong user yet35unchecked: they all expect access, and the wrong-user run signs in only as users our seed made; none of them lacks the permission
failed for its own user
0a test that fails for its own user says nothing about the wrong one
BookStack · the run of 26 Sep 2026, 17:42 UTC and its wrong-user run at 17:57 UTC · all 304 tests · commit 0f5164ec · featkpr's records

See the wrong-user run for yourself, on a call

Tim opens this run in featkpr: any of the 304 tests, both of its runs, and why it passed or failed.

Ask for a private demo

What featkpr missed #

This is a separate check, run on 26 Sep 2026. featkpr onboarded BookStack again from a fresh start with no help, on a replay of the admin crawl, and compared each step with an answer key: the route list Laravel prints, the forms in BookStack’s templates, what the seed creates, and so on. For routes the key is the same list the reader uses, so that row counts what was dropped; for every other step the key is independent of featkpr. Of 2,372 things in the key, featkpr gathered 1,437, missed 605 at the step that should have found them, and lost the rest because an earlier step missed them. The misses are counted by step and by reason.

Most misses are of two kinds: a reader that did not see something, and a limit we set on purpose for now, such as sending only create forms. A thing an early step misses is also lost to every step after it, which is why a few early gaps cost the later steps so much.

Step, checked againstgathered · missed at this step · lost at an earlier stepCommonest reason, with one real case
Routes readthe route list Laravel prints 343 of 359 · 16 missed not built yetBookStack's console commands, such as bookstack:assign-sort-rule: no part of featkpr reads commands a person runs yet.
the audit's own wordsbookstack:assign-sort-rule · not built: no adapter reads console commands: an entry point a person runs is never a candidate
Permission checks foundchecks in each handler and route middleware 193 of 194 · 1 missed a reader missed itPUT /api/users/{id} checks its permission with a name built while it runs, so there is no name in the code to read.
the audit's own wordsPUT /api/users/{id} · detector gap: the handler calls userCan with a computed name (no literal to read); the pass stored no presence
Routes a feature citesevery route, against what the naming said 356 of 587 · 231 missed a reader missed itDELETE /ajax/page/{id}: no named feature claims this route, and nothing on its page links it to one.
the audit's own wordsDELETE /ajax/page/{id} · detector gap: no reply cites the route, and contains joins it to nothing a feature cites (no view or string of its page)
Pages reachedevery GET route 96 of 198 · 102 missed a reader missed itGET /ajax/tags/suggest/names: the crawl reached it, but it is not a page, and featkpr kept no record that it was reached.
the audit's own wordsGET /ajax/tags/suggest/names · detector gap: the crawl reached it (other: not an HTML page) and no stored fact says so; only the crawl report holds it
Screens capturedGET routes that draw a page 77 of 137 · 5 missed · 55 lost earlier a limit we setGET /api-tokens/{userId}/create: the crawl skips API-token pages on purpose.
the audit's own wordsGET /api-tokens/{userId}/create · cap: the policy's no_shots list
Forms foundthe templates' forms 62 of 93 · 19 missed · 12 lost earlier a reader missed itThe image manager's delete form: a script sets where it sends, so the template names no route to join it to.
the audit's own wordsDELETE @resources/views/pages/parts/image-manager-form.blade.php:84 · detector gap: its action is not a URL the template spells (set by script, or a variable): no route to join
Forms sentthe forms that change something 4 of 87 · 52 missed · 31 lost earlier a limit we setDELETE /api-tokens/{userId}/{tokenId}: the crawl sends only forms that create something, and this one deletes.
the audit's own wordsDELETE /api-tokens/{userId}/{tokenId} · cap: not on the policy's create-only allow-list (submit fills only allow-listed forms)
Flows foundentry to target, per role 141 of 191 · 24 missed · 26 lost earlier a limit we setFrom the home page to a chapter: every way there runs through records the crawl made itself, and those flows are left out on purpose.
the audit's own wordsGET / → GET /books/{bookSlug}/chapter/{chapterSlug} · cap: every path passes through what the crawl made itself (a page carrying a submission's marker, or the record a writing GET made): held by 'flows clean'
Outcomes per featurewho gets what, per permission 86 of 404 · 112 missed · 206 lost earlier a limit we setGET /: no named feature starts at the home page, so nobody's outcomes on it were drafted.
the audit's own wordsGET / · cap: one tree per named feature, from its root: the naming cites the route (or what its page shows) and roots no feature's tree here, nor at an atom its gate reads
Tests writtenone per stored outcome 76 of 86 · 10 missed not built yetDELETE /settings/users/{id}/mfa, the refused case: this route checks no permission, and every test template expects one.
the audit's own wordsDELETE /settings/users/{id}/mfa · refused · not built: an ungated leaf on DELETE /settings/users/{id}/mfa: every template declares gated shapes only
Seeded records seenwhat the seed creates 3 of 9 · 6 missed a limit we setThe seeded user “M6 Content Only”: the crawl found its page, and a cap on pages of one pattern left it out.
the audit's own wordsthe seeded user 'M6 Content Only' (M6 Content-only reader) · cap: the crawl found /user/m6-content-only and the per-pattern cap left it out
Effects seenmail, downloads, off-host links 0 of 27 · 27 missed a reader missed itGET /api/docs/download: the crawl downloaded the file, and kept its type, size and hash only in its own report.
the audit's own words/api/docs/download · detector gap: the crawl downloaded it; its type, size and hash stay in the crawl report
BookStack · the missed-items check of 26 Sep 2026, exported · commit 0f5164ec · a replay of the admin crawl · of 2,372 things: 1,437 gathered, 605 missed at their own step, 330 lost at an earlier step · of the 605, 592 are featkpr’s own gaps; the audit puts 13 down to BookStack’s side (pages only a signed-out visitor sees, forms nothing links to)

A miss costs the steps after it

Pages reached102 missed here
55 lostScreens captured77 of 137
12 lostForms found62 of 93
Forms found19 missed here
31 lostForms sent4 of 87
26 lostFlows found141 of 191
Routes a feature cites231 missed here
206 lostOutcomes per feature86 of 404
each thing is counted missed once, at the step that lost it, and as lost earlier at every later step that needed it

Why, across all steps

  1. 295a reader missed it the step ran, and its reader did not see this one
  2. 199a limit we set left out by a cap or an allow-list, on purpose for now
  3. 67not built yet no part of featkpr reads this kind of thing yet
  4. 28needed a record first the page needs an id no crawled page linked to
  5. 10needed another user only a signed-out visitor sees it, and no signed-out crawl ran
  6. 3nothing links to it no page renders it by a name the source spells
  7. 3kept out on purpose an off-host address the crawl never follows
605 missed things by reason · “a limit we set” is a cap or an allow-list chosen on purpose for now
Every step against every reason
a reader missed ita limit we setnot built yetneeded a record firstneeded another usernothing links to itkept out on purposelost earlier Routes read 16 Permission checks found 1 Routes a feature cites 231 Pages reached 3833283 Screens captured 555 Forms found 97312 Forms sent 5231 Flows found 2426 Outcomes per feature 112206 Tests written 46 Seeded records seen 6 Effects seen 12123 all steps29519967281033

Why these counts differ from the totals #

The missed-items check is a smaller, separate run: from a fresh start, on a replay of the crawl, with no running copy. So its counts are not the totals above, and some count a different unit. The table puts the two side by side.

QuantityIn the totalsIn the missed-items checkWhy they differ
Routes343 routesread from the code, in the check of 26 Sep 2026343 of 359the check of 26 Sep 2026, exportedthe same count: the totals take their routes from this check’s first step
Features307 featuresfeatkpr's records, exported 28 Sep 2026356 of 587the check of 26 Sep 2026, exporteda different unit: the check counts routes a named feature cites, and it names with a recorded naming run
Screens167 screensthe crawl of 25 Sep 2026, read from the app77 of 137the check of 26 Sep 2026, exportedthe crawl keeps a picture per visit; the check counts the distinct routes that draw a page, on a replay of a recorded crawl
Forms sent8 formsthe crawl of 25 Sep 2026, read from the app4 of 87the check of 26 Sep 2026, exportedtwo different crawls: the replayed one sends only the forms on a create-only allow-list
Tests481 written, 304 runfeatkpr's records, exported 28 Sep 202676 of 86the check of 26 Sep 2026, exportedthe check starts from nothing, so it holds only the outcomes it drafted itself
The run269 proven by the wrong-user checkthe run of 26 Sep 2026, 17:42 UTCnot runthe check of 26 Sep 2026, exportedthe check needs no running copy, so running the tests and the wrong-user check sit outside it

What this report does not show #

It shows no caught bug. Every test passed at this commit. The wrong-user check shows that 269 tests would catch their permission check going missing; none has caught one yet.

The tests call BookStack directly, without a browser. Browser tests of the drafted flows come after a person keeps them. The crawl signed in as the Admin only, and sent only create forms. The copy ran no queue worker and no scheduler. The 35 unchecked tests need a user our seed has not made yet: one without their permission.

Shown, with its evidence

  • the 304 tests that ran all pass for their own user
  • 269 of them fail as the wrong user, as they must
  • 605 misses, counted by the step that lost them and why
  • every screen, test and count dated and tied to commit 0f5164ec

Not shown yet

  • a caught bug: every test passed at the commit we ran
  • tests in a browser: these call BookStack directly
  • the 35 unchecked tests: they need a user without the permission
  • a signed-out crawl, forms that edit or delete, and a production-like copy

How to check it yourself #

The wrong-user test in the figure needs nothing of ours. On your own copy of BookStack at commit 0f5164ec: make a role that holds only content-export, give it view access to one book through that book’s own permissions, and sign in as a user in that role. Send the book’s Convert to Shelf form (POST /books/{slug}/convert-to-shelf, with the page’s CSRF token). BookStack sends you to the home page. Sign in as an Editor and send the same form: BookStack answers 302 and moves on to the new shelf.

Every other figure names its source under it: the run and its commit, the missed-items check, or the screen it was read from. Ask on a call and we walk through any of them.

Who made it #

Tim Derzhavets ran featkpr and wrote this report, following How we publish (draft of 27 Sep 2026). Anything that passed as the wrong user would be looked at by a person before it was published, and a suspected bug would go to BookStack’s maintainers privately first. featkpr is not affiliated with BookStack. Ask for a correction or a removal at /removal.

See this run live, on a call #

Tim walks you through BookStack in featkpr, the map, the flows and the runs, and answers your questions first.

Ask for a private demo

Open original

↑ ↓ to move, Enter to open, Esc to close