1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-24 08:33:29 +00:00

[PM-32471] [Defect] Importers have regressed during folder migration (#19079)

* relax type-checking and add importer test coverage

* satisfy lint
This commit is contained in:
John Harrington
2026-02-20 09:31:49 -07:00
committed by GitHub
parent a610ce01a2
commit c623407621
3 changed files with 137 additions and 1 deletions

View File

@@ -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}`<TRequest extends { id: string } | { organizationId: string }>`
* 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;
}
}

View File

@@ -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]);
});
});
});

View File

@@ -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<CipherView> = {}) {
const cipher = new CipherView();