From b6b5a3de7211855f0983256e1368130b72c2f135 Mon Sep 17 00:00:00 2001 From: Hiroki Tokunaga Date: Thu, 5 Nov 2026 19:10:23 +0900 Subject: [PATCH 1/2] refactor(common): decouple ConfigHttpServer from Swing dialogs ConfigHttpServer lives in packetproxy.common, yet it drove GUIMain and JOptionPane directly to confirm a remote config overwrite, and wrote the received config into the database inline. That tied the HTTP handler to Swing and made it impossible to exercise without a running GUI. Move the confirmation behind a ConfigHttpUiActions interface, implemented by SwingConfigHttpUiActions in packetproxy.gui, and move the database write into ConfigSettingsWriter. GUIOptionHubServer injects both through the constructor. The overwrite decision is unchanged: only NO_OPTION rejects. The window still stays always-on-top from selecting the Options tab until the dialog closes. The reset now sits in a finally block, so an exception while the dialog is up no longer leaves the window pinned on top. Co-authored-by: Cursor Co-Authored-By: Claude Opus 5.5 Co-authored-by: Cursor --- .../packetproxy/common/ConfigHttpServer.java | 43 ++++------------- .../common/ConfigHttpUiActions.java | 27 +++++++++++ .../common/ConfigSettingsWriter.java | 46 +++++++++++++++++++ .../packetproxy/gui/GUIOptionHubServer.java | 4 +- .../gui/SwingConfigHttpUiActions.java | 46 +++++++++++++++++++ 5 files changed, 130 insertions(+), 36 deletions(-) create mode 100644 src/main/java/core/packetproxy/common/ConfigHttpUiActions.java create mode 100644 src/main/java/core/packetproxy/common/ConfigSettingsWriter.java create mode 100644 src/main/java/core/packetproxy/gui/SwingConfigHttpUiActions.java diff --git a/src/main/java/core/packetproxy/common/ConfigHttpServer.java b/src/main/java/core/packetproxy/common/ConfigHttpServer.java index b2932d91..46dedabe 100644 --- a/src/main/java/core/packetproxy/common/ConfigHttpServer.java +++ b/src/main/java/core/packetproxy/common/ConfigHttpServer.java @@ -6,17 +6,20 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import javax.swing.*; -import packetproxy.gui.GUIMain; import packetproxy.model.*; public class ConfigHttpServer extends NanoHTTPD { private final String allowedAccessToken; + private final ConfigHttpUiActions uiActions; + private final ConfigSettingsWriter settingsWriter; - public ConfigHttpServer(String hostname, int port, String allowedAccessToken) { + public ConfigHttpServer(String hostname, int port, String allowedAccessToken, ConfigHttpUiActions uiActions, + ConfigSettingsWriter settingsWriter) { super(hostname, port); this.allowedAccessToken = allowedAccessToken; + this.uiActions = uiActions; + this.settingsWriter = settingsWriter; } private void fixUpServerList(Map serverMap, List serverList) { @@ -117,18 +120,7 @@ public Response serve(IHTTPSession session) { try { - GUIMain.getInstance().setAlwaysOnTop(true); - GUIMain.getInstance().setVisible(true); - - GUIMain.getInstance().getTabbedPane().setSelectedIndex(GUIMain.Panes.OPTIONS.ordinal()); - - int option = JOptionPane.showConfirmDialog(GUIMain.getInstance(), - I18nString.get("Do you want to overwrite config?"), I18nString.get("Loading config"), - JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE); - - GUIMain.getInstance().setAlwaysOnTop(false); - - if (option != JOptionPane.YES_OPTION) { + if (!uiActions.confirmOverwriteConfig()) { return NanoHTTPD.newFixedLengthResponse(Response.Status.UNAUTHORIZED, MIME_HTML, null); } @@ -137,26 +129,7 @@ public Response serve(IHTTPSession session) { session.parseBody(map); String json = map.get("postData"); - ConfigDaoHub daoHub = new Gson().fromJson(json, ConfigDaoHub.class); - - Database.getInstance().dropConfigs(); - - for (ListenPort listenPort : daoHub.listenPortList) { - - ListenPorts.getInstance().create(listenPort); - } - for (Server server : daoHub.serverList) { - - Servers.getInstance().create(server); - } - for (Modification mod : daoHub.modificationList) { - - Modifications.getInstance().create(mod); - } - for (SSLPassThrough passThrough : daoHub.sslPassThroughList) { - - SSLPassThroughs.getInstance().create(passThrough); - } + settingsWriter.applyFromJson(json); Response res = NanoHTTPD.newFixedLengthResponse(Response.Status.OK, "application/json", "{\"status\": \"ok\"}"); diff --git a/src/main/java/core/packetproxy/common/ConfigHttpUiActions.java b/src/main/java/core/packetproxy/common/ConfigHttpUiActions.java new file mode 100644 index 00000000..41253062 --- /dev/null +++ b/src/main/java/core/packetproxy/common/ConfigHttpUiActions.java @@ -0,0 +1,27 @@ +/* + * Copyright 2026 DeNA Co., Ltd. + * + * 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 packetproxy.common; + +/** リモート設定 HTTP API から必要な GUI 操作。 */ +public interface ConfigHttpUiActions { + + /** + * 設定タブを表示し、設定を上書きしてよいかユーザーに確認する。 + * + * @return 上書きを許可する場合 true + */ + boolean confirmOverwriteConfig(); +} diff --git a/src/main/java/core/packetproxy/common/ConfigSettingsWriter.java b/src/main/java/core/packetproxy/common/ConfigSettingsWriter.java new file mode 100644 index 00000000..7ab499bd --- /dev/null +++ b/src/main/java/core/packetproxy/common/ConfigSettingsWriter.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026 DeNA Co., Ltd. + * + * 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 packetproxy.common; + +import com.google.gson.Gson; +import packetproxy.model.*; + +/** リモート設定 JSON を DB に反映する。 */ +public class ConfigSettingsWriter { + + public void applyFromJson(String json) throws Exception { + var daoHub = new Gson().fromJson(json, ConfigDaoHub.class); + + Database.getInstance().dropConfigs(); + + for (ListenPort listenPort : daoHub.listenPortList) { + + ListenPorts.getInstance().create(listenPort); + } + for (Server server : daoHub.serverList) { + + Servers.getInstance().create(server); + } + for (Modification mod : daoHub.modificationList) { + + Modifications.getInstance().create(mod); + } + for (SSLPassThrough passThrough : daoHub.sslPassThroughList) { + + SSLPassThroughs.getInstance().create(passThrough); + } + } +} diff --git a/src/main/java/core/packetproxy/gui/GUIOptionHubServer.java b/src/main/java/core/packetproxy/gui/GUIOptionHubServer.java index eee56367..b009122c 100644 --- a/src/main/java/core/packetproxy/gui/GUIOptionHubServer.java +++ b/src/main/java/core/packetproxy/gui/GUIOptionHubServer.java @@ -11,6 +11,7 @@ import javax.swing.*; import org.apache.commons.lang3.RandomStringUtils; import packetproxy.common.ConfigHttpServer; +import packetproxy.common.ConfigSettingsWriter; import packetproxy.common.I18nString; import packetproxy.model.ConfigBoolean; import packetproxy.model.ConfigString; @@ -104,7 +105,8 @@ private void stopServer() { private void startServer() throws Exception { String accessToken = new ConfigString("SharingConfigsAccessToken").getString(); - this.server = new ConfigHttpServer("localhost", 32349, accessToken); + this.server = new ConfigHttpServer("localhost", 32349, accessToken, new SwingConfigHttpUiActions(), + new ConfigSettingsWriter()); this.server.start(); } diff --git a/src/main/java/core/packetproxy/gui/SwingConfigHttpUiActions.java b/src/main/java/core/packetproxy/gui/SwingConfigHttpUiActions.java new file mode 100644 index 00000000..f8ec4d8e --- /dev/null +++ b/src/main/java/core/packetproxy/gui/SwingConfigHttpUiActions.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026 DeNA Co., Ltd. + * + * 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 packetproxy.gui; + +import javax.swing.JOptionPane; +import packetproxy.common.ConfigHttpUiActions; +import packetproxy.common.I18nString; + +public class SwingConfigHttpUiActions implements ConfigHttpUiActions { + + @Override + public boolean confirmOverwriteConfig() { + try { + return confirmOverwriteConfigInternal(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private boolean confirmOverwriteConfigInternal() throws Exception { + var main = GUIMain.getInstance(); + main.setAlwaysOnTop(true); + try { + main.setVisible(true); + main.getTabbedPane().setSelectedIndex(GUIMain.Panes.OPTIONS.ordinal()); + int option = JOptionPane.showConfirmDialog(main, I18nString.get("Do you want to overwrite config?"), + I18nString.get("Loading config"), JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE); + return option == JOptionPane.YES_OPTION; + } finally { + main.setAlwaysOnTop(false); + } + } +} From a5e0e562abd86279ca3aa0edbf5b12b72f975575 Mon Sep 17 00:00:00 2001 From: Hiroki Tokunaga Date: Thu, 5 Nov 2026 19:10:23 +0900 Subject: [PATCH 2/2] test(common): cover ConfigHttpServer auth and overwrite confirmation With the Swing dialog and the database write behind injected collaborators, ConfigHttpServer.serve() can be called directly with a mocked session. Pin down the paths that guard a remote config overwrite. A missing, empty or wrong access token returns 401 without touching the UI or the database. A declined confirmation returns 401 without writing. An accepted one passes postData to ConfigSettingsWriter and returns 200. Co-authored-by: Cursor Co-Authored-By: Claude Opus 5.5 Co-authored-by: Cursor --- .../common/ConfigHttpServerTest.java | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/test/java/packetproxy/common/ConfigHttpServerTest.java diff --git a/src/test/java/packetproxy/common/ConfigHttpServerTest.java b/src/test/java/packetproxy/common/ConfigHttpServerTest.java new file mode 100644 index 00000000..2bfd416e --- /dev/null +++ b/src/test/java/packetproxy/common/ConfigHttpServerTest.java @@ -0,0 +1,123 @@ +/* + * Copyright 2026 DeNA Co., Ltd. + * + * 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 packetproxy.common; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import fi.iki.elonen.NanoHTTPD.IHTTPSession; +import fi.iki.elonen.NanoHTTPD.Method; +import fi.iki.elonen.NanoHTTPD.Response; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +public class ConfigHttpServerTest { + + private static final String TOKEN = "secret-token"; + + private ConfigHttpUiActions uiActions; + private ConfigSettingsWriter settingsWriter; + private ConfigHttpServer server; + + @BeforeEach + public void setUp() { + uiActions = mock(ConfigHttpUiActions.class); + settingsWriter = mock(ConfigSettingsWriter.class); + server = new ConfigHttpServer("127.0.0.1", 0, TOKEN, uiActions, settingsWriter); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"wrong-token"}) + public void serve_rejectsUnauthorizedToken(String authorization) throws Exception { + var session = mockSession(Method.POST, "/config", authorization, null); + + var response = server.serve(session); + + assertEquals(Response.Status.UNAUTHORIZED, response.getStatus()); + verify(uiActions, never()).confirmOverwriteConfig(); + verify(settingsWriter, never()).applyFromJson(anyString()); + } + + @Test + public void serve_postConfig_rejectsWhenConfirmDenied() throws Exception { + when(uiActions.confirmOverwriteConfig()).thenReturn(false); + var session = mockSession(Method.POST, "/config", TOKEN, "{\"servers\":[]}"); + + var response = server.serve(session); + + assertEquals(Response.Status.UNAUTHORIZED, response.getStatus()); + verify(uiActions).confirmOverwriteConfig(); + verify(settingsWriter, never()).applyFromJson(anyString()); + } + + @Test + public void serve_postConfig_appliesJsonWhenConfirmAccepted() throws Exception { + when(uiActions.confirmOverwriteConfig()).thenReturn(true); + var postData = "{\"servers\":[]}"; + var session = mockSession(Method.POST, "/config", TOKEN, postData); + + var response = server.serve(session); + + assertEquals(Response.Status.OK, response.getStatus()); + assertEquals("application/json", response.getMimeType()); + assertEquals("{\"status\": \"ok\"}", readBody(response)); + verify(uiActions).confirmOverwriteConfig(); + verify(settingsWriter).applyFromJson(postData); + } + + private static IHTTPSession mockSession(Method method, String uri, String authorization, String postData) + throws Exception { + var session = mock(IHTTPSession.class); + when(session.getMethod()).thenReturn(method); + when(session.getUri()).thenReturn(uri); + Map headers = new HashMap<>(); + if (authorization != null) { + headers.put("authorization", authorization); + } + when(session.getHeaders()).thenReturn(Collections.unmodifiableMap(headers)); + if (postData != null) { + doAnswer(invocation -> { + Map map = invocation.getArgument(0); + map.put("postData", postData); + return null; + }).when(session).parseBody(any()); + } + return session; + } + + private static String readBody(Response response) throws Exception { + InputStream data = response.getData(); + if (data == null) { + return null; + } + return new String(data.readAllBytes(), StandardCharsets.UTF_8); + } +}