Files
Oleg Isonen 16fa5ab0e3 feat: Basic authentication (#5779)
Closes https://github.com/webstudio-is/webstudio/issues/3991


Adds HTTP Basic Authentication support for published pages and
project-level `.wsauth` rules.

This introduces a new `@webstudio-is/wsauth` package that owns the
generic authentication format logic: parsing, validation, serialization,
route matching, resource generation, and request authentication. The
current supported method is `basic`, with the package structured so
additional auth methods can be added later.

Builder changes include:
- Page Settings now has an `Authentication` section for per-page Basic
Auth credentials.
- Project Settings now has an `Authentication` section for editing
`.wsauth` route rules.
- Page Settings was split into section-specific files with focused
tests.
- Auth UI shows Pro badges/info tooltips but remains editable.
- Publishing to custom domains is blocked on plans without `allowAuth`
when either page auth or project auth is configured.
- ProBadge now renders as a real pricing link and uses improved text
alignment.

Build/publish changes include:
- CLI prebuild generates and merges `.wsauth` entries.
- Published app templates enforce auth for HTML/XML routes.
- Generated auth resources are shared between build output and runtime.
2026-05-27 18:42:08 +02:00

816 lines
22 KiB
TypeScript

import { describe, test, expect } from "vitest";
import { getPermissions } from "./permissions";
// Free plan permission values
const defaultPlanPermissions = {
canDownloadAssets: false,
canRestoreBackups: false,
allowDynamicData: false,
allowAuth: false,
allowContentMode: false,
allowStagingPublish: false,
allowAdditionalPermissions: false,
maxContactEmailsPerProject: 0,
maxDomainsAllowedPerUser: 0,
maxDailyPublishesPerUser: 10,
maxWorkspaces: 0,
canInviteMembers: false,
canCreateWorkspace: false,
};
const proPlan = {
canDownloadAssets: true,
canRestoreBackups: true,
allowAdditionalPermissions: true,
allowDynamicData: true,
allowAuth: true,
allowContentMode: true,
allowStagingPublish: true,
maxContactEmailsPerProject: 5,
maxDomainsAllowedPerUser: 100,
maxDailyPublishesPerUser: 100,
maxWorkspaces: 0,
maxProjectsAllowedPerUser: 100,
maxAssetsPerProject: 500,
seatsIncluded: 0,
maxSeatsPerWorkspace: 0,
};
const freePlan = {
canDownloadAssets: false,
canRestoreBackups: false,
allowAdditionalPermissions: false,
allowDynamicData: false,
allowAuth: false,
allowContentMode: false,
allowStagingPublish: false,
maxContactEmailsPerProject: 0,
maxDomainsAllowedPerUser: 0,
maxDailyPublishesPerUser: 10,
maxWorkspaces: 0,
maxProjectsAllowedPerUser: 2,
maxAssetsPerProject: 50,
seatsIncluded: 0,
maxSeatsPerWorkspace: 0,
};
describe("getPermissions", () => {
describe("role-based permissions", () => {
test("owner has full role permissions", () => {
expect(
getPermissions({
role: "own",
planFeatures: freePlan,
workspaces: [],
})
).toEqual({
canCreateProject: true,
canDuplicate: true,
canRename: true,
canShare: true,
canDelete: true,
canTransfer: true,
canEditTags: true,
canOpenSettings: true,
canPublishToStagingOnly: false,
...defaultPlanPermissions,
});
});
test("administrators can create, duplicate, rename, and edit tags", () => {
const result = getPermissions({
role: "administrators",
planFeatures: freePlan,
workspaces: [],
});
expect(result).toEqual({
canCreateProject: true,
canDuplicate: true,
canRename: true,
canShare: false,
canDelete: false,
canTransfer: true,
canEditTags: true,
canOpenSettings: false,
canPublishToStagingOnly: false,
...defaultPlanPermissions,
});
});
test("builders can create, duplicate, rename, and edit tags", () => {
const result = getPermissions({
role: "builders",
planFeatures: freePlan,
workspaces: [],
});
expect(result).toEqual({
canCreateProject: true,
canDuplicate: true,
canRename: true,
canShare: false,
canDelete: false,
canTransfer: false,
canEditTags: true,
canOpenSettings: false,
canPublishToStagingOnly: true,
...defaultPlanPermissions,
});
});
test("editors can rename and edit tags only", () => {
const result = getPermissions({
role: "editors",
planFeatures: freePlan,
workspaces: [],
});
expect(result).toEqual({
canCreateProject: false,
canDuplicate: false,
canRename: true,
canShare: false,
canDelete: false,
canTransfer: false,
canEditTags: true,
canOpenSettings: false,
canPublishToStagingOnly: false,
...defaultPlanPermissions,
});
});
test("viewers have no role-based permissions", () => {
const result = getPermissions({
role: "viewers",
planFeatures: freePlan,
workspaces: [],
});
expect(result).toEqual({
canCreateProject: false,
canDuplicate: false,
canRename: false,
canShare: false,
canDelete: false,
canTransfer: false,
canEditTags: false,
canOpenSettings: false,
canPublishToStagingOnly: false,
...defaultPlanPermissions,
});
});
});
describe("canCreateProject by role", () => {
test("own → true", () => {
expect(
getPermissions({
role: "own",
planFeatures: freePlan,
workspaces: [],
}).canCreateProject
).toBe(true);
});
test("administrators → true", () => {
expect(
getPermissions({
role: "administrators",
planFeatures: freePlan,
workspaces: [],
}).canCreateProject
).toBe(true);
});
test("builders → true", () => {
expect(
getPermissions({
role: "builders",
planFeatures: freePlan,
workspaces: [],
}).canCreateProject
).toBe(true);
});
test("editors → false", () => {
expect(
getPermissions({
role: "editors",
planFeatures: freePlan,
workspaces: [],
}).canCreateProject
).toBe(false);
});
test("viewers → false", () => {
expect(
getPermissions({
role: "viewers",
planFeatures: freePlan,
workspaces: [],
}).canCreateProject
).toBe(false);
});
});
describe("plan-based permissions", () => {
test("pro plan enables all plan features", () => {
const result = getPermissions({
role: "own",
planFeatures: proPlan,
workspaces: [],
});
expect(result).toMatchObject({
allowDynamicData: true,
allowAuth: true,
allowContentMode: true,
allowStagingPublish: true,
allowAdditionalPermissions: true,
maxContactEmailsPerProject: 5,
maxDomainsAllowedPerUser: 100,
maxDailyPublishesPerUser: 100,
maxWorkspaces: 0,
canInviteMembers: false,
canCreateWorkspace: false,
});
});
test("free plan restricts plan features", () => {
const result = getPermissions({
role: "own",
planFeatures: freePlan,
workspaces: [],
});
expect(result).toMatchObject({
allowDynamicData: false,
allowAuth: false,
allowContentMode: false,
allowStagingPublish: false,
allowAdditionalPermissions: false,
maxContactEmailsPerProject: 0,
maxDomainsAllowedPerUser: 0,
maxDailyPublishesPerUser: 10,
maxWorkspaces: 0,
canInviteMembers: false,
canCreateWorkspace: false,
});
});
});
describe("allowContentMode with authPermit", () => {
test("content mode allowed for shared project (view permit)", () => {
const result = getPermissions({
role: "own",
authPermit: "view",
planFeatures: freePlan,
workspaces: [],
});
expect(result.allowContentMode).toBe(true);
});
test("content mode allowed for shared project (edit permit)", () => {
const result = getPermissions({
role: "own",
authPermit: "edit",
planFeatures: freePlan,
workspaces: [],
});
expect(result.allowContentMode).toBe(true);
});
test("content mode allowed for shared project (build permit)", () => {
const result = getPermissions({
role: "own",
authPermit: "build",
planFeatures: freePlan,
workspaces: [],
});
expect(result.allowContentMode).toBe(true);
});
test("content mode allowed for shared project (admin permit)", () => {
const result = getPermissions({
role: "own",
authPermit: "admin",
planFeatures: freePlan,
workspaces: [],
});
expect(result.allowContentMode).toBe(true);
});
test("content mode allowed for shared project with free plan", () => {
const result = getPermissions({
role: "own",
authPermit: "edit",
planFeatures: freePlan,
workspaces: [],
});
expect(result.allowContentMode).toBe(true);
});
test("content mode depends on plan for own project", () => {
expect(
getPermissions({
role: "own",
authPermit: "own",
planFeatures: freePlan,
workspaces: [],
}).allowContentMode
).toBe(false);
expect(
getPermissions({
role: "own",
authPermit: "own",
planFeatures: proPlan,
workspaces: [],
}).allowContentMode
).toBe(true);
});
test("content mode denied without authPermit on free plan", () => {
expect(
getPermissions({
role: "own",
planFeatures: freePlan,
workspaces: [],
}).allowContentMode
).toBe(false);
});
test("content mode allowed without authPermit on pro plan", () => {
const result = getPermissions({
role: "own",
planFeatures: proPlan,
workspaces: [],
});
expect(result.allowContentMode).toBe(true);
});
});
describe("combined role and plan permissions", () => {
test("viewer on free plan has minimal permissions", () => {
expect(
getPermissions({
role: "viewers",
planFeatures: freePlan,
workspaces: [],
})
).toEqual({
canCreateProject: false,
canDuplicate: false,
canRename: false,
canShare: false,
canDelete: false,
canTransfer: false,
canEditTags: false,
canOpenSettings: false,
canDownloadAssets: false,
canRestoreBackups: false,
allowDynamicData: false,
allowAuth: false,
allowContentMode: false,
allowStagingPublish: false,
allowAdditionalPermissions: false,
maxContactEmailsPerProject: 0,
maxDomainsAllowedPerUser: 0,
maxDailyPublishesPerUser: 10,
maxWorkspaces: 0,
canPublishToStagingOnly: false,
canInviteMembers: false,
canCreateWorkspace: false,
});
});
test("builder on pro plan has builder role + all plan features", () => {
expect(
getPermissions({
role: "builders",
planFeatures: proPlan,
workspaces: [],
})
).toEqual({
canCreateProject: true,
canDuplicate: true,
canRename: true,
canShare: false,
canDelete: false,
canTransfer: false,
canEditTags: true,
canOpenSettings: false,
canDownloadAssets: true,
canRestoreBackups: true,
allowDynamicData: true,
allowAuth: true,
allowContentMode: true,
allowStagingPublish: true,
allowAdditionalPermissions: true,
maxContactEmailsPerProject: 5,
maxDomainsAllowedPerUser: 100,
maxDailyPublishesPerUser: 100,
maxWorkspaces: 0,
canPublishToStagingOnly: true,
canInviteMembers: false,
canCreateWorkspace: false,
});
});
test("owner on pro plan has all permissions", () => {
expect(
getPermissions({
role: "own",
planFeatures: proPlan,
workspaces: [],
})
).toEqual({
canCreateProject: true,
canDuplicate: true,
canRename: true,
canShare: true,
canDelete: true,
canTransfer: true,
canEditTags: true,
canOpenSettings: true,
canDownloadAssets: true,
canRestoreBackups: true,
allowDynamicData: true,
allowAuth: true,
allowContentMode: true,
allowStagingPublish: true,
allowAdditionalPermissions: true,
maxContactEmailsPerProject: 5,
maxDomainsAllowedPerUser: 100,
maxDailyPublishesPerUser: 100,
maxWorkspaces: 0,
canPublishToStagingOnly: false,
canInviteMembers: false,
canCreateWorkspace: false,
});
});
test("administrator on free plan has admin role but limited plan features", () => {
expect(
getPermissions({
role: "administrators",
planFeatures: freePlan,
workspaces: [],
})
).toEqual({
canCreateProject: true,
canDuplicate: true,
canRename: true,
canShare: false,
canDelete: false,
canTransfer: true,
canEditTags: true,
canOpenSettings: false,
canDownloadAssets: false,
canRestoreBackups: false,
allowDynamicData: false,
allowAuth: false,
allowContentMode: false,
allowStagingPublish: false,
allowAdditionalPermissions: false,
maxContactEmailsPerProject: 0,
maxDomainsAllowedPerUser: 0,
maxDailyPublishesPerUser: 10,
maxWorkspaces: 0,
canPublishToStagingOnly: false,
canInviteMembers: false,
canCreateWorkspace: false,
});
});
test("editor on pro plan has editor role + all plan features", () => {
expect(
getPermissions({
role: "editors",
planFeatures: proPlan,
workspaces: [],
})
).toEqual({
canCreateProject: false,
canDuplicate: false,
canRename: true,
canShare: false,
canDelete: false,
canTransfer: false,
canEditTags: true,
canOpenSettings: false,
canDownloadAssets: true,
canRestoreBackups: true,
allowDynamicData: true,
allowAuth: true,
allowContentMode: true,
allowStagingPublish: true,
allowAdditionalPermissions: true,
maxContactEmailsPerProject: 5,
maxDomainsAllowedPerUser: 100,
maxDailyPublishesPerUser: 100,
maxWorkspaces: 0,
canPublishToStagingOnly: false,
canInviteMembers: false,
canCreateWorkspace: false,
});
});
});
describe("all three parameters combined", () => {
test("viewer + free plan + shared project enables only content mode", () => {
const result = getPermissions({
role: "viewers",
planFeatures: freePlan,
authPermit: "edit",
workspaces: [],
});
// role: all false
expect(result.canCreateProject).toBe(false);
expect(result.canDuplicate).toBe(false);
expect(result.canRename).toBe(false);
expect(result.canShare).toBe(false);
expect(result.canDelete).toBe(false);
expect(result.canEditTags).toBe(false);
expect(result.canOpenSettings).toBe(false);
// plan: all false except content mode overridden by shared project access
expect(result.allowDynamicData).toBe(false);
expect(result.allowContentMode).toBe(true);
expect(result.allowStagingPublish).toBe(false);
expect(result.allowAdditionalPermissions).toBe(false);
});
test("builder + pro plan + shared project has builder role + all plan features", () => {
const result = getPermissions({
role: "builders",
planFeatures: proPlan,
authPermit: "build",
workspaces: [],
});
expect(result.canCreateProject).toBe(true);
expect(result.canDuplicate).toBe(true);
expect(result.canRename).toBe(true);
expect(result.canShare).toBe(false);
expect(result.canDelete).toBe(false);
expect(result.canEditTags).toBe(true);
expect(result.canOpenSettings).toBe(false);
expect(result.allowDynamicData).toBe(true);
expect(result.allowContentMode).toBe(true);
expect(result.allowStagingPublish).toBe(true);
expect(result.allowAdditionalPermissions).toBe(true);
});
});
describe("maxContactEmailsPerProject", () => {
test("defaults to 0 on free plan", () => {
expect(
getPermissions({
role: "own",
planFeatures: freePlan,
workspaces: [],
}).maxContactEmailsPerProject
).toBe(0);
});
test("reflects plan value", () => {
expect(
getPermissions({
role: "own",
planFeatures: proPlan,
workspaces: [],
}).maxContactEmailsPerProject
).toBe(5);
});
});
describe("maxDomainsAllowedPerUser", () => {
test("defaults to 0 on free plan", () => {
expect(
getPermissions({
role: "own",
planFeatures: freePlan,
workspaces: [],
}).maxDomainsAllowedPerUser
).toBe(0);
});
test("reflects plan value", () => {
expect(
getPermissions({
role: "own",
planFeatures: proPlan,
workspaces: [],
}).maxDomainsAllowedPerUser
).toBe(100);
});
test("free plan has 0", () => {
expect(
getPermissions({
role: "own",
planFeatures: freePlan,
workspaces: [],
}).maxDomainsAllowedPerUser
).toBe(0);
});
});
describe("maxDailyPublishesPerUser", () => {
test("defaults to 10 on free plan", () => {
expect(
getPermissions({
role: "own",
planFeatures: freePlan,
workspaces: [],
}).maxDailyPublishesPerUser
).toBe(10);
});
test("reflects plan value for pro", () => {
expect(
getPermissions({
role: "own",
planFeatures: proPlan,
workspaces: [],
}).maxDailyPublishesPerUser
).toBe(100);
});
test("free plan allows limited publishes", () => {
expect(
getPermissions({
role: "own",
planFeatures: freePlan,
workspaces: [],
}).maxDailyPublishesPerUser
).toBe(10);
});
});
describe("canInviteMembers", () => {
test("false when maxWorkspaces is 0", () => {
expect(
getPermissions({
role: "own",
planFeatures: freePlan,
workspaces: [],
}).canInviteMembers
).toBe(false);
});
test("false when maxWorkspaces is 1", () => {
expect(
getPermissions({
role: "own",
planFeatures: { ...freePlan, maxWorkspaces: 1 },
workspaces: [],
}).canInviteMembers
).toBe(false);
});
test("true when maxWorkspaces is greater than 1", () => {
expect(
getPermissions({
role: "own",
planFeatures: { ...freePlan, maxWorkspaces: 5 },
workspaces: [],
}).canInviteMembers
).toBe(true);
});
});
describe("canCreateWorkspace", () => {
test("false when owned count equals maxWorkspaces", () => {
expect(
getPermissions({
role: "own",
planFeatures: { ...freePlan, maxWorkspaces: 1 },
workspaces: [{ role: "own" }],
}).canCreateWorkspace
).toBe(false);
});
test("false when owned count exceeds maxWorkspaces", () => {
expect(
getPermissions({
role: "own",
planFeatures: { ...freePlan, maxWorkspaces: 1 },
workspaces: [{ role: "own" }, { role: "own" }],
}).canCreateWorkspace
).toBe(false);
});
test("true when owned count is below maxWorkspaces", () => {
expect(
getPermissions({
role: "own",
planFeatures: { ...freePlan, maxWorkspaces: 3 },
workspaces: [{ role: "own" }],
}).canCreateWorkspace
).toBe(true);
});
test("counts only owned workspaces, not shared", () => {
expect(
getPermissions({
role: "own",
planFeatures: { ...freePlan, maxWorkspaces: 2 },
workspaces: [
{ role: "own" },
{ role: "editors" },
{ role: "viewers" },
],
}).canCreateWorkspace
).toBe(true);
});
test("false when maxWorkspaces is 0", () => {
expect(
getPermissions({
role: "own",
planFeatures: freePlan,
workspaces: [],
}).canCreateWorkspace
).toBe(false);
});
test("defaults to empty workspaces", () => {
expect(
getPermissions({
role: "own",
planFeatures: { ...freePlan, maxWorkspaces: 5 },
workspaces: [],
}).canCreateWorkspace
).toBe(true);
});
});
describe("canPublishToStagingOnly", () => {
test("own → false (full publish access)", () => {
expect(
getPermissions({
role: "own",
planFeatures: freePlan,
workspaces: [],
}).canPublishToStagingOnly
).toBe(false);
});
test("administrators → false (full publish access)", () => {
expect(
getPermissions({
role: "administrators",
planFeatures: freePlan,
workspaces: [],
}).canPublishToStagingOnly
).toBe(false);
});
test("builders → true (staging only)", () => {
expect(
getPermissions({
role: "builders",
planFeatures: freePlan,
workspaces: [],
}).canPublishToStagingOnly
).toBe(true);
});
test("editors → false (no publish access)", () => {
expect(
getPermissions({
role: "editors",
planFeatures: freePlan,
workspaces: [],
}).canPublishToStagingOnly
).toBe(false);
});
test("viewers → false (no publish access)", () => {
expect(
getPermissions({
role: "viewers",
planFeatures: freePlan,
workspaces: [],
}).canPublishToStagingOnly
).toBe(false);
});
test("authPermit 'build' → true (share-link builder, staging only)", () => {
expect(
getPermissions({
role: "own",
authPermit: "build",
planFeatures: freePlan,
workspaces: [],
}).canPublishToStagingOnly
).toBe(true);
});
test("authPermit 'admin' → false (share-link admin, full access through canPublish)", () => {
expect(
getPermissions({
role: "own",
authPermit: "admin",
planFeatures: freePlan,
workspaces: [],
}).canPublishToStagingOnly
).toBe(false);
});
});
});