fix(sync): prevent Dropbox setup loop when auth is cancelled (#7131)

save() previously persisted isEnabled:true and closed the dialog before
auth completed, then called sync(). When the user cancelled the auth
code dialog, sync would fail with MissingCredentialsSPError, showing a
snack whose "Configure" action reopened the dialog — the infinite loop
reported in #7131.

Run auth first, then gate save+close on provider.isReady() when the
provider has getAuthHelper. If auth was cancelled or failed, the dialog
stays open and config is left untouched.
This commit is contained in:
Johannes Millan 2026-04-16 19:10:51 +02:00
parent 3fcc1b65d9
commit f89fe1ebc3
2 changed files with 236 additions and 1 deletions

View file

@ -0,0 +1,226 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { MatDialogRef } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { FormlyModule } from '@ngx-formly/core';
import { of } from 'rxjs';
import { DialogSyncInitialCfgComponent } from './dialog-sync-initial-cfg.component';
import { SyncConfigService } from '../sync-config.service';
import { SyncWrapperService } from '../sync-wrapper.service';
import { SyncProviderManager } from '../../../op-log/sync-providers/provider-manager.service';
import { GlobalConfigService } from '../../../features/config/global-config.service';
import { SyncProviderId } from '../../../op-log/sync-providers/provider.const';
import { SyncConfig } from '../../../features/config/global-config.model';
describe('DialogSyncInitialCfgComponent', () => {
let component: DialogSyncInitialCfgComponent;
let fixture: ComponentFixture<DialogSyncInitialCfgComponent>;
let mockDialogRef: jasmine.SpyObj<MatDialogRef<DialogSyncInitialCfgComponent>>;
let mockSyncConfigService: jasmine.SpyObj<SyncConfigService>;
let mockSyncWrapperService: jasmine.SpyObj<SyncWrapperService>;
let mockProviderManager: jasmine.SpyObj<SyncProviderManager>;
let mockGlobalConfigService: jasmine.SpyObj<GlobalConfigService>;
const baseSyncConfig: SyncConfig = {
isEnabled: false,
syncProvider: null,
syncInterval: 300000,
encryptKey: '',
isEncryptionEnabled: false,
localFileSync: {} as any,
webDav: {} as any,
nextcloud: {} as any,
superSync: {} as any,
} as SyncConfig;
beforeEach(async () => {
mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']);
mockSyncConfigService = jasmine.createSpyObj('SyncConfigService', [
'updateSettingsFromForm',
]);
(mockSyncConfigService as any).syncSettingsForm$ = of(baseSyncConfig);
mockSyncConfigService.updateSettingsFromForm.and.resolveTo();
mockSyncWrapperService = jasmine.createSpyObj('SyncWrapperService', [
'configuredAuthForSyncProviderIfNecessary',
'sync',
]);
mockSyncWrapperService.sync.and.resolveTo();
mockProviderManager = jasmine.createSpyObj('SyncProviderManager', [
'getProviderById',
]);
mockGlobalConfigService = jasmine.createSpyObj('GlobalConfigService', [], {
sync$: of(baseSyncConfig),
});
TestBed.configureTestingModule({
imports: [
DialogSyncInitialCfgComponent,
TranslateModule.forRoot(),
FormlyModule.forRoot(),
],
providers: [
provideNoopAnimations(),
{ provide: MatDialogRef, useValue: mockDialogRef },
{ provide: SyncConfigService, useValue: mockSyncConfigService },
{ provide: SyncWrapperService, useValue: mockSyncWrapperService },
{ provide: SyncProviderManager, useValue: mockProviderManager },
{ provide: GlobalConfigService, useValue: mockGlobalConfigService },
],
});
// Replace the Formly-based template with a minimal placeholder so we can
// test the save() business logic without registering every Formly field type.
TestBed.overrideComponent(DialogSyncInitialCfgComponent, {
set: { template: '' },
});
await TestBed.compileComponents();
fixture = TestBed.createComponent(DialogSyncInitialCfgComponent);
component = fixture.componentInstance;
});
describe('save() — auth cancelled (reproduces issue #7131)', () => {
it('should NOT close dialog when Dropbox auth is cancelled and provider is not ready', async () => {
const providerNeedingAuth = {
id: SyncProviderId.Dropbox,
getAuthHelper: () => Promise.resolve({} as any),
isReady: jasmine.createSpy('isReady').and.resolveTo(false),
};
mockProviderManager.getProviderById.and.resolveTo(providerNeedingAuth as any);
mockSyncWrapperService.configuredAuthForSyncProviderIfNecessary.and.resolveTo({
wasConfigured: false,
});
(component as any)._tmpUpdatedCfg = {
...(component as any)._tmpUpdatedCfg,
syncProvider: SyncProviderId.Dropbox,
isEnabled: true,
};
await component.save();
expect(mockDialogRef.close).not.toHaveBeenCalled();
});
it('should NOT save config when Dropbox auth is cancelled and provider is not ready', async () => {
const providerNeedingAuth = {
id: SyncProviderId.Dropbox,
getAuthHelper: () => Promise.resolve({} as any),
isReady: jasmine.createSpy('isReady').and.resolveTo(false),
};
mockProviderManager.getProviderById.and.resolveTo(providerNeedingAuth as any);
mockSyncWrapperService.configuredAuthForSyncProviderIfNecessary.and.resolveTo({
wasConfigured: false,
});
(component as any)._tmpUpdatedCfg = {
...(component as any)._tmpUpdatedCfg,
syncProvider: SyncProviderId.Dropbox,
isEnabled: true,
};
await component.save();
expect(mockSyncConfigService.updateSettingsFromForm).not.toHaveBeenCalled();
});
it('should NOT trigger sync when auth is cancelled', async () => {
const providerNeedingAuth = {
id: SyncProviderId.Dropbox,
getAuthHelper: () => Promise.resolve({} as any),
isReady: jasmine.createSpy('isReady').and.resolveTo(false),
};
mockProviderManager.getProviderById.and.resolveTo(providerNeedingAuth as any);
mockSyncWrapperService.configuredAuthForSyncProviderIfNecessary.and.resolveTo({
wasConfigured: false,
});
(component as any)._tmpUpdatedCfg = {
...(component as any)._tmpUpdatedCfg,
syncProvider: SyncProviderId.Dropbox,
isEnabled: true,
};
await component.save();
expect(mockSyncWrapperService.sync).not.toHaveBeenCalled();
});
});
describe('save() — auth succeeds', () => {
it('should close dialog and save config when Dropbox auth succeeds', async () => {
const configuredProvider = {
id: SyncProviderId.Dropbox,
getAuthHelper: () => Promise.resolve({} as any),
isReady: jasmine.createSpy('isReady').and.resolveTo(true),
};
mockProviderManager.getProviderById.and.resolveTo(configuredProvider as any);
mockSyncWrapperService.configuredAuthForSyncProviderIfNecessary.and.resolveTo({
wasConfigured: true,
});
(component as any)._tmpUpdatedCfg = {
...(component as any)._tmpUpdatedCfg,
syncProvider: SyncProviderId.Dropbox,
isEnabled: true,
};
await component.save();
expect(mockSyncConfigService.updateSettingsFromForm).toHaveBeenCalled();
expect(mockDialogRef.close).toHaveBeenCalled();
});
});
describe('save() — provider without auth requirement', () => {
it('should close dialog and save config for WebDAV (no getAuthHelper)', async () => {
const webdavProvider = {
id: SyncProviderId.WebDAV,
// no getAuthHelper
isReady: jasmine.createSpy('isReady').and.resolveTo(true),
};
mockProviderManager.getProviderById.and.resolveTo(webdavProvider as any);
mockSyncWrapperService.configuredAuthForSyncProviderIfNecessary.and.resolveTo({
wasConfigured: false,
});
(component as any)._tmpUpdatedCfg = {
...(component as any)._tmpUpdatedCfg,
syncProvider: SyncProviderId.WebDAV,
isEnabled: true,
};
await component.save();
expect(mockSyncConfigService.updateSettingsFromForm).toHaveBeenCalled();
expect(mockDialogRef.close).toHaveBeenCalled();
});
});
describe('save() — already configured provider', () => {
it('should close dialog when Dropbox is already configured (wasConfigured=false, isReady=true)', async () => {
const alreadyConfigured = {
id: SyncProviderId.Dropbox,
getAuthHelper: () => Promise.resolve({} as any),
isReady: jasmine.createSpy('isReady').and.resolveTo(true),
};
mockProviderManager.getProviderById.and.resolveTo(alreadyConfigured as any);
mockSyncWrapperService.configuredAuthForSyncProviderIfNecessary.and.resolveTo({
wasConfigured: false,
});
(component as any)._tmpUpdatedCfg = {
...(component as any)._tmpUpdatedCfg,
syncProvider: SyncProviderId.Dropbox,
isEnabled: true,
};
await component.save();
expect(mockSyncConfigService.updateSettingsFromForm).toHaveBeenCalled();
expect(mockDialogRef.close).toHaveBeenCalled();
});
});
});

View file

@ -219,12 +219,21 @@ export class DialogSyncInitialCfgComponent implements AfterViewInit {
isEnabled: this._tmpUpdatedCfg.isEnabled || !this.isWasEnabled(),
};
await this.syncConfigService.updateSettingsFromForm(configToSave as SyncConfig, true);
const providerId = toSyncProviderId(this._tmpUpdatedCfg.syncProvider);
if (providerId && this._tmpUpdatedCfg.isEnabled) {
await this.syncWrapperService.configuredAuthForSyncProviderIfNecessary(providerId);
// If the provider requires auth (e.g. Dropbox) and is still not ready,
// the auth dialog was cancelled or failed. Keep the dialog open so the
// user can retry, and do not persist isEnabled:true with missing credentials
// (which would trigger the "Sync credentials are missing" snack loop — issue #7131).
const provider = await this._providerManager.getProviderById(providerId);
if (provider?.getAuthHelper && !(await provider.isReady())) {
return;
}
}
await this.syncConfigService.updateSettingsFromForm(configToSave as SyncConfig, true);
this._matDialogRef.close();
if (isOnline()) {