diff --git a/go-backend/internal/http/handler/federation_runtime_test.go b/go-backend/internal/http/handler/federation_runtime_test.go index b29f758..17c1520 100644 --- a/go-backend/internal/http/handler/federation_runtime_test.go +++ b/go-backend/internal/http/handler/federation_runtime_test.go @@ -152,6 +152,71 @@ func TestPrepareTunnelCreateStateRemoteAutoPortDefersToFederation(t *testing.T) } } +func TestPrepareTunnelCreateStateAllowsOfflineRemoteMiddleNode(t *testing.T) { + repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db")) + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer repo.Close() + + h := &Handler{repo: repo} + now := time.Now().UnixMilli() + + insertNode := func(name string, status int, portRange string, isRemote int) int64 { + res, execErr := repo.DB().Exec(` + INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0, isRemote, "http://peer", "peer-token", `{"shareId":2}`) + if execErr != nil { + t.Fatalf("insert node %s: %v", name, execErr) + } + id, idErr := res.LastInsertId() + if idErr != nil { + t.Fatalf("node id %s: %v", name, idErr) + } + return id + } + + entryID := insertNode("entry-local", 1, "32000-32010", 0) + remoteMiddleID := insertNode("middle-remote", 0, "33000-33010", 1) + outID := insertNode("out-local", 1, "34000-34010", 0) + + tx, err := repo.DB().Begin() + if err != nil { + t.Fatalf("begin tx: %v", err) + } + defer tx.Rollback() + + req := map[string]interface{}{ + "name": "remote-middle-offline-status", + "inNodeId": []interface{}{ + map[string]interface{}{"nodeId": float64(entryID), "protocol": "tls", "strategy": "round"}, + }, + "chainNodes": []interface{}{ + []interface{}{ + map[string]interface{}{"nodeId": float64(remoteMiddleID), "protocol": "tls", "strategy": "round", "port": float64(0)}, + }, + }, + "outNodeId": []interface{}{ + map[string]interface{}{"nodeId": float64(outID), "protocol": "tls", "strategy": "round", "port": float64(0)}, + }, + } + + state, err := h.prepareTunnelCreateState(tx, req, 2, 0) + if err != nil { + t.Fatalf("prepare state should allow offline remote middle node: %v", err) + } + if len(state.ChainHops) != 1 || len(state.ChainHops[0]) != 1 { + t.Fatalf("expected one middle hop node, got %+v", state.ChainHops) + } + if state.ChainHops[0][0].NodeID != remoteMiddleID { + t.Fatalf("expected remote middle node id %d, got %d", remoteMiddleID, state.ChainHops[0][0].NodeID) + } + if state.Nodes[remoteMiddleID] == nil || state.Nodes[remoteMiddleID].IsRemote != 1 { + t.Fatalf("expected remote middle node metadata in state") + } +} + func TestFederationRuntimeReservePortRejectsWhenShareFlowExceeded(t *testing.T) { repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db")) if err != nil { diff --git a/go-backend/tests/contract/federation_dual_panel_contract_test.go b/go-backend/tests/contract/federation_dual_panel_contract_test.go index b3bece9..0959f65 100644 --- a/go-backend/tests/contract/federation_dual_panel_contract_test.go +++ b/go-backend/tests/contract/federation_dual_panel_contract_test.go @@ -78,6 +78,8 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) { middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-token") exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-token") + stopEntry := startMockNodeSession(t, providerServer.URL, "provider-entry-secret") + defer stopEntry() stopMiddle := startMockNodeSession(t, providerServer.URL, "provider-middle-secret") defer stopMiddle() stopExit := startMockNodeSession(t, providerServer.URL, "provider-exit-secret") @@ -148,6 +150,23 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) { assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 2, middleRemoteNodeID, 44000, 44010) assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 3, exitRemoteNodeID, 45000, 45010) + forwardPayload := map[string]interface{}{ + "name": "dual-panel-remote-entry-forward", + "tunnelId": secondTunnelID, + "remoteAddr": "1.1.1.1:443", + "strategy": "fifo", + } + forwardBody, err := json.Marshal(forwardPayload) + if err != nil { + t.Fatalf("marshal forward payload: %v", err) + } + forwardReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(forwardBody)) + forwardReq.Header.Set("Authorization", consumerAdminToken) + forwardReq.Header.Set("Content-Type", "application/json") + forwardRes := httptest.NewRecorder() + consumerRouter.ServeHTTP(forwardRes, forwardReq) + assertCode(t, forwardRes, 0) + assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, middleShareID, 1) assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, exitShareID, 1) assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, entryShareID, 0)