diff --git a/src/org/labkey/targetedms/GuestAccessManager.java b/src/org/labkey/targetedms/GuestAccessManager.java new file mode 100644 index 000000000..383e541a1 --- /dev/null +++ b/src/org/labkey/targetedms/GuestAccessManager.java @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * 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.labkey.targetedms; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.action.BaseViewAction; +import org.labkey.api.action.SpringActionController; +import org.labkey.api.audit.AuditLogService; +import org.labkey.api.audit.provider.SiteSettingsAuditProvider; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.PropertyManager; +import org.labkey.api.data.PropertyManager.PropertyMap; +import org.labkey.api.data.PropertyManager.WritablePropertyMap; +import org.labkey.api.security.User; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +/** + * Site-admin setting that can require a login for slow targetedms pages that get hit by bots. A master + * switch (off by default) plus one flag per action are stored as site properties on the root container. + * When the master switch is on, each flagged action sends a guest to the login page instead of rendering. + * When it is off, guests can open the pages as before. + * + * This setting does not change actions that already require a login: + * PrecursorAllChromatogramsChartAction, MoleculePrecursorAllChromatogramsChartAction, ShowTransitionListAction + */ +public class GuestAccessManager +{ + // Site properties live in this category on the root container. + private static final String CATEGORY = "TargetedMSGuestAccess"; + private static final String MASTER_KEY = "masterEnabled"; + private static final String TRUE = Boolean.TRUE.toString(); + + /** + * The actions a site admin can choose to require a login. Each entry is keyed by its action class. + * + * The pages that draw many charts are checked by default. The single-chart image actions and the + * precursor table are offered too but are off by default. An admin can also check those to block direct + * requests to them during a bot attack (they show up in high volume during an attack because the detail + * pages embed many of them). + */ + public enum RestrictableAction + { + showProtein(TargetedMSController.ShowProteinAction.class, "Protein details page", true), + showPeptide(TargetedMSController.ShowPeptideAction.class, "Peptide details page", true), + showMolecule(TargetedMSController.ShowMoleculeAction.class, "Small molecule details page", true), + showCalibrationCurve(TargetedMSController.ShowCalibrationCurveAction.class, "Calibration curve details page", true), + showPrecursorList(TargetedMSController.ShowPrecursorListAction.class, "Document details page", false), + showPeakAreas(TargetedMSController.ShowPeakAreasAction.class, "Peak areas chart", false), + showRetentionTimesChart(TargetedMSController.ShowRetentionTimesChartAction.class, "Retention times chart", false), + precursorChromatogramChart(TargetedMSController.PrecursorChromatogramChartAction.class, "Precursor chromatogram", false), + groupChromatogramChart(TargetedMSController.GroupChromatogramChartAction.class, "Protein chromatogram", false); + + private final Class> _actionClass; + private final String _description; + private final boolean _defaultChecked; + + RestrictableAction(Class> actionClass, String description, boolean defaultChecked) + { + _actionClass = actionClass; + _description = description; + _defaultChecked = defaultChecked; + } + + /** + * The action's registered URL name (e.g. "showCalibrationCurve"), derived from the action class. + */ + private String getActionName() + { + return SpringActionController.getActionName(_actionClass); + } + + /** Settings-page label, e.g. "Calibration curve details page (showCalibrationCurve)". */ + public String getLabel() + { + return _description + " (" + getActionName() + ")"; + } + + private boolean isDefaultChecked() + { + return _defaultChecked; + } + + /** + * The restrictable action for this action class, or null if the class is not gated. + */ + @Nullable + public static RestrictableAction forClass(@NotNull Class> actionClass) + { + for (RestrictableAction action : values()) + { + if (action._actionClass.equals(actionClass)) + return action; + } + return null; + } + } + + private GuestAccessManager() + { + } + + private static PropertyMap getProperties() + { + return PropertyManager.getProperties(ContainerManager.getRoot(), CATEGORY); + } + + /** True when the master switch is on. */ + public static boolean isMasterEnabled() + { + return TRUE.equals(getProperties().get(MASTER_KEY)); + } + + /** + * The saved (or default) checkbox state for an action, independent of the master toggle. Used to render + * the settings page. + */ + public static boolean isActionChecked(@NotNull RestrictableAction action) + { + String saved = getProperties().get(action.name()); + return saved == null ? action.isDefaultChecked() : TRUE.equals(saved); + } + + /** + * True when a guest opening this page should be sent to the login page: the master switch is on AND + * this page's checkbox is checked. This is the single method the actions consult. + */ + public static boolean isRestricted(@NotNull RestrictableAction action) + { + PropertyMap props = getProperties(); + if (!TRUE.equals(props.get(MASTER_KEY))) + return false; + // saved true/false is the admin's explicit choice (an explicit uncheck is respected). Absent means + // never decided - e.g. an action added in a later release - so fall back to the action's default: + // a new default-checked action is gated once the master switch is on, without waiting for a re-save. + String saved = props.get(action.name()); + return saved == null ? action.isDefaultChecked() : TRUE.equals(saved); + } + + /** The set of currently-checked actions (independent of the master toggle). */ + private static Set getCheckedActions() + { + PropertyMap props = getProperties(); + Set checked = EnumSet.noneOf(RestrictableAction.class); + for (RestrictableAction action : RestrictableAction.values()) + { + String saved = props.get(action.name()); + if (saved == null ? action.isDefaultChecked() : TRUE.equals(saved)) + checked.add(action); + } + return checked; + } + + /** + * Persist the settings on the root container and, if anything changed, write a site-settings audit entry + * recording who changed it and the new state. + */ + public static void save(@NotNull User user, boolean masterEnabled, @NotNull Set checkedActions) + { + boolean oldMaster = isMasterEnabled(); + Set oldChecked = getCheckedActions(); + + // When the master switch is off, the per-action checkboxes are disabled on the settings page and + // do not post, so preserve the previously saved per-action selections rather than overwriting them. + Set newChecked = masterEnabled ? checkedActions : oldChecked; + + WritablePropertyMap props = PropertyManager.getWritableProperties(ContainerManager.getRoot(), CATEGORY, true); + props.put(MASTER_KEY, Boolean.toString(masterEnabled)); + for (RestrictableAction action : RestrictableAction.values()) + props.put(action.name(), Boolean.toString(newChecked.contains(action))); + props.save(); + + if (masterEnabled != oldMaster || !newChecked.equals(oldChecked)) + { + // When the master switch is off, no actions are enforced even though per-action selections are + // preserved, so report the effective (enforced) set rather than the saved checkboxes. + String enforced = masterEnabled ? describe(newChecked) : "(none - master switch off)"; + String comment = "Targeted MS Guest Access settings updated. Master switch: " + (masterEnabled ? "on" : "off") + + ". Actions requiring login for guests: " + enforced + "."; + SiteSettingsAuditProvider.SiteSettingsAuditEvent event = + new SiteSettingsAuditProvider.SiteSettingsAuditEvent(ContainerManager.getRoot(), comment); + AuditLogService.get().addEvent(user, event); + } + } + + private static String describe(Set actions) + { + if (actions.isEmpty()) + return "(none)"; + // Report in enum declaration order for a stable, readable list. + List names = new ArrayList<>(); + for (RestrictableAction action : RestrictableAction.values()) + { + if (actions.contains(action)) + names.add(action.getActionName()); + } + return String.join(", ", names); + } +} diff --git a/src/org/labkey/targetedms/TargetedMSController.java b/src/org/labkey/targetedms/TargetedMSController.java index 1fbda6177..fbe3abd9a 100644 --- a/src/org/labkey/targetedms/TargetedMSController.java +++ b/src/org/labkey/targetedms/TargetedMSController.java @@ -53,6 +53,7 @@ import org.labkey.api.action.ApiResponse; import org.labkey.api.action.ApiSimpleResponse; import org.labkey.api.action.ApiUsageException; +import org.labkey.api.action.BaseViewAction; import org.labkey.api.action.ExportAction; import org.labkey.api.action.FormHandlerAction; import org.labkey.api.action.FormViewAction; @@ -306,6 +307,7 @@ import java.util.Collections; import java.util.Comparator; import java.util.Date; +import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; @@ -589,6 +591,141 @@ public void addNavTrail(NavTree root) } } + // ------------------------------------------------------------------------ + // Site-admin settings page: require a login for guests on targetedms actions targeted by bots. + // ------------------------------------------------------------------------ + public static class GuestAccessSettingsForm + { + private boolean _masterEnabled; + private String[] _restrictedActions = new String[0]; + + public boolean isMasterEnabled() + { + return _masterEnabled; + } + + public void setMasterEnabled(boolean masterEnabled) + { + _masterEnabled = masterEnabled; + } + + public String[] getRestrictedActions() + { + return _restrictedActions; + } + + public void setRestrictedActions(String[] restrictedActions) + { + _restrictedActions = restrictedActions == null ? new String[0] : restrictedActions; + } + + /** The checked actions as an enum set, ignoring any unrecognized keys. */ + public Set getCheckedActions() + { + Set checked = EnumSet.noneOf(GuestAccessManager.RestrictableAction.class); + for (String name : _restrictedActions) + { + try + { + checked.add(GuestAccessManager.RestrictableAction.valueOf(name)); + } + catch (IllegalArgumentException ignored) + { + // Skip anything that is not a current action key + } + } + return checked; + } + } + + /** One row on the settings page: an action, its label, and whether its checkbox is currently checked. */ + public static class GuestAccessActionState + { + private final GuestAccessManager.RestrictableAction _action; + private final boolean _checked; + + public GuestAccessActionState(GuestAccessManager.RestrictableAction action, boolean checked) + { + _action = action; + _checked = checked; + } + + public String getName() + { + return _action.name(); + } + + public String getLabel() + { + return _action.getLabel(); + } + + public boolean isChecked() + { + return _checked; + } + } + + /** Model for the settings JSP: the master switch state plus one row per restrictable action. */ + public static class GuestAccessSettingsBean + { + private final boolean _masterEnabled; + private final List _actions; + + public GuestAccessSettingsBean() + { + _masterEnabled = GuestAccessManager.isMasterEnabled(); + _actions = new ArrayList<>(); + for (GuestAccessManager.RestrictableAction action : GuestAccessManager.RestrictableAction.values()) + _actions.add(new GuestAccessActionState(action, GuestAccessManager.isActionChecked(action))); + } + + public boolean isMasterEnabled() + { + return _masterEnabled; + } + + public List getActions() + { + return _actions; + } + } + + @RequiresPermission(ApplicationAdminPermission.class) + public class GuestAccessSettingsAction extends FormViewAction + { + @Override + public void validateCommand(GuestAccessSettingsForm target, Errors errors) + { + } + + @Override + public ModelAndView getView(GuestAccessSettingsForm form, boolean reshow, BindException errors) + { + return new JspView<>("/org/labkey/targetedms/view/guestAccessSettings.jsp", new GuestAccessSettingsBean(), errors); + } + + @Override + public boolean handlePost(GuestAccessSettingsForm form, BindException errors) + { + GuestAccessManager.save(getUser(), form.isMasterEnabled(), form.getCheckedActions()); + return true; + } + + @Override + public URLHelper getSuccessURL(GuestAccessSettingsForm form) + { + // Reshow the settings page with the saved values. + return new ActionURL(GuestAccessSettingsAction.class, getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + urlProvider(AdminUrls.class).addAdminNavTrail(root, "Targeted MS Guest Access", getClass(), getContainer()); + } + } + // ------------------------------------------------------------------------ // Action to create a Raw Data tab // ------------------------------------------------------------------------ @@ -1971,6 +2108,8 @@ public class GroupChromatogramChartAction extends ExportAction @Override public ModelAndView getView(ChromatogramForm form, BindException errors) { + HtmlView loginGate = getGuestLoginGate(getClass(), getViewContext(), getContainer()); + if (loginGate != null) + return loginGate; + long peptideId = form.getId(); // peptide Id Peptide peptide = PeptideManager.getPeptide(getContainer(), peptideId); @@ -3102,9 +3247,12 @@ public ModelAndView getView(ChromatogramForm form, BindException errors) @Override public void addNavTrail(NavTree root) { + // Add the top-level crumb unconditionally so the page has a title even on the guest login-gate + // path, where getView returns early and _run is never set (the test framework flags a titleless + // action as a failure). + root.addChild("Targeted MS Runs", getShowListURL(getContainer())); if (null != _run) { - root.addChild("Targeted MS Runs", getShowListURL(getContainer())); root.addChild(_run.getDescription(), getShowRunURL(getContainer(), _run.getId())); root.addChild(_sequence); } @@ -3123,6 +3271,10 @@ public static class ShowMoleculeAction extends SimpleViewAction> actionClass, ViewContext context) + { + GuestAccessManager.RestrictableAction action = GuestAccessManager.RestrictableAction.forClass(actionClass); + return action != null && context.getUser().isGuest() && GuestAccessManager.isRestricted(action); + } + + /** + * Returns a login view when a guest should be sent to the login page for this action (the site-admin + * master switch is on AND this action's checkbox is checked), otherwise null so the action runs as + * normal. See {@link GuestAccessSettingsAction}. + */ + @Nullable + private static HtmlView getGuestLoginGate(Class> actionClass, ViewContext context, Container container) + { + return isGuestGated(actionClass, context) ? getLoginView(context, container) : null; + } + + /** + * Same check as {@link #getGuestLoginGate}, but for the chart actions that write an image. Those cannot + * return the HTML login view, so a restricted guest is redirected to the login page instead. + */ + private static void redirectGuestToLoginForChart(Class> actionClass, ViewContext context, Container container) + { + if (isGuestGated(actionClass, context)) + throw new RedirectException(PageFlowUtil.urlProvider(LoginUrls.class).getLoginURL(container, context.getActionURL())); + } + @RequiresPermission(ReadPermission.class) public class ShowPrecursorListAction extends ShowRunSplitDetailsAction { @@ -4529,6 +4718,13 @@ public ShowPrecursorListAction(ViewContext ctx) setViewContext(ctx); } + @Override + public ModelAndView getView(RunDetailsForm form, BindException errors) throws Exception + { + HtmlView loginGate = getGuestLoginGate(getClass(), getViewContext(), getContainer()); + return loginGate != null ? loginGate : super.getView(form, errors); + } + @Override protected DocumentPrecursorsView createQueryView(RunDetailsForm form, BindException errors, boolean forExport, String dataRegion) { @@ -5508,6 +5704,10 @@ public class ShowProteinAction extends SimpleViewAction @Override public ModelAndView getView(final ProteinForm form, BindException errors) { + HtmlView loginGate = getGuestLoginGate(getClass(), getViewContext(), getContainer()); + if (loginGate != null) + return loginGate; + PeptideGroup group = PeptideGroupManager.getPeptideGroup(getContainer(), form.getId()); if (group == null) { @@ -8242,9 +8442,11 @@ public ShowCalibrationCurveAction() @Override public void addNavTrail(NavTree root) { + // Add the top-level crumb unconditionally so the page has a title even on the guest login-gate + // path, where validate() returns early and _run is never set. + root.addChild("Targeted MS Runs", getShowListURL(getContainer())); if (null != _run) { - root.addChild("Targeted MS Runs", getShowListURL(getContainer())); root.addChild(_run.getDescription(), getShowCalibrationCurvesURL(getContainer(), _run.getId())); if (_curvePlotView.getChart().getMolecule() != null) { @@ -8256,6 +8458,11 @@ public void addNavTrail(NavTree root) @Override public void validate(CalibrationCurveForm form, BindException errors) { + // Skip the expensive CalibrationCurveView construction below. Gating only in getView would be too late, + // because the expensive work is done here in validate(). + if (isGuestGated(getClass(), getViewContext())) + return; + _curvePlotView = new CalibrationCurveView(getUser(), getContainer(), form.getCalibrationCurveId()); CalibrationCurveEntity chart = _curvePlotView.getChart().getCalibrationCurveEntity(); //ensure that the experiment run is valid and exists within the current container @@ -8277,6 +8484,11 @@ protected QueryView createQueryView(CalibrationCurveForm form, BindException err @Override public ModelAndView getView(CalibrationCurveForm calibrationCurveForm, BindException errors) { + // A restricted guest is short-circuited here; validate() already skipped the expensive work. + HtmlView loginGate = getGuestLoginGate(getClass(), getViewContext(), getContainer()); + if (loginGate != null) + return loginGate; + CalibrationCurveChart chart = _curvePlotView.getChart(); GeneralMolecule molecule = chart.getMolecule(); if (molecule == null) diff --git a/src/org/labkey/targetedms/TargetedMSModule.java b/src/org/labkey/targetedms/TargetedMSModule.java index 573a74790..c532fed13 100644 --- a/src/org/labkey/targetedms/TargetedMSModule.java +++ b/src/org/labkey/targetedms/TargetedMSModule.java @@ -37,6 +37,7 @@ import org.labkey.api.module.Module; import org.labkey.api.module.ModuleContext; import org.labkey.api.module.ModuleHtmlView; +import org.labkey.api.module.ModuleLoader; import org.labkey.api.module.ModuleProperty; import org.labkey.api.module.SpringModule; import org.labkey.api.pipeline.PipelineService; @@ -654,6 +655,14 @@ protected void startupAfterSpringConfig(ModuleContext moduleContext) ActionURL chromatogramURL = new ActionURL(TargetedMSController.ChromatogramCrawlerAction.class, ContainerManager.getRoot()); AdminConsole.addLink(AdminConsole.SettingsLinkType.Premium, "Targeted MS Chromatogram Crawler", chromatogramURL, ApplicationAdminPermission.class); + // Only offer the guest-access setting on servers where the PanoramaPublic module is active (e.g. PanoramaWeb), + // Use the "panoramapublic" string (not the module class) to avoid a dependency on that module. + if (ModuleLoader.getInstance().hasModule("panoramapublic")) + { + ActionURL guestAccessURL = new ActionURL(TargetedMSController.GuestAccessSettingsAction.class, ContainerManager.getRoot()); + AdminConsole.addLink(AdminConsole.SettingsLinkType.Premium, "Targeted MS Guest Access", guestAccessURL, ApplicationAdminPermission.class); + } + FileContentService fcs = FileContentService.get(); if(null != fcs) { diff --git a/src/org/labkey/targetedms/view/guestAccessSettings.jsp b/src/org/labkey/targetedms/view/guestAccessSettings.jsp new file mode 100644 index 000000000..c5f3b1fc2 --- /dev/null +++ b/src/org/labkey/targetedms/view/guestAccessSettings.jsp @@ -0,0 +1,83 @@ +<% +/* + * Copyright (c) 2026 LabKey Corporation + * + * 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. + */ +%> +<%@ page import="org.labkey.api.view.HttpView" %> +<%@ page import="org.labkey.targetedms.TargetedMSController" %> +<%@ page import="org.labkey.targetedms.TargetedMSController.GuestAccessActionState" %> +<%@ page import="org.labkey.targetedms.TargetedMSController.GuestAccessSettingsBean" %> +<%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib" %> +<%@ page extends="org.labkey.api.jsp.JspBase" %> +<% + GuestAccessSettingsBean bean = (GuestAccessSettingsBean) HttpView.currentView().getModelBean(); +%> +
+

+ Use this to require a login on some Panorama pages when a lot of bot traffic is hitting public + data. When the master switch below is on, each checked page requires a logged-in user, and a + guest is sent to the login page. When the master switch is off (the default), all pages + work normally and stay open to guests on public data. Leave this off during normal operation. +

+

+ This does not change the pages that always require a login for guests. +

+ + +
+ +
+ + + <% for (GuestAccessActionState state : bean.getActions()) { %> + + + + <% } %> +
+ +
+ +
+ <%=button("Save").submit(true)%> +
+
+ + diff --git a/test/src/org/labkey/test/tests/targetedms/TargetedMSGuestAccessTest.java b/test/src/org/labkey/test/tests/targetedms/TargetedMSGuestAccessTest.java new file mode 100644 index 000000000..95892d9aa --- /dev/null +++ b/test/src/org/labkey/test/tests/targetedms/TargetedMSGuestAccessTest.java @@ -0,0 +1,302 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * 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.labkey.test.tests.targetedms; + +import org.junit.After; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.labkey.remoteapi.query.SelectRowsCommand; +import org.labkey.remoteapi.query.SelectRowsResponse; +import org.labkey.test.BaseWebDriverTest; +import org.labkey.test.Locator; +import org.labkey.test.WebTestHelper; +import org.labkey.test.util.ApiPermissionsHelper; +import org.labkey.test.util.LogMethod; +import org.openqa.selenium.WebElement; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.labkey.test.util.PermissionsHelper.READER_ROLE; + +/** + * Tests the site-admin "Targeted MS Guest Access" setting (GuestAccessSettingsAction). + * + * With the master switch off (the default), guests can view the slow pages on public data as before. + * With the master switch on, each checked action requires a login. Unchecking an action lets guests use + * it again. + * + * Two kinds of action are covered, and they signal a blocked guest differently: + * - Detail pages (showProtein, showPeptide) show an inline login view at the same URL, whose text is + * "Login to view this data". + * - Chart-image endpoints (precursorChromatogramChart) cannot return that HTML, so they redirect a + * blocked guest to the standard login page. A blocked guest lands on login.view (the gate runs before + * the id is looked up), while an allowed guest renders the real chart at the same URL. + * + * This does not change the pages that always require a login for guests (e.g. the precursor + * "all chromatograms" page), which stay blocked no matter what the switch is set to. + */ +@Category({}) +@BaseWebDriverTest.ClassTimeout(minutes = 3) +public class TargetedMSGuestAccessTest extends TargetedMSTest +{ + private static final String SKY_FILE = "MRMer.zip"; + private static final String TARGET_PROTEIN = "YAL038W"; + private static final String TARGET_PEPTIDE = "LTSLNVVAGSDLR"; + private static final String LOGIN_MESSAGE = "Login to view this data"; + + // Action keys as stored/posted by the settings page (must match the GuestAccessManager enum names). + private static final String SHOW_PROTEIN = "showProtein"; + private static final String SHOW_PEPTIDE = "showPeptide"; + private static final String SHOW_PRECURSOR_LIST = "showPrecursorList"; + private static final String PRECURSOR_CHROMATOGRAM_CHART = "precursorChromatogramChart"; + + private static final Locator MASTER_SWITCH = Locator.id("tms-require-login-master"); + + // Captured during setup and read from the @Test method. + private static String proteinUrl; // showProtein detail page + private static String peptideUrl; // showPeptide detail page + private static String chartUrl; // precursorChromatogramChart image endpoint + private static String requiresLoginUrl; // PrecursorAllChromatogramsChartAction, always blocked for guests + private static String precursorListUrl; // showPrecursorList HTML page (a QueryView action) + private static String precursorListExportUrl; // its TSV export URL (dispatched before the HTML view) + + // The site-wide Guest Access state as it was before this test, so the @After restore can put it back + // exactly (these are root-container properties shared across the whole server, not scoped to this test's + // folder). Restored in @After rather than doCleanup so it runs even when project cleanup is skipped + // (clean=false). + private static boolean origMasterEnabled; + private static List origCheckedKeys; + + @Override + protected String getProjectName() + { + return getClass().getSimpleName() + " Project"; + } + + @BeforeClass + public static void initProject() throws Exception + { + TargetedMSGuestAccessTest init = getCurrentTest(); + init.doSetup(); + } + + @LogMethod + private void doSetup() throws Exception + { + // Remember the site-wide Guest Access settings before touching anything, so they can be restored after the test. + captureOriginalGuestAccessSettings(); + + setupFolder(FolderType.Experiment); + importData(SKY_FILE); + + // Capture the URLs we will later request as a guest. + goToDashboard(); + clickAndWait(Locator.linkWithText(SKY_FILE)); + + // The document's default page is the precursor list (showPrecursorList, a QueryView action). Capture + // it and its TSV export URL. + precursorListUrl = getCurrentRelativeURL(); + precursorListExportUrl = precursorListUrl + (precursorListUrl.contains("?") ? "&" : "?") + "exportType=exportRowsTsv"; + + clickAndWait(Locator.linkWithText(TARGET_PROTEIN)); + proteinUrl = getCurrentRelativeURL(); + + clickAndWait(Locator.linkWithText(TARGET_PEPTIDE)); + peptideUrl = getCurrentRelativeURL(); + + // Look up a real precursorChromInfoId from the database and build the chart URL from it. A real id + // renders an actual chart in the master-off "allowed" case; the gate still runs before the id lookup, + // so the master-on case redirects to login regardless of the id. + SelectRowsCommand chromInfoQuery = new SelectRowsCommand("targetedms", "PrecursorChromInfo"); + chromInfoQuery.setColumns(List.of("Id")); + chromInfoQuery.setMaxRows(1); + SelectRowsResponse chromInfoRows = chromInfoQuery.execute(createDefaultConnection(), getCurrentContainerPath()); + assertFalse("Expected at least one PrecursorChromInfo row", chromInfoRows.getRows().isEmpty()); + Number chromInfoId = (Number) chromInfoRows.getRows().getFirst().get("Id"); + chartUrl = WebTestHelper.buildURL("targetedms", getCurrentContainerPath(), PRECURSOR_CHROMATOGRAM_CHART, + Map.of("id", String.valueOf(chromInfoId.longValue()))); + + // Peptide -> precursor "all chromatograms" page, which always requires a login for guests. + clickAndWait(Locator.linkWithImage("TransitionGroupLib.png")); + requiresLoginUrl = getCurrentRelativeURL(); + + // Make the data public so a guest has read access. The guest-access gate is what we are testing, + // not the underlying read permission. + goToProjectHome(getProjectName()); + new ApiPermissionsHelper(this).setSiteGroupPermissions("Guests", READER_ROLE); + } + + /** Read the current site-wide Guest Access state from the settings page (must be signed in as site admin). */ + private void captureOriginalGuestAccessSettings() + { + beginAt(WebTestHelper.buildURL("targetedms", "/", "guestAccessSettings")); + origMasterEnabled = MASTER_SWITCH.findElement(getDriver()).isSelected(); + origCheckedKeys = new ArrayList<>(); + for (WebElement box : Locator.tagWithClass("input", "tms-require-login-action").findElements(getDriver())) + { + if (box.isSelected()) + origCheckedKeys.add(box.getAttribute("value")); + } + } + + @After + public void restoreGuestAccessSettings() + { + // Put the site-wide Guest Access settings back the way the test found them. This runs in @After, + // not doCleanup, so it still happens when project cleanup is skipped (clean=false). + if (origCheckedKeys == null) + return; // setup did not get far enough to capture the originals + + // The test signs out at the end, and the settings page requires a site admin, so sign back in first. + ensureSignedInAsPrimaryTestUser(); + + // The per-action checkboxes only post while the master switch is on, so re-apply the original + // selections with it on, then set the master switch back to its original value. + saveGuestAccessSettings(true, origCheckedKeys.toArray(new String[0])); + if (!origMasterEnabled) + saveGuestAccessSettings(false); + } + + @Test + public void testGuestAccessToggle() + { + // 1. Master OFF: establish the baseline explicitly rather than trusting the ambient site-wide state. + // The master switch is a root-container property shared across the whole server, so a prior aborted + // run could leave it on; set it off here (we are still signed in as the site admin) so the "guests + // can view" checks below are deterministic. Guests can then view the pages and the chart, while the + // always-on page stays blocked. + saveGuestAccessSettings(false); + signOut(); + assertGuestAllowedPage(proteinUrl, TARGET_PROTEIN); + assertGuestAllowedPage(peptideUrl, TARGET_PEPTIDE); + assertGuestChartAllowed(chartUrl); + assertGuestPageBlocked(requiresLoginUrl); + + // 2. Master ON, gate the two pages and the chart endpoint: guests are sent to login on all three. + signIn(); + saveGuestAccessSettings(true, SHOW_PROTEIN, SHOW_PEPTIDE, PRECURSOR_CHROMATOGRAM_CHART); + signOut(); + assertGuestPageBlocked(proteinUrl); + assertGuestPageBlocked(peptideUrl); + assertGuestChartBlocked(chartUrl); + assertGuestPageBlocked(requiresLoginUrl); + + // 3. Gate only showPeptide: showProtein and the chart open back up, showPeptide stays blocked. + signIn(); + saveGuestAccessSettings(true, SHOW_PEPTIDE); + signOut(); + assertGuestAllowedPage(proteinUrl, TARGET_PROTEIN); + assertGuestChartAllowed(chartUrl); + assertGuestPageBlocked(peptideUrl); + assertGuestPageBlocked(requiresLoginUrl); // always-on page stays blocked regardless of the switch + + // 4. Master OFF again: back to fully open. + signIn(); + saveGuestAccessSettings(false); + signOut(); + assertGuestAllowedPage(proteinUrl, TARGET_PROTEIN); + assertGuestAllowedPage(peptideUrl, TARGET_PEPTIDE); + assertGuestChartAllowed(chartUrl); + + // 5. A QueryView action's export URL is a separate direct request, not a link reached through the HTML + // page: showPrecursorList.view?...&exportType=exportRowsTsv serves a TSV. QueryViewAction.getView + // dispatches that export branch before it would reach getHtmlView, so a client requesting the export + // URL directly (e.g. a crawler replaying or constructing it) bypasses a gate placed in getHtmlView. + // Gating showPrecursorList in getView closes that: a guest is sent to login for both the page and a + // direct export request. + assertGuestAllowedPage(precursorListUrl, TARGET_PROTEIN); + assertGuestNotLoginGated(precursorListExportUrl); + signIn(); + saveGuestAccessSettings(true, SHOW_PRECURSOR_LIST); + signOut(); + assertGuestPageBlocked(precursorListUrl); + assertGuestPageBlocked(precursorListExportUrl); + } + + /** + * Set the master switch and, when on, gate exactly the given action keys (any others are unchecked). + * Must be signed in as a site admin. The per-action checkboxes are disabled by the page's script until + * the master switch is on, so set the master switch first. + */ + @LogMethod + private void saveGuestAccessSettings(boolean masterEnabled, String... checkedActionKeys) + { + beginAt(WebTestHelper.buildURL("targetedms", "/", "guestAccessSettings")); + setCheckbox(MASTER_SWITCH, masterEnabled); + if (masterEnabled) + { + // Start from a known state: uncheck every action, then check just the requested ones. + for (WebElement box : Locator.tagWithClass("input", "tms-require-login-action").findElements(getDriver())) + { + if (box.isSelected()) + box.click(); + } + for (String key : checkedActionKeys) + setCheckbox(Locator.checkboxByNameAndValue("restrictedActions", key), true); + } + clickButton("Save"); + } + + /** A detail page a guest can view: no login view, and the expected content actually rendered. */ + private void assertGuestAllowedPage(String url, String expectedContent) + { + beginAt(url); + // The login view renders "Login" as a link, so the phrase is split by a tag in the HTML source. + // Check the rendered body text (as TargetedMSExperimentTest.verifyNoGuestAccessMessage does). + assertFalse("Guest was unexpectedly shown the login view at " + url, getBodyText().contains(LOGIN_MESSAGE)); + assertTextPresent(expectedContent); + } + + /** A detail page blocked for a guest: the inline login view is shown at the same URL. */ + private void assertGuestPageBlocked(String url) + { + beginAt(url); + assertTrue("Expected the guest login view at " + url, getBodyText().contains(LOGIN_MESSAGE)); + } + + /** A URL a guest is not login-gated on: the login view is not shown (the response may be data or not-found). */ + private void assertGuestNotLoginGated(String url) + { + beginAt(url); + assertFalse("Guest was unexpectedly shown the login view at " + url, getBodyText().contains(LOGIN_MESSAGE)); + } + + /** A chart-image endpoint a guest can reach: an actual chart image is served, not a login redirect. */ + private void assertGuestChartAllowed(String url) + { + beginAt(url); + assertFalse("Guest was unexpectedly redirected to login for " + url, + getCurrentRelativeURL().contains("login.view")); + // Confirm a real chart image was served (not a not-found page). This also verifies the chart id is + // valid for this folder: a wrong id would render an HTML not-found page instead of an image. + String contentType = (String) executeScript("return document.contentType;"); + assertTrue("Expected a chart image at " + url + " but got content type " + contentType, + contentType != null && contentType.startsWith("image/")); + } + + /** A chart-image endpoint blocked for a guest: redirected to the standard login page. */ + private void assertGuestChartBlocked(String url) + { + beginAt(url); + assertTrue("Expected a redirect to the login page for " + url + ", but landed on " + getCurrentRelativeURL(), + getCurrentRelativeURL().contains("login.view")); + } +}