Files
webstudio/apps/builder/app/services/api-router.server.test.ts
Oleg Isonen 3d0f19ffe0 fix: complete and speed up publish validation (#6478)
## Summary

Complete Validate for large projects with linked MDX content, using the
same validation API in the dialog and MCP. Publishing continues
independently of diagnostics.

- Load MDX sources omitted from the embedded artifact and report storage
failures with typed diagnostic reasons.
- Reuse parsed MDX and template scope indexes; cache source reads in the
shared asset loader.
- Exclude conservative non-MDX query candidates while retaining explicit
invalid references for diagnostics.
- Give validation requests a 60-second server execution budget.
- Support both server bundles in E2E tests and make OAuth logging
initialization repeatable.
- Raise the project bundle import limit from 20 to 32 MiB to reproduce
the customer project.

## TODO / acceptance checklist

- [x] Import and inspect the customer project locally and on staging.
- [x] Reproduce missing embedded MDX sources and slow template
materialization.
- [x] Load omitted MDX sources and discover nested template
dependencies.
- [x] Report source-read failures using typed reasons rather than
matching error messages.
- [x] Reuse template scope indexes and remove duplicate source caching.
- [x] Route dialog Validate through the same API as MCP, after flushing
editor content.
- [x] Preserve publishing in parallel with diagnostics, including failed
or unfinished checks.
- [x] Fix JSON collection files being treated as MDX query candidates.
- [x] Preserve explicit invalid file references so they still produce
diagnostics.
- [x] Fix E2E routing across both generated server bundles and repeated
OAuth logging initialization.
- [x] Demonstrate the new review regressions fail before the fixes and
pass after them.
- [x] Run targeted tests, relevant typechecks, lint, formatting, package
boundaries, and generated API checks.
- [x] Run the full fixture pipeline and production builder build; no
generated fixture changes.
- [x] Build the exact staging project locally after the final review
changes.
- [x] Validate the exact staging project through MCP/CLI after the final
deployment.
- [x] Complete an actual staging publish after the final deployment.
- [x] Address PR comments: simplify the stream mock, explain every
diagnostic reason, and retain the existing diagnostic code contract.
- [x] Review documentation impact; these fixes restore expected behavior
and require no user instruction changes.
- [x] Finish CI on the final commit, including all six Builder E2E
shards.

## Verification

Local checks passed: 179 project-build tests, 78 CLI prebuild tests, 8
asset publication tests, 42 server tests, and 9 publish UI/helper tests.
The imported staging project also builds successfully.

The new explicit-reference regression and repeated-initialization
regression both failed before their fixes and passed afterward. A
production build serves the login route and dispatches tRPC requests to
its separate server bundle. Validation on staging project
`b9d23e54-5dbf-45c4-b6d8-bc4cf842a468` completed in 25.3 seconds after
deployment and 23.5 seconds after publishing: 92 documents included, 0
omitted, and 0 MDX errors. The [original staging publish job
rerun](https://github.com/webstudio-is/webstudio-saas/actions/runs/36647770120/job/109819143695)
succeeded end to end with the final artifact, including CLI/Remix
builds, worker deployment, and database updates. [CI on commit
`01a9d772dc`](https://github.com/webstudio-is/webstudio/actions/runs/36696154831)
is green, including all six Builder E2E shards and Windows/Ubuntu
fixture checks.

The comment follow-ups simplify the publication test mock and document
all diagnostic reasons. Production behavior is unchanged. The 8
publication tests, 6 schema tests, package typecheck, formatter, and
lint pass. Disabling the source cache makes the simplified regression
fail as expected. Final staging validation passed in 27.1 seconds with
92 documents and no omissions or MDX errors. One additional publish
attempt hit a database statement timeout during project sync, before the
build. A [retry with no code changes succeeded end to
end](https://github.com/webstudio-is/webstudio-saas/actions/runs/36647770120/job/109826767589).
CI is green on `01a9d772dc`.

Ref #3550
2026-09-30 11:40:15 +02:00

1534 lines
45 KiB
TypeScript

import { access } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { createDefaultPages } from "@webstudio-is/project-build";
import type { CompactBuild } from "@webstudio-is/project-build";
import { createPublishedProjectBundleFixture } from "@webstudio-is/protocol/fixtures";
import * as projectBuild from "@webstudio-is/project-build/server";
import * as projectApi from "@webstudio-is/project/index.server";
import {
buildPatchTransaction,
publicApiContractVersion,
publicApiOperationRequiresServerSupport,
publicApiOperations,
} from "@webstudio-is/protocol";
import {
authorizeProject,
type AppContext,
} from "@webstudio-is/trpc-interface/index.server";
import { db as authDb } from "@webstudio-is/authorization-token/index.server";
import { blockComponent } from "@webstudio-is/sdk";
import { AssetIndexRevisionError } from "@webstudio-is/content-engine";
import { DocumentSourceDiagnosticsError } from "@webstudio-is/content-engine/compiler";
import * as assetUploader from "@webstudio-is/asset-uploader/server";
import { apiRouter, __testing__ } from "./api-router.server";
import * as assetQueryPreview from "./asset-query-preview.server";
import * as db from "~/shared/db";
import {
getApiRouterProcedures,
getProcedureInputSchemaMetadata,
getProcedurePublicApiPermit,
} from "./api-router-introspection.server";
import {
assertApiProjectPermit,
assertApiTokenPermit,
getTokenPermits,
} from "./api-permits.server";
const servicesDir = new URL(".", import.meta.url);
const { assertContentOrBuildPayload, assertApiPublishDomains } = __testing__;
const createContext = (
allowAdditionalPermissions: boolean,
authorization: AppContext["authorization"] = {
type: "token",
authToken: "secret-token",
ownerId: "user-1",
}
) =>
({
authorization,
planFeatures: {
allowAdditionalPermissions,
},
}) as AppContext;
const createToken = (
overrides: Partial<Awaited<ReturnType<typeof authDb.getTokenInfo>>> = {}
) =>
({
token: "token-1",
projectId: "project-1",
name: "Token",
relation: "builders",
createdAt: "2024-01-01T00:00:00.000Z",
canClone: true,
canCopy: true,
canPublish: false,
canUseApi: true,
...overrides,
}) as Awaited<ReturnType<typeof authDb.getTokenInfo>>;
type RuntimeCallerProcedure = (input: unknown) => Promise<unknown>;
type RuntimeApiCaller = Record<string, Record<string, RuntimeCallerProcedure>>;
type ApiRouterCaller = ReturnType<typeof apiRouter.createCaller>;
const createCaller = (context: AppContext) =>
apiRouter.createCaller(context) as ApiRouterCaller & RuntimeApiCaller;
describe("api router build operation adapters", () => {
test("submits only configured marketplace products for review", async () => {
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(createToken());
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
const loadBuild = vi
.spyOn(projectBuild, "loadDevBuildByProjectId")
.mockResolvedValueOnce({ marketplaceProduct: undefined } as never)
.mockResolvedValueOnce({
marketplaceProduct: {
category: "pageTemplates",
name: "Acme Template",
thumbnailAssetId: "asset-id",
author: "Acme Studio",
email: "hello@example.com",
website: "https://example.com",
issues: "",
description: "Reusable template project for Acme landing pages.",
},
} as never);
const setApprovalStatus = vi
.spyOn(projectApi, "setMarketplaceApprovalStatus")
.mockResolvedValue({ marketplaceApprovalStatus: "PENDING" } as never);
const caller = createCaller(createContext(true));
await expect(
caller.projects.submitMarketplaceProduct({
projectId: "project-1",
acknowledgePublicSubmission: true,
})
).rejects.toThrow("Complete the marketplace product metadata");
expect(setApprovalStatus).not.toHaveBeenCalled();
await expect(
caller.projects.submitMarketplaceProduct({
projectId: "project-1",
acknowledgePublicSubmission: true,
})
).resolves.toEqual({ marketplaceApprovalStatus: "PENDING" });
expect(loadBuild).toHaveBeenCalledTimes(2);
expect(setApprovalStatus).toHaveBeenCalledWith(
{
projectId: "project-1",
marketplaceApprovalStatus: "PENDING",
},
expect.anything()
);
});
test("exposes query validation and persisted asset query reads to API clients", async () => {
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(createToken());
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
vi.spyOn(projectBuild, "loadDevBuildByProjectId").mockResolvedValue({
props: [],
dataSources: [],
resources: [],
} as never);
vi.spyOn(assetQueryPreview, "previewProjectAssetQuery").mockResolvedValue({
data: {
items: [{ id: "post" }],
totalCount: 1,
hasMore: false,
},
__diagnostics__: {
scope: "query-preview",
query: {
usedBytes: 100,
maxBytes: 512_000,
unboundedBytes: 100,
includedDocumentCount: 1,
omittedDocumentCount: 0,
truncated: false,
},
database: {
usedBytes: 200,
maxBytes: 512_000,
unboundedBytes: 200,
includedDocumentCount: 2,
omittedDocumentCount: 0,
truncated: false,
},
},
} as never);
vi.spyOn(assetUploader, "loadBuilderAssetFieldCatalog").mockResolvedValue({
format: "webstudio-builder-asset-field-catalog",
version: 1,
canonicalRevision: `sha256:${"a".repeat(64)}`,
documentCount: 1,
fields: {
"properties.slug": {
types: ["string"],
occurrences: 1,
queryPath: ["properties", "slug"],
},
},
});
const caller = createCaller(createContext(true));
await expect(
caller.assetQueries.validate({
projectId: "project-1",
query: {
where: {
all: [
{
field: ["properties", "slug"],
operator: "eq",
value: "post",
},
],
},
limit: 1,
},
})
).resolves.toMatchObject({
valid: true,
referencedFieldPaths: expect.arrayContaining([["properties", "slug"]]),
filterCount: 1,
});
await expect(
caller.assetQueries.validate({
projectId: "project-1",
query: {
where: {
all: [
{
field: ["properties", "missing"],
operator: "exists",
value: true,
},
],
},
},
})
).resolves.toMatchObject({
valid: true,
warnings: ["Asset field properties.missing is not currently observed"],
issues: [
{
severity: "warning",
code: "UNOBSERVED_FIELD",
path: ["query", "where", "all", "0", "field"],
message: "Asset field properties.missing is not currently observed",
},
],
});
await expect(
caller.assetQueries.preview({
projectId: "project-1",
query: { limit: 10 },
})
).resolves.toMatchObject({
data: {
items: [{ id: "post" }],
totalCount: 1,
},
__diagnostics__: {
scope: "query-preview",
query: { truncated: false },
database: { truncated: false },
},
});
expect(assetQueryPreview.previewProjectAssetQuery).toHaveBeenCalledWith({
projectId: "project-1",
request: expect.objectContaining({
query: expect.objectContaining({ limit: 10 }),
}),
context: expect.anything(),
});
vi.mocked(assetQueryPreview.previewProjectAssetQuery).mockRejectedValueOnce(
new AssetIndexRevisionError()
);
await expect(
caller.assetQueries.preview({
projectId: "project-1",
query: { limit: 10 },
indexRevision: `sha256:${"0".repeat(64)}`,
})
).rejects.toMatchObject({
code: "CONFLICT",
cause: { webstudioCode: "STALE_INDEX" },
});
vi.mocked(assetQueryPreview.previewProjectAssetQuery).mockRejectedValueOnce(
new DocumentSourceDiagnosticsError([
{
severity: "warning",
code: "FRONTMATTER_INVALID",
message: "Markdown frontmatter contains invalid YAML",
assetId: "markdown",
path: "content/broken.md",
line: 4,
column: 1,
},
{
severity: "error",
code: "invalid-mdx",
message: "Unexpected closing tag",
assetId: "mdx",
path: "content/broken.mdx",
line: 8,
column: 3,
reference: "Card",
nodeType: "mdxJsxFlowElement",
reason: "unexpected-close-tag",
sourceRange: {
start: { line: 8, column: 3, offset: 80 },
end: { line: 8, column: 10, offset: 87 },
},
},
])
);
await expect(
caller.assetQueries.preview({
projectId: "project-1",
query: { limit: 10 },
})
).rejects.toMatchObject({
code: "BAD_REQUEST",
cause: {
webstudioCode: "INVALID_REQUEST",
issues: [
{
code: "FRONTMATTER_INVALID",
path: ["content/broken.md"],
message: "Markdown frontmatter contains invalid YAML",
constraint: "FRONTMATTER_INVALID",
detail:
"Line 4, column 1. Scope: query. Severity: warning. Asset: markdown",
},
{
code: "invalid-mdx",
path: ["content/broken.mdx"],
message: "Unexpected closing tag",
constraint: "invalid-mdx",
detail:
"Line 8, column 3. Scope: query. Severity: error. Asset: mdx",
severity: "error",
scope: "query",
phase: "source",
assetId: "mdx",
file: "content/broken.mdx",
line: 8,
column: 3,
reference: "Card",
nodeType: "mdxJsxFlowElement",
reason: "unexpected-close-tag",
sourceRange: {
start: { line: 8, column: 3, offset: 80 },
end: { line: 8, column: 10, offset: 87 },
},
},
],
},
});
await expect(
caller.assetQueries.validate({
projectId: "project-1",
query: {
where: {
all: [
{ field: ["width"], operator: "contains", value: 1 },
{ field: ["height"], operator: "startsWith", value: "1" },
{
field: ["properties", "missing"],
operator: "eq",
value: true,
},
],
},
},
})
).rejects.toMatchObject({
code: "BAD_REQUEST",
cause: {
webstudioCode: "INVALID_REQUEST",
issues: [
expect.objectContaining({
code: "INCOMPATIBLE_OPERATOR",
path: ["query", "where", "all", "0", "operator"],
message: "Operator contains is incompatible with width",
}),
expect.objectContaining({
code: "INCOMPATIBLE_OPERATOR",
path: ["query", "where", "all", "1", "operator"],
message: "Operator startsWith is incompatible with height",
}),
expect.objectContaining({
code: "UNOBSERVED_FIELD",
path: ["query", "where", "all", "2", "field"],
message: "Asset field properties.missing is not currently observed",
}),
],
},
});
await expect(
caller.assetQueries.fieldCatalog({ projectId: "project-1" })
).resolves.toMatchObject({
fields: { "properties.slug": { types: ["string"] } },
});
});
test("allows CLI clients to refresh pages without project settings", async () => {
const build = {
id: "build-1",
projectId: "project-1",
version: 1,
pages: createDefaultPages({ rootInstanceId: "root" }),
projectSettings: { agents: "Project instructions" },
} as unknown as Awaited<
ReturnType<typeof projectBuild.loadDevBuildByProjectId>
>;
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(createToken());
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
vi.spyOn(projectBuild, "loadDevBuildByProjectId").mockResolvedValue(build);
const caller = createCaller({
...createContext(true),
apiClient: {
type: "cli",
version: "0.276.0",
contractVersion: undefined,
},
});
await expect(
caller.build.get({ projectId: "project-1", include: ["pages"] })
).resolves.toMatchObject({
projectId: "project-1",
buildId: "build-1",
version: 1,
pages: expect.any(Array),
});
});
test("loads assets and folders together for build snapshots", async () => {
const build = {
id: "build-1",
projectId: "project-1",
version: 1,
pages: createDefaultPages({ rootInstanceId: "root" }),
} as unknown as Awaited<
ReturnType<typeof projectBuild.loadDevBuildByProjectId>
>;
const asset = { id: "asset-1", folderId: "folder-1" };
const folder = { id: "folder-1", name: "Images" };
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(createToken());
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
vi.spyOn(projectBuild, "loadDevBuildByProjectId").mockResolvedValue(build);
const loadAssetData = vi
.spyOn(assetUploader, "loadAssetDataByProject")
.mockResolvedValue({ assets: [asset], assetFolders: [folder] } as never);
const loadFolders = vi.spyOn(assetUploader, "loadAssetFoldersByProject");
const caller = createCaller({
...createContext(true),
apiClient: {
type: "cli",
version: "0.276.0",
contractVersion: publicApiContractVersion,
},
});
await expect(
caller.build.get({
projectId: "project-1",
include: ["assets", "assetFolders"],
})
).resolves.toMatchObject({
assets: [asset],
assetFolders: [folder],
});
expect(loadAssetData).toHaveBeenCalledOnce();
expect(loadFolders).not.toHaveBeenCalled();
});
test("public operation catalog paths exist on the api router", () => {
const procedures = getApiRouterProcedures(apiRouter);
for (const operation of publicApiOperations) {
if (operation.path === undefined) {
continue;
}
const procedure = procedures[operation.path.replace(/^api\./, "")] as
| { _def?: Partial<Record<typeof operation.method, boolean>> }
| undefined;
expect(procedure).toBeDefined();
expect(procedure?._def?.[operation.method]).toBe(true);
}
});
test("api router procedures are all described by the public operation catalog", () => {
const procedures = getApiRouterProcedures(apiRouter);
expect(
Object.keys(procedures)
.map((path) => `api.${path}`)
.sort()
).toEqual(
publicApiOperations
.flatMap((operation) =>
operation.path === undefined ? [] : [operation.path]
)
.sort()
);
});
test("operation catalog input fields match api router inputs", () => {
const procedures = getApiRouterProcedures(apiRouter);
for (const operation of publicApiOperations) {
if (operation.path === undefined) {
continue;
}
const procedure = procedures[operation.path.replace(/^api\./, "")];
const metadata = getProcedureInputSchemaMetadata(procedure);
expect(metadata.inputFields).toEqual(operation.inputFields);
expect(metadata.requiredInputFields).toEqual(
operation.requiredInputFields
);
expect(metadata.inputFieldTypes).toEqual(operation.inputFieldTypes);
expect(metadata.inputJsonSchema).toEqual(operation.inputSchema);
}
});
test("operation catalog permits match api router metadata", () => {
const procedures = getApiRouterProcedures(apiRouter);
for (const operation of publicApiOperations) {
if (operation.path === undefined) {
continue;
}
const procedure = procedures[operation.path.replace(/^api\./, "")];
expect(getProcedurePublicApiPermit(procedure)).toBe(operation.permit);
}
});
test("does not keep an api-only build operations service", async () => {
await expect(
access(join(servicesDir.pathname, "build-operations.server.ts"))
).rejects.toThrow();
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("api router permits", () => {
test("adds api permit only when token and plan both allow it", () => {
const token = createToken();
expect(getTokenPermits(token, createContext(true))).toEqual([
"view",
"edit",
"build",
"api",
]);
expect(getTokenPermits(token, createContext(false))).toEqual([
"view",
"edit",
"build",
]);
expect(
getTokenPermits(
{
...token,
canUseApi: false,
},
createContext(true)
)
).toEqual(["view", "edit", "build"]);
});
test("maps token relation to project permits", () => {
expect(
getTokenPermits(createToken({ relation: "viewers" }), createContext(true))
).toEqual(["view", "api"]);
expect(
getTokenPermits(createToken({ relation: "editors" }), createContext(true))
).toEqual(["view", "edit", "api"]);
expect(
getTokenPermits(
createToken({ relation: "builders" }),
createContext(true)
)
).toEqual(["view", "edit", "build", "api"]);
expect(
getTokenPermits(
createToken({ relation: "administrators" }),
createContext(true)
)
).toEqual(["view", "edit", "build", "admin", "api"]);
});
test("accepts cookie auth for project-scoped api procedures", async () => {
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
await expect(
assertApiProjectPermit(
createContext(true, {
type: "user",
userId: "user-1",
sessionCreatedAt: 0,
isLoggedInToBuilder: async () => true,
}),
"project-1",
"view"
)
).resolves.toEqual({
type: "user",
permits: ["view"],
});
});
test("rejects cookie auth without the requested project permit", async () => {
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(false);
await expect(
assertApiProjectPermit(
createContext(true, {
type: "user",
userId: "user-1",
sessionCreatedAt: 0,
isLoggedInToBuilder: async () => true,
}),
"project-1",
"edit"
)
).rejects.toThrow("You don't have access to this project");
});
test("preserves content-only permissions for cookie-authenticated editors", async () => {
const hasProjectPermit = vi
.spyOn(authorizeProject, "hasProjectPermit")
.mockImplementation(async ({ permit }) => permit === "edit");
const context = createContext(true, {
type: "user",
userId: "user-1",
sessionCreatedAt: 0,
isLoggedInToBuilder: async () => true,
});
await expect(
assertApiProjectPermit(context, "project-1", "edit")
).resolves.toEqual({ type: "user", permits: ["edit"] });
hasProjectPermit.mockResolvedValue(true);
await expect(
assertApiProjectPermit(context, "project-1", "edit")
).resolves.toEqual({ type: "user", permits: ["edit", "build"] });
});
test("rejects tokens from another project", async () => {
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(
createToken({ projectId: "project-2" })
);
await expect(
assertApiProjectPermit(createContext(true), "project-1", "view")
).rejects.toThrow("Authorization token is not valid for project");
});
test("rejects tokens without api permission", async () => {
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(
createToken({ canUseApi: false })
);
await expect(assertApiTokenPermit(createContext(true))).rejects.toThrow(
"Authorization token cannot use Builder API"
);
await expect(
assertApiProjectPermit(createContext(true), "project-1", "view")
).rejects.toThrow("Authorization token cannot use Builder API");
});
test("allows token introspection without api permission", async () => {
const token = createToken({ canUseApi: false, relation: "editors" });
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(token);
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
const caller = createCaller(createContext(true));
await expect(caller.auth.me()).resolves.toEqual({
actor: { type: "token", tokenId: token.token },
projectId: token.projectId,
relation: "editors",
permits: ["view", "edit"],
});
await expect(
caller.projects.permissions({ projectId: token.projectId })
).resolves.toEqual({
relation: "editors",
permits: ["view", "edit"],
canView: true,
canEdit: true,
canBuild: false,
canAdmin: false,
canOwn: false,
canUseApi: false,
canPublish: false,
canPublishProjectDomain: false,
canPublishCustomDomains: false,
apiContract: {
version: publicApiContractVersion,
operationIds: publicApiOperations.flatMap((operation) =>
publicApiOperationRequiresServerSupport(operation)
? [operation.id]
: []
),
},
});
});
test("returns publish capabilities from token permissions", async () => {
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
const caller = createCaller(createContext(true));
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(
createToken({ relation: "editors", canPublish: true })
);
await expect(
caller.projects.permissions({ projectId: "project-1" })
).resolves.toMatchObject({
relation: "editors",
canPublish: true,
canPublishProjectDomain: true,
canPublishCustomDomains: true,
});
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(
createToken({ relation: "builders", canPublish: false })
);
await expect(
caller.projects.permissions({ projectId: "project-1" })
).resolves.toMatchObject({
relation: "builders",
canPublish: false,
canPublishProjectDomain: true,
canPublishCustomDomains: false,
});
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(
createToken({ relation: "administrators", canPublish: true })
);
await expect(
caller.projects.permissions({ projectId: "project-1" })
).resolves.toMatchObject({
relation: "administrators",
canPublish: true,
canPublishProjectDomain: true,
canPublishCustomDomains: true,
});
});
test("rejects tokens when current plan does not allow api permission", async () => {
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(createToken());
await expect(
assertApiProjectPermit(createContext(false), "project-1", "view")
).rejects.toThrow("Authorization token cannot use Builder API");
});
test("rejects tokens without the required relation permit", async () => {
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(
createToken({ relation: "viewers" })
);
await expect(
assertApiProjectPermit(createContext(true), "project-1", "edit")
).rejects.toThrow("Authorization token does not have edit permission");
});
test("requires build permission before semantic build mutation handlers", async () => {
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(
createToken({ relation: "viewers" })
);
const caller = createCaller(createContext(true));
await expect(
caller.pages.update({
projectId: "project-1",
pageId: "page-id",
values: {},
})
).rejects.toThrow("Authorization token does not have build permission");
});
test("checks project authorization after token permission checks", async () => {
const hasProjectPermit = vi
.spyOn(authorizeProject, "hasProjectPermit")
.mockResolvedValue(false);
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(createToken());
await expect(
assertApiProjectPermit(createContext(true), "project-1", "view")
).rejects.toThrow("You don't have access to this project");
expect(hasProjectPermit).toHaveBeenCalledWith(
{ projectId: "project-1", permit: "view" },
expect.anything()
);
});
test("returns token and effective permits when access is allowed", async () => {
const token = createToken({ relation: "editors" });
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(token);
await expect(
assertApiProjectPermit(createContext(true), "project-1", "edit")
).resolves.toEqual({
type: "token",
token,
permits: ["view", "edit", "api"],
});
});
test("requires publish permission for api publish domains", () => {
const project = { domain: "project.wstd.dev" };
expect(() =>
assertApiPublishDomains({
auth: { type: "user", permits: ["edit"] },
domains: ["custom.example.com"],
project,
})
).not.toThrow();
expect(() =>
assertApiPublishDomains({
auth: {
type: "token",
token: createToken({ relation: "editors", canPublish: false }),
permits: ["view", "edit", "api"],
},
domains: ["project.wstd.dev"],
project,
})
).toThrow("Authorization token does not have publish permission");
expect(() =>
assertApiPublishDomains({
auth: {
type: "token",
token: createToken({ relation: "editors", canPublish: true }),
permits: ["view", "edit", "api"],
},
domains: ["custom.example.com"],
project,
})
).not.toThrow();
expect(() =>
assertApiPublishDomains({
auth: {
type: "token",
token: createToken({ relation: "builders", canPublish: false }),
permits: ["view", "edit", "build", "api"],
},
domains: ["project.wstd.dev"],
project,
})
).not.toThrow();
expect(() =>
assertApiPublishDomains({
auth: {
type: "token",
token: createToken({ relation: "builders", canPublish: false }),
permits: ["view", "edit", "build", "api"],
},
domains: ["custom.example.com"],
project,
})
).toThrow("Authorization token does not have publish permission");
expect(() =>
assertApiPublishDomains({
auth: {
type: "token",
token: createToken({
relation: "administrators",
canPublish: true,
}),
permits: ["view", "edit", "build", "admin", "api"],
},
domains: ["custom.example.com"],
project,
})
).not.toThrow();
});
test.each([
{
target: "staging" as const,
domains: ["project.wstd.dev"],
expected: ["project.wstd.dev"],
},
{
target: "production" as const,
domains: ["custom.example.com"],
expected: ["custom.example.com"],
},
{
target: "production" as const,
domains: undefined,
expected: ["project.wstd.dev", "custom.example.com"],
},
])(
"validates $target publish domains $domains and content without publishing",
async ({ target, domains, expected }) => {
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(
createToken({ relation: "editors", canPublish: true })
);
vi.spyOn(projectApi, "loadById").mockResolvedValue({
domain: "project.wstd.dev",
domainsVirtual: [
{ domain: "custom.example.com", status: "ACTIVE", verified: true },
],
} as never);
const loadBuild = vi
.spyOn(projectBuild, "loadDevBuildByProjectId")
.mockResolvedValue({
dataSources: [],
props: [],
resources: [],
instances: [],
} as never);
const loadBundle = vi
.spyOn(db, "loadProjectBundleByProjectId")
.mockResolvedValue(createPublishedProjectBundleFixture());
const caller = createCaller(createContext(true));
await expect(
caller.publish.validate({
projectId: "project-1",
target,
domains,
})
).resolves.toMatchObject({
valid: true,
target,
domains: expected,
diagnostics: { mdxOmissions: [], mdxErrors: [] },
});
expect(loadBuild).toHaveBeenCalledWith(expect.anything(), "project-1");
expect(loadBundle).toHaveBeenCalledWith(
"project-1",
expect.anything(),
expect.objectContaining({
build: expect.objectContaining({ instances: [] }),
onMdxTemplateOmissions: expect.any(Function),
})
);
}
);
test.each(
[
[],
["unknown.example.com"],
["unverified.example.com"],
["inactive.example.com"],
["project.wstd.dev", "unknown.example.com"],
].map((domains) => ({ domains }))
)(
"rejects unusable publish domains $domains before loading content",
async ({ domains }) => {
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(
createToken({ canPublish: true })
);
vi.spyOn(projectApi, "loadById").mockResolvedValue({
domain: "project.wstd.dev",
domainsVirtual: [
{
domain: "unverified.example.com",
status: "ACTIVE",
verified: false,
},
{ domain: "inactive.example.com", status: "PENDING", verified: true },
],
} as never);
const loadBuild = vi.spyOn(projectBuild, "loadDevBuildByProjectId");
const loadBundle = vi
.spyOn(db, "loadProjectBundleByProjectId")
.mockResolvedValue(createPublishedProjectBundleFixture());
const caller = createCaller(createContext(true));
await expect(
caller.publish.validate({
projectId: "project-1",
target: "production",
domains,
})
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(loadBuild).not.toHaveBeenCalled();
expect(loadBundle).not.toHaveBeenCalled();
}
);
test.each(["variable", "prop"])(
"rejects a missing resource referenced by a %s before diagnostics",
async (source) => {
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(createToken());
vi.spyOn(projectApi, "loadById").mockResolvedValue({
domain: "project.wstd.dev",
domainsVirtual: [],
} as never);
vi.spyOn(projectBuild, "loadDevBuildByProjectId").mockResolvedValue({
resources: [],
instances: [],
dataSources:
source === "variable"
? [
{
id: "broken",
type: "resource",
name: "Broken",
scopeInstanceId: "body",
resourceId: "missing-resource",
},
]
: [],
props:
source === "prop"
? [
{
id: "broken",
instanceId: "body",
name: "data",
type: "resource",
value: "missing-resource",
},
]
: [],
} as never);
const loadBundle = vi
.spyOn(db, "loadProjectBundleByProjectId")
.mockResolvedValue(createPublishedProjectBundleFixture());
const caller = createCaller(createContext(true));
await expect(
caller.publish.validate({
projectId: "project-1",
target: "staging",
})
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(loadBundle).not.toHaveBeenCalled();
}
);
test("does not run publish diagnostics when the selected domains are forbidden", async () => {
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(
createToken({ relation: "builders", canPublish: false })
);
vi.spyOn(projectApi, "loadById").mockResolvedValue({
domain: "project.wstd.dev",
} as never);
const loadBundle = vi.spyOn(db, "loadProjectBundleByProjectId");
const caller = createCaller(createContext(true));
await expect(
caller.publish.validate({
projectId: "project-1",
target: "production",
domains: ["custom.example.com"],
})
).rejects.toThrow("Authorization token does not have publish permission");
expect(loadBundle).not.toHaveBeenCalled();
});
test("allows editor api tokens to commit content-mode payloads only", () => {
const build = {
id: "build",
projectId: "project-1",
version: 1,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
pages: createDefaultPages({ rootInstanceId: "block" }),
breakpoints: [],
styles: [],
styleSources: [],
styleSourceSelections: [],
props: [],
dataSources: [],
resources: [],
instances: [
{
type: "instance",
id: "block",
component: blockComponent,
children: [{ type: "id", value: "text-instance" }],
},
{
type: "instance",
id: "text-instance",
component: "ws:element",
tag: "p",
children: [{ type: "text", value: "Old" }],
},
],
marketplaceProduct: {},
} as unknown as CompactBuild;
const editorAuth = {
type: "token",
token: createToken({ relation: "editors" }),
permits: ["view", "edit", "api"],
} satisfies Awaited<ReturnType<typeof assertApiProjectPermit>>;
const builderAuth = {
type: "token",
token: createToken({ relation: "builders" }),
permits: ["view", "edit", "build", "api"],
} satisfies Awaited<ReturnType<typeof assertApiProjectPermit>>;
const contentPayload: z.infer<typeof buildPatchTransaction>["payload"] = [
{
namespace: "instances",
patches: [
{
op: "replace",
path: ["text-instance", "children", 0],
value: { type: "text", value: "New" },
},
],
},
];
const designPayload: z.infer<typeof buildPatchTransaction>["payload"] = [
{
namespace: "styles",
patches: [
{
op: "add",
path: ["style"],
value: {
breakpointId: "base",
property: "color",
styleSourceId: "local",
value: { type: "keyword", value: "red" },
},
},
],
},
];
expect(() =>
assertContentOrBuildPayload({
auth: editorAuth,
build,
payload: contentPayload,
})
).not.toThrow();
expect(() =>
assertContentOrBuildPayload({
auth: editorAuth,
build,
payload: designPayload,
})
).toThrow("content mode");
expect(() =>
assertContentOrBuildPayload({
auth: builderAuth,
build,
payload: designPayload,
})
).not.toThrow();
});
test("commits content-mode mutations with editor api tokens", async () => {
const build = {
id: "build-1",
projectId: "project-1",
version: 3,
pages: createDefaultPages({ rootInstanceId: "block" }),
breakpoints: [],
styles: [],
styleSources: [],
styleSourceSelections: [],
props: [],
dataSources: [],
resources: [],
instances: [
{
type: "instance",
id: "block",
component: blockComponent,
children: [{ type: "id", value: "text-instance" }],
},
{
type: "instance",
id: "text-instance",
component: "ws:element",
tag: "p",
children: [{ type: "text", value: "Old" }],
},
],
marketplaceProduct: {},
} as unknown as Awaited<
ReturnType<typeof projectBuild.loadDevBuildByProjectId>
>;
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(
createToken({ relation: "editors" })
);
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
vi.spyOn(projectBuild, "loadDevBuildByProjectId").mockResolvedValue(build);
const patchBuild = vi
.spyOn(projectApi, "patchBuild")
.mockResolvedValue({ status: "ok", version: 4 });
const caller = createCaller(createContext(true));
await expect(
caller.instances.updateText({
projectId: "project-1",
instanceId: "text-instance",
childIndex: 0,
text: "New",
mode: "text",
})
).resolves.toMatchObject({
version: 4,
instanceId: "text-instance",
childIndex: 0,
mode: "text",
});
expect(patchBuild).toHaveBeenCalledWith(
expect.objectContaining({
buildId: "build-1",
projectId: "project-1",
clientVersion: 3,
}),
expect.objectContaining({
authorization: expect.objectContaining({ type: "token" }),
})
);
});
test("does not commit empty runtime mutation payloads", async () => {
const build = {
id: "build-1",
projectId: "project-1",
version: 7,
pages: createDefaultPages({ rootInstanceId: "root" }),
breakpoints: [],
styles: [],
styleSources: [],
styleSourceSelections: [],
props: [],
dataSources: [],
resources: [],
instances: [
{
type: "instance",
id: "root",
component: blockComponent,
children: [],
},
],
marketplaceProduct: {},
} as unknown as Awaited<
ReturnType<typeof projectBuild.loadDevBuildByProjectId>
>;
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(createToken());
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
vi.spyOn(projectBuild, "loadDevBuildByProjectId").mockResolvedValue(build);
const patchBuild = vi.spyOn(projectApi, "patchBuild");
const caller = createCaller(createContext(true));
await expect(
caller.pages.update({
projectId: "project-1",
pageId: build.pages.homePageId,
values: {},
})
).resolves.toEqual({
version: 7,
pageId: build.pages.homePageId,
});
expect(patchBuild).not.toHaveBeenCalled();
});
});
describe("api instance queries", () => {
const createInstanceQueryBuild = () =>
({
id: "build-1",
projectId: "project-1",
version: 1,
pages: createDefaultPages({ rootInstanceId: "root-1" }),
instances: [
{
type: "instance",
id: "root-1",
component: "Body",
tag: "body",
label: "Root",
children: [
{ type: "id", value: "hero-1" },
{ type: "text", value: "Root text" },
],
},
{
type: "instance",
id: "hero-1",
component: blockComponent,
tag: "section",
label: "Hero",
children: [{ type: "expression", value: "system.params.slug" }],
},
],
props: [],
styles: [],
styleSources: [],
styleSourceSelections: [],
resources: [],
dataSources: [],
breakpoints: [],
}) as unknown as Awaited<
ReturnType<typeof projectBuild.loadDevBuildByProjectId>
>;
test("lists instances through server semantics", async () => {
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(createToken());
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
vi.spyOn(projectBuild, "loadDevBuildByProjectId").mockResolvedValue(
createInstanceQueryBuild()
);
const caller = createCaller(createContext(true));
await expect(
caller.instances.list({
projectId: "project-1",
pagePath: "",
maxDepth: 1,
labelContains: "Hero",
})
).resolves.toMatchObject({
detail: "compact",
total: 1,
instances: [
{
id: "hero-1",
label: "Hero",
component: blockComponent,
tag: "section",
depth: 1,
childCount: 1,
parentId: "root-1",
indexWithinParent: 0,
},
],
});
});
test("lists text and expression children through server semantics", async () => {
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(createToken());
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
vi.spyOn(projectBuild, "loadDevBuildByProjectId").mockResolvedValue(
createInstanceQueryBuild()
);
const caller = createCaller(createContext(true));
await expect(
caller.instances.listTexts({
projectId: "project-1",
pagePath: "",
mode: "expression",
})
).resolves.toEqual({
texts: [
{
instanceId: "hero-1",
childIndex: 0,
component: blockComponent,
label: "Hero",
mode: "expression",
valuePreview: "system.params.slug",
valueLength: 18,
truncated: false,
},
],
detail: "compact",
total: 1,
returnedCount: 1,
nextCursor: null,
filters: { pagePath: "", mode: "expression" },
});
});
test("lists project entities through semantic routes", async () => {
const build = createInstanceQueryBuild();
build.pages.folders.set("folder-1", {
id: "folder-1",
name: "Blog",
slug: "blog",
children: ["page-1"],
});
build.pages.folders
.get(build.pages.rootFolderId)
?.children.push("folder-1");
build.pages.pages.set("page-1", {
id: "page-1",
name: "Post",
path: "/first-post",
title: "Post",
rootInstanceId: "hero-1",
meta: { description: "Post description" },
});
build.styleSources.push({
id: "token-1",
type: "token",
name: "Primary",
});
build.styles.push({
styleSourceId: "token-1",
breakpointId: "base",
property: "color",
value: { type: "keyword", value: "red" },
});
build.styleSourceSelections.push({
instanceId: "hero-1",
values: ["token-1"],
});
build.dataSources.push(
{
id: "variable-1",
type: "variable",
scopeInstanceId: "hero-1",
name: "Title",
value: { type: "string", value: "Hello" },
},
{
id: "resource-data-source-1",
type: "resource",
scopeInstanceId: "hero-1",
name: "Posts",
resourceId: "resource-1",
}
);
build.resources.push({
id: "resource-1",
name: "Posts",
method: "get",
url: '"https://example.com/posts"',
headers: [],
});
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(createToken());
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
vi.spyOn(projectBuild, "loadDevBuildByProjectId").mockResolvedValue(build);
const caller = createCaller(createContext(true));
await expect(
caller.pages.list({ projectId: "project-1" })
).resolves.toMatchObject({
pages: [
expect.objectContaining({ isHome: true, path: "" }),
expect.objectContaining({
id: "page-1",
path: "/blog/first-post",
parentFolderId: "folder-1",
}),
],
});
await expect(
caller.pages.getByPath({
projectId: "project-1",
path: "/blog/first-post",
})
).resolves.toMatchObject({
id: "page-1",
meta: { description: "Post description" },
});
await expect(
caller.folders.list({ projectId: "project-1" })
).resolves.toMatchObject({
folders: expect.arrayContaining([
expect.objectContaining({
id: "folder-1",
slug: "blog",
parentFolderId: build.pages.rootFolderId,
children: ["page-1"],
}),
]),
});
await expect(
caller.designTokens.list({
projectId: "project-1",
withUsage: true,
})
).resolves.toMatchObject({
detail: "compact",
total: 1,
tokens: [
{
id: "token-1",
name: "Primary",
declarationCount: 1,
usageCount: 1,
},
],
});
await expect(
caller.variables.list({
projectId: "project-1",
scopeInstanceId: "hero-1",
})
).resolves.toEqual({
variables: [
{
id: "variable-1",
name: "Title",
scopeInstanceId: "hero-1",
valueType: "string",
},
],
detail: "compact",
total: 1,
returnedCount: 1,
nextCursor: null,
filters: { scopeInstanceId: "hero-1" },
});
await expect(
caller.resources.list({
projectId: "project-1",
scopeInstanceId: "hero-1",
})
).resolves.toEqual({
resources: [
{
id: "resource-1",
name: "Posts",
method: "get",
url: '"https://example.com/posts"',
scopeInstanceId: "hero-1",
exposedAsDataSource: true,
dataSourceId: "resource-data-source-1",
},
],
detail: "compact",
total: 1,
returnedCount: 1,
nextCursor: null,
filters: { scopeInstanceId: "hero-1" },
});
});
});