@itwin/core-backend
Version:
iTwin.js backend components
453 lines • 25.3 kB
JavaScript
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
import { expect } from "chai";
import * as fs from "fs-extra";
import { extname } from "path";
import * as sinon from "sinon";
import { Guid } from "@itwin/core-bentley";
import { Range3d } from "@itwin/core-geometry";
import { SettingsPriority } from "../../workspace/Settings";
import { settingsWorkspaceDbName } from "../../workspace/SettingsDb";
import { WorkspaceEditor } from "../../workspace/WorkspaceEditor";
import { IModelTestUtils } from "../IModelTestUtils";
import { validateWorkspaceContainerId } from "../../internal/workspace/WorkspaceImpl";
import { _nativeDb } from "../../internal/Symbols";
import { CloudSqlite } from "../../CloudSqlite";
import { BlobContainer } from "../../BlobContainerService";
describe("WorkspaceFile", () => {
let editor;
let workspace;
before(() => {
editor = WorkspaceEditor.construct();
workspace = editor.workspace;
});
after(() => {
editor.close();
});
async function makeEditableDb(props, manifest) {
const container = editor.getContainer({ ...props, accessToken: "" });
const wsFile = await container.createDb({ ...props, manifest });
wsFile.open();
return wsFile;
}
function compareFiles(file1, file2) {
expect(fs.lstatSync(file1).size).equal(fs.lstatSync(file2).size);
expect(fs.readFileSync(file1)).to.deep.equal(fs.readFileSync(file2));
}
it("WorkspaceContainer names", () => {
const expectBadName = (names) => {
names.forEach((containerId) => {
expect(() => validateWorkspaceContainerId(containerId), containerId).to.throw("containerId");
});
};
expectBadName([
"",
" ",
"12", // too short
"a\\b",
`a"b`,
"a:b",
"a.b",
"a?b",
"a*b",
"a|b",
"123--4",
"Abc",
"return\r",
"newline\n",
"a".repeat(64), // too long
"-leading-dash",
"trailing-dash-"
]);
validateWorkspaceContainerId(Guid.createValue()); // guids should be valid
});
it("WorkspaceDbNames", () => {
const expectBadName = (names) => {
names.forEach((dbName) => {
expect(() => CloudSqlite.validateDbName(dbName)).to.throw("dbName");
});
};
expectBadName([
"",
" ",
"1/2",
"a\\b",
`a"b`,
"base:1.2.3",
"a:b",
"a.b",
"a?b",
"a*b",
"a|b",
"con",
"prn",
"return\r",
"newline\n",
"a".repeat(256), // too long
" leading space",
"trailing space ",
"per.iod",
"hash#tag",
"back`tick",
"single'quote",
]);
CloudSqlite.validateDbName(Guid.createValue()); // guids should be valid
});
it("WorkspaceDb version fallback", () => {
expect(CloudSqlite.validateDbVersion("")).equals("0.0.0");
expect(CloudSqlite.makeSemverName("db1", "")).equals("db1:0.0.0");
expect(() => CloudSqlite.validateDbVersion(" ")).to.throw("invalid version specification");
});
it("create new WorkspaceDb", async () => {
const manifest = { workspaceName: "resources for acme users", contactName: "contact me" };
const wsFile = await makeEditableDb({ containerId: "acme-engineering-inc-2", dbName: "db1", baseUri: "", storageType: "azure" }, manifest);
const inFile = IModelTestUtils.resolveAssetFile("test.setting.json5");
const testRange = new Range3d(1.2, 2.3, 3.4, 4.5, 5.6, 6.7);
let blobVal = new Uint8Array(testRange.toFloat64Array().buffer);
let strVal = "this is test1";
const strRscName = "string-resource/1";
const blobRscName = "blob.resource:1";
const fileRscName = "settings files/my settings/a.json5";
let testManifest = wsFile.manifest;
expect(testManifest.workspaceName).equals(manifest.workspaceName);
expect(testManifest.contactName).equals(manifest.contactName);
wsFile.updateManifest({ ...testManifest, contactName: "new contact" });
testManifest = wsFile.manifest;
expect(testManifest.workspaceName).equals(manifest.workspaceName);
expect(testManifest.contactName).equals("new contact");
expect(() => wsFile.addFile(fileRscName, "bad file name")).to.throw("no such file");
expect(() => wsFile.updateFile(fileRscName, inFile)).to.throw("error replacing");
expect(() => wsFile.removeFile(fileRscName)).to.throw("does not exist");
wsFile.addBlob(blobRscName, blobVal);
wsFile.addString(strRscName, strVal);
expect(wsFile.getString(strRscName)).equals(strVal);
expect(wsFile.getBlob(blobRscName)).to.deep.equal(blobVal);
strVal = "updated string";
blobVal = Uint8Array.from([0, 1, 2, 3]);
wsFile.updateString(strRscName, strVal);
wsFile.updateBlob(blobRscName, blobVal);
expect(wsFile.getString(strRscName)).equals(strVal);
expect(wsFile.getBlob(blobRscName)).to.deep.equal(blobVal);
wsFile.removeBlob(blobRscName);
wsFile.removeString(strRscName);
expect(wsFile.getString(strRscName)).to.be.undefined;
expect(wsFile.getBlob(blobRscName)).to.be.undefined;
wsFile.addFile(fileRscName, inFile);
const writeFile = sinon.spy(wsFile.sqliteDb[_nativeDb], "extractEmbeddedFile");
expect(writeFile.callCount).eq(0);
const outFile = wsFile.getFile(fileRscName);
expect(writeFile.callCount).eq(1);
expect(extname(outFile)).equals(".json5");
compareFiles(inFile, outFile);
let outFile2 = wsFile.getFile(fileRscName);
expect(writeFile.callCount).eq(1);
expect(outFile).eq(outFile2);
const inFile2 = IModelTestUtils.resolveAssetFile("TestSettings.schema.json");
wsFile.updateFile(fileRscName, inFile2);
outFile2 = wsFile.getFile(fileRscName);
expect(writeFile.callCount).eq(2);
expect(outFile).eq(outFile2);
compareFiles(inFile2, outFile);
});
it("stamps lastEditedAt and lastEditedBy when closing an editable WorkspaceDb during a write-lock session", async () => {
const clock = sinon.useFakeTimers(Date.parse("2026-05-12T16:05:00.000Z"));
const wsFile = await makeEditableDb({ containerId: "last-edited-at-test", dbName: "db1", baseUri: "", storageType: "azure" }, { workspaceName: "last edited at test" });
try {
// Local (`baseUri: ""`) workspaces have no real cloud container. Inject a fake carrying the
// hidden `writeLockHeldBy` that `acquireWriteLock` would normally set on a real container;
// the close path reads it via `CloudSqlite.getWriteLockHeldBy`.
const fakeCloudContainer = {};
CloudSqlite.addHiddenProperty(fakeCloudContainer, "writeLockHeldBy", "Unit Test User");
Object.defineProperty(wsFile.container, "cloudContainer", { configurable: true, get: () => fakeCloudContainer });
wsFile.close();
// Drop the override so the lazy manifest reload below opens the local file normally
// (with no cloud container) and does not re-stamp on its internal close.
Reflect.deleteProperty(wsFile.container, "cloudContainer");
expect(wsFile.manifest.lastEditedBy).equals("Unit Test User");
expect(wsFile.manifest.lastEditedAt).equals("2026-05-12T16:05:00.000Z");
}
finally {
if (wsFile.isOpen)
wsFile.close();
clock.restore();
}
});
it("load workspace settings", async () => {
const settingsFile = IModelTestUtils.resolveAssetFile("test.setting.json5");
const defaultDb = await makeEditableDb({ containerId: "default", dbName: "db1", baseUri: "", storageType: "azure" }, { workspaceName: "default resources", contactName: "contact 123" });
defaultDb.addString("default-settings", fs.readFileSync(settingsFile, "utf-8"));
defaultDb.close();
const settings = workspace.settings;
await workspace.loadSettingsDictionary({ dbName: "db1", containerId: "default", baseUri: "", storageType: "azure", resourceName: "default-settings", priority: SettingsPriority.defaults });
expect(settings.getSetting("editor/renderWhitespace")).equals("selection");
const workspaceName = "all fonts workspace";
const schemaFile = IModelTestUtils.resolveAssetFile("TestSettings.schema.json");
const fontsDb = await makeEditableDb({ containerId: "fonts", dbName: "fonts", baseUri: "", storageType: "azure" }, { workspaceName, contactName: "font guy" });
fontsDb.addFile("Helvetica.ttf", schemaFile, "ttf");
fontsDb.close();
});
it("settingsWorkspaceDbName discovers all string resources as dictionaries", async () => {
const containerProps = { containerId: "load-all-test", baseUri: "", storageType: "azure" };
const db = await makeEditableDb({ ...containerProps, dbName: settingsWorkspaceDbName }, { workspaceName: "load-all workspace" });
db.addString("dict-a", JSON.stringify({ "editor/renderWhitespace": "all" }));
db.addString("dict-b", JSON.stringify({ "editor/fontSize": 14 }));
db.close();
const settings = workspace.settings;
await workspace.loadSettingsDictionary({
...containerProps,
priority: SettingsPriority.iTwin,
dbName: settingsWorkspaceDbName,
});
expect(settings.getSetting("editor/renderWhitespace")).equals("all");
expect(settings.getSetting("editor/fontSize")).equals(14);
});
it("settingsWorkspaceDbName loads resourceName last when set", async () => {
const containerProps = { containerId: "load-all-ignore-rsc", baseUri: "", storageType: "azure" };
const db = await makeEditableDb({ ...containerProps, dbName: settingsWorkspaceDbName }, { workspaceName: "load-all-priority" });
db.addString("first", JSON.stringify({ "editor/tabSize": 2, "editor/shared": "first" }));
db.addString("second", JSON.stringify({ "editor/wordWrap": "on", "editor/shared": "second" }));
db.close();
const settings = workspace.settings;
await workspace.loadSettingsDictionary({
...containerProps,
priority: SettingsPriority.defaults,
dbName: settingsWorkspaceDbName,
resourceName: "second",
});
// All dictionaries are loaded, with resourceName loaded last.
expect(settings.getSetting("editor/tabSize")).equals(2);
expect(settings.getSetting("editor/wordWrap")).equals("on");
expect(settings.getSetting("editor/shared")).equals("second");
});
it("settingsWorkspaceDbName continues loading sibling dictionaries after a resource fails", async () => {
const containerProps = { containerId: "load-all-continue-on-error", baseUri: "", storageType: "azure" };
const db = await makeEditableDb({ ...containerProps, dbName: settingsWorkspaceDbName }, { workspaceName: "load-all-continue-on-error" });
db.addString("dict-valid", JSON.stringify({ "editor/continueAfterError": true }));
db.addString("dict-invalid", "not valid json");
db.close();
const problems = [];
await workspace.loadSettingsDictionary({
...containerProps,
priority: SettingsPriority.defaults,
dbName: settingsWorkspaceDbName,
// Force this valid resource to be evaluated last.
resourceName: "dict-valid",
}, problems);
expect(workspace.settings.getSetting("editor/continueAfterError")).to.equal(true);
expect(problems.length).to.equal(1);
});
describe("workspace db version cache key", () => {
afterEach(() => sinon.restore());
function getContainer(containerId, props) {
return editor.getContainer({ containerId, baseUri: "", storageType: "azure", accessToken: "", ...props });
}
function createFakeCloudContainer(containerId) {
return {
connect: () => { },
disconnect: () => { },
checkForChanges: () => { },
queryDatabase: () => undefined,
queryDatabases: () => [],
acquireWriteLock: () => { },
releaseWriteLock: () => { },
abandonChanges: () => { },
containerId,
baseUri: "https://example.invalid",
storageType: "azure",
isPublic: false,
};
}
function stubCloudLookup(containerId, resolveVersion) {
sinon.stub(CloudSqlite, "createCloudContainer").returns(createFakeCloudContainer(containerId));
sinon.stub(CloudSqlite, "querySemverMatch").callsFake((props) => CloudSqlite.makeSemverName(props.dbName, resolveVersion(props)));
sinon.stub(CloudSqlite, "isSemverEditable").returns(true);
}
it("local getWorkspaceDb ignores version selectors for cache identity", async () => {
const container = getContainer("workspace-cache-local");
await container.createDb({ dbName: "test-db", manifest: { workspaceName: "workspace-cache-local" } });
const db1 = container.getWorkspaceDb({ dbName: "test-db" });
const db2 = container.getWorkspaceDb({ dbName: "test-db", version: "1.0.0", includePrerelease: true });
expect(db1).to.equal(db2);
});
it("local createDb with omitted dbName matches an explicit default-name lookup", async () => {
const container = getContainer("workspace-cache-local-default-name");
const db1 = await container.createDb({ manifest: { workspaceName: "workspace-cache-local-default-name" } });
const db2 = container.getEditableDb({ dbName: "workspace-db" });
expect(db1).to.equal(db2);
});
it("cloud getWorkspaceDb reuses the same instance for semver-equivalent selectors", () => {
const containerId = "workspace-cache-cloud-equivalent";
stubCloudLookup(containerId, (props) => props.version === "1" ? "1.0.0" : (props.version ?? "1.0.0"));
const container = getContainer(containerId, { baseUri: "https://example.invalid" });
const db1 = container.getWorkspaceDb({ dbName: "test-db", version: "1" });
const db2 = container.getWorkspaceDb({ dbName: "test-db", version: "1.0.0" });
expect(db1).to.equal(db2);
});
it("cloud getWorkspaceDb returns different instances for different resolved versions", () => {
const containerId = "workspace-cache-cloud-different";
stubCloudLookup(containerId, (props) => props.version ?? "1.0.0");
const container = getContainer(containerId, { baseUri: "https://example.invalid" });
const db1 = container.getWorkspaceDb({ dbName: "test-db", version: "1.0.0" });
const db2 = container.getWorkspaceDb({ dbName: "test-db", version: "2.0.0" });
expect(db1).to.not.equal(db2);
});
it("cloud getWorkspaceDb respects includePrerelease only when it resolves differently", () => {
const containerId = "workspace-cache-cloud-prerelease";
stubCloudLookup(containerId, (props) => props.includePrerelease ? "1.1.0-beta.1" : "1.0.0");
const container = getContainer(containerId, { baseUri: "https://example.invalid" });
const stable = container.getWorkspaceDb({ dbName: "test-db", version: "*" });
const prerelease = container.getWorkspaceDb({ dbName: "test-db", version: "*", includePrerelease: true });
expect(stable).to.not.equal(prerelease);
});
it("createDb with explicit version returns the cached editable db for that resolved version", async () => {
const containerId = "workspace-create-version";
stubCloudLookup(containerId, (props) => props.version ?? "0.0.0");
sinon.stub(CloudSqlite, "uploadDb").resolves();
const container = getContainer(containerId, { baseUri: "https://example.invalid" });
const db = await container.createDb({ dbName: "test-db", version: "1.0.0", manifest: { workspaceName: "workspace-create-version" } });
const dbAgain = container.getEditableDb({ dbName: "test-db", version: "1.0.0" });
expect(db).to.equal(dbAgain);
});
it("createDb without an explicit version returns the cached editable db for version 0.0.0", async () => {
const containerId = "workspace-create-default-version";
stubCloudLookup(containerId, (props) => props.version ?? "0.0.0");
sinon.stub(CloudSqlite, "uploadDb").resolves();
const container = getContainer(containerId, { baseUri: "https://example.invalid" });
const db = await container.createDb({ dbName: "test-db", manifest: { workspaceName: "workspace-create-default-version" } });
const dbAgain = container.getEditableDb({ dbName: "test-db", version: "0.0.0" });
expect(db).to.equal(dbAgain);
});
it("cloud createDb uploads using the requested version", async () => {
const containerId = "workspace-cloud-explicit-version";
stubCloudLookup(containerId, (props) => props.version ?? "0.0.0");
const uploadDb = sinon.stub(CloudSqlite, "uploadDb").resolves();
const container = getContainer(containerId, { baseUri: "https://example.invalid" });
await container.createDb({ dbName: "test-db", version: "1.2.3", manifest: { workspaceName: "workspace-cloud-explicit-version" } });
expect(uploadDb.calledOnce).to.be.true;
expect(uploadDb.firstCall.args[1].dbName).to.equal("test-db:1.2.3");
});
it("cloud createDb defaults omitted version to 0.0.0", async () => {
const containerId = "workspace-cloud-default-version";
stubCloudLookup(containerId, (props) => props.version ?? "0.0.0");
const uploadDb = sinon.stub(CloudSqlite, "uploadDb").resolves();
const container = getContainer(containerId, { baseUri: "https://example.invalid" });
await container.createDb({ dbName: "test-db", manifest: { workspaceName: "workspace-cloud-default-version" } });
expect(uploadDb.calledOnce).to.be.true;
expect(uploadDb.firstCall.args[1].dbName).to.equal("test-db:0.0.0");
});
});
describe("getContainerAsync token resolution", () => {
afterEach(() => sinon.restore());
it("preserves an explicitly-provided accessToken", async () => {
const requestTokenStub = sinon.stub(CloudSqlite, "requestToken").rejects(new Error("should not be called"));
const getContainerStub = sinon.stub(workspace, "getContainer").callsFake((args) => {
return { fromProps: args };
});
const props = {
containerId: "explicit-token-test",
baseUri: "https://some-cloud-uri",
storageType: "azure",
accessToken: "my-explicit-token",
};
const container = await workspace.getContainerAsync(props);
expect(requestTokenStub.called).to.be.false;
expect(getContainerStub.calledOnce).to.be.true;
expect(getContainerStub.firstCall.args[0].accessToken).to.equal("my-explicit-token");
expect(container.fromProps.accessToken).to.equal("my-explicit-token");
});
it("uses empty token for local containers with empty baseUri", async () => {
const requestTokenStub = sinon.stub(CloudSqlite, "requestToken").rejects(new Error("should not be called"));
const getContainerStub = sinon.stub(workspace, "getContainer").callsFake((args) => {
return { fromProps: args };
});
const props = {
containerId: "local-token-test",
baseUri: "",
storageType: "azure",
};
const container = await workspace.getContainerAsync(props);
expect(requestTokenStub.called).to.be.false;
expect(getContainerStub.calledOnce).to.be.true;
expect(getContainerStub.firstCall.args[0].accessToken).to.equal("");
expect(container.fromProps.accessToken).to.equal("");
});
it("calls requestToken when no accessToken is provided for a cloud container", async () => {
const requestTokenStub = sinon.stub(CloudSqlite, "requestToken").resolves("resolved-token");
const getContainerStub = sinon.stub(workspace, "getContainer").callsFake((args) => {
return { fromProps: args };
});
const props = {
containerId: "cloud-no-token-test",
baseUri: "https://some-cloud-uri",
storageType: "azure",
};
const container = await workspace.getContainerAsync(props);
expect(requestTokenStub.calledOnce).to.be.true;
expect(getContainerStub.calledOnce).to.be.true;
expect(getContainerStub.firstCall.args[0].accessToken).to.equal("resolved-token");
expect(container.fromProps.accessToken).to.equal("resolved-token");
});
});
describe("findContainers", () => {
const testContainerId = "mock-workspace-container-id";
const testITwinId = "mock-itwin-id";
const testIModelId = "mock-imodel-id";
let savedService;
function createMockService(containers) {
return {
create: async () => ({ containerId: testContainerId, baseUri: "https://mock.blob.core/", provider: "azure" }),
delete: async () => { },
queryScope: async () => ({ iTwinId: testITwinId }),
queryMetadata: async () => ({ containerType: "workspace", label: "mock" }),
queryContainersMetadata: async (_userToken, args) => {
return containers.filter((c) => (args.containerType === undefined || c.containerType === args.containerType) &&
(args.iTwinId === testITwinId || args.iTwinId === undefined));
},
updateJson: async () => { },
requestToken: async (_props) => ({
token: "",
scope: { iTwinId: testITwinId },
provider: "azure",
expiration: new Date(Date.now() + 3600000),
metadata: { containerType: "workspace", label: "mock" },
baseUri: "",
}),
};
}
before(() => {
savedService = BlobContainer.service;
});
afterEach(() => {
BlobContainer.service = savedService;
});
it("finds containers by iTwinId", async () => {
BlobContainer.service = createMockService([
{ containerId: testContainerId, containerType: "workspace", label: "Test Workspace", iTwinId: testITwinId },
]);
const containers = await editor.findContainers({ iTwinId: testITwinId });
expect(containers).to.have.length(1);
expect(containers[0].fromProps.containerId).to.equal(testContainerId);
});
it("finds a container by iTwinId and iModelId", async () => {
BlobContainer.service = createMockService([
{ containerId: "imodel-scoped-container", containerType: "workspace", label: "iModel Workspace", iTwinId: testITwinId },
]);
const containers = await editor.findContainers({ iTwinId: testITwinId, iModelId: testIModelId });
expect(containers).to.have.length(1);
expect(containers[0].fromProps.containerId).to.equal("imodel-scoped-container");
});
it("returns empty array when no workspace containers are found", async () => {
BlobContainer.service = createMockService([]);
const containers = await editor.findContainers({ iTwinId: "nonexistent-itwin" });
expect(containers).to.have.length(0);
});
it("throws when BlobContainer.service is not available", async () => {
BlobContainer.service = undefined;
await expect(editor.findContainers({ iTwinId: testITwinId }))
.to.be.rejectedWith(/BlobContainer.service is not available/);
});
});
});
//# sourceMappingURL=Workspace.test.js.map