Merge pull request #7111 from ouchihao/codex/bug-proxy-clear-credentials

fix(proxy): allow explicitly clearing stored credentials
This commit is contained in:
Wesley Liddick
2026-09-15 08:52:58 +08:00
committed by GitHub
9 changed files with 166 additions and 20 deletions
@@ -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,
})
}
}
@@ -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)
}
+4 -4
View File
@@ -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",
@@ -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,
+4 -4
View File
@@ -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
@@ -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)
}
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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)
@@ -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<typeof import('vue-i18n')>('vue-i18n'),
useI18n: () => ({ t: (key: string) => key }),
}))
const mountView = () => shallowMount(ProxiesView, {
global: { stubs: {
AppLayout: { template: '<div><slot /></div>' },
TablePageLayout: { template: '<div><slot name="table" /></div>' },
DataTable: { props: ['data'], template: '<div v-for="row in data" :key="row.id"><slot name="cell-actions" :row="row" /></div>' },
BaseDialog: { props: ['show'], template: '<div v-if="show"><slot /><slot name="footer" /></div>' },
} },
})
let wrapper: ReturnType<typeof mountView>
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<HTMLInputElement>('#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')
})
})