Skip to content
Merged
2 changes: 1 addition & 1 deletion src/app/home/home.page.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
[(ngModel)]="input.host"
inputmode="url"
type="text"
(ionInput)="update()"
(ionInput)="onHostChange()"
(keyup.enter)="hideKeyboard()"
></ion-input>
</ion-item>
Expand Down
33 changes: 33 additions & 0 deletions src/app/home/home.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { informationCircleOutline, warning, copy } from 'ionicons/icons';

import { Input } from '../../models/Input';
import { PasswordsService } from '../passwords.service';
import { PatternMatcherService } from '../pattern-matcher.service';
import { Settings } from '../../models/Settings';
import { SettingsAdvanced } from '../../models/SettingsAdvanced';
import { SettingsService } from '../settings.service';
Expand All @@ -21,6 +22,7 @@ export class HomePageComponent implements OnInit {
private changeDetector = inject(ChangeDetectorRef);
loadingController = inject(LoadingController);
private passwordsService = inject(PasswordsService);
private patternMatcher = inject(PatternMatcherService);
private platform = inject(Platform);
private settingsService = inject(SettingsService);
toast = inject(ToastController);
Expand All @@ -38,6 +40,7 @@ export class HomePageComponent implements OnInit {
private expiry_timer_id: number;
private loading: HTMLIonLoadingElement;
protected master_password_hash?: string;
private userChangedProfile = false; // Track if user manually changed profile

constructor() {
addIcons({ informationCircleOutline, warning, copy });
Expand Down Expand Up @@ -87,6 +90,11 @@ export class HomePageComponent implements OnInit {
if (settings instanceof SettingsAdvanced) {
this.advanced_mode = true;
this.input.active_profile_id = settings.active_profile_id;

// Auto-select profile based on patterns if user hasn't manually changed it
if (!this.userChangedProfile && this.input.host.length > 0) {
this.autoSelectProfileForHost(settings);
}
}

if (this.input.master_password.length === 0 || this.input.host.length === 0) {
Expand Down Expand Up @@ -132,11 +140,36 @@ export class HomePageComponent implements OnInit {

switchProfile(event: any) {
if (this.settings instanceof SettingsAdvanced) {
this.userChangedProfile = true; // Mark that user manually changed profile
this.settings.setActiveProfile(event.detail.value);
this.settingsService.save(this.settings);
}
}

/**
* Auto-select a profile based on URL pattern matching
* Note: Auto-selected profile is temporary and won't persist to storage
* unless the user manually selects it or generates a password with it
*/
private autoSelectProfileForHost(settings: SettingsAdvanced) {
const matchingProfile = this.patternMatcher.findMatchingProfile(this.input.host, settings.profiles);

if (matchingProfile && matchingProfile.profile_id !== this.input.active_profile_id) {
this.input.active_profile_id = matchingProfile.profile_id;
settings.setActiveProfile(matchingProfile.profile_id);
// Note: We don't save settings here to avoid constant writes as user types.
// The profile selection is temporary for this session.
}
}

/**
* Reset the userChangedProfile flag when host changes
*/
onHostChange() {
this.userChangedProfile = false;
this.update();
}

copy() {
Clipboard.write({ string: this.output_password }).then(() => {
this.toast.create({
Expand Down
220 changes: 220 additions & 0 deletions src/app/import.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { TestBed } from '@angular/core/testing';
import { ImportService } from './import.service';
import { Profile } from '../models/Profile';

describe('ImportService', () => {
let service: ImportService;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [ImportService]
});
service = TestBed.inject(ImportService);
});

it('should be created', () => {
expect(service).toBeTruthy();
});

describe('parseSitePatterns', () => {
it('should parse wildcard patterns from RDF', () => {
const xmlString = `<?xml version="1.0"?>
<RDF:RDF xmlns:NS1="http://passwordmaker.mozdev.org/rdf#"
xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<RDF:Description
NS1:pattern0="domain1.com"
NS1:patternenabled0="true"
NS1:patterndesc0=""
NS1:patterntype0="wildcard"
NS1:pattern1="domain2.uk"
NS1:patternenabled1="true"
NS1:patterndesc1=""
NS1:patterntype1="wildcard"
/>
</RDF:RDF>
`;

const parser = new DOMParser();
const doc = parser.parseFromString(xmlString, 'text/xml');
const element = doc.getElementsByTagName('RDF:Description')[0];

const patterns = service['parseSitePatterns'](element);

expect(patterns.length).toBe(2);
expect(patterns[0]).toEqual({
pattern: 'domain1.com',
enabled: true,
type: 'wildcard',
description: ''
});
expect(patterns[1]).toEqual({
pattern: 'domain2.uk',
enabled: true,
type: 'wildcard',
description: ''
});
});

it('should parse regex patterns from RDF', () => {
const xmlString = `<?xml version="1.0"?>
<RDF:RDF xmlns:NS1="http://passwordmaker.mozdev.org/rdf#"
xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<RDF:Description
NS1:pattern0="https?://my\\.example\\.com/.*"
NS1:patternenabled0="true"
NS1:patterndesc0=""
NS1:patterntype0="regex"
/>
</RDF:RDF>
`;

const parser = new DOMParser();
const doc = parser.parseFromString(xmlString, 'text/xml');
const element = doc.getElementsByTagName('RDF:Description')[0];

const patterns = service['parseSitePatterns'](element);

expect(patterns.length).toBe(1);
expect(patterns[0].type).toBe('regex');
expect(patterns[0].pattern).toBe('https?://my\\.example\\.com/.*');
});

it('should skip disabled patterns', () => {
const xmlString = `<?xml version="1.0"?>
<RDF:RDF xmlns:NS1="http://passwordmaker.mozdev.org/rdf#"
xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<RDF:Description
NS1:pattern0="enabled.com"
NS1:patternenabled0="true"
NS1:patterntype0="wildcard"
NS1:pattern1="disabled.com"
NS1:patternenabled1="false"
NS1:patterntype1="wildcard"
/>
</RDF:RDF>
`;

const parser = new DOMParser();
const doc = parser.parseFromString(xmlString, 'text/xml');
const element = doc.getElementsByTagName('RDF:Description')[0];

const patterns = service['parseSitePatterns'](element);

expect(patterns.length).toBe(1);
expect(patterns[0].pattern).toBe('enabled.com');
});
});

describe('generateRdfExport', () => {
it('should export patterns in RDF format', () => {
const profile = new Profile();
profile.name = 'Test Profile';
profile.patterns = [
{
pattern: 'example.com',
enabled: true,
type: 'wildcard',
description: 'Test pattern'
},
{
pattern: 'https?://.*\\.test\\.com/.*',
enabled: true,
type: 'regex',
description: ''
}
];

const rdf = service.generateRdfExport([profile]);

expect(rdf).toContain('NS1:pattern0="example.com"');
expect(rdf).toContain('NS1:patternenabled0="true"');
expect(rdf).toContain('NS1:patterntype0="wildcard"');
expect(rdf).toContain('NS1:patterndesc0="Test pattern"');

expect(rdf).toContain('NS1:pattern1="https?://.*\\.test\\.com/.*"');
expect(rdf).toContain('NS1:patternenabled1="true"');
expect(rdf).toContain('NS1:patterntype1="regex"');
});

it('should not export disabled patterns', () => {
const profile = new Profile();
profile.name = 'Test Profile';
profile.patterns = [
{
pattern: 'disabled.com',
enabled: false,
type: 'wildcard',
description: ''
}
];

const rdf = service.generateRdfExport([profile]);

expect(rdf).not.toContain('disabled.com');
});

it('should handle profiles with no patterns', () => {
const profile = new Profile();
profile.name = 'Test Profile';
profile.patterns = [];

const rdf = service.generateRdfExport([profile]);

expect(rdf).toContain('NS1:name="Test Profile"');
expect(rdf).not.toContain('NS1:pattern');
});

it('should escape XML special characters in patterns', () => {
const profile = new Profile();
profile.name = 'Test Profile';
profile.patterns = [
{
pattern: 'test<>&"\'pattern',
enabled: true,
type: 'wildcard',
description: 'desc<>&"\''
}
];

const rdf = service.generateRdfExport([profile]);

expect(rdf).toContain('&lt;');
expect(rdf).toContain('&gt;');
expect(rdf).toContain('&amp;');
expect(rdf).toContain('&quot;');
expect(rdf).toContain('&#39;');
});
});

describe('convertToProfile', () => {
it('should convert imported patterns to Profile', () => {
const imported = {
title: 'Test Profile',
patterns: [
{
pattern: 'example.com',
enabled: true,
type: 'wildcard' as const,
description: 'Test'
}
]
};

const profile = service.convertToProfile(imported);

expect(profile.name).toBe('Test Profile');
expect(profile.patterns.length).toBe(1);
expect(profile.patterns[0].pattern).toBe('example.com');
});

it('should handle missing patterns', () => {
const imported = {
title: 'Test Profile'
};

const profile = service.convertToProfile(imported);

expect(profile.patterns).toEqual([]);
});
});
});
Loading
Loading