diff --git a/backend/internal/handler/admin/account_data.go b/backend/internal/handler/admin/account_data.go index 86c626b877..ea1cff840e 100644 --- a/backend/internal/handler/admin/account_data.go +++ b/backend/internal/handler/admin/account_data.go @@ -318,8 +318,8 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest) Protocol: proxy.Protocol, Host: proxy.Host, Port: proxy.Port, - Username: proxy.Username, - Password: proxy.Password, + Username: &proxy.Username, + Password: &proxy.Password, }) } } @@ -394,8 +394,8 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest) Protocol: created.Protocol, Host: created.Host, Port: created.Port, - Username: created.Username, - Password: created.Password, + Username: &created.Username, + Password: &created.Password, }) } } diff --git a/backend/internal/handler/admin/proxy_credentials_update_test.go b/backend/internal/handler/admin/proxy_credentials_update_test.go new file mode 100644 index 0000000000..6f60b4d72c --- /dev/null +++ b/backend/internal/handler/admin/proxy_credentials_update_test.go @@ -0,0 +1,41 @@ +//go:build unit + +package admin + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestProxyHandlerUpdateCredentialPresence(t *testing.T) { + gin.SetMode(gin.TestMode) + check := func(name, body string, username, password *string) { + t.Helper() + t.Run(name, func(t *testing.T) { + svc := &proxyPartialUpdateService{} + router := gin.New() + router.PUT("/proxies/:id", NewProxyHandler(svc).Update) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/proxies/9", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.NotNil(t, svc.input) + require.Equal(t, username, svc.input.Username) + require.Equal(t, password, svc.input.Password) + }) + } + empty, username, password := "", "user", "pass" + check("omitted", `{"status":"inactive"}`, nil, nil) + check("null preserves", `{"username":null,"password":null}`, nil, nil) + check("clear both", `{"username":"","password":""}`, &empty, &empty) + check("clear username only", `{"username":""}`, &empty, nil) + check("clear password only", `{"password":""}`, nil, &empty) + check("trim values", `{"username":" user ","password":" pass "}`, &username, &password) + check("whitespace clears", `{"username":" ","password":" "}`, &empty, &empty) +} diff --git a/backend/internal/handler/admin/proxy_data.go b/backend/internal/handler/admin/proxy_data.go index ea59e02ff4..5de17cf00f 100644 --- a/backend/internal/handler/admin/proxy_data.go +++ b/backend/internal/handler/admin/proxy_data.go @@ -182,8 +182,8 @@ func (h *ProxyHandler) ImportData(c *gin.Context) { Protocol: existing.Protocol, Host: existing.Host, Port: existing.Port, - Username: existing.Username, - Password: existing.Password, + Username: &existing.Username, + Password: &existing.Password, } if _, err := h.adminService.UpdateProxy(ctx, existing.ID, updateInput); err != nil { result.Errors = append(result.Errors, DataImportError{ @@ -266,8 +266,8 @@ func (h *ProxyHandler) ImportData(c *gin.Context) { Protocol: created.Protocol, Host: created.Host, Port: created.Port, - Username: created.Username, - Password: created.Password, + Username: &created.Username, + Password: &created.Password, }); err != nil { result.Errors = append(result.Errors, DataImportError{ Kind: "proxy", diff --git a/backend/internal/handler/admin/proxy_handler.go b/backend/internal/handler/admin/proxy_handler.go index 6c1961d944..3902eaf2f8 100644 --- a/backend/internal/handler/admin/proxy_handler.go +++ b/backend/internal/handler/admin/proxy_handler.go @@ -45,8 +45,8 @@ type UpdateProxyRequest struct { Protocol string `json:"protocol" binding:"omitempty,oneof=http https socks5 socks5h"` Host string `json:"host"` Port int `json:"port" binding:"omitempty,min=1,max=65535"` - Username string `json:"username"` - Password string `json:"password"` + Username *string `json:"username"` + Password *string `json:"password"` Status string `json:"status" binding:"omitempty,oneof=active inactive"` ExpiresAt dto.NullableInt64Field `json:"expires_at"` FallbackMode string `json:"fallback_mode" binding:"omitempty,oneof=none proxy direct"` @@ -187,13 +187,19 @@ func (h *ProxyHandler) Update(c *gin.Context) { t := time.Unix(*req.ExpiresAt.Value, 0).UTC() expiresAt = &t } + if req.Username != nil { + *req.Username = strings.TrimSpace(*req.Username) + } + if req.Password != nil { + *req.Password = strings.TrimSpace(*req.Password) + } proxy, err := h.adminService.UpdateProxy(c.Request.Context(), proxyID, &service.UpdateProxyInput{ Name: strings.TrimSpace(req.Name), Protocol: strings.TrimSpace(req.Protocol), Host: strings.TrimSpace(req.Host), Port: req.Port, - Username: strings.TrimSpace(req.Username), - Password: strings.TrimSpace(req.Password), + Username: req.Username, + Password: req.Password, Status: strings.TrimSpace(req.Status), ExpiresAt: expiresAt, ClearExpiresAt: req.ExpiresAt.Set && expiresAt == nil, diff --git a/backend/internal/service/admin_proxy.go b/backend/internal/service/admin_proxy.go index 0475330a01..bf0b89fe28 100644 --- a/backend/internal/service/admin_proxy.go +++ b/backend/internal/service/admin_proxy.go @@ -134,11 +134,11 @@ func (s *adminServiceImpl) UpdateProxy(ctx context.Context, id int64, input *Upd if input.Port != 0 { proxy.Port = input.Port } - if input.Username != "" { - proxy.Username = input.Username + if input.Username != nil { + proxy.Username = *input.Username } - if input.Password != "" { - proxy.Password = input.Password + if input.Password != nil { + proxy.Password = *input.Password } if input.Status != "" { proxy.Status = input.Status diff --git a/backend/internal/service/admin_proxy_credentials_update_test.go b/backend/internal/service/admin_proxy_credentials_update_test.go new file mode 100644 index 0000000000..87a7e64660 --- /dev/null +++ b/backend/internal/service/admin_proxy_credentials_update_test.go @@ -0,0 +1,33 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAdminProxyUpdateCredentials(t *testing.T) { + check := func(name string, input UpdateProxyInput, username, password string) { + t.Helper() + t.Run(name, func(t *testing.T) { + repo := &updatingProxyRepoStub{proxyRepoStub: &proxyRepoStub{}, proxy: &Proxy{ + ID: 9, Username: "old-user", Password: "old-pass", FallbackMode: FallbackModeNone, + }} + svc := &adminServiceImpl{proxyRepo: repo} + got, err := svc.UpdateProxy(context.Background(), 9, &input) + require.NoError(t, err) + require.Equal(t, username, got.Username) + require.Equal(t, password, got.Password) + require.Equal(t, 1, repo.updateCalls) + }) + } + empty, username, password := "", "new-user", "new-pass" + check("omitted", UpdateProxyInput{Status: "inactive"}, "old-user", "old-pass") + check("clear both", UpdateProxyInput{Username: &empty, Password: &empty}, "", "") + check("clear username only", UpdateProxyInput{Username: &empty}, "", "old-pass") + check("clear password only", UpdateProxyInput{Password: &empty}, "old-user", "") + check("replace both", UpdateProxyInput{Username: &username, Password: &password}, username, password) +} diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 7a3644a597..1c90cffc22 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -541,8 +541,8 @@ type UpdateProxyInput struct { Protocol string Host string Port int - Username string - Password string + Username *string + Password *string Status string ExpiresAt *time.Time ClearExpiresAt bool diff --git a/frontend/src/views/admin/ProxiesView.vue b/frontend/src/views/admin/ProxiesView.vue index 978c042761..4eac7e6e90 100644 --- a/frontend/src/views/admin/ProxiesView.vue +++ b/frontend/src/views/admin/ProxiesView.vue @@ -1461,7 +1461,7 @@ const handleUpdateProxy = async () => { protocol: editForm.protocol, host: editForm.host.trim(), port: editForm.port, - username: editForm.username.trim() || null, + username: editForm.username.trim(), status: editForm.status, expires_at: editForm.expires_at ? Math.floor(new Date(editForm.expires_at).getTime() / 1000) : null, fallback_mode: editForm.fallback_mode, @@ -1471,7 +1471,7 @@ const handleUpdateProxy = async () => { // Only include password if user actually modified the field if (editPasswordDirty.value) { - updateData.password = editForm.password.trim() || null + updateData.password = editForm.password.trim() } await adminAPI.proxies.update(editingProxy.value.id, updateData) diff --git a/frontend/src/views/admin/__tests__/ProxiesView.credentials.spec.ts b/frontend/src/views/admin/__tests__/ProxiesView.credentials.spec.ts new file mode 100644 index 0000000000..de01c43e23 --- /dev/null +++ b/frontend/src/views/admin/__tests__/ProxiesView.credentials.spec.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, shallowMount } from '@vue/test-utils' +import ProxiesView from '../ProxiesView.vue' + +const { list, update, getAllWithCount } = vi.hoisted(() => ({ list: vi.fn(), update: vi.fn(), getAllWithCount: vi.fn() })) +vi.mock('@/api/admin', () => ({ adminAPI: { proxies: { list, update, getAllWithCount } } })) +vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showError: vi.fn(), showSuccess: vi.fn() }) })) +vi.mock('vue-i18n', async () => ({ + ...await vi.importActual('vue-i18n'), + useI18n: () => ({ t: (key: string) => key }), +})) +const mountView = () => shallowMount(ProxiesView, { + global: { stubs: { + AppLayout: { template: '
' }, + TablePageLayout: { template: '
' }, + DataTable: { props: ['data'], template: '
' }, + BaseDialog: { props: ['show'], template: '
' }, + } }, +}) +let wrapper: ReturnType +beforeEach(() => { + vi.clearAllMocks() + list.mockResolvedValue({ items: [{ id: 9, name: 'proxy', protocol: 'http', host: 'proxy.example', port: 8080, username: 'old-user', status: 'active' }], total: 1, pages: 1 }) + getAllWithCount.mockResolvedValue([]) + update.mockResolvedValue({}) +}) +afterEach(() => wrapper?.unmount()) +async function edit() { + wrapper = mountView(); await flushPromises() + await wrapper.findAll('button').find(button => button.text() === 'common.edit')!.trigger('click') +} +async function submit() { + await wrapper.get('#edit-proxy-form').trigger('submit'); await flushPromises() + expect(update).toHaveBeenCalledTimes(1) + return update.mock.calls[0][1] +} + +describe('proxy credential updates', () => { + it('sends an explicit empty username when cleared', async () => { + await edit() + const username = wrapper.findAll('#edit-proxy-form input').find(input => input.element.value === 'old-user')! + await username.setValue('') + const payload = await submit() + expect(payload.username).toBe('') + expect(payload).not.toHaveProperty('password') + }) + + it('sends an explicit empty password when the user clears the field', async () => { + await edit() + await wrapper.get('#edit-proxy-form input[type="password"]').setValue('') + expect((await submit()).password).toBe('') + }) + + it('omits an untouched password when saving other settings', async () => { + await edit() + const payload = await submit() + expect(payload.username).toBe('old-user') + expect(payload).not.toHaveProperty('password') + }) + + it('keeps trimming a replacement password', async () => { + await edit() + await wrapper.get('#edit-proxy-form input[type="password"]').setValue(' new-password ') + expect((await submit()).password).toBe('new-password') + }) +})