Repository: buttercup/buttercup-core
Pull request #340 (2024-02-06 20:21:38)

Vault source entry search
New search class for vault sources.


Patch:
diff --git a/source/index.common.ts b/source/index.common.ts
index ee1c121..6594a6f 100644
--- a/source/index.common.ts
+++ b/source/index.common.ts
@@ -80,6 +80,7 @@ export { SearchResult } from "./search/BaseSearch.js";
 export { VaultEntrySearch as Search } from "./search/VaultEntrySearch.js"; // compat @todo remove
 export { VaultEntrySearch } from "./search/VaultEntrySearch.js";
 export { VaultFacadeEntrySearch } from "./search/VaultFacadeEntrySearch.js";
+export { VaultSourceEntrySearch } from "./search/VaultSourceEntrySearch.js";
 export { SearchKey, buildSearcher } from "./search/searcher.js";
 
 export { AppEnv, AppEnvGetPropertyOptions } from "./env/core/appEnv.js";
diff --git a/source/search/BaseSearch.ts b/source/search/BaseSearch.ts
index bf06f6a..6079af0 100644
--- a/source/search/BaseSearch.ts
+++ b/source/search/BaseSearch.ts
@@ -2,8 +2,8 @@ import levenshtein from "fast-levenshtein";
 import { StorageInterface } from "../storage/StorageInterface.js";
 import { buildSearcher } from "./searcher.js";
 import { Vault } from "../core/Vault.js";
-import { EntryID, EntryType, GroupID, VaultFacade, VaultID } from "../types.js";
 import { extractTagsFromSearchTerm, tagsMatchSearch } from "./tags.js";
+import { EntryID, EntryType, GroupID, VaultFacade, VaultID, VaultSourceID } from "../types.js";
 
 interface DomainScores {
     [domain: string]: number;
@@ -28,6 +28,7 @@ export interface SearchResult {
     id: EntryID;
     properties: { [property: string]: string };
     tags: Array<string>;
+    sourceID?: VaultSourceID;
     urls: Array<string>;
     vaultID: VaultID;
 }
@@ -78,11 +79,20 @@ export class BaseSearch {
 
     /**
      * Last search results
+     * @deprecated Use `getResults` instead
      */
     get results(): Array<SearchResult> {
         return this._results;
     }
 
+    /**
+     * Get last search results
+     * @returns An array of results
+     */
+    getResults(): Array<SearchResult> {
+        return this._results;
+    }
+
     /**
      * Increment the score of a URL in an entry
      * @param vaultID The vault ID
@@ -110,7 +120,7 @@ export class BaseSearch {
      * Prepare the search instance by processing
      * entries
      */
-    async prepare() {
+    async prepare(): Promise<void> {
         this._entries = [];
         this._scores = {};
         for (const target of this._targets) {
diff --git a/source/search/VaultSourceEntrySearch.ts b/source/search/VaultSourceEntrySearch.ts
new file mode 100644
index 0000000..9e3096e
--- /dev/null
+++ b/source/search/VaultSourceEntrySearch.ts
@@ -0,0 +1,82 @@
+import { SearchResult, SearcherFactory } from "./BaseSearch.js";
+import { VaultSource } from "../core/VaultSource.js";
+import { Vault } from "../core/Vault.js";
+import { StorageInterface } from "../storage/StorageInterface.js";
+import { VaultEntrySearch } from "./VaultEntrySearch.js";
+import { VaultSourceStatus } from "../types.js";
+
+export class VaultSourceEntrySearch extends VaultEntrySearch {
+    _sources: Array<VaultSource>;
+
+    constructor(
+        sources: Array<VaultSource>,
+        memory?: StorageInterface,
+        searcherFactory?: SearcherFactory
+    ) {
+        const vaults: Array<Vault> = sources.reduce((output, source) => {
+            if (source.status === VaultSourceStatus.Unlocked) {
+                return [...output, source.vault];
+            }
+            return output;
+        }, []);
+        super(vaults, memory, searcherFactory);
+        this._sources = [...sources];
+    }
+
+    /**
+     * Last search results
+     * @deprecated Use `getResults` instead
+     */
+    get results(): Array<SearchResult> {
+        return this.getResults();
+    }
+
+    /**
+     * Get last search results
+     * @returns An array of results
+     */
+    getResults(): Array<SearchResult> {
+        return super.getResults().map((res) => {
+            const output = res;
+            const source = this._sources.find((src) => src?.vault?.id === output.vaultID);
+            if (source) {
+                output.sourceID = source.id;
+            }
+            return output;
+        });
+    }
+
+    /**
+     * Search for entries by term
+     * @param term The term to search for
+     * @returns An array of search results
+     */
+    searchByTerm(term: string): Array<SearchResult> {
+        const results = super.searchByTerm(term);
+        return results.map((res) => {
+            const output = res;
+            const source = this._sources.find((src) => src?.vault?.id === output.vaultID);
+            if (source) {
+                output.sourceID = source.id;
+            }
+            return output;
+        });
+    }
+
+    /**
+     * Search for entries by URL
+     * @param url The URL to search with
+     * @returns An array of search results
+     */
+    searchByURL(url: string): Array<SearchResult> {
+        const results = super.searchByURL(url);
+        return results.map((res) => {
+            const output = res;
+            const source = this._sources.find((src) => src?.vault?.id === output.vaultID);
+            if (source) {
+                output.sourceID = source.id;
+            }
+            return output;
+        });
+    }
+}


Test patch:
diff --git a/test/integration/search/VaultEntrySearch.spec.js b/test/integration/search/VaultEntrySearch.spec.js
index 92e7414..4f31daf 100644
--- a/test/integration/search/VaultEntrySearch.spec.js
+++ b/test/integration/search/VaultEntrySearch.spec.js
@@ -1,66 +1,10 @@
 import { expect } from "chai";
-import {
-    Entry,
-    EntryType,
-    Group,
-    MemoryStorageInterface,
-    Vault,
-    VaultEntrySearch
-} from "../../../dist/node/index.js";
+import { EntryType, MemoryStorageInterface, VaultEntrySearch } from "../../../dist/node/index.js";
+import { createSampleVault } from "./helpers.js";
 
 describe("VaultEntrySearch", function () {
     beforeEach(function () {
-        const vault = (this.vault = new Vault());
-        const groupA = vault.createGroup("Email");
-        groupA
-            .createEntry("Personal Mail")
-            .setProperty("username", "green.monkey@fastmail.com")
-            .setProperty("password", "df98Sm2.109x{91")
-            .setProperty("url", "https://fastmail.com")
-            .setAttribute(Entry.Attributes.FacadeType, EntryType.Website);
-        groupA
-            .createEntry("Work")
-            .setProperty("username", "j.crowley@gmov.edu.au")
-            .setProperty("password", "#f05c.*skU3")
-            .setProperty("URL", "gmov.edu.au/portal/auth")
-            .addTags("job");
-        groupA
-            .createEntry("Work logs")
-            .setProperty("username", "j.crowley@gmov.edu.au")
-            .setProperty("password", "#f05c.*skU3")
-            .setProperty("URL", "https://logs.gmov.edu.au/sys30/atc.php")
-            .addTags("job");
-        const groupB = vault.createGroup("Bank");
-        groupB
-            .createEntry("MyBank")
-            .setProperty("username", "324654356346")
-            .setProperty("PIN", "1234")
-            .setAttribute(Entry.Attributes.FacadeType, EntryType.Login)
-            .addTags("finance", "banking");
-        groupB
-            .createEntry("Insurance")
-            .setProperty("username", "testing-user")
-            .setProperty("URL", "http://test.org/portal-int/login.aspx")
-            .addTags("finance");
-        const groupC = vault.createGroup("General");
-        groupC
-            .createEntry("Clipart")
-            .setProperty("username", "gmonkey123")
-            .setProperty("password", "test93045")
-            .setProperty("Url", "clipart.com");
-        groupC
-            .createEntry("Wordpress")
-            .setProperty("username", "gmonkey1234")
-            .setProperty("password", "passw0rd")
-            .setProperty("Url", "https://wordpress.com/")
-            .setProperty("Login URL", "https://wordpress.com/account/login.php");
-        const trashGroup = vault.createGroup("Trash");
-        trashGroup
-            .createEntry("Ebay")
-            .setProperty("username", "gmk123@hotmail.com")
-            .setProperty("password", "passw0rd")
-            .setProperty("Url", "https://ebay.com/");
-        trashGroup.setAttribute(Group.Attribute.Role, "trash");
+        this.vault = createSampleVault();
     });
 
     it("can be instantiated", function () {
diff --git a/test/integration/search/VaultSourceEntrySearch.spec.js b/test/integration/search/VaultSourceEntrySearch.spec.js
new file mode 100644
index 0000000..c666619
--- /dev/null
+++ b/test/integration/search/VaultSourceEntrySearch.spec.js
@@ -0,0 +1,119 @@
+import { expect } from "chai";
+import {
+    EntryType,
+    MemoryStorageInterface,
+    VaultSourceEntrySearch
+} from "../../../dist/node/index.js";
+import { createSampleManager } from "./helpers.js";
+
+describe("VaultSourceEntrySearch", function () {
+    beforeEach(async function () {
+        const [, source] = await createSampleManager();
+        this.source = source;
+        this.vault = source.vault;
+    });
+
+    it("can be instantiated", function () {
+        expect(() => {
+            new VaultSourceEntrySearch([this.source]);
+        }).to.not.throw();
+    });
+
+    describe("instance", function () {
+        beforeEach(function () {
+            this.storage = new MemoryStorageInterface();
+            this.search = new VaultSourceEntrySearch([this.source], this.storage);
+            return this.search.prepare();
+        });
+
+        describe("searchByTerm", function () {
+            it("finds results by term", function () {
+                const results = this.search.searchByTerm("work").map((res) => res.properties.title);
+                expect(results[0]).to.equal("Work");
+                expect(results[1]).to.equal("Work logs");
+                expect(results[2]).to.equal("Wordpress");
+            });
+
+            it("excludes trash entries", function () {
+                const results = this.search.searchByTerm("ebay");
+                expect(results).to.have.lengthOf(0);
+            });
+
+            it("returns resulting entry type", function () {
+                const [res] = this.search.searchByTerm("Personal Mail");
+                expect(res).to.have.property("entryType", EntryType.Website);
+            });
+
+            it("returns results including entry group IDs", function () {
+                const [res] = this.search.searchByTerm("Personal Mail");
+                expect(res).to.have.property("groupID").that.is.a("string");
+            });
+
+            it("returns results using a single tag, no search", function () {
+                const results = this.search.searchByTerm("#job").map((res) => res.properties.title);
+                expect(results).to.deep.equal(["Work", "Work logs"]);
+            });
+
+            it("returns results using multiple tags, no search", function () {
+                const results = this.search
+                    .searchByTerm("#finance #banking")
+                    .map((res) => res.properties.title);
+                expect(results).to.deep.equal(["MyBank"]);
+            });
+
+            it("returns results using tags and search", function () {
+                const results = this.search
+                    .searchByTerm("#job logs")
+                    .map((res) => res.properties.title);
+                expect(results).to.deep.equal(["Work logs"]);
+            });
+
+            describe("results", function () {
+                it("contain source ID", function () {
+                    const [res] = this.search.searchByTerm("Personal Mail");
+                    expect(res).to.have.property("sourceID", this.source.id);
+                });
+            });
+        });
+
+        describe("searchByURL", function () {
+            it("finds results by URL", function () {
+                const results = this.search.searchByURL("https://wordpress.com/homepage/test/org");
+                expect(results).to.have.length.above(0);
+                expect(results[0]).to.have.nested.property("properties.title", "Wordpress");
+            });
+
+            it("excludes trash entries", function () {
+                const results = this.search.searchByURL("ebay.com");
+                expect(results).to.have.lengthOf(0);
+            });
+
+            it("finds multiple similar results", function () {
+                const results = this.search.searchByURL("https://gmov.edu.au/portal/");
+                expect(results).to.have.lengthOf(2);
+                expect(results[0].properties.title).to.equal("Work");
+                expect(results[1].properties.title).to.equal("Work logs");
+            });
+
+            it("supports ordering", function () {
+                const [entry] = this.vault.findEntriesByProperty("title", "Work logs");
+                return this.storage
+                    .setValue(
+                        `bcup_search_${this.vault.id}`,
+                        JSON.stringify({
+                            [entry.id]: {
+                                "gmov.edu.au": 1
+                            }
+                        })
+                    )
+                    .then(() => this.search.prepare())
+                    .then(() => {
+                        const results = this.search.searchByURL("https://gmov.edu.au/portal/");
+                        expect(results).to.have.lengthOf(2);
+                        expect(results[0].properties.title).to.equal("Work logs");
+                        expect(results[1].properties.title).to.equal("Work");
+                    });
+            });
+        });
+    });
+});
diff --git a/test/integration/search/helpers.js b/test/integration/search/helpers.js
new file mode 100644
index 0000000..9159609
--- /dev/null
+++ b/test/integration/search/helpers.js
@@ -0,0 +1,85 @@
+import {
+    Credentials,
+    Entry,
+    EntryType,
+    Group,
+    Vault,
+    VaultManager,
+    VaultSource
+} from "../../../dist/node/index.js";
+
+export async function createSampleManager() {
+    const manager = new VaultManager({
+        autoUpdate: false
+    });
+    const creds = Credentials.fromDatasource(
+        {
+            type: "memory",
+            property: `test:${Math.floor(Math.random() * 1000000)}`
+        },
+        "test"
+    );
+    const credsStr = await creds.toSecureString();
+    const source = new VaultSource("Refs test", "memory", credsStr);
+    await manager.addSource(source);
+    await source.unlock(Credentials.fromPassword("test"), {
+        initialiseRemote: true
+    });
+    createSampleVault(source.vault);
+    await source.save();
+    return [manager, source];
+}
+
+export function createSampleVault(vault = new Vault()) {
+    const groupA = vault.createGroup("Email");
+    groupA
+        .createEntry("Personal Mail")
+        .setProperty("username", "green.monkey@fastmail.com")
+        .setProperty("password", "df98Sm2.109x{91")
+        .setProperty("url", "https://fastmail.com")
+        .setAttribute(Entry.Attributes.FacadeType, EntryType.Website);
+    groupA
+        .createEntry("Work")
+        .setProperty("username", "j.crowley@gmov.edu.au")
+        .setProperty("password", "#f05c.*skU3")
+        .setProperty("URL", "gmov.edu.au/portal/auth")
+        .addTags("job");
+    groupA
+        .createEntry("Work logs")
+        .setProperty("username", "j.crowley@gmov.edu.au")
+        .setProperty("password", "#f05c.*skU3")
+        .setProperty("URL", "https://logs.gmov.edu.au/sys30/atc.php")
+        .addTags("job");
+    const groupB = vault.createGroup("Bank");
+    groupB
+        .createEntry("MyBank")
+        .setProperty("username", "324654356346")
+        .setProperty("PIN", "1234")
+        .setAttribute(Entry.Attributes.FacadeType, EntryType.Login)
+        .addTags("finance", "banking");
+    groupB
+        .createEntry("Insurance")
+        .setProperty("username", "testing-user")
+        .setProperty("URL", "http://test.org/portal-int/login.aspx")
+        .addTags("finance");
+    const groupC = vault.createGroup("General");
+    groupC
+        .createEntry("Clipart")
+        .setProperty("username", "gmonkey123")
+        .setProperty("password", "test93045")
+        .setProperty("Url", "clipart.com");
+    groupC
+        .createEntry("Wordpress")
+        .setProperty("username", "gmonkey1234")
+        .setProperty("password", "passw0rd")
+        .setProperty("Url", "https://wordpress.com/")
+        .setProperty("Login URL", "https://wordpress.com/account/login.php");
+    const trashGroup = vault.createGroup("Trash");
+    trashGroup
+        .createEntry("Ebay")
+        .setProperty("username", "gmk123@hotmail.com")
+        .setProperty("password", "passw0rd")
+        .setProperty("Url", "https://ebay.com/");
+    trashGroup.setAttribute(Group.Attribute.Role, "trash");
+    return vault;
+}