fix(inbounds): keep the stored client list and enable on inbound save

Invariant: saving an inbound's configuration never changes which clients it
holds nor whether it is enabled; both have their own endpoints. The edit
modal posts back the clients and the enable flag it loaded when it opened.
A client added meanwhile (another admin, the bot, the API, LDAP) was
detached and its stats deleted; a client deleted meanwhile came back with
its credentials, restoring access that had been revoked; an inbound
switched off meanwhile was switched back on.

For every save but a master's node-sync push, UpdateInbound now takes the
client list and enable from the row it re-reads inside the writer; this
replaces the lifecycle-only carry from the previous commit. Client
validation (renewal schedule, Hysteria auth, TUIC credentials) moves after
that swap so it judges the clients actually saved: a protocol switch keeps
the stored clients, and #6268's refusal must apply to them.

The edit form no longer loads or sends clients, so neither the JSON editor
nor validation sees a copy the server ignores, and the enable switch shows
only when adding; the list toggle (/setEnable) covers existing inbounds.

Tests that added or re-keyed clients through a panel inbound save pinned
the old rule; they now drive the master-push path, where payload clients
still apply.
This commit is contained in:
MHSanaei
2026-09-28 13:23:48 +02:00
parent fb7418f7bd
commit 823db05966
13 changed files with 207 additions and 96 deletions
+10 -12
View File
@@ -60,12 +60,11 @@ _openapi:
at most once. at most once.
url: '#delete-many-inbounds-in-one-call-processes-the-list-sequentially-failures-are-reported-per-id-and-the-rest-still-proceed-restarts-xray-at-most-once' url: '#delete-many-inbounds-in-one-call-processes-the-list-sequentially-failures-are-reported-per-id-and-the-rest-still-proceed-restarts-xray-at-most-once'
- depth: 2 - depth: 2
title: Replace an inbound’s configuration. Body shape mirrors /add. Clients the title: 'Replace an inbound’s configuration. Body shape mirrors /add, but the
inbound already holds keep their stored enable, expiryTime, totalGB, inbound keeps its stored client list and enable flag: settings.clients
reset, resetDay, resetWeekday and resetMax — change those through the and enable in the body are ignored. Manage clients through the
/panel/api/clients endpoints. Heavy on inbounds with thousands of /panel/api/clients endpoints and toggle the inbound with /setEnable.'
clients — prefer /setEnable for enable-only flips. url: '#replace-an-inbounds-configuration-body-shape-mirrors-add-but-the-inbound-keeps-its-stored-client-list-and-enable-flag-settingsclients-and-enable-in-the-body-are-ignored-manage-clients-through-the-panelapiclients-endpoints-and-toggle-the-inbound-with-setenable'
url: '#replace-an-inbounds-configuration-body-shape-mirrors-add-clients-the-inbound-already-holds-keep-their-stored-enable-expirytime-totalgb-reset-resetday-resetweekday-and-resetmax--change-those-through-the-panelapiclients-endpoints-heavy-on-inbounds-with-thousands-of-clients--prefer-setenable-for-enable-only-flips'
- depth: 2 - depth: 2
title: Toggle only the enable flag without serialising the whole settings JSON. title: Toggle only the enable flag without serialising the whole settings JSON.
Recommended for UI switches on large inbounds. Recommended for UI switches on large inbounds.
@@ -153,12 +152,11 @@ _openapi:
failures are reported per id and the rest still proceed. Restarts xray failures are reported per id and the rest still proceed. Restarts xray
at most once. at most once.
id: delete-many-inbounds-in-one-call-processes-the-list-sequentially-failures-are-reported-per-id-and-the-rest-still-proceed-restarts-xray-at-most-once id: delete-many-inbounds-in-one-call-processes-the-list-sequentially-failures-are-reported-per-id-and-the-rest-still-proceed-restarts-xray-at-most-once
- content: Replace an inbound’s configuration. Body shape mirrors /add. Clients - content: 'Replace an inbound’s configuration. Body shape mirrors /add, but the
the inbound already holds keep their stored enable, expiryTime, inbound keeps its stored client list and enable flag: settings.clients
totalGB, reset, resetDay, resetWeekday and resetMax — change those and enable in the body are ignored. Manage clients through the
through the /panel/api/clients endpoints. Heavy on inbounds with /panel/api/clients endpoints and toggle the inbound with /setEnable.'
thousands of clients — prefer /setEnable for enable-only flips. id: replace-an-inbounds-configuration-body-shape-mirrors-add-but-the-inbound-keeps-its-stored-client-list-and-enable-flag-settingsclients-and-enable-in-the-body-are-ignored-manage-clients-through-the-panelapiclients-endpoints-and-toggle-the-inbound-with-setenable
id: replace-an-inbounds-configuration-body-shape-mirrors-add-clients-the-inbound-already-holds-keep-their-stored-enable-expirytime-totalgb-reset-resetday-resetweekday-and-resetmax--change-those-through-the-panelapiclients-endpoints-heavy-on-inbounds-with-thousands-of-clients--prefer-setenable-for-enable-only-flips
- content: Toggle only the enable flag without serialising the whole settings - content: Toggle only the enable flag without serialising the whole settings
JSON. Recommended for UI switches on large inbounds. JSON. Recommended for UI switches on large inbounds.
id: toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds id: toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds
+1 -1
View File
@@ -5603,7 +5603,7 @@
"tags": [ "tags": [
"Inbounds" "Inbounds"
], ],
"summary": "Replace an inbound’s configuration. Body shape mirrors /add. Clients the inbound already holds keep their stored enable, expiryTime, totalGB, reset, resetDay, resetWeekday and resetMax — change those through the /panel/api/clients endpoints. Heavy on inbounds with thousands of clients — prefer /setEnable for enable-only flips.", "summary": "Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable.",
"operationId": "post_panel_api_inbounds_update_id", "operationId": "post_panel_api_inbounds_update_id",
"parameters": [ "parameters": [
{ {
+1 -1
View File
@@ -5603,7 +5603,7 @@
"tags": [ "tags": [
"Inbounds" "Inbounds"
], ],
"summary": "Replace an inbound’s configuration. Body shape mirrors /add. Clients the inbound already holds keep their stored enable, expiryTime, totalGB, reset, resetDay, resetWeekday and resetMax — change those through the /panel/api/clients endpoints. Heavy on inbounds with thousands of clients — prefer /setEnable for enable-only flips.", "summary": "Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable.",
"operationId": "post_panel_api_inbounds_update_id", "operationId": "post_panel_api_inbounds_update_id",
"parameters": [ "parameters": [
{ {
+15 -2
View File
@@ -353,9 +353,22 @@ export function dropLegacyOptionalEmpties(
} }
} }
export function formValuesToWirePayload(values: InboundFormValues): WireInboundPayload { // An existing inbound's clients change only through the client endpoints, so
// the edit form neither loads them nor sends them back.
export function withoutClients(values: InboundFormValues): InboundFormValues {
const settings = { ...(values.settings as Record<string, unknown> | undefined) };
delete settings.clients;
return { ...values, settings } as InboundFormValues;
}
export function formValuesToWirePayload(
values: InboundFormValues,
options: { omitClients?: boolean } = {},
): WireInboundPayload {
const settingsPruned = (pruneEmpty(values.settings ?? {}) ?? {}) as Record<string, unknown>; const settingsPruned = (pruneEmpty(values.settings ?? {}) ?? {}) as Record<string, unknown>;
if (Array.isArray(settingsPruned.clients)) { if (options.omitClients) {
delete settingsPruned.clients;
} else if (Array.isArray(settingsPruned.clients)) {
settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients); settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients);
} }
let streamPruned = values.streamSettings let streamPruned = values.streamSettings
+1 -1
View File
@@ -325,7 +325,7 @@ export const sections: readonly Section[] = [
method: 'POST', method: 'POST',
path: '/panel/api/inbounds/update/:id', path: '/panel/api/inbounds/update/:id',
summary: summary:
'Replace an inbound’s configuration. Body shape mirrors /add. Clients the inbound already holds keep their stored enable, expiryTime, totalGB, reset, resetDay, resetWeekday and resetMax — change those through the /panel/api/clients endpoints. Heavy on inbounds with thousands of clients — prefer /setEnable for enable-only flips.', 'Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable.',
params: [{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' }], params: [{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' }],
body: inboundBody, body: inboundBody,
}, },
@@ -19,7 +19,11 @@ import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils'; import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
import type { RealityScanResult } from '@/generated/types'; import type { RealityScanResult } from '@/generated/types';
import { rawInboundToFormValues, formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter'; import {
rawInboundToFormValues,
formValuesToWirePayload,
withoutClients,
} from '@/lib/xray/inbound-form-adapter';
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults'; import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
import { generateAwgObfuscation } from '@/lib/xray/amneziawg-obfuscation'; import { generateAwgObfuscation } from '@/lib/xray/amneziawg-obfuscation';
import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag'; import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag';
@@ -439,7 +443,9 @@ export default function InboundFormModal({
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const initial = const initial =
mode === 'edit' && dbInbound ? rawInboundToFormValues(dbInbound) : buildAddModeValues(); mode === 'edit' && dbInbound
? withoutClients(rawInboundToFormValues(dbInbound))
: buildAddModeValues();
methods.reset(initial); methods.reset(initial);
setScanResult(null); setScanResult(null);
setActiveTab('basic'); setActiveTab('basic');
@@ -556,13 +562,8 @@ export default function InboundFormModal({
}, [mode, methods]); }, [mode, methods]);
const saveValues = async () => { const saveValues = async () => {
/* // settings.fallbacks has no bound field; shouldUnregister=false keeps it from
* getValues() returns the entire form store, including settings.clients and // the reset object. An edit sends no clients: the server keeps the stored ones.
* settings.fallbacks which have no bound field (clients are managed via the
* standalone Client modal, not this inbound modal). With shouldUnregister
* false those pass-through sub-trees survive from the reset object, so the
* update wire payload never silently drops every client on save.
*/
const values = methods.getValues() as InboundFormValues; const values = methods.getValues() as InboundFormValues;
const parsed = InboundFormSchema.safeParse(values); const parsed = InboundFormSchema.safeParse(values);
if (!parsed.success) { if (!parsed.success) {
@@ -577,7 +578,7 @@ export default function InboundFormModal({
} }
setSaving(true); setSaving(true);
try { try {
const payload = formValuesToWirePayload(parsed.data); const payload = formValuesToWirePayload(parsed.data, { omitClients: mode === 'edit' });
const url = const url =
mode === 'edit' && dbInbound mode === 'edit' && dbInbound
? `/panel/api/inbounds/update/${dbInbound.id}` ? `/panel/api/inbounds/update/${dbInbound.id}`
@@ -615,9 +616,11 @@ export default function InboundFormModal({
const basicTab = ( const basicTab = (
<> <>
<FormField name="enable" label={t('enable')} valueProp="checked"> {mode === 'add' && (
<Switch /> <FormField name="enable" label={t('enable')} valueProp="checked">
</FormField> <Switch id="inbound-enable" />
</FormField>
)}
<FormField name="remark" label={t('pages.inbounds.remark')}> <FormField name="remark" label={t('pages.inbounds.remark')}>
<Input /> <Input />
@@ -317,4 +317,34 @@ describe('InboundFormModal', () => {
); );
}); });
}); });
// Clients and enable change through their own endpoints; the server keeps the
// stored ones, so the edit form must neither send nor validate its stale copy.
it('edit save neither sends nor validates the clients it loaded', async () => {
const post = vi.mocked(HttpUtil.post);
post.mockClear();
const dbInbound = cloneLikeVlessInbound('example.com:443');
const legacy = new DBInbound({
...dbInbound,
settings: {
...(dbInbound.settings as Record<string, unknown>),
clients: [{ email: 'legacy', id: '' }],
},
});
renderCloneLikeEdit(legacy);
fireEvent.click(primaryButton());
await waitFor(() => expect(post).toHaveBeenCalled());
const payload = post.mock.calls[0][1] as { settings: string };
expect(JSON.parse(payload.settings)).not.toHaveProperty('clients');
});
it('offers the enable switch when adding an inbound but not when editing one', () => {
renderModal();
expect(document.getElementById('inbound-enable')).not.toBeNull();
cleanup();
renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
expect(document.getElementById('inbound-enable')).toBeNull();
});
}); });
@@ -48,7 +48,8 @@ func TestClientRenewalWriteValidation(t *testing.T) {
} }
_, _, err = inboundSvc.AddInbound(&update) _, _, err = inboundSvc.AddInbound(&update)
case "update inbound": case "update inbound":
_, _, err = inboundSvc.UpdateInbound(&update) // A panel save keeps the stored clients; only a master's push writes them.
_, _, err = (&InboundService{FromNodeSync: true}).UpdateInbound(&update)
case "add inbound client": case "add inbound client":
client.Email = "invalid-new-client" client.Email = "invalid-new-client"
update.Settings = clientsSettings(t, []model.Client{client}) update.Settings = clientsSettings(t, []model.Client{client})
+37 -29
View File
@@ -1684,6 +1684,35 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
return needRestart, nil return needRestart, nil
} }
func (s *InboundService) validateUpdatedInboundClients(inbound *model.Inbound) error {
clients, err := s.GetClients(inbound)
if err != nil {
return err
}
if err := validateClientsRenewal(clients); err != nil {
return err
}
for _, client := range clients {
switch inbound.Protocol {
case model.Hysteria:
if client.Auth == "" {
return common.NewError("empty client ID")
}
case model.TUIC:
if client.ID == "" {
return common.NewError("empty client ID")
}
if client.Password == "" {
return common.NewError("tuic client requires a password")
}
if client.Email == "" {
return common.NewError("empty client email")
}
}
}
return nil
}
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) { func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
legacyShareAddr := legacyMtprotoShareAddr(inbound) legacyShareAddr := legacyMtprotoShareAddr(inbound)
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay) inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
@@ -1706,34 +1735,6 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
} }
inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex) inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
clients, err := s.GetClients(inbound)
if err != nil {
return inbound, false, err
}
if err := validateClientsRenewal(clients); err != nil {
return inbound, false, err
}
if inbound.Protocol == model.Hysteria {
for _, client := range clients {
if client.Auth == "" {
return inbound, false, common.NewError("empty client ID")
}
}
}
if inbound.Protocol == model.TUIC {
for _, client := range clients {
if client.ID == "" {
return inbound, false, common.NewError("empty client ID")
}
if client.Password == "" {
return inbound, false, common.NewError("tuic client requires a password")
}
if client.Email == "" {
return inbound, false, common.NewError("empty client email")
}
}
}
// Grandfather a row that was already stored incomplete so it stays editable; // Grandfather a row that was already stored incomplete so it stays editable;
// only a save that breaks a previously valid TLS block is refused. // only a save that breaks a previously valid TLS block is refused.
if !s.FromNodeSync { if !s.FromNodeSync {
@@ -1776,8 +1777,15 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
return err return err
} }
oldInbound = stored oldInbound = stored
// The form posts back the clients and enable it loaded; both have their
// own endpoints, so only a master's push may change them here.
if !s.FromNodeSync { if !s.FromNodeSync {
inbound.Settings = keepStoredClientLifecycle(inbound.Settings, stored.Settings) inbound.Settings = keepStoredClients(inbound.Settings, stored.Settings)
inbound.Enable = stored.Enable
}
// On the clients actually saved: a protocol switch keeps the stored ones.
if err := s.validateUpdatedInboundClients(inbound); err != nil {
return err
} }
conflict, cErr := checkPortConflictTx(tx, inbound, inbound.Id) conflict, cErr := checkPortConflictTx(tx, inbound, inbound.Id)
if cErr != nil { if cErr != nil {
@@ -8,10 +8,12 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/database/model"
) )
// An inbound save keeps the stored clients, so switching to Hysteria is judged
// on them: ones with no auth would leave an inbound nobody can connect to.
func TestUpdateInbound_RejectsHysteriaClientWithoutAuth(t *testing.T) { func TestUpdateInbound_RejectsHysteriaClientWithoutAuth(t *testing.T) {
setupConflictDB(t) setupConflictDB(t)
seedInboundConflict(t, "in-45001-tcp", "0.0.0.0", 45001, model.VLESS, seedInboundConflict(t, "in-45001-tcp", "0.0.0.0", 45001, model.VLESS,
`{"network":"tcp"}`, `{"clients":[]}`) `{"network":"tcp"}`, `{"clients":[{"email":"hysteria@x","enable":true,"password":"not-hysteria-auth"}]}`)
var existing model.Inbound var existing model.Inbound
if err := database.GetDB().Where("tag = ?", "in-45001-tcp").First(&existing).Error; err != nil { if err := database.GetDB().Where("tag = ?", "in-45001-tcp").First(&existing).Error; err != nil {
@@ -20,7 +22,7 @@ func TestUpdateInbound_RejectsHysteriaClientWithoutAuth(t *testing.T) {
update := existing update := existing
update.Protocol = model.Hysteria update.Protocol = model.Hysteria
update.Settings = `{"clients":[{"email":"hysteria@x","enable":true,"password":"not-hysteria-auth"}]}` update.Settings = `{"clients":[]}`
svc := &InboundService{} svc := &InboundService{}
if _, _, err := svc.UpdateInbound(&update); err == nil || !strings.Contains(err.Error(), "empty client ID") { if _, _, err := svc.UpdateInbound(&update); err == nil || !strings.Contains(err.Error(), "empty client ID") {
@@ -54,7 +56,8 @@ func TestUpdateInbound_PreservesHysteriaClientAuth(t *testing.T) {
update := existing update := existing
update.Settings = `{"clients":[{"email":"hysteria@x","enable":true,"password":"` + password + `","auth":"` + wantAuth + `"}]}` update.Settings = `{"clients":[{"email":"hysteria@x","enable":true,"password":"` + password + `","auth":"` + wantAuth + `"}]}`
svc := &InboundService{} // Only a master's push still carries clients through an inbound save.
svc := &InboundService{FromNodeSync: true}
if _, _, err := svc.UpdateInbound(&update); err != nil { if _, _, err := svc.UpdateInbound(&update); err != nil {
t.Fatalf("UpdateInbound: %v", err) t.Fatalf("UpdateInbound: %v", err)
} }
@@ -84,7 +84,8 @@ func TestUpdateInboundMtprotoUnchangedDoesNotRestart(t *testing.T) {
if !strings.Contains(update.Settings, mtprotoTestSecretD) { if !strings.Contains(update.Settings, mtprotoTestSecretD) {
t.Fatal("fixture must contain the re-keyed secret") t.Fatal("fixture must contain the re-keyed secret")
} }
_, needRestart, err := svc.UpdateInbound(&update) // Clients reach an inbound save only as a master's push to its node.
_, needRestart, err := (&InboundService{FromNodeSync: true}).UpdateInbound(&update)
if err != nil { if err != nil {
t.Fatalf("UpdateInbound: %v", err) t.Fatalf("UpdateInbound: %v", err)
} }
+10 -32
View File
@@ -140,44 +140,22 @@ func mergeClientLists(base, ours, current []any) []any {
return out return out
} }
// Client limits and state the inbound form never owns: the client endpoints and // keepStoredClients puts the stored client list back into an inbound save's
// traffic jobs change them, so a form opened earlier must not write them back. // payload: clients change through the client endpoints, never this form.
var storedClientLifecycleKeys = []string{"enable", "expiryTime", "totalGB", "reset", "resetDay", "resetWeekday", "resetMax"} func keepStoredClients(payload, stored string) string {
// keepStoredClientLifecycle copies those keys from the stored settings onto every
// payload client the inbound already holds; new clients keep what they carry.
func keepStoredClientLifecycle(payload, stored string) string {
var payloadM, storedM map[string]any var payloadM, storedM map[string]any
if json.Unmarshal([]byte(payload), &payloadM) != nil || json.Unmarshal([]byte(stored), &storedM) != nil { if json.Unmarshal([]byte(payload), &payloadM) != nil || json.Unmarshal([]byte(stored), &storedM) != nil {
return payload return payload
} }
storedClients, _ := storedM["clients"].([]any) storedClients, has := storedM["clients"]
storedBy := indexClientsByEmail(storedClients) if reflect.DeepEqual(payloadM["clients"], storedClients) {
payloadClients, _ := payloadM["clients"].([]any)
changed := false
for _, entry := range payloadClients {
p, email := clientEntryEmail(entry)
s, known := storedBy[email]
if !known {
continue
}
for _, key := range storedClientLifecycleKeys {
sv, inStored := s[key]
pv, inPayload := p[key]
if inStored == inPayload && reflect.DeepEqual(sv, pv) {
continue
}
changed = true
if inStored {
p[key] = sv
} else {
delete(p, key)
}
}
}
if !changed {
return payload return payload
} }
if has {
payloadM["clients"] = storedClients
} else {
delete(payloadM, "clients")
}
b, err := json.MarshalIndent(payloadM, "", " ") b, err := json.MarshalIndent(payloadM, "", " ")
if err != nil { if err != nil {
return payload return payload
@@ -5,6 +5,7 @@ import (
"time" "time"
"github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray" "github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm" "gorm.io/gorm"
@@ -92,3 +93,78 @@ func TestInboundUpdateKeepsTrafficAddedMidEdit(t *testing.T) {
t.Fatalf("inbound after edit: up=%d down=%d remark=%q, want 100/50/edited", saved.Up, saved.Down, saved.Remark) t.Fatalf("inbound after edit: up=%d down=%d remark=%q, want 100/50/edited", saved.Up, saved.Down, saved.Remark)
} }
} }
func inboundLinksEmail(t *testing.T, inboundId int, email string) bool {
t.Helper()
var n int64
if err := database.GetDB().Table("client_inbounds").
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Where("client_inbounds.inbound_id = ? AND clients.email = ?", inboundId, email).
Count(&n).Error; err != nil {
t.Fatalf("count links: %v", err)
}
return n > 0
}
// A client added while the modal was open is not in the list it posts back;
// saving the inbound must not detach it.
func TestInboundFormSaveKeepsClientAddedWhileOpen(t *testing.T) {
setupBulkDB(t)
ib := seedRenewableNeighbour(t, 23204, nil)
form := *ib
if _, err := (&ClientService{}).AddInboundClient(&InboundService{}, &model.Inbound{
Id: ib.Id, Settings: clientsSettings(t, []model.Client{{Email: "z@stale", ID: "aaaaaaaa-0000-0000-0000-00000000000c", Enable: true}}),
}); err != nil {
t.Fatalf("AddInboundClient: %v", err)
}
form.Remark = "edited"
if _, _, err := (&InboundService{}).UpdateInbound(&form); err != nil {
t.Fatalf("UpdateInbound: %v", err)
}
if _, ok := settingsClient(t, ib.Id, "z@stale"); !ok || !inboundLinksEmail(t, ib.Id, "z@stale") {
t.Fatalf("client added while the form was open was dropped: in settings=%v linked=%v", ok, inboundLinksEmail(t, ib.Id, "z@stale"))
}
}
// A client deleted while the modal was open is still in the list it posts
// back; saving must not restore its access.
func TestInboundFormSaveDoesNotResurrectDeletedClient(t *testing.T) {
setupBulkDB(t)
ib := seedRenewableNeighbour(t, 23205, nil)
form := *ib
if _, err := (&ClientService{}).DelInboundClientByEmail(&InboundService{}, ib.Id, "x@stale", false, true); err != nil {
t.Fatalf("DelInboundClientByEmail: %v", err)
}
form.Remark = "edited"
if _, _, err := (&InboundService{}).UpdateInbound(&form); err != nil {
t.Fatalf("UpdateInbound: %v", err)
}
if _, ok := settingsClient(t, ib.Id, "x@stale"); ok || inboundLinksEmail(t, ib.Id, "x@stale") {
t.Fatalf("deleted client came back: in settings=%v linked=%v", ok, inboundLinksEmail(t, ib.Id, "x@stale"))
}
}
// An inbound switched off while the modal was open stays off when the form,
// which still holds enable=true, is saved.
func TestInboundFormSaveKeepsEnableToggledWhileOpen(t *testing.T) {
setupBulkDB(t)
ib := seedRenewableNeighbour(t, 23206, nil)
form := *ib
if _, err := (&InboundService{}).SetInboundEnable(ib.Id, false); err != nil {
t.Fatalf("SetInboundEnable: %v", err)
}
form.Remark = "edited"
if _, _, err := (&InboundService{}).UpdateInbound(&form); err != nil {
t.Fatalf("UpdateInbound: %v", err)
}
saved, err := (&InboundService{}).GetInbound(ib.Id)
if err != nil {
t.Fatalf("GetInbound: %v", err)
}
if saved.Enable || saved.Remark != "edited" {
t.Fatalf("after save: enable=%v remark=%q, want disabled and edited", saved.Enable, saved.Remark)
}
}