package identityprovider import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" "vpn-platform/backend/internal/store" ) func TestQueryTenantUsesAppCredentialsWithoutPersistingOrLeaking(t *testing.T) { dir := t.TempDir() t.Setenv("VPN_DATA_DIR", dir) if err := store.Init(dir); err != nil { t.Fatal(err) } previous := upstreamClient defer func() { upstreamClient = previous }() upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) { response := `{"code":0,"tenant_access_token":"temporary-token"}` switch req.URL.String() { case feishuTenantToken: if req.Method != http.MethodPost || req.Header.Get("Content-Type") != "application/json" { t.Fatal("invalid token request") } var creds tenantCredentials if json.NewDecoder(req.Body).Decode(&creds) != nil || creds.AppID != "cli_test" || creds.AppSecret != "private-value" { t.Fatal("invalid credentials") } case feishuTenantQuery: if req.Method != http.MethodGet || req.Header.Get("Authorization") != "Bearer temporary-token" { t.Fatal("invalid tenant request") } response = `{"code":0,"data":{"tenant":{"tenant_key":"tenant_123","name":"Example"}}}` default: t.Fatalf("unexpected endpoint: %s", req.URL) } return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(response)), Header: make(http.Header)}, nil })} gin.SetMode(gin.TestMode) server := gin.New() p := New() server.POST("/tenant", p.QueryTenant) server.PUT("/config", p.Configure) query := []byte(`{"app_id":"cli_test","app_secret":"private-value"}`) request := httptest.NewRequest(http.MethodPost, "/tenant", bytes.NewReader(query)) request.RemoteAddr = "127.0.0.1:12345" request.Header.Set("X-Forwarded-Proto", "https") resp := httptest.NewRecorder() server.ServeHTTP(resp, request) if resp.Code != 200 || !strings.Contains(resp.Body.String(), `"tenant_key":"tenant_123"`) || strings.Contains(resp.Body.String(), "temporary-token") || strings.Contains(resp.Body.String(), "private-value") { t.Fatalf("unexpected query response: %d %s", resp.Code, resp.Body.String()) } if _, err := current(); err == nil { t.Fatal("query unexpectedly persisted configuration") } request = httptest.NewRequest(http.MethodPut, "/config", strings.NewReader(`{"app_id":"cli_test","app_secret":"private-value","enabled":false}`)) request.RemoteAddr = "127.0.0.1:12345" request.Header.Set("X-Forwarded-Proto", "https") resp = httptest.NewRecorder() server.ServeHTTP(resp, request) if resp.Code != 200 { t.Fatalf("save failed: %d %s", resp.Code, resp.Body.String()) } request = httptest.NewRequest(http.MethodPost, "/tenant", strings.NewReader(`{"app_id":"cli_test"}`)) request.RemoteAddr = "127.0.0.1:12345" request.Header.Set("X-Forwarded-Proto", "https") resp = httptest.NewRecorder() server.ServeHTTP(resp, request) if resp.Code != 200 { t.Fatalf("saved credentials not reused: %d %s", resp.Code, resp.Body.String()) } request = httptest.NewRequest(http.MethodPut, "/config", strings.NewReader(`{"app_id":"cli_other","tenant_key":"tenant_123","enabled":false}`)) request.RemoteAddr = "127.0.0.1:12345" request.Header.Set("X-Forwarded-Proto", "https") resp = httptest.NewRecorder() server.ServeHTTP(resp, request) if resp.Code != 400 { t.Fatalf("new app reused old secret: %d %s", resp.Code, resp.Body.String()) } cfg, err := current() if err != nil || cfg.AppID != "cli_test" { t.Fatal("rejected app change modified saved configuration") } request = httptest.NewRequest(http.MethodPost, "/tenant", bytes.NewReader(query)) resp = httptest.NewRecorder() server.ServeHTTP(resp, request) if resp.Code != 403 { t.Fatalf("plaintext request accepted: %d", resp.Code) } } func TestQueryTenantRejectsWrongAppAndUpstreamFailure(t *testing.T) { dir := t.TempDir() t.Setenv("VPN_DATA_DIR", dir) if err := store.Init(dir); err != nil { t.Fatal(err) } secret, err := store.EncryptSecret("private-value") if err != nil { t.Fatal(err) } if _, err := resolveTenantCredentials(tenantCredentials{AppID: "cli_other"}, "cli_test", secret); err == nil { t.Fatal("old secret reused for another app") } creds, err := resolveTenantCredentials(tenantCredentials{AppID: "cli_test"}, "cli_test", secret) if err != nil || creds.AppSecret != "private-value" { t.Fatal("saved secret not reused for same app") } previous := upstreamClient defer func() { upstreamClient = previous }() upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) { return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":999,"msg":"private-value"}`)), Header: make(http.Header)}, nil })} _, _, err = queryFeishuTenant(httptest.NewRequest(http.MethodGet, "/", nil).Context(), creds) if err == nil || strings.Contains(err.Error(), "private-value") { t.Fatalf("unsafe upstream error: %v", err) } } func TestTenantErrorsIdentifyStageWithoutLeakingFeishuMessage(t *testing.T) { previous := upstreamClient defer func() { upstreamClient = previous }() for _, tc := range []struct { name, endpoint, payload, want string status int }{ {"tenant denied", feishuTenantQuery, `{"code":99991672,"msg":"secret in upstream error"}`, "99991672", 403}, {"unexpected tenant endpoint", feishuTenantQuery, `not json`, "HTTP 404", 404}, {"token denied", feishuTenantToken, `{"code":10003,"msg":"secret in upstream error"}`, "10003", 401}, } { t.Run(tc.name, func(t *testing.T) { upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) { body := `{"code":0,"tenant_access_token":"temporary-token"}` status := 200 if req.URL.String() == tc.endpoint { body, status = tc.payload, tc.status } return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil })} _, _, err := queryFeishuTenant(context.Background(), tenantCredentials{AppID: "cli_test", AppSecret: "private-value"}) if err == nil || !strings.Contains(err.Error(), tc.want) || strings.Contains(err.Error(), "secret in upstream error") || strings.Contains(err.Error(), "private-value") { t.Fatalf("unsafe or unhelpful error: %v", err) } }) } } func TestAdminNeverEchoesFeishuSecretAndKeepsEncryptedValue(t *testing.T) { dir := t.TempDir() t.Setenv("VPN_DATA_DIR", dir) if err := store.Init(dir); err != nil { t.Fatal(err) } gin.SetMode(gin.TestMode) server := gin.New() p := New() server.PUT("/config", func(c *gin.Context) { c.Set("username", "admin"); p.Configure(c) }) server.GET("/config", p.Status) body := []byte(`{"app_id":"cli_test","app_secret":"feishu-private-value","tenant_key":"tenant_1","enabled":false}`) resp := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPut, "/config", bytes.NewReader(body)) request.RemoteAddr = "127.0.0.1:12345" request.Header.Set("X-Forwarded-Proto", "https") server.ServeHTTP(resp, request) if resp.Code != 200 || strings.Contains(resp.Body.String(), "feishu-private-value") { t.Fatalf("unsafe response: %d %s", resp.Code, resp.Body.String()) } rejected := httptest.NewRecorder() server.ServeHTTP(rejected, httptest.NewRequest(http.MethodPut, "/config", bytes.NewReader(body))) if rejected.Code != http.StatusForbidden { t.Fatal("plaintext management request accepted credentials") } cfg, err := current() if err != nil || cfg.AppSecret == "feishu-private-value" { t.Fatal("app secret stored in plaintext") } plain, err := store.DecryptSecret(cfg.AppSecret) if err != nil || plain != "feishu-private-value" { t.Fatal("app secret not recoverable") } var saved map[string]any if json.Unmarshal(resp.Body.Bytes(), &saved) != nil || saved["client_id"] == "" || saved["client_secret"] != nil { t.Fatal("client secret must remain server-side") } resp = httptest.NewRecorder() server.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/config", nil)) if resp.Code != 200 || strings.Contains(resp.Body.String(), "feishu-private-value") || strings.Contains(resp.Body.String(), cfg.ClientSecret) { t.Fatal("secret leaked from status") } }