diff --git a/src/test/java/org/codelibs/fess/app/web/api/admin/general/ApiAdminGeneralActionTest.java b/src/test/java/org/codelibs/fess/app/web/api/admin/general/ApiAdminGeneralActionTest.java new file mode 100644 index 000000000..f13de042c --- /dev/null +++ b/src/test/java/org/codelibs/fess/app/web/api/admin/general/ApiAdminGeneralActionTest.java @@ -0,0 +1,394 @@ +/* + * Copyright 2012-2025 CodeLibs Project and the Others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +package org.codelibs.fess.app.web.api.admin.general; + +import java.lang.reflect.Field; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; + +import org.codelibs.fess.Constants; +import org.codelibs.fess.app.web.api.ApiResult; +import org.codelibs.fess.app.web.base.FessBaseAction; +import org.codelibs.fess.helper.SystemHelper; +import org.codelibs.fess.ldap.LdapManager; +import org.codelibs.fess.mylasta.direction.FessConfig; +import org.codelibs.fess.unit.UnitFessTestCase; +import org.codelibs.fess.util.ComponentUtil; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.lastaflute.web.response.JsonResponse; +import org.lastaflute.web.validation.Required; + +/** + * Contract tests for {@code PUT /api/admin/general}. + * + *

The endpoint is not a plain "store what was sent" update. It first rebuilds the whole settings + * body out of what is stored ({@code AdminGeneralAction.updateForm}), then overlays the request with + * {@code BeanUtil.copyBeanToBean(body, newBody, CopyOptions::excludeNull)}, and only the merged + * result reaches {@code AdminGeneralAction.updateConfig}. The validation that runs in between is + * deliberately split: the bean constraints are evaluated against the raw request body, while the + * correlated SPNEGO rule is evaluated against the merged result.

+ * + *

That merge-then-validate glue is what these tests pin, because nothing else exercises it: the + * two shared halves have their own tests in {@code AdminGeneralActionTest}, and the integration + * suite's {@code GeneralTests} only reads. Each test drives the real {@code put$index} execute + * method, so the merge, the bean constraints and the correlated rule run in the order the action + * uses them. The one thing left outside is LastaFlute's own translation of the thrown + * {@code ValidationErrorException} into an HTTP 400 body, which happens in the API failure hook + * after the execute method has already returned.

+ */ +public class ApiAdminGeneralActionTest extends UnitFessTestCase { + + private static final String PREAUTH_PASSWORD_KEY = "spnego.preauth.password"; + + private static final String ALLOW_BASIC_KEY = "spnego.allow.basic"; + + private static final String PROMPT_NTLM_KEY = "spnego.prompt.ntlm"; + + private static final String ALLOWED_REALMS_KEY = "spnego.allowed.realms"; + + private static final String SSO_TYPE_SPNEGO = "spnego"; + + /** + * {@code updateConfig} writes every general setting into the shared system properties, so this + * test class needs its own container to keep those values out of the other test classes. + * + * @return true to create the container for each test + */ + @Override + protected boolean isUseOneTimeContainer() { + return true; + } + + @Override + public void setUp(final TestInfo testInfo) throws Exception { + super.setUp(testInfo); + ComponentUtil.register(new LdapManager(), "ldapManager"); + // updateConfig refreshes design files and re-reads app values, which are unrelated to the + // stored properties under test and pull in further components. + ComponentUtil.register(new SystemHelper() { + @Override + public List refreshDesignJspFiles() { + return Collections.emptyList(); + } + + @Override + public void updateSystemProperties() { + // nothing + } + }, "systemHelper"); + } + + // =================================================================================== + // Masked secret: omitted vs emptied + // ================================== + + @Test + public void test_put$index_omittedPreauthPassword_keepsStoredSecret() throws Exception { + // updateForm renders a stored spnego.preauth.password as the "**********" mask, excludeNull + // leaves that mask in place when the request omits the field, and updateConfig skips any + // value made only of mask characters. Break any one of those three -- drop excludeNull, or + // store the raw body instead of the merged one -- and a request that never mentioned the + // password silently deletes it. + final FessConfig fessConfig = ComponentUtil.getFessConfig(); + fessConfig.setSystemProperty(PREAUTH_PASSWORD_KEY, "stored-secret"); + + final ApiAdminGeneralAction action = createInjectedAction(); + final EditBody body = newStorableBody(); + assertNull(body.spnegoPreauthPassword, "the request must omit the password for this test to mean anything"); + + assertOkResponse(action.put$index(body)); + + assertEquals("stored-secret", ComponentUtil.getSystemProperties().getProperty(PREAUTH_PASSWORD_KEY)); + } + + @Test + public void test_put$index_emptyPreauthPassword_clearsStoredSecret() throws Exception { + // The asymmetry that makes the test above worth having: an explicitly empty password is a + // meaningful setting rather than "no opinion", because the SPNEGO library only falls back to + // a keytab when both the pre-authentication user name and the password are empty. + // excludeNull excludes null, not "", so the empty string survives the merge and updateConfig + // removes the stored key. + final FessConfig fessConfig = ComponentUtil.getFessConfig(); + fessConfig.setSystemProperty(PREAUTH_PASSWORD_KEY, "stored-secret"); + + final ApiAdminGeneralAction action = createInjectedAction(); + final EditBody body = newStorableBody(); + body.spnegoPreauthPassword = ""; + + assertOkResponse(action.put$index(body)); + + assertNull(ComponentUtil.getSystemProperties().getProperty(PREAUTH_PASSWORD_KEY)); + } + + @Test + public void test_put$index_suppliedPreauthPassword_replacesStoredSecret() throws Exception { + // The mask guard must not degenerate into "never write the password". + final FessConfig fessConfig = ComponentUtil.getFessConfig(); + fessConfig.setSystemProperty(PREAUTH_PASSWORD_KEY, "stored-secret"); + + final ApiAdminGeneralAction action = createInjectedAction(); + final EditBody body = newStorableBody(); + body.spnegoPreauthPassword = "rotated-secret"; + + assertOkResponse(action.put$index(body)); + + assertEquals("rotated-secret", ComponentUtil.getSystemProperties().getProperty(PREAUTH_PASSWORD_KEY)); + } + + // =================================================================================== + // The merge fills in the request + // =============================== + + @Test + public void test_put$index_omittedPlainField_keepsStoredValue() throws Exception { + // The same merge protects the ordinary (unmasked) settings: a request must not blank out + // everything it did not mention. + final FessConfig fessConfig = ComponentUtil.getFessConfig(); + fessConfig.setSystemProperty(ALLOWED_REALMS_KEY, "TRUSTED.EXAMPLE,PARTNER.EXAMPLE"); + + final ApiAdminGeneralAction action = createInjectedAction(); + final EditBody body = newStorableBody(); + assertNull(body.spnegoAllowedRealms, "the request must omit the realms for this test to mean anything"); + + assertOkResponse(action.put$index(body)); + + assertEquals("TRUSTED.EXAMPLE,PARTNER.EXAMPLE", ComponentUtil.getSystemProperties().getProperty(ALLOWED_REALMS_KEY)); + } + + @Test + public void test_put$index_bodyWithOnlyRequiredFields_isStorable() throws Exception { + // updateConfig unboxes seven Integer fields but only three of them are @Required, so a body + // carrying exactly what the constraints demand can only be stored because the merge supplied + // the other four from what updateForm read back. + final FessConfig fessConfig = ComponentUtil.getFessConfig(); + fessConfig.setPurgeSearchLogDay(45); + + final ApiAdminGeneralAction action = createInjectedAction(); + final EditBody body = newRequiredOnlyBody(); + assertNull(body.purgeSearchLogDay, "the request must omit the purge day for this test to mean anything"); + + assertOkResponse(action.put$index(body)); + + assertEquals(45, ComponentUtil.getFessConfig().getPurgeSearchLogDay()); + } + + // =================================================================================== + // Correlated rule runs against the merged result + // =============================================== + + @Test + public void test_put$index_ntlmPromptRule_readsStoredValuesForOmittedFields() throws Exception { + // sso.type, spnego.allow.basic and spnego.prompt.ntlm are all stored and none of them is in + // the request, yet the rule must still fire: it is evaluated against what is about to be + // stored, not against what the request happened to carry. + storeUnsupportedSpnegoCombination(); + + final ApiAdminGeneralAction action = createInjectedAction(); + final EditBody body = newStorableBody(); + assertNull(body.ssoType, "the request must omit ssoType for this test to mean anything"); + assertNull(body.spnegoAllowBasic, "the request must omit spnegoAllowBasic for this test to mean anything"); + assertNull(body.spnegoPromptNtlm, "the request must omit spnegoPromptNtlm for this test to mean anything"); + + assertValidationError(() -> action.put$index(body)).handle(data -> { + data.requiredMessageOf("spnegoPromptNtlm", "errors.spnego_prompt_ntlm_requires_basic"); + }); + } + + @Test + public void test_put$index_ntlmPromptRule_acceptsSupportedStoredCombination() throws Exception { + // Negative control for the test above: the very same omitted-everything request is accepted + // when the stored combination is a supported one, so the rejection there comes from the + // stored values and not merely from the request being partial. + final FessConfig fessConfig = ComponentUtil.getFessConfig(); + fessConfig.setSsoType(SSO_TYPE_SPNEGO); + fessConfig.setSystemProperty(ALLOW_BASIC_KEY, Constants.TRUE); + fessConfig.setSystemProperty(PROMPT_NTLM_KEY, Constants.TRUE); + + final ApiAdminGeneralAction action = createInjectedAction(); + + assertOkResponse(action.put$index(newStorableBody())); + + assertEquals(Constants.TRUE, ComponentUtil.getSystemProperties().getProperty(PROMPT_NTLM_KEY)); + } + + @Test + public void test_put$index_ntlmPromptRule_acceptsRequestThatFixesStoredCombination() throws Exception { + // The other half of the merge: the request supplies the missing half of the correlated pair, + // so what is about to be stored is a supported combination and the save goes through. + storeUnsupportedSpnegoCombination(); + + final ApiAdminGeneralAction action = createInjectedAction(); + final EditBody body = newStorableBody(); + body.spnegoAllowBasic = Constants.TRUE; + + assertOkResponse(action.put$index(body)); + + assertEquals(Constants.TRUE, ComponentUtil.getSystemProperties().getProperty(ALLOW_BASIC_KEY)); + assertEquals(Constants.TRUE, ComponentUtil.getSystemProperties().getProperty(PROMPT_NTLM_KEY)); + } + + // =================================================================================== + // @Required is checked on the request + // ================================== + + @Test + public void test_put$index_partialBodyWithoutRequiredFields_isRejected() throws Exception { + // The bean constraints are evaluated against the raw request body, not against the merged + // result, so a genuinely partial PUT is refused even though the merge could have supplied + // every missing value from what is stored. That is deliberate, but it is easy to miss in the + // code -- validateApi takes "body" while the rule nested inside it takes "newBody" -- and it + // would invert without a sound if the two arguments were ever unified. + final FessConfig fessConfig = ComponentUtil.getFessConfig(); + fessConfig.setSystemProperty(PREAUTH_PASSWORD_KEY, "stored-secret"); + + final ApiAdminGeneralAction action = createInjectedAction(); + final EditBody body = new EditBody(); + body.spnegoAllowedRealms = "TRUSTED.EXAMPLE"; + + assertValidationError(() -> action.put$index(body)).handle(data -> { + data.requiredMessageOf("dayForCleanup", Required.class); + data.requiredMessageOf("crawlingThreadCount", Required.class); + data.requiredMessageOf("failureCountThreshold", Required.class); + data.requiredMessageOf("csvFileEncoding", Required.class); + }); + + // Nothing may have been written: updateConfig runs only after validateApi returns normally. + assertEquals("stored-secret", ComponentUtil.getSystemProperties().getProperty(PREAUTH_PASSWORD_KEY)); + assertNull(ComponentUtil.getSystemProperties().getProperty(ALLOWED_REALMS_KEY)); + } + + @Test + public void test_put$index_partialBodyReportsRequiredAndCorrelationTogether() throws Exception { + // The correlated rule is not gated behind the bean constraints: ActionValidator collects the + // hibernate violations first, then runs the extra validation lambda into the same message + // set, and throws once at the end. A partial PUT that also lands on the unsupported SPNEGO + // combination therefore reports both problems in one response instead of one per round trip. + storeUnsupportedSpnegoCombination(); + + final ApiAdminGeneralAction action = createInjectedAction(); + final EditBody body = new EditBody(); + + assertValidationError(() -> action.put$index(body)).handle(data -> { + data.requiredMessageOf("dayForCleanup", Required.class); + data.requiredMessageOf("spnegoPromptNtlm", "errors.spnego_prompt_ntlm_requires_basic"); + }); + } + + // =================================================================================== + // Helpers + // ======= + + /** + * Stores the SPNEGO settings that the correlated rule must reject: the SPNEGO provider is + * selected, the NTLM prompt is on, and Basic authentication -- which the library needs before it + * can downgrade an NTLM token -- is off. + */ + private void storeUnsupportedSpnegoCombination() { + final FessConfig fessConfig = ComponentUtil.getFessConfig(); + fessConfig.setSsoType(SSO_TYPE_SPNEGO); + fessConfig.setSystemProperty(ALLOW_BASIC_KEY, Constants.FALSE); + fessConfig.setSystemProperty(PROMPT_NTLM_KEY, Constants.TRUE); + } + + /** + * Creates the smallest request body that satisfies the bean constraints: the four fields + * {@code EditForm} marks {@code @Required}, and nothing else. + * + * @return the request body to hand to {@code put$index} + */ + private EditBody newRequiredOnlyBody() { + final EditBody body = new EditBody(); + body.dayForCleanup = 20; + body.crawlingThreadCount = 5; + body.failureCountThreshold = 10; + body.csvFileEncoding = "UTF-8"; + return body; + } + + /** + * Creates a request body carrying every {@code Integer} field {@code updateConfig} unboxes, on + * top of the {@code @Required} ones. The tests that assert on what was stored use this so that a + * broken merge shows up as the wrong stored value rather than as an unboxing + * {@code NullPointerException} on an unrelated field. + * + * @return the request body to hand to {@code put$index} + */ + private EditBody newStorableBody() { + final EditBody body = newRequiredOnlyBody(); + body.purgeSearchLogDay = 30; + body.purgeJobLogDay = 30; + body.purgeUserInfoDay = 30; + body.purgeSuggestSearchLogDay = 30; + return body; + } + + /** + * Wires an {@link ApiAdminGeneralAction} through UTFlute's {@code inject()} for the framework + * fields that {@code validateApi()}/{@code asJson()} need, then fills in the fess-specific + * collaborators that {@code fess.xml} -- not loaded by the unit-test container -- would + * otherwise supply. Mirrors {@code ApiAdminSearchlistActionTest#createInjectedAction}. + * + * @return the action, ready to have its execute method called + * @throws Exception if the reflective wiring fails + */ + private ApiAdminGeneralAction createInjectedAction() throws Exception { + suppressBindingOf(org.codelibs.fess.app.web.base.login.FessLoginAssist.class); + // FessApiAction declares an @Resource AccessTokenService whose own AccessTokenBhv @Resource + // cannot be assembled in the unit container; only isAccessAllowed() uses it, and that is + // never reached when the execute method is called directly. + suppressBindingOf(org.codelibs.fess.app.service.AccessTokenService.class); + final ApiAdminGeneralAction action = new ApiAdminGeneralAction(); + inject(action); + + final Field systemHelperField = FessBaseAction.class.getDeclaredField("systemHelper"); + systemHelperField.setAccessible(true); + if (systemHelperField.get(action) == null) { + systemHelperField.set(action, ComponentUtil.getSystemHelper()); + } + + final Field fessConfigField = FessBaseAction.class.getDeclaredField("fessConfig"); + fessConfigField.setAccessible(true); + if (fessConfigField.get(action) == null) { + fessConfigField.set(action, ComponentUtil.getFessConfig()); + } + + return action; + } + + /** + * Asserts the endpoint answered with the OK status. The status is read reflectively because + * {@code ApiResponse} exposes no getter for it. + * + * @param response the response returned by {@code put$index} + * @throws Exception if the reflective read fails + */ + private void assertOkResponse(final JsonResponse response) throws Exception { + assertNotNull(response, "put$index must return a response"); + final ApiResult result = response.getJsonResult(); + assertNotNull(result, "put$index must return a JSON result"); + + final Field responseField = ApiResult.class.getDeclaredField("response"); + responseField.setAccessible(true); + final Object apiResponse = responseField.get(result); + assertNotNull(apiResponse, "the JSON result must carry a response"); + + final Field statusField = ApiResult.ApiResponse.class.getDeclaredField("status"); + statusField.setAccessible(true); + assertEquals(ApiResult.Status.OK.getId(), ((Integer) statusField.get(apiResponse)).intValue()); + } +}