diff --git a/src/app/components/settings/settings.component.spec.ts b/src/app/components/settings/settings.component.spec.ts index 1ecc6a9..fbe699f 100644 --- a/src/app/components/settings/settings.component.spec.ts +++ b/src/app/components/settings/settings.component.spec.ts @@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { environment } from '../../../environments/environment'; import { ImportExportService } from '../../services/import-export.service'; import { TmdbCredential, TmdbSettingsService } from '../../services/tmdb-settings.service'; +import { ProviderSettingsService } from '../../services/provider-settings.service'; import { SettingsComponent } from './settings.component'; import { vi, afterEach, beforeEach } from 'vitest'; @@ -413,4 +414,61 @@ describe('SettingsComponent', () => { expect(fixture.nativeElement.textContent).toContain('— Built'); expect(fixture.nativeElement.textContent).toContain('unknown'); }); + + it('syncs provider signals after successful import', async () => { + const providerMock = { + isEnabled: vi.fn().mockReturnValue(false), + isAdultIncluded: vi.fn().mockReturnValue(false), + getAdultDisplayMode: vi.fn().mockReturnValue('blur' as const), + getTitlePreference: vi.fn().mockReturnValue(['romaji', 'english', 'native'] as const), + }; + TestBed.configureTestingModule({ + providers: [ + { + provide: ImportExportService, + useValue: { + exportData: vi.fn(), + getRecoveryBackups: vi.fn().mockResolvedValue([]), + exportRecoveryBackup: vi.fn().mockResolvedValue(undefined), + importData: vi.fn().mockResolvedValue(undefined), + }, + }, + { + provide: TmdbSettingsService, + useValue: { + token: vi.fn(() => ''), + key: vi.fn(() => ''), + getCredential: vi.fn(() => null), + saveCredentials: vi.fn(), + clearCredentials: vi.fn(), + }, + }, + { provide: ProviderSettingsService, useValue: providerMock }, + ], + }); + const exportService = TestBed.inject(ImportExportService); + vi.spyOn(window, 'confirm').mockReturnValue(true); + const fixture = TestBed.createComponent(SettingsComponent); + expect(fixture.componentInstance.includeAdult()).toBe(false); + + // Simulate import changing provider settings on disk + providerMock.isAdultIncluded.mockReturnValue(true); + providerMock.isEnabled.mockReturnValue(true); + providerMock.getAdultDisplayMode.mockReturnValue('hide' as const); + providerMock.getTitlePreference.mockReturnValue(['native', 'english', 'romaji'] as const); + + const target = { + files: [ + new File(['{"a":1}'], 'export.json', { type: 'application/json' }), + ] as unknown as FileList, + value: '', + }; + await fixture.componentInstance.onFileSelected({ target } as unknown as Event); + expect(exportService.importData).toHaveBeenCalledOnce(); + expect(fixture.componentInstance.includeAdult()).toBe(true); + expect(fixture.componentInstance.tmdbEnabled()).toBe(true); + expect(fixture.componentInstance.adultDisplayMode()).toBe('hide'); + expect(fixture.componentInstance.titleOrder()).toEqual(['native', 'english', 'romaji']); + expect(fixture.componentInstance.successMessage()).toBe('Data imported successfully'); + }); }); diff --git a/src/app/components/settings/settings.component.ts b/src/app/components/settings/settings.component.ts index 6d394ee..77e0207 100644 --- a/src/app/components/settings/settings.component.ts +++ b/src/app/components/settings/settings.component.ts @@ -589,6 +589,7 @@ export class SettingsComponent implements OnInit { try { await this.importExportService.importData(file); + this.syncProviderSignals(); this.successMessage.set('Data imported successfully'); setTimeout(() => this.successMessage.set(null), 3000); input.value = ''; @@ -599,4 +600,13 @@ export class SettingsComponent implements OnInit { input.value = ''; } } + + private syncProviderSignals(): void { + this.tmdbEnabled.set(this.providerSettingsService.isEnabled('tmdb')); + this.jikanEnabled.set(this.providerSettingsService.isEnabled('jikan')); + this.anilistEnabled.set(this.providerSettingsService.isEnabled('anilist')); + this.includeAdult.set(this.providerSettingsService.isAdultIncluded()); + this.adultDisplayMode.set(this.providerSettingsService.getAdultDisplayMode()); + this.titleOrder.set(this.providerSettingsService.getTitlePreference()); + } } diff --git a/src/app/services/import-export.service.spec.ts b/src/app/services/import-export.service.spec.ts index ef68b9b..9f7afea 100644 --- a/src/app/services/import-export.service.spec.ts +++ b/src/app/services/import-export.service.spec.ts @@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { ImportExportService } from './import-export.service'; import { StorageService } from './storage.service'; import { ImageStorageService } from './image-storage.service'; +import { ProviderSettingsService } from './provider-settings.service'; import { vi, afterEach } from 'vitest'; describe('ImportExportService', () => { @@ -16,6 +17,10 @@ describe('ImportExportService', () => { exportImages: ReturnType; parseExportedImages: ReturnType; }; + let providerSettings: { + exportSettings: ReturnType; + importSettings: ReturnType; + }; function readBlobText(blob: Blob): Promise { return new Promise((resolve) => { @@ -38,11 +43,23 @@ describe('ImportExportService', () => { .mockResolvedValue([{ id: 'poster-1', type: 'image/png', data: 'base64-data' }]), parseExportedImages: vi.fn().mockResolvedValue([]), }; + providerSettings = { + exportSettings: vi.fn().mockReturnValue({ + tmdb: false, + jikan: true, + anilist: false, + includeAdult: true, + titlePreference: ['native', 'english', 'romaji'], + adultDisplayMode: 'hide', + }), + importSettings: vi.fn(), + }; TestBed.configureTestingModule({ providers: [ { provide: StorageService, useValue: storageService }, { provide: ImageStorageService, useValue: imageStorage }, + { provide: ProviderSettingsService, useValue: providerSettings }, ], }); importExportService = TestBed.inject(ImportExportService); @@ -74,7 +91,71 @@ describe('ImportExportService', () => { expect(JSON.parse(text)).toEqual({ data: exportPayload, images: [{ id: 'poster-1', type: 'image/png', data: 'base64-data' }], + preferences: { + providerSettings: { + tmdb: false, + jikan: true, + anilist: false, + includeAdult: true, + titlePreference: ['native', 'english', 'romaji'], + adultDisplayMode: 'hide', + }, + }, + }); + }); + + it('always includes non-sensitive preferences and never leaks tokens', async () => { + storageService.getData.mockReturnValue({ items: [] }); + // Simulate tokens in localStorage – service must not read them + const store: Record = {}; + const mockStorage = { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { + store[k] = v; + }, + removeItem: (k: string) => { + delete store[k]; + }, + clear: () => { + for (const k of Object.keys(store)) delete store[k]; + }, + } as unknown as Storage; + Object.defineProperty(window, 'localStorage', { + value: mockStorage, + writable: true, + configurable: true, + }); + Object.defineProperty(globalThis, 'localStorage', { + value: mockStorage, + writable: true, + configurable: true, + }); + window.localStorage.setItem('tmdbReadAccessToken', 'secret-token'); + window.localStorage.setItem('tmdbApiKey', 'secret-key'); + const { createObjectURL } = mockDownload(); + + await importExportService.exportData(); + + const blob = createObjectURL.mock.calls[0][0] as Blob; + const text = await readBlobText(blob); + expect(text).not.toContain('secret-token'); + expect(text).not.toContain('secret-key'); + expect(text).not.toContain('tmdbReadAccessToken'); + expect(text).not.toContain('tmdbApiKey'); + const parsed = JSON.parse(text) as { + preferences: { providerSettings: Record }; + }; + expect(parsed.preferences.providerSettings).toEqual({ + tmdb: false, + jikan: true, + anilist: false, + includeAdult: true, + titlePreference: ['native', 'english', 'romaji'], + adultDisplayMode: 'hide', }); + expect(providerSettings.exportSettings).toHaveBeenCalledOnce(); + window.localStorage.removeItem('tmdbReadAccessToken'); + window.localStorage.removeItem('tmdbApiKey'); }); }); @@ -159,6 +240,114 @@ describe('ImportExportService', () => { await expect(importExportService.importData(file)).rejects.toThrow('Storage busy'); }); + + it('imports old portable export without preferences', async () => { + const file = mockFile(JSON.stringify({ data: { items: [] }, images: [] })); + imageStorage.parseExportedImages.mockResolvedValue([]); + storageService.importDataWithImages.mockResolvedValue(undefined); + + await importExportService.importData(file); + + expect(providerSettings.importSettings).not.toHaveBeenCalled(); + expect(storageService.importDataWithImages).toHaveBeenCalledWith({ items: [] }, []); + }); + + it('imports legacy raw data without preferences', async () => { + const raw = { + schemaVersion: 9, + lastModifiedAt: new Date().toISOString(), + groups: {}, + items: {}, + }; + const file = mockFile(JSON.stringify(raw)); + imageStorage.parseExportedImages.mockResolvedValue([]); + storageService.importDataWithImages.mockResolvedValue(undefined); + + await importExportService.importData(file); + + expect(providerSettings.importSettings).not.toHaveBeenCalled(); + expect(storageService.importDataWithImages).toHaveBeenCalledWith(raw, []); + }); + + it('restores providerSettings when preferences are present', async () => { + const providerSettingsPayload = { + tmdb: false, + jikan: false, + anilist: true, + includeAdult: true, + titlePreference: ['native', 'english', 'romaji'], + adultDisplayMode: 'hide', + }; + const payload = { + data: { items: [] }, + images: [], + preferences: { providerSettings: providerSettingsPayload }, + }; + const file = mockFile(JSON.stringify(payload)); + storageService.importDataWithImages.mockResolvedValue(undefined); + imageStorage.parseExportedImages.mockResolvedValue([]); + + await importExportService.importData(file); + + expect(providerSettings.importSettings).toHaveBeenCalledWith(providerSettingsPayload); + expect(storageService.importDataWithImages).toHaveBeenCalledWith({ items: [] }, []); + }); + + it('ignores invalid preferences without failing the import', async () => { + const payload = { + data: { items: [] }, + images: [], + preferences: { providerSettings: { tmdb: 'bad', titlePreference: ['bad'] } }, + }; + const file = mockFile(JSON.stringify(payload)); + providerSettings.importSettings.mockImplementation(() => { + throw new Error('invalid'); + }); + storageService.importDataWithImages.mockResolvedValue(undefined); + imageStorage.parseExportedImages.mockResolvedValue([]); + + await expect(importExportService.importData(file)).resolves.toBeUndefined(); + expect(storageService.importDataWithImages).toHaveBeenCalledWith({ items: [] }, []); + expect(providerSettings.importSettings).toHaveBeenCalled(); + }); + + it('never imports sensitive tokens even if injected', async () => { + const payload = { + data: { items: [] }, + images: [], + preferences: { + providerSettings: { + tmdb: true, + jikan: true, + anilist: true, + includeAdult: false, + titlePreference: ['romaji', 'english', 'native'], + adultDisplayMode: 'show', + }, + tmdbReadAccessToken: 'evil-token', + tmdbApiKey: 'evil-key', + }, + }; + const file = mockFile(JSON.stringify(payload)); + storageService.importDataWithImages.mockResolvedValue(undefined); + imageStorage.parseExportedImages.mockResolvedValue([]); + + await importExportService.importData(file); + + expect(providerSettings.importSettings).toHaveBeenCalledWith( + expect.objectContaining({ + tmdb: true, + includeAdult: false, + }), + ); + // importSettings should only receive providerSettings, never the injected token keys + const calledWith = providerSettings.importSettings.mock.calls[0][0] as Record< + string, + unknown + >; + expect(calledWith).not.toHaveProperty('tmdbReadAccessToken'); + expect(calledWith).not.toHaveProperty('tmdbApiKey'); + }); }); function mockDownload(): { diff --git a/src/app/services/import-export.service.ts b/src/app/services/import-export.service.ts index 499714a..3cc585b 100644 --- a/src/app/services/import-export.service.ts +++ b/src/app/services/import-export.service.ts @@ -1,6 +1,17 @@ import { Injectable, inject } from '@angular/core'; import { StorageService } from './storage.service'; import { ImageStorageService } from './image-storage.service'; +import { ProviderSettings, ProviderSettingsService } from './provider-settings.service'; + +export interface ExportPreferences { + providerSettings: ProviderSettings; +} + +export interface PortableExport { + data: unknown; + images: unknown; + preferences?: ExportPreferences; +} @Injectable({ providedIn: 'root', @@ -8,12 +19,20 @@ import { ImageStorageService } from './image-storage.service'; export class ImportExportService { private storageService = inject(StorageService); private imageStorage = inject(ImageStorageService); + private providerSettings = inject(ProviderSettingsService); async exportData(): Promise { - this.downloadJson( - { data: this.storageService.getData(), images: await this.imageStorage.exportImages() }, - 'watch-list-export', - ); + const payload: PortableExport = { + data: this.storageService.getData(), + images: await this.imageStorage.exportImages(), + preferences: { + providerSettings: this.providerSettings.exportSettings(), + }, + }; + // Only non-sensitive preferences are ever exported. + // providerSettings (tmdb/jikan/anilist, includeAdult, titlePreference, adultDisplayMode) + // is safe to share; tmdbReadAccessToken/apiKey from tmdb-settings.service.ts:3 are never read here. + this.downloadJson(payload, 'watch-list-export'); } async getRecoveryBackups(): Promise<{ key: string; timestamp: Date }[]> { @@ -47,6 +66,7 @@ export class ImportExportService { isPortableExport(payload) ? payload.images : [], ); await this.storageService.importDataWithImages(data, images); + this.applyPreferencesIfPresent(payload); } catch (error) { if (error instanceof SyntaxError) { throw new Error('Invalid JSON file', { cause: error }); @@ -54,8 +74,32 @@ export class ImportExportService { throw error; } } + + private applyPreferencesIfPresent(payload: unknown): void { + if (!isPortableExport(payload) || !hasPreferences(payload)) { + return; + } + const raw = payload.preferences.providerSettings; + // Never import sensitive keys; ProviderSettingsService normalizes and ignores unknown fields. + try { + this.providerSettings.importSettings(raw); + } catch { + // Invalid preferences must never break the data import. + } + } } -function isPortableExport(value: unknown): value is { data: unknown; images: unknown } { +function isPortableExport(value: unknown): value is PortableExport { return !!value && typeof value === 'object' && 'data' in value && 'images' in value; } + +function hasPreferences( + value: object, +): value is PortableExport & { preferences: ExportPreferences } { + return ( + 'preferences' in value && + !!value.preferences && + typeof value.preferences === 'object' && + 'providerSettings' in (value.preferences as Record) + ); +} diff --git a/src/app/services/provider-settings.service.ts b/src/app/services/provider-settings.service.ts index e3360cb..93175ef 100644 --- a/src/app/services/provider-settings.service.ts +++ b/src/app/services/provider-settings.service.ts @@ -98,6 +98,34 @@ export class ProviderSettingsService { this.save(next); } + exportSettings(): ProviderSettings { + return structuredClone(this.enabled()); + } + + importSettings(raw: unknown): void { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return; + } + const normalized = this.normalizeRawSettings(raw as Record); + this.enabled.set(normalized); + this.save(normalized); + } + + private normalizeRawSettings(obj: Record): ProviderSettings { + return { + tmdb: typeof obj['tmdb'] === 'boolean' ? obj['tmdb'] : DEFAULT_PROVIDER_SETTINGS.tmdb, + jikan: typeof obj['jikan'] === 'boolean' ? obj['jikan'] : DEFAULT_PROVIDER_SETTINGS.jikan, + anilist: + typeof obj['anilist'] === 'boolean' ? obj['anilist'] : DEFAULT_PROVIDER_SETTINGS.anilist, + includeAdult: + typeof obj['includeAdult'] === 'boolean' + ? obj['includeAdult'] + : DEFAULT_PROVIDER_SETTINGS.includeAdult, + titlePreference: this.normalizeTitlePreference(obj['titlePreference']), + adultDisplayMode: this.normalizeAdultDisplayMode(obj['adultDisplayMode']), + }; + } + private load(): ProviderSettings { try { const raw = localStorage.getItem(PROVIDER_SETTINGS_KEY); @@ -108,25 +136,13 @@ export class ProviderSettingsService { }; } const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== 'object') { + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return { ...DEFAULT_PROVIDER_SETTINGS, titlePreference: [...DEFAULT_TITLE_PREFERENCE], }; } - const obj = parsed as Record; - return { - tmdb: typeof obj['tmdb'] === 'boolean' ? obj['tmdb'] : DEFAULT_PROVIDER_SETTINGS.tmdb, - jikan: typeof obj['jikan'] === 'boolean' ? obj['jikan'] : DEFAULT_PROVIDER_SETTINGS.jikan, - anilist: - typeof obj['anilist'] === 'boolean' ? obj['anilist'] : DEFAULT_PROVIDER_SETTINGS.anilist, - includeAdult: - typeof obj['includeAdult'] === 'boolean' - ? obj['includeAdult'] - : DEFAULT_PROVIDER_SETTINGS.includeAdult, - titlePreference: this.normalizeTitlePreference(obj['titlePreference']), - adultDisplayMode: this.normalizeAdultDisplayMode(obj['adultDisplayMode']), - }; + return this.normalizeRawSettings(parsed as Record); } catch { return { ...DEFAULT_PROVIDER_SETTINGS,