Skip to content

test(api): pin the merge-then-validate contract of the general settings endpoint - #3265

Open
marevol wants to merge 1 commit into
masterfrom
test/api-admin-general-put
Open

test(api): pin the merge-then-validate contract of the general settings endpoint#3265
marevol wants to merge 1 commit into
masterfrom
test/api-admin-general-put

Conversation

@marevol

@marevol marevol commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Why

ApiAdminGeneralAction.put$index (PUT /api/admin/general) had no unit test, and it is not a plain "store what was sent" update:

final EditBody newBody = new EditBody();
AdminGeneralAction.updateForm(fessConfig, newBody);                 // 1. load stored settings
BeanUtil.copyBeanToBean(body, newBody, CopyOptions::excludeNull);   // 2. overlay the request
validateApi(body, messages -> {                                     // 3. constraints on the RAW body
    if (AdminGeneralAction.isSpnegoNtlmPromptUnsupported(newBody)) {//    correlation on the MERGED one
        messages.addErrorsSpnegoPromptNtlmRequiresBasic("spnegoPromptNtlm");
    }
});
AdminGeneralAction.updateConfig(fessConfig, newBody);               // 4. store the MERGED body

The two shared halves (updateForm / updateConfig) are covered by AdminGeneralActionTest, and the integration suite's GeneralTests.crudTest() only calls testRead() (createTestParam/getUpdateMap are fail(); // Unreachable). The merge-then-validate glue in between — which body each of the four steps sees — was covered nowhere.

What this adds

src/test/java/org/codelibs/fess/app/web/api/admin/general/ApiAdminGeneralActionTest.java, 10 tests that drive the real put$index execute method (so the merge, the bean constraints and the correlated rule run in the action's own order):

Masked secret, both directions

  • an omitted spnego_preauth_password keeps the stored secret — updateForm renders the ********** mask, excludeNull keeps it, updateConfig skips any all-mask value
  • an explicitly empty spnego_preauth_password still deletes the stored value (deliberate: the SPNEGO library only falls back to a keytab when both the pre-auth user name and password are empty), because excludeNull excludes null and not ""
  • a supplied password still replaces the stored one, so the mask guard cannot degenerate into "never write"

The merge fills in the request

  • an omitted plain field keeps its stored value
  • a body carrying only the four @Required fields is storable at all, which it can only be because the merge supplied the other three Integer fields updateConfig unboxes

The correlated rule reads the merged result

  • stored sso.type=spnego + spnego.allow.basic=false + spnego.prompt.ntlm=true, request omits all three → still rejected with errors.spnego_prompt_ntlm_requires_basic
  • negative control: the identical request is accepted when the stored combination is supported
  • the request supplying the missing half (spnegoAllowBasic=true) is accepted

@Required is checked on the request

  • a genuinely partial PUT is rejected with @Required on dayForCleanup, crawlingThreadCount, failureCountThreshold, csvFileEncoding, and nothing is written

Note on one detail

While writing this I checked the assumption that a partial PUT is rejected "before the correlation check ever runs". That is not what happens: ActionValidator.actuallyValidate collects the Hibernate violations, then calls the extra-validation lambda into the same message set, and throws once at the end. So the correlated rule does run on a partial PUT and contributes its message alongside the @Required ones. The last test pins that real behaviour (both messages in one response) rather than the assumed one.

Scope covered / not covered

Covered end to end: updateFormexcludeNull merge → validateApi (bean constraints + correlated rule) → updateConfig, plus the stored state afterwards and the Status.OK payload.

Not covered: LastaFlute's own translation of the thrown ValidationErrorException into an HTTP 400 response body, which happens in the API failure hook after the execute method has returned; and isAccessAllowed(), whose AccessTokenService binding is suppressed as in the sibling API action tests.

Anti-vacuity check

Each contract was verified against a deliberately broken copy of the action in a scratch worktree; every mutation is caught, and the mutations are killed by the tests that own that contract:

Mutation Result
copyBeanToBean(body, newBody) (no excludeNull) 6 failures + 1 error / 10
isSpnegoNtlmPromptUnsupported(body) instead of newBody 2 failures / 10 — exactly the two correlation tests
updateConfig(fessConfig, body) instead of newBody 4 failures + 1 error / 10
validateApi(newBody, …) instead of body 2 failures / 10 — exactly the two @Required tests

Verification

mvn -o clean test -Dtest='ApiAdminGeneralActionTest,AdminGeneralActionTest,ApiAdminSchedulerActionTest,ApiAdminSearchlistActionTest'
Tests run: 34, Failures: 0, Errors: 0, Skipped: 0

ApiAdminGeneralActionTest alone: Tests run: 10, Failures: 0, Errors: 0, Skipped: 0. mvn formatter:format && mvn license:format produced no changes.

Test-only: git diff master --stat is the single new file.

…gs endpoint

PUT /api/admin/general is not a plain "store what was sent" update: it rebuilds
the whole settings body from what is stored, overlays the request with
copyBeanToBean(..., CopyOptions::excludeNull), validates the raw request against
the bean constraints while validating the correlated SPNEGO rule against the
merged result, and only then stores the merged body.

That glue had no test anywhere. AdminGeneralActionTest covers the two shared
halves (updateForm/updateConfig) and the integration suite's GeneralTests only
reads, so nothing exercised the merge itself.

Adds ApiAdminGeneralActionTest, which drives the real put$index execute method
and pins:

- an omitted spnego.preauth.password keeps the stored secret (mask rendered by
  updateForm, kept by excludeNull, skipped by updateConfig), while an explicitly
  empty one still clears it and a supplied one still replaces it
- omitted plain fields keep their stored values, and a body carrying only the
  four @required fields is storable because the merge supplies the other three
  Integer fields updateConfig unboxes
- the SPNEGO NTLM-prompt rule is evaluated against the merged result: it fires
  for a request that omits sso.type, spnego.allow.basic and spnego.prompt.ntlm
  when the stored combination is unsupported, and stands down both when the
  stored combination is fine and when the request supplies the missing half
- the bean constraints are evaluated against the raw request, so a genuinely
  partial PUT is rejected and nothing is written; the correlated rule is not
  gated behind them, so both problems are reported in one response

Each contract was checked against a deliberately broken copy of the action
(excludeNull dropped, rule fed the raw body, raw body stored, merged body
validated); every mutation is caught by the tests that own it.

Test-only change; no production code is touched.
@marevol marevol added this to the 15.9.0 milestone Aug 11, 2026
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.

1 participant