Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nonzero-ontology-versions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@blockprotocol/type-system": patch
---

Reject zero major versions in versioned URLs. Require ontology version constructors to receive integers between 1 and 4294967295.
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, test, vi } from "vitest";

import Page from "./[...slug-maybe-version].page";

const router = vi.hoisted(() => ({
query: {} as Record<string, string[]>,
asPath: "",
}));

vi.mock("next/router", () => ({ useRouter: () => router }));
vi.mock("../../../../../shared/layout", () => ({
getLayoutWithSidebar: () => null,
}));
vi.mock("../../../../shared/not-found", () => ({
NotFound: () => "not found",
}));
vi.mock("../../../../shared/data-type", () => ({
DataType: ({ requestedVersion }: { requestedVersion: string | null }) =>
requestedVersion ?? "latest",
}));
vi.mock("../../../../../shared/generate-link-parameters", () => ({
generateLinkParameters: () => ({ href: "/" }),
}));
vi.mock("../../shared/use-route-namespace", () => ({
useRouteNamespace: () => ({
loading: false,
routeNamespace: { webId: "alice" },
}),
}));
vi.mock("../shared/get-type-base-url", () => ({
getTypeBaseUrl: () => "http://example.com/types/data-type/test/",
}));

const renderVersion = (version?: string) => {
router.asPath = `/@alice/types/data-type/test${version === undefined ? "" : `/v/${version}`}`;
return renderToStaticMarkup(<Page />);
};

describe("data type route versions", () => {
test.each(["0", "4294967296", "-1", "1.5", "invalid", "1invalid"])(
"renders not found for version %s",
(version) => {
expect(renderVersion(version)).toBe("not found");
},
);

test.each(["1", "4294967295"])("renders version %s", (version) => {
expect(renderVersion(version)).toContain(version);
});

test.each(["1?tab=definition", "1#definition"])(
"renders a valid version with a query or fragment: %s",
(version) => {
expect(renderVersion(version)).toContain("1");
expect(renderVersion(version)).not.toContain("not found");
},
);

test("renders the latest version when no version is requested", () => {
expect(renderVersion()).toContain("latest");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import {
componentsFromVersionedUrl,
currentTimestamp,
parseOntologyTypeVersion,
validateVersionedUrl,
} from "@blockprotocol/type-system";

import { generateLinkParameters } from "../../../../../shared/generate-link-parameters";
import { getLayoutWithSidebar } from "../../../../../shared/layout";
import { DataType } from "../../../../shared/data-type";
import { NotFound } from "../../../../shared/not-found";
import { useRouteNamespace } from "../../shared/use-route-namespace";
import { getTypeBaseUrl } from "../shared/get-type-base-url";

Expand Down Expand Up @@ -45,7 +47,7 @@ const Page: NextPageWithLayout = () => {
slug,
_v,
requestedVersionString,
] = router.asPath.split("/") as [
] = router.asPath.replace(/[?#].*$/, "").split("/") as [
"",
`@${string}`,
"types",
Expand Down Expand Up @@ -109,6 +111,14 @@ const Page: NextPageWithLayout = () => {
}
}, [router.query.draft]);

if (
requestedVersionString &&
validateVersionedUrl(`${dataTypeBaseUrl}v/${requestedVersionString}`)
.type === "Err"
) {
return <NotFound resourceLabel={{ label: "data type" }} />;
Comment thread
cursor[bot] marked this conversation as resolved.
}

const requestedVersion = requestedVersionString
? parseOntologyTypeVersion(requestedVersionString)
: null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import {
componentsFromVersionedUrl,
currentTimestamp,
parseOntologyTypeVersion,
validateVersionedUrl,
} from "@blockprotocol/type-system";

import { getLayoutWithSidebar } from "../../../../../shared/layout";
import { EntityType } from "../../../../shared/entity-type";
import { NotFound } from "../../../../shared/not-found";
import { useRouteNamespace } from "../../shared/use-route-namespace";
import { getTypeBaseUrl } from "../shared/get-type-base-url";

Expand Down Expand Up @@ -44,7 +46,7 @@ const Page: NextPageWithLayout = () => {
slug,
_v,
requestedVersionString,
] = router.asPath.split("/") as [
] = router.asPath.replace(/[?#].*$/, "").split("/") as [
"",
`@${string}`,
"types",
Expand Down Expand Up @@ -107,6 +109,14 @@ const Page: NextPageWithLayout = () => {
}
}, [router.query.draft]);

if (
requestedVersionString &&
validateVersionedUrl(`${entityTypeBaseUrl}v/${requestedVersionString}`)
.type === "Err"
) {
return <NotFound resourceLabel={{ label: "entity type" }} />;
}

const requestedVersion = requestedVersionString
? parseOntologyTypeVersion(requestedVersionString)
: null;
Expand Down
6 changes: 2 additions & 4 deletions apps/hash-frontend/src/pages/shared/data-type.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { getRoots } from "@blockprotocol/graph/stdlib";
import {
compareOntologyTypeVersions,
extractVersion,
makeOntologyTypeVersion,
versionedUrlFromComponents,
} from "@blockprotocol/type-system";
import { deserializeQueryDataTypeSubgraphResponse } from "@local/hash-graph-sdk/data-type";
Expand Down Expand Up @@ -331,9 +330,7 @@ export const DataType = ({
throw new Error("Cannot render data type without data type");
}

const currentVersion = draftNewDataType
? makeOntologyTypeVersion({ major: 0 })
: extractVersion(dataType.schema.$id);
const currentVersion = extractVersion(dataType.schema.$id);

const isLatest = !requestedVersion || requestedVersion === latestVersion;

Expand Down Expand Up @@ -374,6 +371,7 @@ export const DataType = ({
{!isReadOnly && (
<EditBarTypeEditor
currentVersion={currentVersion}
isDraft={isDraft}
discardButtonProps={
// @todo confirmation of discard when draft
isDraft
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { useRef, useState } from "react";
import {
extractBaseUrl,
extractVersion,
makeOntologyTypeVersion,
versionedUrlFromComponents,
} from "@blockprotocol/type-system";
import {
Expand Down Expand Up @@ -46,7 +45,7 @@ export const DataTypeHeader = ({

const latestVersionUrl = versionedUrlFromComponents(
extractBaseUrl(dataTypeSchema.$id),
latestVersion ?? makeOntologyTypeVersion({ major: 0 }),
latestVersion ?? currentVersion,
);

const { slideContainerRef, pushToSlideStack } = useSlideStack();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { makeOntologyTypeVersion } from "@blockprotocol/type-system";

import { EditBarTypeEditor } from "./edit-bar-type-editor";

import type { ReactNode } from "react";

const formState = vi.hoisted(() => ({
dirtyFields: {} as Record<string, boolean>,
isSubmitting: false,
}));

const confirmButton = vi.hoisted(() => ({ disabled: false }));

vi.mock("@hashintel/type-editor", () => ({
useEntityTypeFormState: () => formState,
}));

vi.mock("@hashintel/design-system", () => ({
FontAwesomeIcon: () => null,
}));

vi.mock("../../../shared/icons/svg", () => ({
PencilSimpleLine: () => null,
}));

vi.mock("../shared/edit-bar-contents", () => ({
EditBarCollapse: ({
children,
in: visible,
}: {
children: ReactNode;
in: boolean;
}) => (visible ? children : null),
EditBarContainer: ({ children }: { children: ReactNode }) => children,
EditBarContents: ({
label,
confirmButtonProps,
discardButtonProps,
}: {
label: string;
confirmButtonProps: { children: ReactNode; disabled: boolean };
discardButtonProps: { children: ReactNode };
}) => {
confirmButton.disabled = confirmButtonProps.disabled;
return (
<>
{label} {confirmButtonProps.children} {discardButtonProps.children}
</>
);
},
useFreezeScrollWhileTransitioning: () => null,
}));

const renderEditor = (isDraft: boolean, major = 1) =>
renderToStaticMarkup(
<EditBarTypeEditor
currentVersion={makeOntologyTypeVersion({ major })}
isDraft={isDraft}
gentleErrorStyling={false}
discardButtonProps={{}}
/>,
);

describe("EditBarTypeEditor", () => {
beforeEach(() => {
formState.dirtyFields = {};
confirmButton.disabled = false;
});

it("shows creation actions for a pristine draft at version one", () => {
const markup = renderEditor(true);

expect(markup).toContain("this type has not yet been created");
expect(markup).toContain("Create");
expect(markup).toContain("Discard this type");
});

it("shows update actions for an edited existing type at version one", () => {
formState.dirtyFields = { title: true };

const markup = renderEditor(false);

expect(markup).toContain("Version 1 -&gt; 2");
expect(markup).toContain("Publish update");
expect(markup).toContain("Discard changes");
expect(markup).not.toContain("this type has not yet been created");
});

it("hides the edit bar for a pristine existing type", () => {
expect(renderEditor(false)).toBe("");
});

it("renders a pristine existing type at the maximum version", () => {
expect(renderEditor(false, 4_294_967_295)).toBe("");
});

it("disables publishing an update at the maximum version", () => {
formState.dirtyFields = { title: true };

expect(renderEditor(false, 4_294_967_295)).toContain(
"this type has reached the maximum version and cannot be updated",
);
expect(confirmButton.disabled).toBe(true);
});

it("allows publishing the last available version", () => {
formState.dirtyFields = { title: true };

expect(renderEditor(false, 4_294_967_294)).toContain(
"Version 4294967294 -&gt; 4294967295",
);
expect(confirmButton.disabled).toBe(false);
});
});
Loading
Loading