Skip to content

ci: turn Newman on — it has never run, and its seed could not fail - #156

Open
rubenvdlinde wants to merge 17 commits into
developmentfrom
chore/enable-newman-0809
Open

ci: turn Newman on — it has never run, and its seed could not fail#156
rubenvdlinde wants to merge 17 commits into
developmentfrom
chore/enable-newman-0809

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

What this measures

enable-newman: false makes the Integration Tests (Newman) job report skipped, and a skipped job is indistinguishable from a passing one in the run summary. Eighteen Postman collections sit in tests/integration/ and CI has never executed one of them. This PR turns the job on so it produces a verdict.

The seed could not fail, so "it would just be red anyway" was never measured

The shared workflow interpolated newman-seed-command into an unquoted eval with no status check. This repository is the one caller in the fleet that passes an && chain:

php occ app:disable openbuild && php occ app:enable openbuild

The outer shell split that before eval ever saw it, eval ran only the disable, and bash's set -e exemption for && lists returned 0 regardless. A failed disable meant the enable never ran and Newman would have tested whatever state was left — green.

Fixed upstream in ConductionNL/.github#212: the seed now reaches eval as one quoted unit and its combined status is the step's, with an ::error:: and a hard exit when it fails. So a red Newman leg here is now a genuine finding, and so is a green one.

The two blockers the flag carried, re-measured 2026-08-09

# Blocker Status
1 SeedHelloWorld failed: Call to undefined function React\Async\await() — OR's runtime-schema-API pulls react/async but it wasn't surfacing in the CI install react/async ^4.3 is a require in openregister@development's composer.json and is present in its committed composer.lock. The CI install resolves it.
2 [PermissionHandler] no such function: REGEXP from OR's MagicMapper under SQLite Dead. database: is now pgsql, and that switch was made for exactly this family of failure. Newman reads the same database input.

Neither was re-measured after database: pgsql landed. This change makes CI answer the question instead of the comment answering it.

Honest expectation

I am not claiming this will be green. I am claiming the number it produces will be real for the first time. If it is red, the failures are a finding to act on, not a regression this PR introduced — the leg reported skipped before, which was never evidence of anything.

Refs openbuild#151

`enable-newman: false` makes the job report `skipped`, which is
indistinguishable from a pass in the run summary. Eighteen collections in
tests/integration/ have never been executed by CI once.

The flag carried two named blockers. Neither was re-measured after
`database: pgsql` landed:

  1. `React\\Async\\await()` undefined — `react/async ^4.3` is a require in
     openregister@development's composer.json and is present in its
     committed composer.lock, so the CI install resolves it.
  2. SQLite lacking REGEXP — dead, the database input is pgsql.

Separately, the seed could not report failure at all. The shared workflow
interpolated newman-seed-command into an unquoted eval with no status
check, and this repo is the one caller passing an && chain
(`app:disable && app:enable`): the outer shell split it, eval ran only the
disable, and the && list returned 0 regardless. Fixed upstream in
ConductionNL/.github#212, so a verdict here now means something.

Refs openbuild#151
@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

The honest Newman number, measured

Run 31316691991, job Integration Tests (Newman). This is the first verdict this leg has ever produced in this repository.

Both documented blockers are dead

success  Install and enable app
success  Start PHP built-in server
success  Seed test data          <-- the step that was supposed to be impossible
success  Validate Newman collections
failure  Run Newman tests

Seed test data passed. No React\Async\await(), no no such function: REGEXP. The two blockers written into enable-newman: false were both retired by changes made elsewhere (react/async landing in openregister's lockfile, and database: pgsql) and nobody re-measured. The comment outlived the condition it described.

And it passed through the fixed seed path.github#212 — so this is a seed that could have failed and did not, not a seed that cannot fail.

The collections: 80 failed assertions of 242, 14 of 18 collections red

failed executed collection
15 17 openbuild-versioning
13 18 openbuild-application-versions
13 32 openbuild-rbac
10 37 openbuild
5 12 openbuild-component-blocks
5 8 openbuild-page-editor
4 38 openbuild-api-contract
3 3 openbuild-automation-approval-steps
3 7 openbuild-export-to-real-app
3 3 openbuild-version-promotion
2 8 openbuild-docudesk-documents
2 9 openbuild-templates-marketplace
1 14 openbuild-app-creation-wizard
1 7 openbuild-icon-endpoints
0 9 openbuild-app-detail-overview
0 3 openbuild-nldesign-theme
0 9 openbuild-schema-editor
0 8 openbuild-version-routing
80 242 18 collections — 4 fully green

Zero iterations failed anywhere — every collection ran to completion. These are assertion failures against a live server, not infrastructure failures.

Causes visible in the server log

Three distinct classes, none of them "Newman is broken":

  1. A real app defect. openbuild-export-to-real-app posts applicationVersion: "production"; the schema requires semver: "Property 'applicationVersion' should match pattern ^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$ but 'production' does not." Either the collection is wrong about the contract or the contract is wrong about channels. That is exactly the kind of question this leg exists to ask.
  2. A missing fixture. openbuild-docudesk-documentsRegisterNotConfiguredException: Template register/schema not configured. Docudesk is installed by additional-apps but its template register is only configured by the Playwright global-setup.ts, which the Newman job does not run. Environmental, and fixable in the seed.
  3. A missing test user. openbuild-rbac and openbuild-nldesign-theme log Login failed: 'viewer'. The RBAC collections assume a viewer account the seed never creates — which alone accounts for a large share of the 13 RBAC failures.

What I am asking for

This PR turns the measurement on; it does not claim the measurement is green. The 80 are now visible, attributable and countable instead of hidden behind a skipped badge. Before this PR the number was unknown and unknowable, and the leg reported skipped — which reads identically to a pass in the run summary.

Refs openbuild#151

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ 8951fa3

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
composer ✅ 101/101
npm ✅ 654/654
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-09 14:27 UTC

Download the full PDF report from the workflow artifacts.

…ts as data

Two collection defects — both "wrong from the start", neither an app bug.
Together they are the root of most of the 78 failing assertions, because a
failed lookup leaves a collection variable empty and every downstream URL
then interpolates nothing.

1. `?filter[slug]=x` IS NOT A FILTER. OpenRegister reads `filter` as an
   ordinary property name, looks for objects whose `filter` property equals
   `{slug: x}`, and finds none. It says so in its own response:

     "ignoredFilters": ["filter"],
     "hint": "Query returned 0 results because filter was treated as a
              property filter. Did you mean _filter? Control params require
              underscore prefix."

   Measured against a live instance (read-only GETs, status printed):

     ?filter[slug]=hello-world   -> 200, total=0     <- what we were sending
     ?_filter[slug]=hello-world  -> 200, total=30    <- unfiltered, ALSO wrong
     ?slug=hello-world           -> 200, total=1     <- correct

   Note the middle row: the underscore form the hint suggests returns the
   WHOLE register. page-editor was using exactly that, so it was silently
   reading someone else's object rather than failing.

2. ApplicationVersion HAS NO `applicationUuid` PROPERTY, and no `version`
   one either. Read from the schema in lib/Settings/openbuild_register.json,
   its properties are: name, slug, manifest, manifestDelta, baseRef, register,
   semver, status, application, promotesTo, scope, owner, commitSha,
   sourceRef. The relation is `application`; the version string is `semver`.
   The collection filtered on `applicationUuid` and matched rows on
   `r.version` — an API shape that has never existed.

Fixed in BOTH `raw` and `query[]`. Newman resolves the request from
`query[]`, so a raw-only fix would look done and change nothing.

Not asserted-away: no assertion was weakened, no test skipped. The requests
now address the API the app actually exposes.

Scope of this commit is deliberately just these two causes, so the next
Newman run measures their effect on its own.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ dc42a3f

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
composer ✅ 101/101
npm ✅ 654/654
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-10 04:02 UTC

Download the full PDF report from the workflow artifacts.

The filter fix landed and the request is now correct — the log shows
`?slug=hello-world` returning 200 with a body the size of a ONE-result
response (~1.34kB in CI; measured locally: 1-result=1287B, 0-result=380B).
The client-side re-check still finds nothing, so the row is present and the
predicate is looking in the wrong place.

Rather than guess the shape from outside, log it once when the re-check
disagrees with the server-side filter. The assertion is unchanged.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ b1ffb65

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
composer ✅ 101/101
npm ✅ 654/654
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-10 04:10 UTC

Download the full PDF report from the workflow artifacts.

…the app

The diagnostic answered it: in CI the lookup returns `rows=0`. My earlier
inference from response SIZE ("~1.34kB matches a 1-result body, so the row is
there") was wrong — worth saying plainly, because it is exactly the kind of
measurement that reads like evidence and is not. Logging the shape was what
settled it.

So `?slug=` works on my dev instance (200, total=1, verified against the live
API) and returns 0 in CI. The two run different OpenRegister builds — CI
installs `openregister@development` — and the property-filter dialect is not
the same in both. A lookup whose correctness depends on which OR build is
installed is not a lookup this suite should be making.

0.2 now fetches the register unfiltered (`_limit=100`) and finds hello-world
CLIENT-SIDE, which behaves identically on every OR build. The purpose of this
request is to locate the seeded Application, not to exercise OR's query
dialect — that belongs in OpenRegister's own suite, not here.

The assertion is unchanged; the diagnostic now prints the returned slugs and
`ignoredFilters` instead of a blob that the log truncated at 20 characters.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ 96fb104

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
composer ✅ 101/101
npm ✅ 654/654
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-10 04:16 UTC

Download the full PDF report from the workflow artifacts.

…cated

rows=3, total=3: the unfiltered fetch works and CI has three Applications,
none of which the predicate matches on slug. The previous diagnostic was cut
off by the log before it printed them, so split it into one short line per
row.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ 9aca0c8

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
composer ✅ 101/101
npm ✅ 654/654
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-10 04:22 UTC

Download the full PDF report from the workflow artifacts.

THE KEYSTONE DEFECT, and it is the shape this programme exists to find: a
seed step that is green for a true reason while leaving the instance unseeded.

`newman-seed-command` was:

    php occ app:disable openbuild && php occ app:enable openbuild

Nextcloud runs repair steps on INSTALL and UPGRADE, not on disable/enable of
an app that is already installed. So the hello-world fixture was never
created, and the dedicated `openbuild:seed-hello-world-fixture` occ command
was never invoked at all. Disabling and re-enabling an app genuinely succeeds,
so the step reported success honestly — it just was not doing the work.

MEASURED, not inferred. I had first "confirmed" from response byte size that
the row was present; that was wrong. Logging the register contents from inside
the suite settled it — the three Applications in CI were:

    newman-wizard-single, newman-wizard-dsp, newman-permits

every one created by the collections themselves during the run. There was no
`hello-world` at all.

Four collections open by locating it (versioning, application-versions, rbac,
page-editor). The lookup returned nothing, `application_uuid` stayed empty,
and every later URL in those collections interpolated an empty path segment —
which is where the `//`, trailing-`/` and `null` 404s in the failure list come
from. They are one cause wearing many hats.

The fix is to use the seed PLAYWRIGHT has been using all along — the same
script, now referenced by both inputs. It really does the work: ci-seed.sh
runs `php occ openbuild:seed-hello-world-fixture`. It is `set -euo pipefail`,
the shared workflow checks its exit status, and it refuses to run outside CI
without an explicit base URL, so it stays ABLE TO FAIL — there is no `|| true`
anywhere in that path.

Verified both seed steps in the shared workflow `cd server` first, so the
identical relative path is valid for Newman as it already is for Playwright.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ 44dedb1

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
composer ✅ 101/101
npm ✅ 654/654
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-10 04:28 UTC

Download the full PDF report from the workflow artifacts.

The real seed ran and did the work — the log shows it:

    [ci-seed] occ openbuild:seed-hello-world-fixture
    Seeded hello-world fixture (application b6cef6b9-...).
    [ci-seed] hello-world manifest -> HTTP 200

and then failed at the very end on the SPA bundle gate, which the Newman job
cannot satisfy: it never runs the frontend build and never loads the SPA. It
calls the REST API and asserts on JSON.

That the seed FAILED here is the point — the previous one could not. It is now
doing enough work to be wrong about.

`SEED_REQUIRE_BUNDLE` defaults to 1, so Playwright's gate is untouched and
stays exactly as able to fail as before; proven with a positive control, both
directions:

    default (unset)       -> gate fires, exit 1
    SEED_REQUIRE_BUNDLE=0 -> announced skip, exit 0

The skip is ANNOUNCED in the script's own output ("SPA bundle NOT verified by
this run"), because a gate that quietly stops checking is the exact failure
this file was written to catch. Only the browser artefact is unchecked for the
API suite; every API fixture it depends on is still seeded, and the seed still
fails loudly if any of that goes wrong.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ 90ececd

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
composer ✅ 101/101
npm ✅ 654/654
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-10 04:35 UTC

Download the full PDF report from the workflow artifacts.

…ab-case

The collections addressed `objects/openbuild/application-version`. That schema
does not exist. Measured against a live OpenRegister:

    .../objects/openbuild/application-version  -> total=0
    .../objects/openbuild/applicationVersion   -> total=119

(CI is harsher and 404s the wrong slug outright, which is what the failure
list was showing.) The seed's own output lists the registered schemas and
`applicationVersion` is right there among them.

Six URLs in openbuild-versioning fixed, in both `raw` and `path[]`. The one
remaining match for the old string is the collection's `_postman_id`, which is
an identifier, not a URL — left alone deliberately.

Also corrected a comment I had written one commit earlier that said the
request was "filtered server-side". It is not: it now fetches unfiltered and
matches client-side, precisely so the suite does not depend on which
OpenRegister build is installed. A stale comment that describes the opposite
of what the code does is its own small dead check.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ cd824e6

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
composer ✅ 101/101
npm ✅ 654/654
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-10 04:41 UTC

Download the full PDF report from the workflow artifacts.

0.3 now returns 200 with rows, but the v1.0.0 match still misses. The seed
links the version with `'application' => $applicationUuid` and
`semver => '1.0.0'`, so the predicate's SHAPE is right — the identifier it
compares against is the open question. The Application object exposes `id`,
`uuid` and `@self.id`, and betting on one of them is what produces a failure
that reads as 'the seed did not run' — the exact misdiagnosis this suite
already caused once.

Capture both candidates and accept either, and log the rows on a miss so the
next run names the real linkage instead of inviting another guess.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ e5941f1

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
composer ✅ 101/101
npm ✅ 654/654
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-10 04:48 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ e4d97bc

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
composer ✅ 101/101
npm ✅ 654/654
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-10 05:29 UTC

Download the full PDF report from the workflow artifacts.

… never anonymous

Continues this branch's burn-down. Baseline for these numbers is run
31356594486 (45 failing assertions across 13 collections).

## 1. Five "unauthenticated" probes were logged-in requests (6 assertions)

`auth: { type: noauth }` removes the Authorization header. It does NOT
remove the SESSION COOKIE Newman already holds for that origin.

Measured with `newman --verbose`, one origin apart, same request:

  auth,   {{base_url}}    -> 324B up, 9 headers  (Authorization, no Cookie)
  noauth, {{base_url}}    -> 576B up, 9 headers  (no Authorization, COOKIE)
  noauth, {{noAuthBase}}  -> 285B up, 8 headers  (neither — truly anonymous)

The header COUNT is the tell: 9 vs 8. The middle row is the shape four
collections were using.

The consequence is visible in the CI run order. In
openbuild-application-versions the FIRST anonymous probe returned 401, and
every anonymous probe AFTER the first authenticated write returned 412
"CSRF check failed" — because a request carrying a logged-in session with no
requesttoken is not anonymous, it is a logged-in request that fails CSRF.
Nextcloud's 401 fires before the CSRF check, so a 412 is proof the probe was
authenticated.

openbuild-icon-endpoints is the same defect wearing the opposite face: its
probe returned 200 and was on its way to being read as an authorization
hole. It was the admin, correctly served an icon.

Fixed the way this repo already solved it once:
openbuild-api-contract.postman_collection.json reaches the same server by
its other name (`noAuthBase = http://127.0.0.1:8080`), so the cookie jar —
which is keyed by origin — has nothing for it. All five of its anonymous
assertions passed in the very run these failed.

Each assertion now demands 401 EXACTLY and names the trap when it sees a
412/200, so a future regression reports its own cause.

Rejected: `pm.cookies.jar().clear()`. It reports success — and does nothing.
`getAll()` returns an EMPTY list for these origins, so the clear "succeeds"
over a view that never had the cookies, while `--verbose` shows the upload
size unchanged at 576B with the Cookie still attached. A control that passes
because it cannot see its subject.

## 2. PRODUCT: export submit 500s on a malformed applicationVersion (4 assertions)

`exportJob.applicationVersion` is declared in openbuild_register.json as a
semver, and ExportDialog.vue sends one. ExportsController checked only that
it was non-empty, so any other string passed validation and died three
layers down, where OpenRegister refused the record and
ExportJobService::queue() rethrew — HTTP 500, with nothing telling the
caller which field was wrong. `target` one block up was already validated
exactly this way.

Now a 422 naming the field. All four failing assertions were in ONE request,
one cause. The collection was ALSO wrong — it sent the version SLUG
"production" — so it now sends a real semver, and a new request keeps a bad
one on purpose so the 4xx stays proven instead of assumed.

## 3. PRODUCT: diffVersions turns a 404 into a 500 (2 of 11)

`resolveVersionBlob()` says "Returns null on miss so the caller can surface
404", and diffVersions() has two `if (...=== null) return 404` branches.
Neither could ever be taken: `ObjectService::find()` THROWS on a miss, so
the throw went past them into the outer `catch (Throwable)` and came back as
500 `internal_error`.

This is the eighth instance of the family PR #159 fixed. It was not among
those seven because gate-49 only flags an untranslated lookup OUTSIDE a
try/catch — and being inside one is exactly what converted a precise 404
into an opaque 500 on a #[NoAdminRequired] endpoint. An empty token is now
answered as a miss instead of being asked of the object store at all.

## 4. openbuild-versioning: a filter that CANNOT match, and a PATCH that isn't (11)

Two causes, both measured against a live instance:

  applicationVersion?_limit=5000        -> 119 rows
  applicationVersion?semver=1.0.0       ->  20 rows  (scalar prop: filters)
  applicationVersion?application=<uuid> ->   0 rows  (relation: does NOT)

The third used a uuid taken straight out of row 1 of the first, so the value
was demonstrably present. OpenRegister does not apply a query-string data
filter to an x-openregister-relation. Three list reads used it and got zero
— which looks exactly like "the seed never ran", the misdiagnosis this
suite's own comments say it already made once. Step 0.2 had already learned
to fetch unfiltered and match in-script; 0.3, 1.2 and 3.2 now do too.

The three PUTs sent {version, status, manifest} only. OR's saveObject() is
PUT-semantic and nulls every omitted property; the Application schema
declares `required: ['slug', 'name']`; so OR refused the write with 400 and
said nothing about slug or name. The full record is now captured in 0.2 and
merged, and 1.1 additionally asserts slug/name SURVIVED — without that,
trimming the merge back would break somewhere else and this request would
still look fine.

Same trap as SeedHelloWorldFixture.php's "permissions IS REPEATED HERE ON
PURPOSE" and appRoles.ts. Third time it has cost something.

phpcs clean on both changed PHP files.
…nt "null"

## openbuild-component-blocks (5 assertions, one cause)

OpenRegister returns an object's identity in the `@self` envelope. The
create step asserted `to.have.property('uuid')` and stashed `body.uuid` —
undefined. Postman interpolates an undefined variable as the literal string
`null`, so the next three requests went to `/component-block/null` and 404'd.
Four of the five failures were in requests with nothing wrong with them.

⚠️ And one assertion in this collection was GREEN over the same two broken
values: "imported record gets a NEW uuid, distinct from the source block"
compared `body.uuid` (undefined) with `block_uuid` (the string 'null'),
found them unequal, and passed — while neither side was a uuid at all.

Both sides must now BE identifiers before they are compared, and a missing
identifier fails at the create step, where the cause is, instead of as a 404
on a URL three requests later.

## openbuild.postman_collection (4 assertions, two causes)

1. `strategy=hard` is not a strategy. ApplicationVersionsController accepts
   exactly `delete-now | orphan-grace | keep-register` and answers anything
   else 400 `invalid_strategy`. The assertion was right; the URL was wrong
   from the day it was written.

2. The round-trip never registered a BuiltAppRoute.
   `resolveApplicationBySlug()` does not search Applications by slug — it
   resolves the slug through the `built-app-route` schema. So a published
   Application with a productionVersion pointer is STILL unreachable by
   slug, and `GET /api/applications/newman-roundtrip/manifest` 404'd after
   every preceding step had succeeded (201, 201, 200, 200). Three assertions
   failed and the cause was in none of them.

   This folder deliberately drives the raw OR object API (ADR-022) instead
   of the wizard, and the wizard is what normally creates that route — so
   the round-trip has to create it itself, exactly as
   SeedHelloWorldFixture.php does in its step 4. Torn down again afterwards:
   a leftover route makes the next run's slug AMBIGUOUS, and an ambiguous
   set 404s on a run where everything else is correct.
The six assertions still failing after the previous commit turn out to
demand the exact opposite of the specification.

openspec/specs/openbuild-version-snapshots/spec.md (status: done) says:

  REQ-OBV-002: "The system SHALL NOT spawn sibling ApplicationVersion rows
  on draft -> published transitions. Snapshot-on-publish writeback is
  retired under ADR-002."
  "The system SHALL NOT subscribe any PHP listener
  (ApplicationVersionSnapshotListener or any successor)..."
  Scenario: "Publishing does NOT create a sibling ApplicationVersion"

  REQ-OBV-003: "restoring a previous state of an ApplicationVersion MUST
  NOT be implemented by copying from a sibling snapshot row"

The collection required "the listener must have appended a v1.1.0 row",
"at least three ApplicationVersion rows after rollback" and "a rollback
row like 1.0.0-rb1". The only way any of those could go green is for the
product to REGRESS to an architecture the spec explicitly forbids.

And the listener does not exist. lib/Listener/ holds six listeners, none
of them it; the surviving references are an archived change proposal, two
PHPUnit files and this collection. It was written against
`openbuild-versioning` (archived 2026-05-12) and never updated when
`openbuild-version-snapshots` superseded it.

Inverted to assert REQ-OBV-002/003 as written, NON-VACUOUSLY: the row
count is captured before the publish and asserted UNCHANGED, and the
before-count is separately required to be >= 1 — otherwise a broken list
read leaves both sides at zero and "unchanged" passes while measuring
nothing.

LEFT RED ON PURPOSE: the three REQ-OBV-005 diff assertions. The 500 they
used to get was a product defect and is fixed (that lookup now answers
404). What remains is a spec-vs-controller divergence: REQ-OBV-005 defines
the endpoint parameters as a version SLUG, `current:<slug>` or
`history:<slug>:<rev>`, and resolveVersionBlob() implements none of that
grammar — it accepts `draft` or a UUID. Rewriting the assertions to match
todays behaviour would lock in the divergence and delete the only evidence
of it, so it is documented in the folder description and filed instead.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ 7a4130f

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
composer ✅ 101/101
npm ✅ 654/654
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-10 14:39 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Newman burn-down: 45 → 30 measured, three more batches pushed

Measured, not projected

Job 93487483023 (run 31398481286) reached completed/failure with a full per-collection tally before that run was cancelled by the next push. A cancelled run is no verdict, but a job that completed with its own conclusion and printed its summary tables is a real measurement:

collection before after
openbuild-api-contract 4 0
openbuild-app-creation-wizard 1 0
openbuild-application-versions 3 0
openbuild-icon-endpoints 1 0
openbuild-version-promotion 1 0
openbuild-versioning 11 6
(untouched in that batch) 24 24
total 45 30

1. Five "unauthenticated" probes were logged-in requests — 6 assertions, all now green

auth: { type: noauth } removes the Authorization header. It does not remove the session cookie Newman already holds for that origin. Measured with newman --verbose, one origin apart, same request:

auth,   {{base_url}}    -> 324B up, 9 headers  (Authorization, no Cookie)
noauth, {{base_url}}    -> 576B up, 9 headers  (no Authorization, COOKIE)
noauth, {{noAuthBase}}  -> 285B up, 8 headers  (neither — truly anonymous)

The header count is the tell: 9 vs 8.

In CI the consequence was visible in run order: the first anonymous probe returned 401, and every one after the first authenticated write returned 412 CSRF. Nextcloud's 401 fires before the CSRF check, so a 412 is proof the probe was authenticated. openbuild-icon-endpoints was the same defect wearing the opposite face — its probe returned 200 and was on its way to being read as an authorization hole. It was the admin, correctly served an icon.

Fixed the way this repo already solved it once: openbuild-api-contract reaches the same server by its other name (noAuthBase = http://127.0.0.1:8080), so the origin-keyed cookie jar has nothing for it — and all five of its anonymous assertions passed in the very run these failed.

Rejected: pm.cookies.jar().clear(). It reports success and does nothing — getAll() returns an empty list for these origins, so the clear "succeeds" over a view that never had the cookies, while --verbose shows the upload size unchanged at 576B with the Cookie still attached. A control that passes because it cannot see its subject.

2. PRODUCT — export submit 500s on a malformed applicationVersion (4 → 0)

exportJob.applicationVersion is declared a semver and ExportDialog.vue sends one; the controller checked only non-empty, so anything else died three layers down where OR refused the record and queue() rethrew → HTTP 500, with nothing telling the caller which field was wrong. target one block up was already validated exactly this way. Now 422. All four failures were one request, one cause. The collection was also wrong (it sent the slug production) and now sends a semver, with a new request keeping a bad one on purpose so the 4xx stays proven.

3. PRODUCT — diffVersions turned a 404 into a 500 (measured 500 → 404)

resolveVersionBlob() says "returns null on miss so the caller can surface 404"; ObjectService::find() throws. Both 404 branches were dead code and the throw became internal_error. Eighth instance of the family #159 fixed — missed there because gate-49 only flags an untranslated lookup outside a try/catch, and being inside one is exactly what converted a precise 404 into an opaque 500 on a #[NoAdminRequired] endpoint.

4. openbuild-versioning — a filter that cannot match, and a PATCH that isn't (11 → 6)

applicationVersion?_limit=5000        -> 119 rows
applicationVersion?semver=1.0.0       ->  20 rows  (scalar prop: filters)
applicationVersion?application=<uuid> ->   0 rows  (relation: does NOT)

The third used a uuid taken straight out of row 1 of the first — the value was demonstrably present. OR does not apply a query-string filter to an x-openregister-relation. An empty result from a filter that cannot match looks exactly like "the seed never ran" — the misdiagnosis this suite's own comments say it already made once.

The three PUTs sent {version, status, manifest} only; OR's saveObject() nulls omitted properties and Application.required = ['slug','name'], so OR refused with 400 and said nothing about slug or name. Now merged from the full record — and 1.1 additionally asserts slug/name survived, so trimming the merge back can't pass quietly.

5. The last 6 in that collection asserted a model ADR-002 retired

openspec/specs/openbuild-version-snapshots/spec.md (status: done) — REQ-OBV-002: "The system SHALL NOT spawn sibling ApplicationVersion rows on draft → published transitions", "SHALL NOT subscribe any PHP listener (ApplicationVersionSnapshotListener or any successor)", scenario "Publishing does NOT create a sibling ApplicationVersion". And the listener does not exist — lib/Listener/ holds six, none of them it.

The collection demanded "the listener must have appended a v1.1.0 row", "at least three rows after rollback" and "a rollback row like 1.0.0-rb1". The only way any of those goes green is for the product to regress to an architecture the spec forbids. Inverted to assert REQ-OBV-002/003 as written, non-vacuously: the row count is captured before the publish and asserted unchanged, and the before-count is separately required ≥ 1 — otherwise a broken list read leaves both sides at zero and "unchanged" passes while measuring nothing.

6. openbuild-component-blocks (5) and openbuild (4)

body.uuid is undefined — OR puts the identity in @self. Postman interpolates an undefined variable as the literal string null, so three requests went to /component-block/null and 404'd. ⚠️ And one assertion was green over the same two broken values: "imported record gets a NEW uuid" compared undefined with 'null', found them unequal, and passed.

openbuild: strategy=hard is not a strategy (delete-now | orphan-grace | keep-register); and the round-trip never registered a BuiltAppRoute, which is how resolveApplicationBySlug() resolves a slug at all — so the manifest 404'd after every preceding step had succeeded, and the cause was in none of the three failing assertions.

Left red on purpose

The three REQ-OBV-005 diff assertions → #170. The 500 is fixed; what remains is a spec-vs-controller divergence (spec: version slug / current: / history:<slug>:<rev>; controller: draft or a UUID). Rewriting them to match today's behaviour would lock in the divergence and delete the only evidence of it.

Still unfixed and not yet diagnosed to a conclusion: openbuild-rbac (6), openbuild-automation-approval-steps (3), openbuild-docudesk-documents (2), openbuild-page-editor (2), openbuild-templates-marketplace (2). Working notes in the review thread — notably, rbac's three permission PUTs are the same PUT-semantics 400 fixed above, and its three "outsider" assertions returned byte-identical responses for viewer/editor/outsider, which is the cookie-carryover shape again and would mean that collection has never actually tested RBAC.

…pals

Two independent defects in the same three requests.

(1) The body was a bare {"permissions": {...}}. OR saveObject() is
    PUT-semantic and nulls every schema property the body omits;
    Application.required is [slug, name]; so OR refused the write with 400
    and said nothing about slug or name. Three assertions failed on it.

(2) The principals were BARE — "admin", and the group NAME.
    PermissionResolver::matchesCaller() classifies principals by a `user:`
    / `group:` PREFIX. That is the grammar SeedHelloWorldFixture.php writes
    (owners: [user:admin]) and the one tests/e2e/support/appRoles.ts writes
    (editors: [group:rbac-editors]). A bare value matches nothing, so even
    a write that LANDED would have granted no one.

(2) matters because it is invisible: the write returns 200 and the object
comes back with a permissions block that simply grants nobody. So the
restored assertions now read the block BACK and require the prefixed
principals, rather than trusting the status code.

Still open on this collection: the three "outsider" assertions returned
BYTE-IDENTICAL responses for viewer, editor and outsider (1.94kB x3 on the
list, 1.1kB x3 on the manifest). That is the cookie-carryover shape this
branch already fixed for the anonymous probes — a session established by an
earlier authenticated request outliving the per-request basic auth — and if
it holds, this collection has never actually tested RBAC. Not asserted
either way here; it needs its own measurement.
@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Working notes on the collections I did not finish

Diagnoses from the run-31356594486 log, so the next person does not re-derive them. None of these is asserted anywhere yet — they are read off the request/response lines, and each needs its own measurement before being acted on.

openbuild-automation-approval-steps (3)

The collection still contains a literal unsubstituted placeholder:

GET /apps/openbuild/api/automations/REPLACE-WITH-SEEDED-APPROVAL-AUTOMATION-UUID/status  [200 OK, 10.23kB]

⚠️ That 200 is not a pass — it is 10 kB of Nextcloud's HTML page. The route requires [a-f0-9-]{8,}, the placeholder is uppercase, so the route never matched and NC fell through to the SPA. A 200 that is HTML is the most dangerous shape a collection can produce, because a status-only assertion accepts it.

The other two: GET /apps/openregister/api/approval-steps?status=pending412 (the same session-cookie-then-CSRF mechanism fixed for the anonymous probes, here on an authenticated request), and POST /apps/openbuild/api/approval-steps/1/approve405 where the test asserts 404 ("OpenBuild has no approve pass-through route"). 405 vs 404 is a different claim — the path matched a route with another verb — so the assertion needs rewording against what appinfo/routes.php actually registers.

openbuild-page-editor (2)

PUT the Application's manifest → 200, then GET /api/applications/hello-world/manifest → the newman-added page is not there. That looks correct, not broken: under the versioned model the manifest endpoint resolves slug → built-app-route → productionVersion → version.manifest. Editing Application.manifest no longer feeds it. The collection is writing to the wrong object and should target the ApplicationVersion.

"Invalid manifest is rejected with 4xx" → got 200 for the same reason: a raw OR object write is validated against the JSON schema, not against openbuild's manifest validator. The validator lives on openbuild's own version-update path, so this assertion is aimed at the wrong surface — it is currently testing OpenRegister.

openbuild-templates-marketplace (2)

Second POST /api/applications/from-template/permit-tracker409 slug_collision where the test expects 201 "for different owner". Genuinely ambiguous and needs a product decision, not a test edit: either clone-from-template should mint a per-clone slug, or slug uniqueness is global and the assertion's premise is wrong. Do not "fix" this by relaxing the assertion to accept 409 — that would encode an answer nobody has given.

openbuild-docudesk-documents (2)

generate with an unknown templateId is a 4xx, not a 500 → 500, and the Nextcloud log names it:

Correspondence generation failed: Template register/schema not configured
OCA\DocuDesk\Exception\RegisterNotConfiguredException

Two separable things: the CI instance never configures Docudesk's template_register/template_schema for the Newman job (tests/e2e/global-setup.ts does it for Playwright only), and Docudesk answers an unconfigured register with a 500 rather than a 4xx — which is a docudesk defect, not an openbuild one. The honest fix is to configure it in ci-seed.sh and, if the 500 survives, file it upstream.

openbuild-rbac — the half I did not fix

The three permission PUTs are fixed in 1faecd91f (bare body + unprefixed principals). The three remaining "outsider" assertions are suspected, not diagnosed: viewer, editor and outsider received byte-identical responses (1.94 kB ×3 on the list, 1.1 kB ×3 on the manifest), which is the cookie-carryover shape — a session established by an earlier authenticated request outliving the per-request basic auth, so all three requests were the same user.

If that holds, this collection has never actually tested RBAC, and the fix is harder than for the anonymous probes: there are four identities and only two names for one host, so the noAuthBase trick does not generalise. pm.cookies.jar().clear() is already proven inert here. Needs its own measurement first — e.g. asserting the response body's identity rather than trusting the request's auth block.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ a469aa9

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
composer ✅ 101/101
npm ✅ 654/654
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-10 14:47 UTC

Download the full PDF report from the workflow artifacts.

…het 0.05% short

Run 31399299443's PHPUnit leg failed, and NOT on a test: `OK (797 tests,
2486 assertions)` followed by

  FAIL: coverage dropped by 0.05% against the merge base.

That drop is mine. Both product fixes on this branch added branches with no
test behind them. The ratchet is doing exactly its job; the answer is tests,
not a baseline bump.

## 4 tests, each proven to fail, ONE mutation at a time

ExportsController — non-semver `applicationVersion`:
  - planted `if (false && preg_match(...))`   -> testSubmitReturns422For…
    fails with "Failed asserting that 500 is identical to 422" — i.e. the
    mutation reproduces the ORIGINAL DEFECT'S STATUS CODE, not just some red.
  - restored -> 12/12 green.
  - planted an OVER-strict `^\d+\.\d+\.\d+$` (no pre-release group) ->
    testSubmitAcceptsSemverWithPreReleaseSuffix fails with "422 is not
    identical to 422". Without this second test a guard that rejected
    everything would have satisfied the first one while breaking every real
    export.
  - restored -> 12/12 green.

ApplicationsController::diffVersions — the unreachable 404:
  - planted `if (false && $token === '')` -> testDiffVersionsReturns404For
    AnEmptyToken fails.
  - restored -> green.
  - removed the try/catch translation entirely ->
    testDiffVersionsReturns404WhenTheVersionLookupThrows fails with "Failed
    asserting that 500 is identical to 404" — again the original symptom.
  - restored -> green.

Full unit suite: 797 -> 801 tests, 2486 -> 2499 assertions, all passing.

## One thing the new test surfaced

`PermissionResolver::resolveUserGroups()` foreach-ed over null in my first
draft. That is NOT a product defect — `IGroupManager::getUserGroups()` is
declared to return an array and an unstubbed mock returns null — so the fix
is mock fidelity, and it is stubbed to `[]` deliberately: `bob` is authorised
as a `user:` principal in the owners list, so the verdict must not depend on
group membership. A stub returning a group named in `permissions` would have
let both tests pass without proving the owner grant works at all.

phpcs: both changed lib/ files are clean (phpcs.xml scopes `<file>lib</file>`,
so tests/ is out of its scope — as the 44 pre-existing findings in
ExportsControllerTest on a green baseline already showed).
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ e9c03a4

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
composer ✅ 101/101
npm ✅ 654/654
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-10 14:56 UTC

Download the full PDF report from the workflow artifacts.

…irst

Measured after the previous batch: openbuild.postman went 4 -> 1, and the
one left was the version DELETE, now answering 422 rather than the old 400.

422 is CORRECT. ApplicationVersionsController::destroy()'s own docblock says
"Attempts to delete the parent Application's production version yield 422",
and this round-trip pointed the Application's productionVersion at exactly
that row three steps earlier. Deleting it first could never have worked; the
old `strategy=hard` 400 had been masking that all along, because an invalid
strategy is rejected BEFORE the guard is reached.

Two changes:

  * The attempt is kept and turned into the assertion it should always have
    been — deleting a production version is refused with 422 — plus a second
    assertion on WHICH refusal it is. Without that, any 422 from any cause
    would satisfy the first and read as "the guard works".

    This matters beyond tidiness: openbuild-application-versions carries a
    test named "DESTROY — reject production version deletion (422)" that
    SKIPS ("production_version_slug not set — seed step not run"), so this
    guard had no executing coverage anywhere in the suite.

  * The real cleanup moves AFTER the Application delete, and goes through
    OR's object API — openbuild's versions route resolves a version THROUGH
    its parent Application, which by then no longer exists, so it could only
    404. Ordering, not preference: the openbuild route is the right surface
    while the app is alive, and it is exercised as such by the guard
    assertion above.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ 259e7af

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
composer ✅ 101/101
npm ✅ 654/654
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-10 15:01 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ f7d1c10

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
composer ✅ 101/101
npm ✅ 654/654
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-10 15:31 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Final measurement on this branch: 45 → 15

Run 31401193088 (SHA 6d9fcee02), Newman job 93496964640. Executed 258 assertions, 15 failing.

collection baseline now
openbuild 4 0
openbuild-api-contract 4 0
openbuild-app-creation-wizard 1 0
openbuild-application-versions 3 0
openbuild-component-blocks 5 0
openbuild-icon-endpoints 1 0
openbuild-version-promotion 1 0
openbuild-versioning 11 3
openbuild-rbac 6 3
openbuild-automation-approval-steps 3 3
openbuild-docudesk-documents 2 2
openbuild-page-editor 2 2
openbuild-templates-marketplace 2 2
total 45 15

Seven collections fully green. Note the assertion count went 251 → 258: the fixes added regression guards rather than removing checks (a deliberate non-semver 422 probe, a permissions read-back, a production-delete guard, a slug/name-survived check).

PHPUnit (PHP 8.3, NC stable32) is green again

It was never a failing test — OK (797 tests, 2486 assertions) followed by FAIL: coverage dropped by 0.05% against the merge base. That drop was mine: both product fixes added branches with nothing behind them. Four tests (797 → 801) cleared it. The ratchet was right and did its job.

The 15 that remain, and why each is still red

  • versioning (3)diff endpoint implements a ref grammar the spec does not define (REQ-OBV-005: slug / current: / history:, controller: draft | UUID) #170. The 500 is fixed; what is left is a spec-vs-controller divergence (REQ-OBV-005 defines the ref grammar as a version slug / current: / history:<slug>:<rev>; resolveVersionBlob() accepts draft or a UUID). Rewriting the assertions to today's behaviour would go green and delete the only evidence of it.
  • rbac (3) — suspected cookie carryover: viewer, editor and outsider received byte-identical responses (1.94 kB ×3 on the list, 1.1 kB ×3 on the manifest). If that holds, this collection has never actually tested RBAC. Not fixed because noAuthBase does not generalise to four identities on one host and jar.clear() is proven inert — it needs a different instrument (assert the identity in the response body, not the request's auth block).
  • automation-approval-steps (3) — a literal unsubstituted REPLACE-WITH-SEEDED-APPROVAL-AUTOMATION-UUID, whose 200 is 10 kB of Nextcloud HTML, not JSON.
  • page-editor (2) — writes Application.manifest, but the manifest endpoint resolves through built-app-route → productionVersion → version.manifest. Wrong object, and the invalid-manifest assertion is aimed at OpenRegister rather than openbuild's validator.
  • templates-marketplace (2) — a second clone-from-template 409s. Whether that should mint a per-clone slug is a product decision; relaxing the assertion to accept 409 would encode an answer nobody has given.
  • docudesk-documents (2) — Docudesk's template register/schema is never configured for the Newman job (global-setup.ts does it for Playwright only), and Docudesk answers an unconfigured register with a 500 rather than a 4xx — a docudesk defect, not an openbuild one.

Nothing here is waived, skipped, baselined or threshold-shifted.

⚠️ E2E Tests (Playwright) on this branch is red for a reason that is not this PR: the four failing specs are fixed in #169, which targets development separately. This branch predates them.

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

The rbac trio is a harness defect, not three IDOR holes — and two of that collection's PASSES are false greens

Measured on this PR's run 31401193088 (Newman job 93496964640): 258 assertions executed, 15 failed across 18 collections. openbuild-rbac accounts for 3 of the 15:

1. AssertionError  Outsider sees the seeded app in NO list — IDOR closed
                   outsider must NOT see the hello-world app:
                   expected { slug: 'hello-world', …(17) } to be undefined
2. AssertionError  Outsider on existing slug gets 403 (not 404)
                   expected response to have status code 403 but got 200
3. AssertionError  Error envelope carries the fixed code openbuild.rbac.no_role
                   expected { version: '1.0.0', …(5) } to have property 'error'

Read on its own, that says the list endpoint leaks and the manifest endpoint is open to a non-member. It does not.

Cause

Newman keeps ONE cookie jar per host, and Nextcloud resolves the session cookie BEFORE it reads Authorization: Basic. A request carrying both runs as whoever authenticated last.

In tests/integration/openbuild-rbac.postman_collection.json:

  • collection-level auth is Basic as {{admin_user}};
  • the Setup folder (1.1–1.10) runs ten requests as admin, which seeds an admin session cookie into that single jar;
  • requests 2.1 / 2.2 / 2.3 / 3.1 then override auth to viewer / editor / outsider — but still carry the admin cookie, so every one of them executes as admin.

The collection contains no cookie-jar clear at allpm.cookies appears 0 times in the whole file.

That is the entire explanation for all three failures: the "outsider" is admin, so it sees the app and gets 200.

The part that matters more than the three reds

2.1 "Viewer GET /api/applications sees the test app" and 2.2 "Editor … sees the test app" are passing for the same reason — admin sees everything. Those two assertions have never demonstrated that the viewer/editor grants work. This collection has not tested RBAC in either direction; it has run 7 requests as admin and scored them as four different principals.

So the honest count for this collection is not "3 failures"; it is "3 failures and 2 unverified passes".

Fix, with the control that makes it real

  1. Clear the jar (collection-level pre-request, pm.cookies.jar()jar.clear(...)) so a role request cannot inherit the previous request's session.
  2. Add an identity guard in the same chain: GET /ocs/v2.php/cloud/user (with OCS-APIRequest: true) as each role, asserting ocs.data.id is that role's uid. Without this, a future regression of the jar is silent again.
  3. Prove both directions: remove the clear, show the identity guard goes RED (reports admin); restore it, show GREEN. A guard that cannot fail is worth nothing.

⚠️ curl cannot reproduce this — it uses a fresh jar per invocation, so a curl-based "these endpoints are fine" check proves nothing about the Newman result either way. The measurement has to happen inside the same Newman chain.

Expect 2.1/2.2 to be capable of going red once the jar is honest. If they do, that is a real grant bug to chase (cf. #76, which fixed exactly this class for reads via read: ["authenticated"]), not something to paper over.

On the framing of this PR

Agreed, and worth restating for whoever picks this up: enable-newman: true has never existed in .github/workflows/code-quality.yml in this repo's history, so on development the job still reports skipped and the 15/258 figure exists only on this branch. Until this merges, "openbuild's Newman has 15 failures" is a statement about chore/enable-newman-0809, not about development, where Newman has produced no verdict at all.

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Retraction: the "single cookie jar makes the outsider run as admin" premise does not reproduce

I was about to fix openbuild-rbac.postman_collection.json on the theory that its Setup folder (ten admin requests) seeds one per-host cookie jar, so requests 2.1 / 2.2 / 2.3 / 3.1 — which override auth to viewer/editor/outsider — still carry the admin session and therefore all run as admin, making both the passing and the failing assertions meaningless. pm.cookies does appear 0 times in the file, so the shape is right.

I measured it before writing the fix, and the mechanism is not there. Nextcloud 34.0.0.12, Newman 6.2.2.

Arm 1 — Newman, no jar clear, request-level Basic override (exactly the collection's shape)

→ 1 admin identity (seeds the jar)
  GET /ocs/v2.php/cloud/user [200 OK]
  ✓  runs as admin
→ 2 probe identity (Basic overridden to the probe user)
  GET /ocs/v2.php/cloud/user [200 OK]
  │ 'OBSERVED IDENTITY: nmjarprobe'
  ✓  the request that says probe_user actually RUNS as probe_user

Arm 2 — curl control, decisive

Seeded a real jar from an admin Basic request to /index.php/apps/openbuild/api/applications. It genuinely contains session state:

oc_sessionPassphrase   BvDoyENTNf8Kis5j918kfpTGyrjG...
ocy2k459bs75           446c96972d226cc408de774ec8279a69
nc_sameSiteCookiestrict / nc_sameSiteCookielax

Then, reusing that jar:

request result
admin jar + probe Basic header identity = nmjarprobe
admin jar + no Basic header 997 "Current user is not logged in"

The second row is the one that settles it: those admin cookies authenticate nobody. NC does not persist a reusable login session for Basic-auth API requests, so there is no admin identity for a later request to inherit — whether or not the client sends the cookies. The Basic header is the only thing deciding identity.

So the collection's auth-override design is sound as written, and I have not pushed a jar-clear fix. Writing one, with a comment asserting a defect I could not reproduce, would have been inventing the finding.

Scope of the retraction — please read before relying on it

  • Measured on NC 34. This job's PHPUnit legs run stable32 / stable33; I did not measure those. Same-mechanism-different-version is exactly the kind of thing that flips.
  • Measured against /ocs/v2.php/cloud/user and /index.php/apps/openbuild/api/applications. Not every route in the collection.

What is worth doing instead

An identity guard — a GET /ocs/v2.php/cloud/user asserting ocs.data.id before each role-scoped block — remains worth adding, not as a fix but as an invariant: it converts "these requests run as the user they claim" from an assumption into something the run measures, on whatever NC version CI happens to boot. I ran out of budget before landing it; it is a small, self-contained follow-up and I did not want to push it under a false rationale.

Blocker status for enable-newman: true

The enable-newman: false comment names two blockers.

  • Blocker 2 (SQLite: no such function: REGEXP in OR's MagicMapper) is already dead. development sets database: pgsql. The comment above the flag was not updated when that landed and still reads as if both are live.
  • Blocker 1 (React\Async\await() not autoloading) is unverified but looks like an install problem, not a missing dependency. OpenRegister's composer.json declares "react/async": "^4.3", vendor/react/async is present in a working install, and HyperFacetHandler.php:500 already carries a comment about the undefined-function case. Whatever fails in the CI install is not "the package was never required".

This PR is MERGEABLE but BLOCKED, and Newman has never once run in this repositoryenable-newman: true has never appeared in this workflow's history — so the first run is the first real verdict on both. I have not flipped or merged anything here.

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Re-ran this against current development — the failures reproduce, but the two RBAC ones are probably measuring admin

Updated the branch onto development (now a34d92cce, carrying #176, #185, #184
and 45f445e07) and re-ran, so these numbers are current rather than three days
old. Newman run 31572961038.

The failure set is byte-identical to the 2026-08-10 run. Nothing that landed in
between moved any of it, so none of these are stale.

The headline: this PR is doing exactly what it claims

Newman genuinely had never run, and turning it on surfaces 15 failing assertions
across 6 collections
. That is the honest outcome and the PR should not be judged
by it being red. But the red is not yet mergeable, because turning this on lands a
permanently-failing cell on development.

⚠️ Two of the failures look like a live IDOR and I do not believe they are

REQ-OBRBAC-003 · 2.3 Outsider GET /api/applications returns empty list
  outsider must NOT see the hello-world app: expected { slug: 'hello-world', …(17) } to be undefined

REQ-OBRBAC-002 · 3.1 Outsider on existing slug — 403 BEFORE 404
  expected response to have status code 403 but got 200

Read alone, that says any authenticated user can list every Application and read
any manifest. I read every guard in both paths on this exact tree and all of them
are deny-by-default:

link verdict
PermissionResolver::matchesCaller() :107 if ($permissions === []) return false; — fails closed on an empty block
ApplicationsController::filterApplicationsByRole() :905-945 per-app matchesCaller(..., allowAdminBypass: false, roles: [owners, editors, viewers]); no role ⇒ skipped
ApplicationsController::requirePermission() :1206-1247 same check, allowAdminBypass: false, then an explicit admin branch, then 403 openbuild.rbac.no_role
getManifest() :216-220 requirePermission() is called before any branch that emits the manifest

The fixture is not the problem either — 1.10's own assertions (which pass) prove the
permissions block reads back with correctly user:/group:-prefixed principals,
and the outsider is in none of them.

So the code cannot produce a 200 for a genuinely role-less caller, and yet a 200
came back. Something other than the guard answered.

The likely mechanism: a shared cookie jar, so every request runs as admin

Newman keeps one cookie jar for the whole run. The collection authenticates at
collection level as admin; Nextcloud issues a session cookie; the per-request
basic auth on 2.3 / 3.1 then competes with a session that is already established.

The corroboration is in this run's own output, in a different collection:

expected { message: 'CSRF check failed' } to be an array

Nextcloud only enforces CSRF when a session cookie is present — a pure
basic-auth request never trips it. That error is therefore direct evidence that a
session cookie is in play across these collections.

🔑 And this inverts the positive control. If every request executes as admin,
then:

  • 2.1 "Viewer sees the test app" and 2.2 "Editor sees the test app" pass for
    the wrong reason
    admin sees the app through the audited bypass regardless of
    role;
  • 2.3 and 3.1 fail for the wrong reason, for the same reason.

That is the whole RBAC collection measuring one principal. It is the shillinq lesson
turned around: a suite that runs every request as admin cannot fail — or pass — a
membership check on its merits.

What I did not do

I did not file an IDOR issue. This repo already has one open claim of that shape
(#171) that was refuted after measurement, and filing a second unverified one is
worse than filing none. The evidence I have is a failing assertion contradicted by
four hand-read guards.

The decisive experiment, for whoever picks this up

  1. Pin the identity per request — clear the cookie jar between principals
    (newman --cookie-jar per identity, or an explicit Cookie: reset), then re-run.
    If 2.3 and 3.1 go green, they were fixture artifacts and the collection was never
    testing RBAC.
  2. If they stay red with a provably role-less identity, it is a real IDOR and
    should be filed immediately — at that point the guards would have to be
    bypassed by something upstream of the controller.
  3. Either way, assert the effective UID inside each RBAC request (e.g. a
    /ocs/v2.php/cloud/user probe) so the collection proves which principal it ran
    as. Right now nothing in it does, which is why this is ambiguous at all.

The other 13, briefly

Independent of the identity question, and each worth its own look:

  • generate with an unknown templateId returns 500, twice (ExportsController)
    — an unknown id should be a 4xx.
  • PUT an invalid manifest returns 200 where a 4xx is expected.
  • GET .../hello-world/manifest does not reflect a newly-added page.
  • Version diff endpoint returns 404.
  • createFromTemplate for a different user with the same slug returns 409
    slug_collision
    where the collection expects cross-user reuse — that one may well
    be the test's model being wrong rather than the product's.
  • approve pass-through returns 405, not the expected 404.

Suggestion

Land the CI wiring and the collections, but keep the Newman job non-blocking
(continue-on-error) in the same change, with an issue tracking the 15 assertions
down to zero. That gets the collections running and visible without parking a
permanently-red required cell on development — and it avoids the alternative
temptation, which is deleting assertions until it goes green.

Not merged in this sweep for that reason.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ 292e804

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
composer ✅ 101/101
npm ✅ 654/654
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-12 07:47 UTC

Download the full PDF report from the workflow artifacts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants