-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[PM-12598] Create dedicated importer for Password-XP (csv) (#11751)
* Create dedicated password-xp csv importer * Add support for importing unmapped columns as custom fields * Add support for importing folders and assiging items to them * On import into an organization, convert folders to collections * Register importer within importService and make it selectable via the UI Add instructions on how to export from Password XP * Mark method as private * Add docs * Add comment around folder detection * Move test data into separate file --------- Co-authored-by: Daniel James Smith <[email protected]> Co-authored-by: Matt Bishop <[email protected]>
- Loading branch information
1 parent
d4a381e
commit 73632cd
Showing
9 changed files
with
258 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,148 @@ | ||
import { CipherType } from "@bitwarden/common/vault/enums"; | ||
|
||
import { PasswordXPCsvImporter } from "../src/importers"; | ||
import { ImportResult } from "../src/models/import-result"; | ||
|
||
import { noFolder } from "./test-data/passwordxp-csv/no-folder.csv"; | ||
import { withFolders } from "./test-data/passwordxp-csv/passwordxp-with-folders.csv"; | ||
import { withoutFolders } from "./test-data/passwordxp-csv/passwordxp-without-folders.csv"; | ||
|
||
describe("PasswordXPCsvImporter", () => { | ||
let importer: PasswordXPCsvImporter; | ||
|
||
beforeEach(() => { | ||
importer = new PasswordXPCsvImporter(); | ||
}); | ||
|
||
it("should return success false if CSV data is null", async () => { | ||
const data = ""; | ||
const result: ImportResult = await importer.parse(data); | ||
expect(result.success).toBe(false); | ||
}); | ||
|
||
it("should skip rows starting with >>>", async () => { | ||
const result: ImportResult = await importer.parse(noFolder); | ||
expect(result.success).toBe(true); | ||
expect(result.ciphers.length).toBe(0); | ||
}); | ||
|
||
it("should parse CSV data and return success true", async () => { | ||
const result: ImportResult = await importer.parse(withoutFolders); | ||
expect(result.success).toBe(true); | ||
expect(result.ciphers.length).toBe(4); | ||
|
||
let cipher = result.ciphers.shift(); | ||
expect(cipher.type).toBe(CipherType.Login); | ||
expect(cipher.name).toBe("Title2"); | ||
expect(cipher.notes).toBe("Test Notes"); | ||
expect(cipher.login.username).toBe("Username2"); | ||
expect(cipher.login.password).toBe("12345678"); | ||
expect(cipher.login.uris[0].uri).toBe("http://URL2.com"); | ||
|
||
cipher = result.ciphers.shift(); | ||
expect(cipher.type).toBe(CipherType.Login); | ||
expect(cipher.name).toBe("Title Test 1"); | ||
expect(cipher.notes).toBe("Test Notes 2"); | ||
expect(cipher.login.username).toBe("Username1"); | ||
expect(cipher.login.password).toBe("Password1"); | ||
expect(cipher.login.uris[0].uri).toBe("http://URL1.com"); | ||
|
||
cipher = result.ciphers.shift(); | ||
expect(cipher.type).toBe(CipherType.SecureNote); | ||
expect(cipher.name).toBe("Certificate 1"); | ||
expect(cipher.notes).toBe("Test Notes Certicate 1"); | ||
|
||
cipher = result.ciphers.shift(); | ||
expect(cipher.type).toBe(CipherType.Login); | ||
expect(cipher.name).toBe("test"); | ||
expect(cipher.notes).toBe("Test Notes 3"); | ||
expect(cipher.login.username).toBe("testtest"); | ||
expect(cipher.login.password).toBe("test"); | ||
expect(cipher.login.uris[0].uri).toBe("http://test"); | ||
}); | ||
|
||
it("should parse CSV data and import unmapped columns as custom fields", async () => { | ||
const result: ImportResult = await importer.parse(withoutFolders); | ||
expect(result.success).toBe(true); | ||
|
||
const cipher = result.ciphers.shift(); | ||
expect(cipher.type).toBe(CipherType.Login); | ||
expect(cipher.name).toBe("Title2"); | ||
expect(cipher.notes).toBe("Test Notes"); | ||
expect(cipher.login.username).toBe("Username2"); | ||
expect(cipher.login.password).toBe("12345678"); | ||
expect(cipher.login.uris[0].uri).toBe("http://URL2.com"); | ||
|
||
expect(cipher.fields.length).toBe(5); | ||
let field = cipher.fields.shift(); | ||
expect(field.name).toBe("Account"); | ||
expect(field.value).toBe("Account2"); | ||
|
||
field = cipher.fields.shift(); | ||
expect(field.name).toBe("Modified"); | ||
expect(field.value).toBe("27-3-2024 08:11:21"); | ||
|
||
field = cipher.fields.shift(); | ||
expect(field.name).toBe("Created"); | ||
expect(field.value).toBe("27-3-2024 08:11:21"); | ||
|
||
field = cipher.fields.shift(); | ||
expect(field.name).toBe("Expire on"); | ||
expect(field.value).toBe("27-5-2024 08:11:21"); | ||
|
||
field = cipher.fields.shift(); | ||
expect(field.name).toBe("Modified by"); | ||
expect(field.value).toBe("someone"); | ||
}); | ||
|
||
it("should parse CSV data with folders and assign items to them", async () => { | ||
const result: ImportResult = await importer.parse(withFolders); | ||
expect(result.success).toBe(true); | ||
expect(result.ciphers.length).toBe(5); | ||
|
||
expect(result.folders.length).toBe(3); | ||
let folder = result.folders.shift(); | ||
expect(folder.name).toEqual("Test Folder"); | ||
folder = result.folders.shift(); | ||
expect(folder.name).toEqual("Cert folder"); | ||
folder = result.folders.shift(); | ||
expect(folder.name).toEqual("Cert folder/Nested folder"); | ||
|
||
expect(result.folderRelationships.length).toBe(4); | ||
let folderRelationship = result.folderRelationships.shift(); | ||
expect(folderRelationship).toEqual([1, 0]); | ||
folderRelationship = result.folderRelationships.shift(); | ||
expect(folderRelationship).toEqual([2, 1]); | ||
folderRelationship = result.folderRelationships.shift(); | ||
expect(folderRelationship).toEqual([3, 1]); | ||
folderRelationship = result.folderRelationships.shift(); | ||
expect(folderRelationship).toEqual([4, 2]); | ||
folderRelationship = result.folderRelationships.shift(); | ||
}); | ||
|
||
it("should convert folders to collections when importing into an organization", async () => { | ||
importer.organizationId = "someOrg"; | ||
const result: ImportResult = await importer.parse(withFolders); | ||
expect(result.success).toBe(true); | ||
expect(result.ciphers.length).toBe(5); | ||
|
||
expect(result.collections.length).toBe(3); | ||
expect(result.collections[0].name).toEqual("Test Folder"); | ||
expect(result.collectionRelationships[0]).toEqual([1, 0]); | ||
expect(result.collections[1].name).toEqual("Cert folder"); | ||
expect(result.collectionRelationships[1]).toEqual([2, 1]); | ||
expect(result.collectionRelationships[2]).toEqual([3, 1]); | ||
expect(result.collections[2].name).toEqual("Cert folder/Nested folder"); | ||
|
||
expect(result.collectionRelationships.length).toBe(4); | ||
let collectionRelationship = result.collectionRelationships.shift(); | ||
expect(collectionRelationship).toEqual([1, 0]); | ||
collectionRelationship = result.collectionRelationships.shift(); | ||
expect(collectionRelationship).toEqual([2, 1]); | ||
collectionRelationship = result.collectionRelationships.shift(); | ||
expect(collectionRelationship).toEqual([3, 1]); | ||
collectionRelationship = result.collectionRelationships.shift(); | ||
expect(collectionRelationship).toEqual([4, 2]); | ||
collectionRelationship = result.collectionRelationships.shift(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export const noFolder = `Title;User name;Account;URL;Password;Modified;Created;Expire on;Description;Modified by | ||
>>>`; |
13 changes: 13 additions & 0 deletions
13
libs/importer/spec/test-data/passwordxp-csv/passwordxp-with-folders.csv.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
export const withFolders = `Title;User name;Account;URL;Password;Modified;Created;Expire on;Description;Modified by | ||
>>> | ||
Title2;Username2;Account2;http://URL2.com;12345678;27-3-2024 08:11:21;27-3-2024 08:11:21;;; | ||
[Test Folder] | ||
Title Test 1;Username1;Account1;http://URL1.com;Password1;27-3-2024 08:10:52;27-3-2024 08:10:52;;; | ||
[Cert folder] | ||
Certificate 1;;;;;27-3-2024 10:22:39;27-3-2024 10:22:39;;; | ||
test;testtest;;http://test;test;27-3-2024 12:36:59;27-3-2024 12:36:59;;; | ||
[Cert folder\\Nested folder]; | ||
test2;testtest;;http://test;test;27-3-2024 12:36:59;27-3-2024 12:36:59;;;`; |
7 changes: 7 additions & 0 deletions
7
libs/importer/spec/test-data/passwordxp-csv/passwordxp-without-folders.csv.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
export const withoutFolders = `Title;User name;Account;URL;Password;Modified;Created;Expire on;Description;Modified by | ||
>>> | ||
Title2;Username2;Account2;http://URL2.com;12345678;27-3-2024 08:11:21;27-3-2024 08:11:21;27-5-2024 08:11:21;Test Notes;someone | ||
Title Test 1;Username1;Account1;http://URL1.com;Password1;27-3-2024 08:10:52;27-3-2024 08:10:52;;Test Notes 2; | ||
Certificate 1;;;;;27-3-2024 10:22:39;27-3-2024 10:22:39;;Test Notes Certicate 1; | ||
test;testtest;;http://test;test;27-3-2024 12:36:59;27-3-2024 12:36:59;;Test Notes 3; | ||
`; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
import { Utils } from "@bitwarden/common/platform/misc/utils"; | ||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; | ||
|
||
import { ImportResult } from "../models/import-result"; | ||
|
||
import { BaseImporter } from "./base-importer"; | ||
import { Importer } from "./importer"; | ||
|
||
const _mappedColumns = new Set(["Title", "Username", "URL", "Password", "Description"]); | ||
|
||
/** | ||
* PasswordXP CSV importer | ||
*/ | ||
export class PasswordXPCsvImporter extends BaseImporter implements Importer { | ||
/** | ||
* Parses the PasswordXP CSV data. | ||
* @param data | ||
*/ | ||
parse(data: string): Promise<ImportResult> { | ||
// The header column 'User name' is parsed by the parser, but cannot be used as a variable. This converts it to a valid variable name, prior to parsing. | ||
data = data.replace(";User name;", ";Username;"); | ||
|
||
const result = new ImportResult(); | ||
const results = this.parseCsv(data, true, { skipEmptyLines: true }); | ||
if (results == null) { | ||
result.success = false; | ||
return Promise.resolve(result); | ||
} | ||
let currentFolderName = ""; | ||
results.forEach((row) => { | ||
// Skip rows starting with '>>>' as they indicate items following have no folder assigned to them | ||
if (row.Title == ">>>") { | ||
return; | ||
} | ||
|
||
const title = row.Title; | ||
// If the title is in the format [title], then it is a folder name | ||
if (title.startsWith("[") && title.endsWith("]")) { | ||
currentFolderName = title.startsWith("/") | ||
? title.replace("/", "") | ||
: title.substring(1, title.length - 1); | ||
return; | ||
} | ||
|
||
if (!Utils.isNullOrWhitespace(currentFolderName)) { | ||
this.processFolder(result, currentFolderName); | ||
} | ||
|
||
const cipher = this.initLoginCipher(); | ||
cipher.name = this.getValueOrDefault(row.Title); | ||
cipher.login.username = this.getValueOrDefault(row.Username); | ||
cipher.notes = this.getValueOrDefault(row.Description); | ||
cipher.login.uris = this.makeUriArray(row.URL); | ||
cipher.login.password = this.getValueOrDefault(row.Password); | ||
|
||
this.importUnmappedFields(cipher, row, _mappedColumns); | ||
|
||
this.convertToNoteIfNeeded(cipher); | ||
this.cleanupCipher(cipher); | ||
result.ciphers.push(cipher); | ||
}); | ||
|
||
if (this.organization) { | ||
this.moveFoldersToCollections(result); | ||
} | ||
|
||
result.success = true; | ||
return Promise.resolve(result); | ||
} | ||
|
||
private importUnmappedFields(cipher: CipherView, row: any, mappedValues: Set<string>) { | ||
const unmappedFields = Object.keys(row).filter((x) => !mappedValues.has(x)); | ||
unmappedFields.forEach((key) => { | ||
const item = row as any; | ||
this.processKvp(cipher, key, item[key]); | ||
}); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters