mirror of
https://github.com/bitwarden/browser
synced 2026-02-10 05:30:01 +00:00
Finish Basic Handling
This commit is contained in:
@@ -5,59 +5,185 @@ import { BasicFilter, BasicVaultFilterHandler } from "./basic-vault-filter.handl
|
||||
describe("BasicVaultFilterHandler", () => {
|
||||
const sut = new BasicVaultFilterHandler(mock());
|
||||
|
||||
describe("tryParse", () => {
|
||||
it("success", () => {
|
||||
const result = sut.tryParse(
|
||||
'(in:collection:"My Collection" OR in:collection:"Other Collection")',
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
fail("Result is expected to succeed");
|
||||
}
|
||||
|
||||
expect(result.filter).toBe({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toFilter", () => {
|
||||
const cases: { input: BasicFilter; output: string }[] = [
|
||||
{
|
||||
input: {
|
||||
vaults: [null, "org_vault"],
|
||||
collections: ["collection_one", "collection_two"],
|
||||
fields: ["one", "two"],
|
||||
types: ["Login", "Card"],
|
||||
folders: ["folder_one", "folder_two"],
|
||||
},
|
||||
output:
|
||||
'(in:my_vault OR in:org:"org_vault") AND (in:folder:"folder_one" OR in:folder:"folder_two") AND (in:collection:"collection_one" AND in:collection:"collection_two") AND (type:"Login" OR type:"Card") AND (field:"one" AND field:"two")',
|
||||
const successfulCases: { basicFilter: BasicFilter; rawFilter: string }[] = [
|
||||
{
|
||||
basicFilter: {
|
||||
vaults: [null, "org_vault"],
|
||||
collections: ["collection_one", "collection_two"],
|
||||
fields: [],
|
||||
types: ["Login", "Card"],
|
||||
folders: ["folder_one", "folder_two"],
|
||||
},
|
||||
rawFilter:
|
||||
'(in:my_vault OR in:org:"org_vault") AND (in:folder:"folder_one" OR in:folder:"folder_two") AND (in:collection:"collection_one" AND in:collection:"collection_two") AND (type:"Login" OR type:"Card")',
|
||||
},
|
||||
{
|
||||
basicFilter: {
|
||||
vaults: [null, "org_one"],
|
||||
collections: [],
|
||||
fields: [],
|
||||
types: [],
|
||||
folders: [],
|
||||
},
|
||||
rawFilter: `(in:my_vault OR in:org:"org_one")`,
|
||||
},
|
||||
{
|
||||
basicFilter: {
|
||||
vaults: [],
|
||||
collections: ["collection_one", "Collection two"],
|
||||
fields: [],
|
||||
types: [],
|
||||
folders: [],
|
||||
},
|
||||
rawFilter: '(in:collection:"collection_one" AND in:collection:"Collection two")',
|
||||
},
|
||||
{
|
||||
basicFilter: {
|
||||
vaults: [],
|
||||
collections: [],
|
||||
fields: [],
|
||||
types: ["Card", "Login"],
|
||||
folders: [],
|
||||
},
|
||||
rawFilter: '(type:"Card" OR type:"Login")',
|
||||
},
|
||||
{
|
||||
basicFilter: {
|
||||
vaults: [],
|
||||
collections: [],
|
||||
fields: [],
|
||||
types: [],
|
||||
folders: ["folder_one", "Folder two"],
|
||||
},
|
||||
rawFilter: '(in:folder:"folder_one" OR in:folder:"Folder two")',
|
||||
},
|
||||
{
|
||||
basicFilter: {
|
||||
vaults: [],
|
||||
collections: [],
|
||||
fields: [],
|
||||
types: ["Card", "Login"],
|
||||
folders: ["folder_one", "Folder two"],
|
||||
},
|
||||
rawFilter:
|
||||
'(in:folder:"folder_one" OR in:folder:"Folder two") AND (type:"Card" OR type:"Login")',
|
||||
},
|
||||
{
|
||||
// Example of a filter that we could pretty
|
||||
basicFilter: {
|
||||
vaults: [null],
|
||||
collections: [],
|
||||
fields: [],
|
||||
types: [],
|
||||
folders: [],
|
||||
},
|
||||
rawFilter: "(in:my_vault)",
|
||||
},
|
||||
];
|
||||
|
||||
describe("tryParse", () => {
|
||||
it.each(successfulCases)(
|
||||
"successfully parses $rawFilter query into basic filter",
|
||||
({ basicFilter, rawFilter }, done) => {
|
||||
const result = sut.tryParse(rawFilter);
|
||||
|
||||
if (!result.success) {
|
||||
done("Result is expected to succeed");
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.filter).toEqual(basicFilter);
|
||||
done();
|
||||
},
|
||||
);
|
||||
|
||||
// These are cases where they are parsable but they would never be generated this way via the normal basic
|
||||
const extraAllowedSyntax: { basicFilter: BasicFilter; rawFilter: string }[] = [
|
||||
{
|
||||
input: {
|
||||
basicFilter: {
|
||||
vaults: [null],
|
||||
collections: [],
|
||||
fields: [],
|
||||
types: [],
|
||||
folders: [],
|
||||
},
|
||||
output: "(in:my_vault)",
|
||||
rawFilter: "in:my_vault",
|
||||
},
|
||||
{
|
||||
input: {
|
||||
basicFilter: {
|
||||
vaults: [],
|
||||
collections: [],
|
||||
fields: ["Banking"],
|
||||
collections: ["my_collection"],
|
||||
fields: [],
|
||||
types: [],
|
||||
folders: [],
|
||||
},
|
||||
output: '(field:"Banking")',
|
||||
rawFilter: 'in:collection:"my_collection"',
|
||||
},
|
||||
{
|
||||
basicFilter: {
|
||||
vaults: [],
|
||||
collections: [],
|
||||
fields: [],
|
||||
types: ["Login"],
|
||||
folders: [],
|
||||
},
|
||||
rawFilter: 'type:"Login"',
|
||||
},
|
||||
{
|
||||
basicFilter: {
|
||||
vaults: [],
|
||||
collections: [],
|
||||
fields: [],
|
||||
types: [],
|
||||
folders: ["my_folder"],
|
||||
},
|
||||
rawFilter: 'in:folder:"my_folder"',
|
||||
},
|
||||
];
|
||||
|
||||
it.each(cases)("translates basic filter to $output", ({ input, output }) => {
|
||||
const actualOutput = sut.toFilter(input);
|
||||
it.each(extraAllowedSyntax)(
|
||||
"allows parsing of filter $rawFilter",
|
||||
({ basicFilter, rawFilter }, done) => {
|
||||
const result = sut.tryParse(rawFilter);
|
||||
|
||||
expect(actualOutput).toEqual(output);
|
||||
if (!result.success) {
|
||||
done("Result is expected to succeed");
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.filter).toEqual(basicFilter);
|
||||
done();
|
||||
},
|
||||
);
|
||||
|
||||
const unrepresentableInBasic: string[] = [
|
||||
// We use OR for folders
|
||||
'(in:folder:"folder_one" AND in:folder:"Folder two")',
|
||||
// We use OR for vaults
|
||||
'(in:my_vault AND in:org:"Org one")',
|
||||
// We use AND for collections but we could offer the selection and in the case this could be valid too
|
||||
'(in:collection:"Collection one" OR in:collection:"Collection two")',
|
||||
// We use OR for type
|
||||
'(type:"Login" AND type:"Card")',
|
||||
// We wouldn't put the same expression in multiple groups - This is a place where we could get smarter
|
||||
'(type:"Login") AND (type:"Card")',
|
||||
];
|
||||
|
||||
it.each(unrepresentableInBasic)("does not succeed when filter is %s", (filter) => {
|
||||
const result = sut.tryParse(filter);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toFilter", () => {
|
||||
it.each(successfulCases)(
|
||||
"translates basic filter to $rawFilter",
|
||||
({ basicFilter, rawFilter }) => {
|
||||
const actualOutput = sut.toFilter(basicFilter);
|
||||
|
||||
expect(actualOutput).toEqual(rawFilter);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import {
|
||||
AstNode,
|
||||
isAnd,
|
||||
isBinary,
|
||||
isFieldTerm,
|
||||
isInCollection,
|
||||
isInFolder,
|
||||
isInMyVault,
|
||||
isInOrg,
|
||||
isOr,
|
||||
isParentheses,
|
||||
isTypeFilter,
|
||||
} from "../search/ast";
|
||||
import { parseQuery } from "../search/parse";
|
||||
|
||||
export type BasicFilter = {
|
||||
@@ -13,14 +26,187 @@ export class BasicVaultFilterHandler {
|
||||
constructor(private readonly logService: LogService) {}
|
||||
|
||||
tryParse(rawFilter: string): { success: true; filter: BasicFilter } | { success: false } {
|
||||
// TODO: Handle vaults
|
||||
const expectedBinaryOperator: Record<string, string> = {
|
||||
vaults: "or",
|
||||
folders: "or",
|
||||
collections: "and",
|
||||
types: "or",
|
||||
fields: "and",
|
||||
};
|
||||
|
||||
// Parse into AST
|
||||
const ast = parseQuery(rawFilter, this.logService).ast;
|
||||
|
||||
if (ast.type !== "search") {
|
||||
const basicFilter: Partial<BasicFilter> = {
|
||||
vaults: null,
|
||||
folders: null,
|
||||
collections: null,
|
||||
types: null,
|
||||
fields: null,
|
||||
};
|
||||
|
||||
type VisitData = {
|
||||
operator: "or" | "and" | null;
|
||||
type: keyof BasicFilter | null;
|
||||
values: string[];
|
||||
};
|
||||
|
||||
const isValidOperator = (data: VisitData) => {
|
||||
if (data.type == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data.values.length === 1) {
|
||||
// If there aren't multiple values, a null operator is fine and implied
|
||||
return data.operator === null;
|
||||
}
|
||||
|
||||
return expectedBinaryOperator[data.type] === data.operator;
|
||||
};
|
||||
|
||||
const visitNode = (node: AstNode, data: VisitData): boolean => {
|
||||
if (isParentheses(node)) {
|
||||
return visitNode(node.inner, data);
|
||||
}
|
||||
|
||||
if (isBinary(node)) {
|
||||
if (data.operator != null && data.operator !== node.type) {
|
||||
// All inner operators must be the same
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set the operator in case it is null
|
||||
data.operator = node.type;
|
||||
// Visit both left and right
|
||||
return visitNode(node.left, data) && visitNode(node.right, data);
|
||||
}
|
||||
|
||||
let expressionType: keyof BasicFilter = null;
|
||||
let value: string | null | undefined = undefined;
|
||||
|
||||
if (isInMyVault(node)) {
|
||||
expressionType = "vaults";
|
||||
// null is used to indicate personal vault in basic filter
|
||||
value = null;
|
||||
} else if (isInOrg(node)) {
|
||||
expressionType = "vaults";
|
||||
value = node.org;
|
||||
} else if (isInFolder(node)) {
|
||||
expressionType = "folders";
|
||||
value = node.folder;
|
||||
} else if (isInCollection(node)) {
|
||||
expressionType = "collections";
|
||||
value = node.collection;
|
||||
} else if (isTypeFilter(node)) {
|
||||
expressionType = "types";
|
||||
value = node.cipherType;
|
||||
} else if (isFieldTerm(node)) {
|
||||
expressionType = "fields";
|
||||
value = node.field;
|
||||
} else {
|
||||
// There are various nodes we don't support in the basic filter, this is likely one of those.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data.type == null) {
|
||||
data.type = expressionType;
|
||||
} else if (data.type !== expressionType) {
|
||||
// We've previously visited a node of a different type, unsupported
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
throw new Error("Unreachable");
|
||||
}
|
||||
|
||||
// Is the string quoted?
|
||||
if (value != null && value[0] === '"' && value[value.length - 1] === '"') {
|
||||
// Unquote the string if it's quoted, ideally the ast offers this.
|
||||
value = value.substring(1, value.length - 1);
|
||||
}
|
||||
|
||||
data.values.push(value);
|
||||
return true;
|
||||
};
|
||||
|
||||
const visitTopLevel = (node: AstNode): boolean => {
|
||||
if (isParentheses(node)) {
|
||||
// Process top-level parentheses
|
||||
// Process singular group
|
||||
// TODO: Visit out "useless" parentheses
|
||||
const parenthesesData: VisitData = {
|
||||
operator: null,
|
||||
type: null,
|
||||
values: [],
|
||||
};
|
||||
|
||||
if (!visitNode(node.inner, parenthesesData)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (basicFilter[parenthesesData.type] != null) {
|
||||
// We've already got data for this type
|
||||
return false;
|
||||
}
|
||||
|
||||
basicFilter[parenthesesData.type] = parenthesesData.values;
|
||||
return isValidOperator(parenthesesData);
|
||||
} else if (isAnd(node)) {
|
||||
// Process top-level and
|
||||
return visitTopLevel(node.left) && visitTopLevel(node.right);
|
||||
} else if (isOr(node)) {
|
||||
// We do not support top level or
|
||||
return false;
|
||||
} else {
|
||||
// Process singular node
|
||||
const singularData: VisitData = {
|
||||
operator: null,
|
||||
type: null,
|
||||
values: [],
|
||||
};
|
||||
const visitResult = visitNode(node, singularData);
|
||||
|
||||
if (!visitResult) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (singularData.operator != null) {
|
||||
// If one of these nodes came back with an operator, it was not the kind of node we were expecting
|
||||
return false;
|
||||
}
|
||||
|
||||
if (singularData.values.length !== 1) {
|
||||
// If one of these nodes came back with multiple values, it was not the kind of node we were expecting
|
||||
return false;
|
||||
}
|
||||
|
||||
if (basicFilter[singularData.type] != null) {
|
||||
// Can't have multiple groups about the same type
|
||||
return false;
|
||||
}
|
||||
|
||||
basicFilter[singularData.type] = singularData.values;
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
if (visitTopLevel(ast.contents)) {
|
||||
// Normalize filter for return
|
||||
return {
|
||||
success: true,
|
||||
filter: {
|
||||
vaults: basicFilter.vaults ?? [],
|
||||
collections: basicFilter.collections ?? [],
|
||||
folders: basicFilter.folders ?? [],
|
||||
fields: basicFilter.fields ?? [],
|
||||
types: basicFilter.types ?? [],
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
toFilter(basicFilter: BasicFilter) {
|
||||
|
||||
@@ -99,6 +99,10 @@ export function isOr(x: AstNode): x is Or {
|
||||
return x.type === "or";
|
||||
}
|
||||
|
||||
export function isBinary(x: AstNode): x is And | Or {
|
||||
return x.type === "and" || x.type === "or";
|
||||
}
|
||||
|
||||
export type Term = AstNodeBase & {
|
||||
type: "term";
|
||||
value: string;
|
||||
|
||||
Reference in New Issue
Block a user