diff --git a/libs/common/src/vault/models/request/folder-with-id.request.ts b/libs/common/src/vault/models/request/folder-with-id.request.ts index 8af890048ba..aecb12a05fc 100644 --- a/libs/common/src/vault/models/request/folder-with-id.request.ts +++ b/libs/common/src/vault/models/request/folder-with-id.request.ts @@ -1,12 +1,25 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore import { Folder } from "../domain/folder"; import { FolderRequest } from "./folder.request"; export class FolderWithIdRequest extends FolderRequest { + /** + * Declared as `string` (not `string | null`) to satisfy the + * {@link UserKeyRotationDataProvider}`` + * constraint on `FolderService`. + * + * At runtime this is `null` for new import folders. PR #17077 enforced strict type-checking on + * folder models, changing this assignment to `folder.id ?? ""` — causing the importer to send + * `{"id":""}` instead of `{"id":null}`, which the server rejected. + * The `|| null` below restores the pre-migration behavior while `@ts-strict-ignore` above + * allows the `null` assignment against the `string` declaration. + */ id: string; constructor(folder: Folder) { super(folder); - this.id = folder.id ?? ""; + this.id = folder.id || null; } } diff --git a/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.spec.ts b/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.spec.ts index 8f1a281050f..44ee35568d0 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.spec.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.spec.ts @@ -120,4 +120,106 @@ describe("BitwardenCsvImporter", () => { expect(result.ciphers.length).toBe(1); expect(result.ciphers[0].archivedDate).toBeUndefined(); }); + + describe("Individual vault imports with folders", () => { + beforeEach(() => { + importer.organizationId = null; + }); + + it("should parse folder and create a folder relationship", async () => { + const data = + `folder,favorite,type,name,login_uri,login_username,login_password` + + `\nSocial,0,login,Facebook,https://facebook.com,user@example.com,password`; + + const result = await importer.parse(data); + + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(1); + expect(result.folders.length).toBe(1); + expect(result.folders[0].name).toBe("Social"); + expect(result.folderRelationships).toHaveLength(1); + expect(result.folderRelationships[0]).toEqual([0, 0]); + }); + + it("should deduplicate folders when multiple items share the same folder", async () => { + const data = + `folder,favorite,type,name,login_uri,login_username,login_password` + + `\nSocial,0,login,Facebook,https://facebook.com,user1,pass1` + + `\nSocial,0,login,Twitter,https://twitter.com,user2,pass2`; + + const result = await importer.parse(data); + + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(2); + expect(result.folders.length).toBe(1); + expect(result.folders[0].name).toBe("Social"); + expect(result.folderRelationships).toHaveLength(2); + expect(result.folderRelationships[0]).toEqual([0, 0]); + expect(result.folderRelationships[1]).toEqual([1, 0]); + }); + + it("should create parent folders for nested folder paths", async () => { + const data = + `folder,favorite,type,name,login_uri,login_username,login_password` + + `\nWork/Email,0,login,Gmail,https://gmail.com,user@work.com,pass`; + + const result = await importer.parse(data); + + expect(result.success).toBe(true); + expect(result.folders.length).toBe(2); + expect(result.folders.map((f) => f.name)).toContain("Work/Email"); + expect(result.folders.map((f) => f.name)).toContain("Work"); + expect(result.folderRelationships).toHaveLength(1); + expect(result.folderRelationships[0]).toEqual([0, 0]); + }); + + it("should create no folder or relationship when folder column is empty", async () => { + const data = + `folder,favorite,type,name,login_uri,login_username,login_password` + + `\n,0,login,No Folder Item,https://example.com,user,pass`; + + const result = await importer.parse(data); + + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(1); + expect(result.folders.length).toBe(0); + expect(result.folderRelationships).toHaveLength(0); + }); + }); + + describe("organization collection import", () => { + it("should set collectionRelationships mapping ciphers to collections", async () => { + const data = + `collections,type,name,login_uri,login_username,login_password` + + `\ncol1,login,Item1,https://example.com,user1,pass1` + + `\ncol2,login,Item2,https://example.com,user2,pass2`; + + const result = await importer.parse(data); + + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(2); + expect(result.collections.length).toBe(2); + // Each cipher maps to its own collection + expect(result.collectionRelationships).toHaveLength(2); + expect(result.collectionRelationships[0]).toEqual([0, 0]); + expect(result.collectionRelationships[1]).toEqual([1, 1]); + }); + + it("should deduplicate collections and map both ciphers to the shared collection", async () => { + const data = + `collections,type,name,login_uri,login_username,login_password` + + `\nShared,login,Item1,https://example.com,user1,pass1` + + `\nShared,login,Item2,https://example.com,user2,pass2`; + + const result = await importer.parse(data); + + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(2); + expect(result.collections.length).toBe(1); + expect(result.collections[0].name).toBe("Shared"); + expect(result.collectionRelationships).toHaveLength(2); + expect(result.collectionRelationships[0]).toEqual([0, 0]); + expect(result.collectionRelationships[1]).toEqual([1, 0]); + }); + }); }); diff --git a/libs/importer/src/services/import.service.spec.ts b/libs/importer/src/services/import.service.spec.ts index 33a1e47a4ce..5f9b3c4b085 100644 --- a/libs/importer/src/services/import.service.spec.ts +++ b/libs/importer/src/services/import.service.spec.ts @@ -15,6 +15,8 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { Folder } from "@bitwarden/common/vault/models/domain/folder"; +import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; @@ -280,6 +282,25 @@ describe("ImportService", () => { }); }); +describe("FolderWithIdRequest", () => { + function makeFolder(id: string): Folder { + const folder = new Folder(); + folder.id = id; + return folder; + } + + it("preserves a real folder id", () => { + const guid = "f1a2b3c4-d5e6-7890-abcd-ef1234567890"; + const request = new FolderWithIdRequest(makeFolder(guid)); + expect(request.id).toBe(guid); + }); + + it("sends null when folder id is empty string (new import folder)", () => { + const request = new FolderWithIdRequest(makeFolder("")); + expect(request.id).toBeNull(); + }); +}); + function createCipher(options: Partial = {}) { const cipher = new CipherView();