package contract_test import ( "bytes" "database/sql" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "go-backend/internal/auth" "go-backend/internal/http/response" ) func TestTunnelCreateWithIPPreferenceContract(t *testing.T) { secret := "contract-jwt-secret" router, repo := setupContractRouter(t, secret) now := time.Now().UnixMilli() adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) if err != nil { t.Fatalf("generate admin token: %v", err) } insertDualStackNode := func(name, v4, v6, portRange string) int64 { if err := 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) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, name, name+"-secret", v4, v4, v6, portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil { t.Fatalf("insert node %s: %v", name, err) } return mustLastInsertID(t, repo, name) } entryID := insertDualStackNode("ip-pref-entry", "10.50.0.1", "2001:db8::1", "50000-50010") exitID := insertDualStackNode("ip-pref-exit", "10.50.0.2", "2001:db8::2", "51000-51010") for _, tc := range []struct { name string preference string }{ {"v4-preference", "v4"}, {"v6-preference", "v6"}, {"empty-preference", ""}, } { t.Run(tc.name, func(t *testing.T) { payload := `{"name":"tunnel-` + tc.name + `","type":2,"flow":99999,"status":1,"ipPreference":"` + tc.preference + `","inNodeId":[{"nodeId":` + jsonInt(entryID) + `,"protocol":"tls"}],"chainNodes":[],"outNodeId":[{"nodeId":` + jsonInt(exitID) + `,"protocol":"tls"}]}` req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewBufferString(payload)) req.Header.Set("Authorization", adminToken) req.Header.Set("Content-Type", "application/json") res := httptest.NewRecorder() router.ServeHTTP(res, req) var out response.R if err := json.NewDecoder(res.Body).Decode(&out); err != nil { t.Fatalf("decode response: %v", err) } stored, err := tryQueryString(t, repo, `SELECT COALESCE(ip_preference, '') FROM tunnel WHERE name = ?`, "tunnel-"+tc.name) if err != nil { if err == sql.ErrNoRows { t.Skipf("tunnel not created (nodes offline), skipping DB verification") } t.Fatalf("query ip_preference: %v", err) } if stored != tc.preference { t.Fatalf("expected ip_preference=%q in DB, got %q", tc.preference, stored) } }) } } func TestTunnelUpdateIPPreferenceContract(t *testing.T) { secret := "contract-jwt-secret" router, repo := setupContractRouter(t, secret) now := time.Now().UnixMilli() adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) if err != nil { t.Fatalf("generate admin token: %v", err) } insertDualStackNode := func(name, v4, v6, portRange string) int64 { if err := 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) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, name, name+"-secret", v4, v4, v6, portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil { t.Fatalf("insert node %s: %v", name, err) } return mustLastInsertID(t, repo, name) } entryID := insertDualStackNode("upd-entry", "10.60.0.1", "2001:db8:1::1", "60000-60010") exitID := insertDualStackNode("upd-exit", "10.60.0.2", "2001:db8:1::2", "61000-61010") if err := repo.DB().Exec(` INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, "update-ip-pref-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0, "").Error; err != nil { t.Fatalf("insert tunnel: %v", err) } tunnelID := mustLastInsertID(t, repo, "update-ip-pref-tunnel") payload := `{"id":` + jsonInt(tunnelID) + `,"name":"update-ip-pref-tunnel","type":2,"flow":99999,"trafficRatio":1.0,"status":1,"ipPreference":"v6","inNodeId":[{"nodeId":` + jsonInt(entryID) + `,"protocol":"tls"}],"chainNodes":[],"outNodeId":[{"nodeId":` + jsonInt(exitID) + `,"protocol":"tls"}]}` req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewBufferString(payload)) req.Header.Set("Authorization", adminToken) req.Header.Set("Content-Type", "application/json") res := httptest.NewRecorder() router.ServeHTTP(res, req) var out response.R if err := json.NewDecoder(res.Body).Decode(&out); err != nil { t.Fatalf("decode response: %v", err) } stored := mustQueryString(t, repo, `SELECT COALESCE(ip_preference, '') FROM tunnel WHERE id = ?`, tunnelID) if stored != "v6" { t.Fatalf("expected ip_preference='v6' after update, got %q", stored) } } func TestTunnelListReturnsIPPreferenceContract(t *testing.T) { secret := "contract-jwt-secret" router, repo := setupContractRouter(t, secret) now := time.Now().UnixMilli() adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) if err != nil { t.Fatalf("generate admin token: %v", err) } err = repo.DB().Exec(` INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, "list-ip-pref-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0, "v6").Error if err != nil { t.Fatalf("insert tunnel: %v", err) } req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil) req.Header.Set("Authorization", adminToken) res := httptest.NewRecorder() router.ServeHTTP(res, req) var out response.R if err := json.NewDecoder(res.Body).Decode(&out); err != nil { t.Fatalf("decode response: %v", err) } if out.Code != 0 { t.Fatalf("expected code 0, got %d (msg=%s)", out.Code, out.Msg) } tunnels, ok := out.Data.([]interface{}) if !ok || len(tunnels) == 0 { t.Fatalf("expected non-empty tunnel list, got %v", out.Data) } found := false for _, raw := range tunnels { tm, ok := raw.(map[string]interface{}) if !ok { continue } if tm["name"] == "list-ip-pref-tunnel" { found = true pref, _ := tm["ipPreference"].(string) if pref != "v6" { t.Fatalf("expected ipPreference='v6' in list response, got %q", pref) } break } } if !found { t.Fatal("tunnel 'list-ip-pref-tunnel' not found in list response") } } func TestIPPreferenceColumnDefaultContract(t *testing.T) { _, repo := setupContractRouter(t, "contract-jwt-secret") now := time.Now().UnixMilli() if err := repo.DB().Exec(` INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, "no-pref-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil { t.Fatalf("insert tunnel without ip_preference: %v", err) } stored := mustQueryString(t, repo, `SELECT COALESCE(ip_preference, '') FROM tunnel WHERE name = ?`, "no-pref-tunnel") if stored != "" { t.Fatalf("expected default ip_preference='', got %q", stored) } } func TestIPPreferenceColumnMigrationContract(t *testing.T) { _, repo := setupContractRouter(t, "contract-jwt-secret") colCount := mustQueryInt(t, repo, `SELECT COUNT(*) FROM pragma_table_info('tunnel') WHERE name = 'ip_preference'`) if colCount != 1 { t.Fatalf("expected ip_preference column to exist in tunnel table, found %d", colCount) } } func TestIPPreferenceCoalesceNullSafety(t *testing.T) { _, repo := setupContractRouter(t, "contract-jwt-secret") now := time.Now().UnixMilli() if err := repo.DB().Exec(` INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL) `, "null-pref-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil { t.Skipf("DB does not allow NULL ip_preference (NOT NULL constraint): %v", err) } stored := mustQueryString(t, repo, `SELECT COALESCE(ip_preference, '') FROM tunnel WHERE name = ?`, "null-pref-tunnel") if stored != "" { t.Fatalf("COALESCE should convert NULL to empty string, got %q", stored) } } func TestDualStackNodeIPFieldsStoredContract(t *testing.T) { _, repo := setupContractRouter(t, "contract-jwt-secret") now := time.Now().UnixMilli() if err := 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) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, "ds-verify-node", "ds-secret", "10.70.0.1", "10.70.0.1", "2001:db8:2::1", "70000-70010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil { t.Fatalf("insert dual-stack node: %v", err) } v4, v6 := mustQueryTwoNullStrings(t, repo, `SELECT server_ip_v4, server_ip_v6 FROM node WHERE name = ?`, "ds-verify-node") if !v4.Valid || v4.String != "10.70.0.1" { t.Fatalf("expected server_ip_v4='10.70.0.1', got %v", v4) } if !v6.Valid || v6.String != "2001:db8:2::1" { t.Fatalf("expected server_ip_v6='2001:db8:2::1', got %v", v6) } } func TestIPPreferenceValidValuesContract(t *testing.T) { _, repo := setupContractRouter(t, "contract-jwt-secret") now := time.Now().UnixMilli() for _, pref := range []string{"", "v4", "v6"} { name := "valid-pref-" + pref if pref == "" { name = "valid-pref-empty" } if err := repo.DB().Exec(` INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, name, 1.0, 2, "tls", 99999, now, now, 1, nil, 0, pref).Error; err != nil { t.Fatalf("insert tunnel with ip_preference=%q: %v", pref, err) } stored := mustQueryString(t, repo, `SELECT COALESCE(ip_preference, '') FROM tunnel WHERE name = ?`, name) if stored != pref { t.Fatalf("expected ip_preference=%q, got %q for %s", pref, stored, name) } } }