feat(server): entitlement based model (#14996)

#### PR Dependency Tree


* **PR #14996** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Admin mutations to grant/revoke commercial entitlements.
  * New Doc comment-update permission.
  * Realtime user/workspace quota-state endpoints and live-update rooms.

* **Bug Fixes**
  * More accurate readable-doc filtering and permission evaluation.

* **Refactor**
* Workspace feature management moved to entitlement-based model;
permission and quota pipelines redesigned.
  * Admin workspace UI now edits flags only (feature toggles removed).

* **Tests**
* Extensive new and updated tests for permissions, entitlements, quota,
projection, and backfills.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14996?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-05-19 22:48:05 +08:00
committed by GitHub
parent 103ad2a810
commit c53457691d
150 changed files with 13463 additions and 2458 deletions
@@ -5,6 +5,7 @@ import ava, { type ExecutionContext, type TestFn } from 'ava';
import Sinon from 'sinon';
import { Cache, CryptoHelper } from '../../base';
import { EntitlementService } from '../../core/entitlement';
import { Models, WorkspaceRole } from '../../models';
import { CopilotAccessPolicy } from '../../plugins/copilot/access';
import { ByokService } from '../../plugins/copilot/byok';
@@ -14,6 +15,11 @@ import {
ByokKeyTestStatus,
ByokProvider,
} from '../../plugins/copilot/byok/types';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../plugins/payment/types';
import { createTestingModule, type TestingModule } from '../utils';
interface Context {
@@ -24,11 +30,18 @@ interface Context {
byok: ByokService;
crypto: CryptoHelper;
cache: Cache;
entitlement: EntitlementService;
}
const test = ava as TestFn<Context>;
const test = ava.serial as TestFn<Context>;
const originalNamespace = globalThis.env.NAMESPACE;
const originalDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
test.before(async t => {
Object.assign(globalThis.env, {
NAMESPACE: 'dev',
DEPLOYMENT_TYPE: 'affine',
});
const module = await createTestingModule();
t.context.module = module;
t.context.models = module.get(Models);
@@ -37,6 +50,7 @@ test.before(async t => {
t.context.byok = module.get(ByokService);
t.context.crypto = module.get(CryptoHelper);
t.context.cache = module.get(Cache);
t.context.entitlement = module.get(EntitlementService);
});
test.beforeEach(async t => {
@@ -45,6 +59,10 @@ test.beforeEach(async t => {
test.after.always(async t => {
await t.context.module.close();
Object.assign(globalThis.env, {
NAMESPACE: originalNamespace,
DEPLOYMENT_TYPE: originalDeploymentType,
});
});
async function createUserWorkspace(t: ExecutionContext<Context>) {
@@ -59,6 +77,73 @@ function workspaceHash(workspaceId: string) {
return createHash('sha256').update(workspaceId).digest('hex').slice(0, 12);
}
async function grantUserPlan(
t: ExecutionContext<Context>,
userId: string,
feature: ByokUserPlanFeature = 'pro_plan_v1'
) {
if (feature === 'unlimited_copilot') {
await t.context.entitlement.upsertFromCloudSubscription({
targetId: userId,
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
});
return;
}
await t.context.entitlement.upsertFromCloudSubscription({
targetId: userId,
plan: SubscriptionPlan.Pro,
recurring:
feature === 'lifetime_pro_plan_v1'
? SubscriptionRecurring.Lifetime
: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
});
}
async function revokeUserPlan(
t: ExecutionContext<Context>,
userId: string,
feature: ByokUserPlanFeature = 'pro_plan_v1'
) {
if (feature === 'unlimited_copilot') {
await t.context.entitlement.revokeCloudSubscription({
targetId: userId,
plan: SubscriptionPlan.AI,
});
return;
}
await t.context.entitlement.revokeCloudSubscription({
targetId: userId,
plan: SubscriptionPlan.Pro,
});
}
async function grantTeamPlan(
t: ExecutionContext<Context>,
workspaceId: string
) {
await t.context.entitlement.upsertFromCloudSubscription({
targetId: workspaceId,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
});
}
async function revokeTeamPlan(
t: ExecutionContext<Context>,
workspaceId: string
) {
await t.context.entitlement.revokeCloudSubscription({
targetId: workspaceId,
plan: SubscriptionPlan.Team,
});
}
type ByokMatrixCase = {
name: string;
role: WorkspaceRole;
@@ -110,25 +195,13 @@ async function createByokMatrixWorkspace(
);
}
if (input.team) {
await t.context.models.workspaceFeature.add(
workspace.id,
'team_plan_v1',
'test'
);
await grantTeamPlan(t, workspace.id);
}
if (input.ownerPlan) {
await t.context.models.userFeature.add(
owner.id,
input.ownerPlanFeature ?? 'pro_plan_v1',
'test'
);
await grantUserPlan(t, owner.id, input.ownerPlanFeature);
}
if (input.actorPlan && actor.id !== owner.id) {
await t.context.models.userFeature.add(
actor.id,
input.actorPlanFeature ?? 'pro_plan_v1',
'test'
);
await grantUserPlan(t, actor.id, input.actorPlanFeature);
}
return { owner, actor, workspace };
@@ -252,7 +325,7 @@ for (const matrixCase of byokManagementMatrix) {
test('byok service persists encrypted server keys and never returns plaintext', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const primary = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
@@ -325,7 +398,7 @@ test('byok service persists encrypted server keys and never returns plaintext',
test('byok service preserves server key fields during partial updates', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const key = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
@@ -381,7 +454,7 @@ test('byok service preserves server key fields during partial updates', async t
test('local leases are short lived and do not persist keys to server configs', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const before = Date.now();
const lease = await t.context.byok.createLocalLease({
@@ -486,7 +559,7 @@ test('local leases persist normalized custom endpoints', async t => {
).get(() => true);
t.teardown(() => customEndpointSupported.restore());
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const lease = await t.context.byok.createLocalLease({
workspaceId: workspace.id,
@@ -659,13 +732,10 @@ for (const matrixCase of byokProfileAvailabilityMatrix) {
}
if (matrixCase.revokeOwnerPlan) {
await t.context.models.userFeature.remove(owner.id, 'pro_plan_v1');
await revokeUserPlan(t, owner.id);
}
if (matrixCase.revokeTeam) {
await t.context.models.workspaceFeature.remove(
workspace.id,
'team_plan_v1'
);
await revokeTeamPlan(t, workspace.id);
}
if (matrixCase.demoteActor) {
await t.context.models.workspaceUser.set(
@@ -695,7 +765,7 @@ test('BYOK profile availability: local-only workspace does not resolve BYOK prof
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const profiles = await t.context.byok.getProfiles({
workspaceId: randomUUID(),
@@ -707,7 +777,7 @@ test('BYOK profile availability: local-only workspace does not resolve BYOK prof
test('test key failure disables a saved key and success restores it', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const key = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
userId: user.id,
@@ -778,7 +848,7 @@ test('test key failure disables a saved key and success restores it', async t =>
test('local key test does not mutate saved server config', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const key = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
userId: user.id,
@@ -817,7 +887,7 @@ test('local key test does not mutate saved server config', async t => {
test('Gemini key test sends key in header and returns safe failure message', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const fetch = Sinon.stub(globalThis, 'fetch').resolves(
new Response(
@@ -852,7 +922,7 @@ test('Gemini key test sends key in header and returns safe failure message', asy
test('FAL key test uses read-only platform API probe endpoint', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const fetch = Sinon.stub(globalThis, 'fetch').resolves(
new Response('{}', { status: 200 })
@@ -877,7 +947,7 @@ test('FAL key test uses read-only platform API probe endpoint', async t => {
test('provider test failures do not return raw provider response body', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const cases = [
{
body: 'authorization: Bearer token=a+b%2F==',
@@ -925,7 +995,7 @@ test('provider test failures do not return raw provider response body', async t
test('dispatch failure disables server BYOK key by provider id', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const key = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
userId: user.id,
@@ -956,7 +1026,7 @@ test('dispatch failure disables server BYOK key by provider id', async t => {
test('dispatch accounting ignores provider ids from another workspace hash', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const otherWorkspace = await t.context.models.workspace.create(user.id);
const key = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
@@ -996,7 +1066,7 @@ test('dispatch accounting ignores provider ids from another workspace hash', asy
test('effective profiles use local lease before server keys and skip disabled keys', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const serverKey = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
userId: user.id,
@@ -1067,7 +1137,7 @@ test('effective profiles use local lease before server keys and skip disabled ke
test('capability warnings match server Gemini background coverage', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const emptySettings = await t.context.byok.getSettings(workspace.id, user.id);
t.deepEqual(
@@ -732,7 +732,7 @@ test('should be able to chat with special image model', async t => {
promptName
);
const messageId = await createCopilotMessage(app, sessionId, 'some-tag', [
`https://example.com/${promptName}.jpg`,
smallestPng,
]);
const ret3 = await chatWithImages(app, sessionId, messageId);
t.is(
@@ -17,6 +17,7 @@ import {
import { ConfigModule } from '../../base/config';
import { AuthService } from '../../core/auth';
import { QuotaModule } from '../../core/quota';
import { QuotaStateService } from '../../core/quota/state';
import { StorageModule, WorkspaceBlobStorage } from '../../core/storage';
import {
ContextCategories,
@@ -101,6 +102,7 @@ type Context = {
actionBridge: ActionRuntimeBridge;
cronJobs: CopilotCronJobs;
subscription: SubscriptionService;
quotaState: QuotaStateService;
};
const buildTurn = (
@@ -199,6 +201,7 @@ test.before(async t => {
const workspaceEmbedding = module.get(CopilotWorkspaceService);
const cronJobs = module.get(CopilotCronJobs);
const subscription = module.get(SubscriptionService);
const quotaState = module.get(QuotaStateService);
t.context.module = module;
t.context.auth = auth;
@@ -225,6 +228,7 @@ test.before(async t => {
t.context.workspaceEmbedding = workspaceEmbedding;
t.context.cronJobs = cronJobs;
t.context.subscription = subscription;
t.context.quotaState = quotaState;
await module.initTestingDB();
});
@@ -2172,7 +2176,7 @@ test('model selection policy should resolve requested optional models consistent
});
test('capability policy host should gate pro model requests by subscription status', async t => {
const { subscription, module } = t.context;
const { quotaState, subscription, module } = t.context;
const capabilityPolicy = module.get(CapabilityPolicyHost);
const mockStatus = (status?: SubscriptionStatus) => {
@@ -2181,6 +2185,10 @@ test('capability policy host should gate pro model requests by subscription stat
// @ts-expect-error mock
getSubscription: async () => (status ? { status } : null),
}));
Sinon.stub(quotaState, 'reconcileUserQuotaState').resolves({
plan: status === SubscriptionStatus.Active ? 'pro' : 'free',
flags: {},
} as Awaited<ReturnType<QuotaStateService['reconcileUserQuotaState']>>);
};
// payment disabled -> allow requested if in optional; pro not blocked
@@ -3,7 +3,7 @@ import test from 'ava';
import { z } from 'zod';
import type { DocReader } from '../../core/doc';
import type { AccessController } from '../../core/permission';
import type { PermissionAccess } from '../../core/permission';
import type { Models } from '../../models';
import {
LlmRequest,
@@ -404,7 +404,7 @@ test('doc_read should return specific sync errors for unavailable docs', async t
user: () => ({
workspace: () => ({ doc: () => ({ can: async () => true }) }),
}),
} as unknown as AccessController;
} as unknown as PermissionAccess;
for (const testCase of cases) {
let docReaderCalled = false;
@@ -447,7 +447,7 @@ test('document search tools should return sync error for local workspace', async
docs: async () => [],
}),
}),
} as unknown as AccessController;
} as unknown as PermissionAccess;
const models = {
workspace: {
@@ -510,7 +510,7 @@ test('doc_semantic_search should return empty array when nothing matches', async
docs: async () => [],
}),
}),
} as unknown as AccessController;
} as unknown as PermissionAccess;
const models = {
workspace: {
@@ -542,7 +542,7 @@ test('doc_semantic_search should pass BYOK route context into embedding matches'
docs: async () => [],
}),
}),
} as unknown as AccessController;
} as unknown as PermissionAccess;
const models = {
workspace: {
@@ -595,7 +595,7 @@ test('blob_read should return explicit error when attachment context is missing'
}),
}),
}),
} as unknown as AccessController;
} as unknown as PermissionAccess;
const blobTool = createBlobReadTool(
buildBlobContentGetter(ac, null).bind(null, {
@@ -57,6 +57,21 @@ function getSnapshot(timestamp: number = Date.now()): DocRecord {
};
}
test('history max age converts quota seconds to milliseconds', async t => {
Sinon.restore();
const options = m.get(DocStorageOptions);
// @ts-expect-error private service boundary is asserted here
Sinon.stub(options.quota, 'getWorkspaceQuota').resolves({
name: 'Pro',
blobLimit: 1,
storageQuota: 1,
historyPeriod: 30,
memberLimit: 1,
});
t.is(await options.historyMaxAge('1'), 30_000);
});
test('should create doc history if never created before', async t => {
// @ts-expect-error private method
Sinon.stub(adapter, 'lastDocHistory').resolves(null);
@@ -273,16 +273,64 @@ e2e('should update comment work', async t => {
t.truthy(result.updateComment);
});
e2e('should update comment failed by another user', async t => {
e2e('should update comment work by doc Editor', async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
workspaceId: teamWorkspace.id,
docId,
userId: member.id,
type: DocRole.Editor,
});
await app.login(owner);
const createResult = await app.gql({
query: createCommentMutation,
variables: {
input: {
workspaceId: workspace.id,
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
},
},
});
await app.login(member);
const result = await app.gql({
query: updateCommentMutation,
variables: {
input: {
id: createResult.createComment.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test update' }],
},
},
},
});
t.truthy(result.updateComment);
});
e2e('should update comment failed without update permission', async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
workspaceId: teamWorkspace.id,
docId,
userId: member.id,
type: DocRole.Reader,
});
await app.login(owner);
const createResult = await app.gql({
query: createCommentMutation,
variables: {
input: {
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
@@ -1145,15 +1193,79 @@ e2e('should update reply work when user is reply owner', async t => {
t.truthy(result.updateReply);
});
e2e('should update reply failed when user is not reply owner', async t => {
e2e('should update reply work by doc Editor', async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
workspaceId: teamWorkspace.id,
docId,
userId: member.id,
type: DocRole.Editor,
});
await app.login(owner);
const createResult = await app.gql({
query: createCommentMutation,
variables: {
input: {
workspaceId: workspace.id,
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
},
},
});
const createReplyResult = await app.gql({
query: createReplyMutation,
variables: {
input: {
commentId: createResult.createComment.id,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
},
},
});
await app.login(member);
const result = await app.gql({
query: updateReplyMutation,
variables: {
input: {
id: createReplyResult.createReply.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test update' }],
},
},
},
});
t.truthy(result.updateReply);
});
e2e('should update reply failed without update permission', async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
workspaceId: teamWorkspace.id,
docId,
userId: member.id,
type: DocRole.Reader,
});
await app.login(owner);
const createResult = await app.gql({
query: createCommentMutation,
variables: {
input: {
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
@@ -28,37 +28,43 @@ e2e('should render doc share page with apple-itunes-app meta tag', async t => {
);
});
e2e(
e2e.serial(
'should render doc share page without apple-itunes-app meta tag when selfhosted',
async t => {
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
// @ts-expect-error override
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
await using app = await createApp();
try {
await using app = await createApp();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
});
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
});
const docSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
// set public to true
await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: docSnapshot.id,
public: true,
});
const docSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
// set public to true
await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: docSnapshot.id,
public: true,
});
const res = await app
.GET(`/workspace/${workspace.id}/${docSnapshot.id}`)
.expect(200)
.expect('Content-Type', 'text/html; charset=utf-8');
const res = await app
.GET(`/workspace/${workspace.id}/${docSnapshot.id}`)
.expect(200)
.expect('Content-Type', 'text/html; charset=utf-8');
t.notRegex(
res.text,
/<meta name="apple-itunes-app" content="app-id=6736937980" \/>/
);
t.notRegex(
res.text,
/<meta name="apple-itunes-app" content="app-id=6736937980" \/>/
);
} finally {
// @ts-expect-error restore mutable test env singleton
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
}
}
);
@@ -69,6 +69,64 @@ e2e('should get recently updated docs', async t => {
t.is(recentlyUpdatedDocs.edges[2].node.title, doc1.title);
});
e2e('should filter recently updated docs by doc read permission', async t => {
const owner = await app.signup();
const member = await app.createUser();
await app.login(member);
await app.switchUser(owner);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
type: WorkspaceRole.Collaborator,
});
const privateSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: privateSnapshot.id,
title: 'private-doc',
defaultRole: DocRole.None,
});
const publicSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
const publicDoc = await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: publicSnapshot.id,
title: 'public-doc',
defaultRole: DocRole.None,
public: true,
});
await app.switchUser(member);
const {
workspace: { recentlyUpdatedDocs },
} = await app.gql({
query: getRecentlyUpdatedDocsQuery,
variables: {
workspaceId: workspace.id,
pagination: {
first: 10,
},
},
});
t.is(recentlyUpdatedDocs.totalCount, 1);
t.deepEqual(
recentlyUpdatedDocs.edges.map(edge => edge.node.id),
[publicDoc.docId]
);
});
e2e(
'should get doc with public attribute when doc snapshot not exists',
async t => {
@@ -15,9 +15,18 @@ import {
R2StorageProvider,
} from '../../../base/storage/providers/r2';
import { SIGNED_URL_EXPIRED } from '../../../base/storage/providers/utils';
import { WorkspaceBlobStorage } from '../../../core/storage';
import { EntitlementService } from '../../../core/entitlement';
import {
CommentAttachmentStorage,
WorkspaceBlobStorage,
} from '../../../core/storage';
import { MULTIPART_THRESHOLD } from '../../../core/storage/constants';
import { R2UploadController } from '../../../core/storage/r2-proxy';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../../plugins/payment/types';
import { app, e2e, Mockers } from '../test';
class MockR2Provider extends R2StorageProvider {
@@ -160,6 +169,8 @@ async function setBlobStorage(storage: StorageProviderConfig) {
configFactory.override({ storages: { blob: { storage } } });
const blobStorage = app.get(WorkspaceBlobStorage);
await blobStorage.onConfigInit();
const commentAttachmentStorage = app.get(CommentAttachmentStorage);
await commentAttachmentStorage.onConfigInit();
const controller = app.get(R2UploadController);
// reset cached provider in controller
(controller as any).provider = null;
@@ -245,7 +256,13 @@ async function getBlobUploadPartUrl(
}
async function setupWorkspace() {
const owner = await app.signup({ feature: 'pro_plan_v1' });
const owner = await app.signup();
await app.get(EntitlementService).upsertFromCloudSubscription({
targetId: owner.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
});
const workspace = await app.create(Mockers.Workspace, { owner });
return { owner, workspace };
}
@@ -435,7 +452,13 @@ e2e(
e2e(
'should still fallback to graphql when provider does not support presign',
async t => {
await setBlobStorage(defaultBlobStorage);
await setBlobStorage({
provider: 'fs',
bucket: 'test-fallback-bucket',
config: {
path: '/tmp/affine-r2-proxy-test',
},
});
const { workspace } = await setupWorkspace();
const buffer = Buffer.from('graph');
@@ -1,6 +1,11 @@
import { randomUUID } from 'node:crypto';
import { mock } from 'node:test';
import {
Config,
ConfigFactory,
type StorageProviderConfig,
} from '../../../base';
import { CommentAttachmentStorage } from '../../../core/storage';
import { Mockers } from '../../mocks';
import { app, e2e } from '../test';
@@ -21,6 +26,11 @@ e2e.afterEach.always(() => {
mock.reset();
});
async function useCommentAttachmentBlobStorage(storage: StorageProviderConfig) {
app.get(ConfigFactory).override({ storages: { blob: { storage } } });
await app.get(CommentAttachmentStorage).onConfigInit();
}
// #region comment attachment
e2e(
@@ -61,35 +71,50 @@ e2e(
}
);
e2e('should get comment attachment body', async t => {
e2e.serial('should get comment attachment body', async t => {
const defaultBlobStorage = structuredClone(
app.get(Config).storages.blob.storage
);
await useCommentAttachmentBlobStorage({
provider: 'fs',
bucket: 'test-comment-attachment',
config: {
path: '/tmp/affine-test-comment-attachment',
},
});
const { owner, workspace } = await createWorkspace();
await app.login(owner);
const docId = randomUUID();
const key = randomUUID();
const attachment = app.get(CommentAttachmentStorage);
await attachment.put(
workspace.id,
docId,
key,
'test.txt',
Buffer.from('test'),
owner.id
);
try {
const docId = randomUUID();
const key = randomUUID();
const attachment = app.get(CommentAttachmentStorage);
await attachment.put(
workspace.id,
docId,
key,
'test.txt',
Buffer.from('test'),
owner.id
);
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
);
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
);
t.is(res.status, 200);
t.is(res.headers['content-type'], 'text/plain');
t.is(res.headers['content-length'], '4');
t.is(res.headers['cache-control'], 'private, max-age=2592000, immutable');
t.regex(
res.headers['last-modified'],
/^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/
);
t.is(res.text, 'test');
t.is(res.status, 200);
t.is(res.headers['content-type'], 'text/plain');
t.is(res.headers['content-length'], '4');
t.is(res.headers['cache-control'], 'private, max-age=2592000, immutable');
t.regex(
res.headers['last-modified'],
/^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/
);
t.is(res.text, 'test');
} finally {
await useCommentAttachmentBlobStorage(defaultBlobStorage);
}
});
e2e('should get comment attachment redirect url', async t => {
@@ -1,28 +1,32 @@
import { randomUUID } from 'node:crypto';
import {
acceptInviteByInviteIdMutation,
approveWorkspaceTeamMemberMutation,
createInviteLinkMutation,
deleteBlobMutation,
getInviteInfoQuery,
getMembersByWorkspaceIdQuery,
inviteByEmailsMutation,
leaveWorkspaceMutation,
releaseDeletedBlobsMutation,
revokeMemberPermissionMutation,
WorkspaceInviteLinkExpireTime,
WorkspaceMemberStatus,
} from '@affine/graphql';
import { faker } from '@faker-js/faker';
import { EntitlementService } from '../../../core/entitlement';
import { WorkspacePolicyService } from '../../../core/permission';
import { Models } from '../../../models';
import { FeatureConfigs } from '../../../models/common/feature';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../../plugins/payment/types';
import { Mockers } from '../../mocks';
import { app, e2e } from '../test';
const TWO_BILLION_BYTES = 2_000_000_000;
async function createWorkspace() {
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
@@ -35,6 +39,23 @@ async function createWorkspace() {
};
}
async function grantTeamPlan(workspaceId: string, quantity: number) {
await app.get(EntitlementService).upsertFromCloudSubscription({
targetId: workspaceId,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
quantity,
});
}
async function revokeTeamPlan(workspaceId: string) {
await app.get(EntitlementService).revokeCloudSubscription({
targetId: workspaceId,
plan: SubscriptionPlan.Team,
});
}
e2e('should invite a user', async t => {
const { owner, workspace } = await createWorkspace();
const u2 = await app.create(Mockers.User);
@@ -91,19 +112,16 @@ e2e('should invite a user', async t => {
e2e('should re-check seat when accepting an email invitation', async t => {
const { owner, workspace } = await createWorkspace();
const member = await app.create(Mockers.User);
await app.create(Mockers.TeamWorkspace, {
id: workspace.id,
quantity: 4,
});
await grantTeamPlan(workspace.id, 12);
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: (await app.create(Mockers.User)).id,
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: (await app.create(Mockers.User)).id,
});
await Promise.all(
Array.from({ length: 10 }).map(async () => {
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: (await app.create(Mockers.User)).id,
});
})
);
await app.login(owner);
const invite = await app.gql({
@@ -116,10 +134,10 @@ e2e('should re-check seat when accepting an email invitation', async t => {
await app.eventBus.emitAsync('workspace.members.allocateSeats', {
workspaceId: workspace.id,
quantity: 4,
quantity: 12,
});
await app.models.workspaceFeature.remove(workspace.id, 'team_plan_v1');
await revokeTeamPlan(workspace.id);
await app.login(member);
await t.throwsAsync(
@@ -147,24 +165,6 @@ e2e.serial(
async t => {
const { owner, workspace } = await createWorkspace();
const member = await app.create(Mockers.User);
const freeStorageQuota = FeatureConfigs.free_plan_v1.configs.storageQuota;
const lifetimeStorageQuota =
FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota;
FeatureConfigs.free_plan_v1.configs.storageQuota = 1;
FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota = 2;
t.teardown(() => {
FeatureConfigs.free_plan_v1.configs.storageQuota = freeStorageQuota;
FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota =
lifetimeStorageQuota;
});
await app.models.userFeature.switchQuota(
owner.id,
'lifetime_pro_plan_v1',
'test setup'
);
await app.login(owner);
const invite = await app.gql({
query: inviteByEmailsMutation,
@@ -174,26 +174,26 @@ e2e.serial(
},
});
await app.models.blob.upsert({
workspaceId: workspace.id,
key: 'overflow-blob',
mime: 'application/octet-stream',
size: 2,
status: 'completed',
uploadId: null,
});
await app.eventBus.emitAsync('user.subscription.canceled', {
userId: owner.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
});
const overflowBlobKeys = Array.from(
{ length: 6 },
(_, index) => `overflow-blob-${index}`
);
await Promise.all(
overflowBlobKeys.map(key =>
app.models.blob.upsert({
workspaceId: workspace.id,
key,
mime: 'application/octet-stream',
size: TWO_BILLION_BYTES,
status: 'completed',
uploadId: null,
})
)
);
t.true(
await app.models.workspaceFeature.has(
workspace.id,
'quota_exceeded_readonly_workspace_v1'
)
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
.isReadonly
);
await app.login(member);
@@ -216,26 +216,13 @@ e2e.serial(
t.is(pendingInvite.status, WorkspaceMemberStatus.Pending);
await app.login(owner);
await app.gql({
query: deleteBlobMutation,
variables: {
workspaceId: workspace.id,
key: 'overflow-blob',
permanently: false,
},
});
await app.gql({
query: releaseDeletedBlobsMutation,
variables: {
workspaceId: workspace.id,
},
});
for (const key of overflowBlobKeys) {
await app.models.blob.delete(workspace.id, key, true);
}
t.false(
await app.models.workspaceFeature.has(
workspace.id,
'quota_exceeded_readonly_workspace_v1'
)
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
.isReadonly
);
await app.login(member);
@@ -596,10 +583,7 @@ e2e(
'should invite by link and send review request notification over quota limit',
async t => {
const { owner, workspace } = await createWorkspace();
await app.create(Mockers.TeamWorkspace, {
id: workspace.id,
quantity: 3,
});
await grantTeamPlan(workspace.id, 3);
await app.login(owner);
const { createInviteLink } = await app.gql({
@@ -639,10 +623,7 @@ e2e(
name: faker.internet.displayName({ firstName: 'Lucy' }),
});
const user2 = await app.create(Mockers.User, {
email: faker.internet.email({
firstName: 'Jeanne',
lastName: 'Doe',
}),
email: `jeanne_doe.${randomUUID()}@affine.pro`,
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
@@ -6,6 +6,7 @@ import {
revokePublicPageMutation,
WorkspaceMemberStatus,
} from '@affine/graphql';
import { PrismaClient } from '@prisma/client';
import { QuotaService } from '../../../core/quota/service';
import { WorkspaceRole } from '../../../models';
@@ -98,7 +99,31 @@ const revokeMember = async (workspaceId: string, userId: string) => {
return revokeMember;
};
e2e('should set new invited users to AllocatingSeat', async t => {
const cancelTeamWorkspace = async (workspaceId: string) => {
const db = app.get(PrismaClient);
await db.entitlement.updateMany({
where: {
targetType: 'workspace',
targetId: workspaceId,
plan: 'team',
},
data: { status: 'revoked' },
});
await db.subscription.updateMany({
where: {
targetId: workspaceId,
plan: SubscriptionPlan.Team,
},
data: { status: 'canceled' },
});
await app.eventBus.emitAsync('workspace.subscription.canceled', {
workspaceId,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
});
};
e2e('should set new invited users to waiting-seat status', async t => {
const { owner, workspace } = await createTeamWorkspace();
await app.login(owner);
@@ -117,7 +142,7 @@ e2e('should set new invited users to AllocatingSeat', async t => {
const invitationInfo = await getInvitationInfo(
result.inviteMembers[0].inviteId!
);
t.is(invitationInfo.status, WorkspaceMemberStatus.AllocatingSeat);
t.is(invitationInfo.status, WorkspaceMemberStatus.NeedMoreSeat);
});
e2e('should allocate seats', async t => {
@@ -151,11 +176,11 @@ e2e('should allocate seats', async t => {
});
t.is(
members.find(m => m.user.id === u1.id)?.status,
members.find(m => m.user?.id === u1.id)?.status,
WorkspaceMemberStatus.Pending
);
t.is(
members.find(m => m.user.id === u2.id)?.status,
members.find(m => m.user?.id === u2.id)?.status,
WorkspaceMemberStatus.Accepted
);
@@ -201,11 +226,11 @@ e2e('should set all rests to NeedMoreSeat', async t => {
});
t.is(
members.find(m => m.user.id === u2.id)?.status,
members.find(m => m.user?.id === u2.id)?.status,
WorkspaceMemberStatus.NeedMoreSeat
);
t.is(
members.find(m => m.user.id === u3.id)?.status,
members.find(m => m.user?.id === u3.id)?.status,
WorkspaceMemberStatus.NeedMoreSeat
);
});
@@ -237,11 +262,7 @@ e2e(
status: WorkspaceMemberStatus.UnderReview,
});
await app.eventBus.emitAsync('workspace.subscription.canceled', {
workspaceId: workspace.id,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
});
await cancelTeamWorkspace(workspace.id);
const [members] = await app.models.workspaceUser.paginate(workspace.id, {
first: 20,
@@ -265,11 +286,7 @@ e2e(
async t => {
const { workspace, owner, admin } = await createTeamWorkspace();
await app.eventBus.emitAsync('workspace.subscription.canceled', {
workspaceId: workspace.id,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
});
await cancelTeamWorkspace(workspace.id);
t.false(await app.models.workspace.isTeamWorkspace(workspace.id));
t.false(
@@ -306,11 +323,7 @@ e2e(
await app.login(owner);
await publishDoc(workspace.id, 'published-doc');
await app.eventBus.emitAsync('workspace.subscription.canceled', {
workspaceId: workspace.id,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
});
await cancelTeamWorkspace(workspace.id);
t.false(await app.models.workspace.isTeamWorkspace(workspace.id));
t.true(
@@ -325,7 +338,7 @@ e2e(
);
await t.throwsAsync(publishDoc(workspace.id, 'blocked-doc'));
await t.notThrowsAsync(revokePublicDoc(workspace.id, 'published-doc'));
await t.throwsAsync(revokePublicDoc(workspace.id, 'published-doc'));
const quota = await app
.get(QuotaService)
@@ -27,6 +27,16 @@ export class MockTeamWorkspace extends Mocker<
quantity,
},
});
await this.db.entitlement.create({
data: {
targetType: 'workspace',
targetId: id,
source: 'cloud_subscription',
plan: 'team',
status: 'active',
quantity,
},
});
await this.db.workspaceFeature.create({
data: {
@@ -45,6 +45,55 @@ export class MockWorkspace extends Mocker<MockWorkspaceInput, MockedWorkspace> {
: undefined,
},
});
const runtimeStateColumns = await this.db.$queryRaw<
Array<{ exists: boolean }>
>`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'workspace_runtime_states'
AND column_name = 'known'
) AS "exists"
`;
if (runtimeStateColumns[0]?.exists) {
await this.db.$executeRaw`
INSERT INTO workspace_runtime_states (
workspace_id,
known,
readonly,
readonly_reasons,
last_reconciled_at,
stale_after,
updated_at
)
VALUES (${workspace.id}, true, false, ARRAY[]::TEXT[], now(), NULL, now())
ON CONFLICT (workspace_id)
DO UPDATE SET
known = true,
readonly = false,
readonly_reasons = ARRAY[]::TEXT[],
last_reconciled_at = now(),
stale_after = NULL,
updated_at = now()
`;
} else {
await this.db.$executeRaw`
INSERT INTO workspace_runtime_states (
workspace_id,
readonly,
readonly_reasons,
stale_at,
updated_at
)
VALUES (${workspace.id}, false, ARRAY[]::TEXT[], NULL, now())
ON CONFLICT (workspace_id)
DO UPDATE SET
readonly = false,
readonly_reasons = ARRAY[]::TEXT[],
stale_at = NULL,
updated_at = now()
`;
}
// create a rootDoc snapshot
if (snapshot) {
@@ -73,6 +73,24 @@ test('should set doc user role', async t => {
t.is(role?.type, DocRole.Manager);
});
test('should batch update existing doc user roles', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
const docId = 'fake-doc-id';
await models.docUser.set(workspace.id, docId, user.id, DocRole.Reader);
const count = await models.docUser.batchSetUserRoles(
workspace.id,
docId,
[user.id],
DocRole.Editor
);
const role = await models.docUser.get(workspace.id, docId, user.id);
t.is(count, 1);
t.is(role?.type, DocRole.Editor);
});
test('should not allow setting doc owner through setDocUserRole', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
@@ -96,6 +114,23 @@ test('should delete doc user role', async t => {
t.is(role, null);
});
test('should delete doc grants by user id', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
const docId = 'fake-doc-id';
await models.docUser.set(workspace.id, docId, user.id, DocRole.Manager);
await models.docUser.deleteByUserId(user.id);
t.is(await models.docUser.get(workspace.id, docId, user.id), null);
t.is(
await db.docGrant.count({
where: { principalType: 'user', principalId: user.id },
}),
0
);
});
test('should paginate doc user roles', async t => {
const workspace = await create();
const docId = 'fake-doc-id';
@@ -1,12 +1,16 @@
import { User } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { AdminFeatureManagementResolver } from '../../core/features/resolver';
import { AvailableUserFeatureConfig } from '../../core/features/types';
import { FeatureType, Models, UserFeatureModel, UserModel } from '../../models';
import { Feature } from '../../models/common/feature';
import { createTestingModule, TestingModule } from '../utils';
interface Context {
module: TestingModule;
model: UserFeatureModel;
resolver: AdminFeatureManagementResolver;
u1: User;
}
@@ -16,6 +20,7 @@ test.before(async t => {
const module = await createTestingModule({});
t.context.model = module.get(UserFeatureModel);
t.context.resolver = module.get(AdminFeatureManagementResolver);
t.context.module = module;
});
@@ -31,6 +36,21 @@ test.after(async t => {
await t.context.module.close();
});
test('configurable user features exclude commercial projection features', t => {
const config = new AvailableUserFeatureConfig();
t.false(config.availableUserFeatures().has(Feature.UnlimitedCopilot));
t.false(config.configurableUserFeatures().has(Feature.UnlimitedCopilot));
});
test('admin feature resolver rejects commercial projection features', async t => {
await t.throwsAsync(
t.context.resolver.updateUserFeatures(t.context.u1.id, [Feature.ProPlan]),
{ message: /not configurable/ }
);
t.deepEqual(await t.context.model.list(t.context.u1.id), []);
});
test('should get null if user feature not found', async t => {
const { model, u1 } = t.context;
const userFeature = await model.get(u1.id, 'ai_early_access');
@@ -39,12 +59,14 @@ test('should get null if user feature not found', async t => {
test('should get user feature', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
const userFeature = await model.get(u1.id, 'free_plan_v1');
t.is(userFeature?.name, 'free_plan_v1');
});
test('should get user quota', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
const userQuota = await model.getQuota(u1.id);
t.snapshot(userQuota?.configs, 'free plan');
});
@@ -52,6 +74,7 @@ test('should get user quota', async t => {
test('should list user features', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
t.like(await model.list(u1.id), ['free_plan_v1']);
});
@@ -68,6 +91,7 @@ test('should list user features by type', async t => {
test('should directly test user feature existence', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
t.true(await model.has(u1.id, 'free_plan_v1'));
t.false(await model.has(u1.id, 'ai_early_access'));
});
@@ -112,6 +136,7 @@ test('should switch user quota', async t => {
test('should not switch user quota if the new quota is the same as the current one', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
await model.switchQuota(u1.id, 'free_plan_v1', 'test not switch');
// @ts-expect-error private
@@ -135,6 +160,7 @@ test('should use pro plan as free for selfhost instance', async t => {
registered: true,
});
await models.userFeature.add(u1.id, 'free_plan_v1', 'legacy projection');
const quota = await models.userFeature.getQuota(u1.id);
t.snapshot(
quota?.configs,
@@ -1,6 +1,7 @@
import { Workspace } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { AdminWorkspaceResolver } from '../../core/workspaces/resolvers/admin';
import {
FeatureType,
UserModel,
@@ -12,6 +13,7 @@ import { createTestingModule, type TestingModule } from '../utils';
interface Context {
module: TestingModule;
model: WorkspaceFeatureModel;
resolver: AdminWorkspaceResolver;
ws: Workspace;
}
@@ -21,6 +23,7 @@ test.before(async t => {
const module = await createTestingModule({});
t.context.model = module.get(WorkspaceFeatureModel);
t.context.resolver = module.get(AdminWorkspaceResolver);
t.context.module = module;
});
@@ -44,6 +47,17 @@ test('should get null if workspace feature not found', async t => {
t.is(userFeature, null);
});
test('admin workspace update changes workspace flags', async t => {
await t.context.resolver.adminUpdateWorkspace({
id: t.context.ws.id,
name: 'updated',
});
t.is(
(await t.context.module.get(WorkspaceModel).get(t.context.ws.id))?.name,
'updated'
);
});
test('should directly test workspace feature existence', async t => {
const { model, ws } = t.context;
@@ -0,0 +1,587 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { PrismaClient } from '@prisma/client';
import test from 'ava';
import { PermissionProjectionChecker } from '../../core/permission/projection-checker';
import {
DocRole,
PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES,
PermissionProjectionModel,
permissionProjectionTriggerErrorCategory,
WorkspaceMemberStatus,
WorkspaceRole,
} from '../../models';
import { createModule } from '../create-module';
import { Mockers } from '../mocks';
const module = await createModule({});
const db = module.get(PrismaClient);
test.after.always(async () => {
await module.close();
});
class TestPermissionProjectionModel extends PermissionProjectionModel {
constructor(private readonly fakeDb: unknown) {
super();
}
protected override get db() {
return this.fakeDb as never;
}
}
let appliedPermissionProjectionTriggerFunctionUpdates = false;
async function applyPermissionProjectionTriggerFunctionUpdates() {
if (appliedPermissionProjectionTriggerFunctionUpdates) {
return;
}
const migration = readFileSync(
join(
process.cwd(),
'migrations/20260512133700_workspace_runtime_states/migration.sql'
),
'utf8'
);
for (const name of [
'affine_permission_project_new_workspace_member',
'affine_permission_project_new_workspace_invitation',
'affine_permission_project_new_doc_access_policy',
'affine_permission_project_new_doc_grant',
]) {
const sql = migration.match(
new RegExp(
`CREATE OR REPLACE FUNCTION ${name}\\(\\)[\\s\\S]*?END\\n\\$\\$;`
)
)?.[0];
if (!sql) {
throw new Error(`Missing migration function ${name}`);
}
await db.$executeRawUnsafe(sql);
}
appliedPermissionProjectionTriggerFunctionUpdates = true;
}
async function hasCurrentWorkspaceInvitationColumns() {
const rows = await db.$queryRaw<{ columnName: string }[]>`
SELECT column_name AS "columnName"
FROM information_schema.columns
WHERE table_name = 'workspace_invitations'
AND column_name IN ('requested_role', 'status', 'kind')
`;
return rows.length === 3;
}
test('PermissionProjectionModel checker returns mismatch and dirty-row counts', async t => {
const queryResults = [
[{ count: 1n }],
[{ count: 2n }],
[{ count: 3n }],
[{ count: 4n }],
[{ count: 5n }],
[{ count: 6n }],
[{ count: 7n }],
[{ count: 8n }],
[{ count: 9n }],
[{ count: 10n }],
[
{ category: 'legacy_doc_external_row', count: 11n },
{ category: 'doc_default_owner', count: 12n },
],
];
const model = new TestPermissionProjectionModel({
$queryRaw: async () => queryResults.shift(),
});
t.deepEqual(await model.checkLegacyProjection(), {
oldWorkspacePolicyMismatch: 1,
oldAcceptedMemberMismatch: 2,
extraProjectedMember: 3,
oldInvitationMismatch: 4,
extraProjectedInvitation: 5,
oldDocGrantMismatch: 6,
extraProjectedDocGrant: 7,
oldDocPolicyMismatch: 8,
extraProjectedDocPolicy: 9,
runtimeStateMissing: 0,
runtimeStateMismatch: 0,
ownerConflict: 10,
oldNewDecisionMismatch: 0,
invalidLegacyRows: {
legacy_doc_external_row: 11,
doc_default_owner: 12,
},
});
});
test('PermissionProjectionModel backfill runs as a single transaction', async t => {
const executed: unknown[] = [];
const model = new TestPermissionProjectionModel({
$transaction: async (callback: (tx: unknown) => Promise<void>) => {
await callback({
$executeRaw: async (query: unknown) => {
executed.push(query);
},
});
},
});
await model.backfillLegacyProjection();
t.is(executed.length, 10);
});
test('PermissionProjectionModel exposes stable trigger metric categories', t => {
t.deepEqual(PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES, [
'owner_conflict',
'invalid_legacy_role',
'foreign_key_missing',
'projection_recursion_guard_missing',
'unknown',
]);
});
test('permission projection migration uses non-recursive origin guard', t => {
const migration = readFileSync(
join(
process.cwd(),
'migrations/20260512133700_workspace_runtime_states/migration.sql'
),
'utf8'
);
const guardBody = migration.match(
/CREATE OR REPLACE FUNCTION affine_permission_should_project_from_legacy\(\)[\s\S]*?END\n\$\$;/
)?.[0];
t.truthy(guardBody);
t.true(
guardBody?.includes('IF NOT affine_permission_projection_enabled() THEN')
);
t.false(
guardBody?.includes('IF NOT affine_permission_should_project_from_legacy()')
);
t.truthy(
migration.match(
/CREATE OR REPLACE FUNCTION affine_permission_should_project_from_new\(\)[\s\S]*?IF NOT affine_permission_projection_enabled\(\) THEN[\s\S]*?END\n\$\$;/
)
);
});
test('permission projection trigger maps legacy workspace permission rows', async t => {
const workspace = await module.create(Mockers.Workspace);
const [admin, pending] = await module.create(Mockers.User, 2);
await db.workspaceUserRole.createMany({
data: [
{
workspaceId: workspace.id,
userId: admin.id,
type: WorkspaceRole.Admin,
status: WorkspaceMemberStatus.Accepted,
},
{
workspaceId: workspace.id,
userId: pending.id,
type: WorkspaceRole.Collaborator,
status: WorkspaceMemberStatus.Pending,
},
],
});
const member = await db.workspaceMember.findFirstOrThrow({
where: {
workspaceId: workspace.id,
userId: admin.id,
state: 'active',
},
});
const invitation = await db.workspaceInvitation.findUniqueOrThrow({
where: {
workspaceId_inviteeUserId: {
workspaceId: workspace.id,
inviteeUserId: pending.id,
},
},
});
t.is(member.role, 'admin');
t.is(invitation.requestedRole, 'member');
t.is(invitation.status, 'pending');
});
test('permission projection trigger maps legacy doc policy rows', async t => {
const workspace = await module.create(Mockers.Workspace);
await db.workspaceDoc.create({
data: {
workspaceId: workspace.id,
docId: 'public-doc',
public: true,
defaultRole: DocRole.Reader,
},
});
const policy = await db.docAccessPolicy.findUniqueOrThrow({
where: {
workspaceId_docId: {
workspaceId: workspace.id,
docId: 'public-doc',
},
},
});
t.is(policy.visibility, 'public');
t.is(policy.publicRole, 'external');
t.is(policy.memberDefaultRole, 'reader');
});
async function hasDocGrantLegacyProjectionColumns() {
const rows = await db.$queryRaw<{ columnName: string }[]>`
SELECT column_name AS "columnName"
FROM information_schema.columns
WHERE table_name = 'doc_grants'
AND column_name IN (
'legacy_workspace_id',
'legacy_doc_id',
'legacy_user_id'
)
`;
return rows.length === 3;
}
test('permission projection trigger maps legacy doc grants and drops dirty rows', async t => {
if (!(await hasDocGrantLegacyProjectionColumns())) {
t.false(
Boolean(process.env.CI),
'current local test database predates doc_grants legacy columns'
);
return;
}
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await db.workspaceDocUserRole.createMany({
data: [
{
workspaceId: workspace.id,
docId: 'valid-grant',
userId: user.id,
type: DocRole.Reader,
},
{
workspaceId: workspace.id,
docId: 'dirty-external',
userId: user.id,
type: DocRole.External,
},
{
workspaceId: workspace.id,
docId: 'dirty-none',
userId: user.id,
type: DocRole.None,
},
],
});
const grants = await db.docGrant.findMany({
where: {
workspaceId: workspace.id,
principalId: user.id,
},
orderBy: {
docId: 'asc',
},
});
t.deepEqual(
grants.map(grant => [grant.docId, grant.role]),
[['valid-grant', 'reader']]
);
});
test('permission projection trigger clears legacy row for non-active new workspace member states', async t => {
await applyPermissionProjectionTriggerFunctionUpdates();
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
const member = await db.workspaceMember.create({
data: {
workspaceId: workspace.id,
userId: user.id,
role: 'member',
state: 'active',
},
});
t.truthy(
await db.workspaceUserRole.findUnique({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
})
);
await db.workspaceMember.update({
where: { id: member.id },
data: { state: 'suspended' },
});
t.is(
await db.workspaceUserRole.findUnique({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
}),
null
);
});
test('permission projection trigger clears legacy row for terminal new invitation statuses', async t => {
if (!(await hasCurrentWorkspaceInvitationColumns())) {
t.false(
Boolean(process.env.CI),
'current local test database predates workspace invitation projection columns'
);
return;
}
await applyPermissionProjectionTriggerFunctionUpdates();
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
const [invitation] = await db.$queryRaw<{ id: string }[]>`
INSERT INTO workspace_invitations (
workspace_id,
invitee_user_id,
requested_role,
status,
kind
)
VALUES (
${workspace.id},
${user.id},
'member',
'pending',
'email'
)
RETURNING id
`;
t.is(
(
await db.workspaceUserRole.findUniqueOrThrow({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
})
).status,
'Pending'
);
await db.$executeRaw`
UPDATE workspace_invitations
SET status = 'declined'
WHERE id = ${invitation.id}
`;
t.is(
await db.workspaceUserRole.findUnique({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
}),
null
);
});
test('permission projection trigger preserves doc metadata when new doc policy is deleted', async t => {
await applyPermissionProjectionTriggerFunctionUpdates();
const workspace = await module.create(Mockers.Workspace);
await db.workspaceDoc.create({
data: {
workspaceId: workspace.id,
docId: 'metadata-doc',
public: true,
defaultRole: DocRole.Reader,
mode: 1,
blocked: true,
title: 'Title',
summary: 'Summary',
publishedAt: new Date('2026-01-01T00:00:00Z'),
},
});
await db.docAccessPolicy.delete({
where: {
workspaceId_docId: {
workspaceId: workspace.id,
docId: 'metadata-doc',
},
},
});
const doc = await db.workspaceDoc.findUniqueOrThrow({
where: {
workspaceId_docId: {
workspaceId: workspace.id,
docId: 'metadata-doc',
},
},
});
t.is(doc.public, false);
t.is(doc.defaultRole, DocRole.Manager);
t.is(doc.publishedAt, null);
t.is(doc.mode, 1);
t.is(doc.blocked, true);
t.is(doc.title, 'Title');
t.is(doc.summary, 'Summary');
});
test('permission projection trigger ignores group doc grants on legacy projection', async t => {
await applyPermissionProjectionTriggerFunctionUpdates();
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await db.docGrant.create({
data: {
workspaceId: workspace.id,
docId: 'group-doc',
principalType: 'user',
principalId: user.id,
role: 'reader',
},
});
await db.docGrant.create({
data: {
workspaceId: workspace.id,
docId: 'group-doc',
principalType: 'group',
principalId: user.id,
role: 'manager',
},
});
await db.docGrant.delete({
where: {
workspaceId_docId_principalType_principalId: {
workspaceId: workspace.id,
docId: 'group-doc',
principalType: 'group',
principalId: user.id,
},
},
});
const legacyGrant = await db.workspaceDocUserRole.findUniqueOrThrow({
where: {
workspaceId_docId_userId: {
workspaceId: workspace.id,
docId: 'group-doc',
userId: user.id,
},
},
});
t.is(legacyGrant.type, DocRole.Reader);
});
test('PermissionProjectionModel parses trigger error metric category', t => {
t.is(
permissionProjectionTriggerErrorCategory(
new Error('permission_projection_error:owner_conflict:duplicate owner')
),
'owner_conflict'
);
t.is(
permissionProjectionTriggerErrorCategory(
new Error('permission_projection_error:unexpected:nope')
),
'unknown'
);
t.is(permissionProjectionTriggerErrorCategory(new Error('other')), null);
});
test('PermissionProjectionChecker reports old/new loader decision mismatches', async t => {
const checker = new PermissionProjectionChecker(
{
workspace: {
findMany: async () => [],
},
$queryRaw: async () => [
{
category: 'active_member_doc',
workspaceId: 'w1',
docId: 'doc1',
userId: 'u1',
workspaceActions: null,
docActions: ['Doc.Read'],
},
{
category: 'explicit_doc_grant',
workspaceId: 'w1',
docId: 'doc2',
userId: 'u1',
workspaceActions: null,
docActions: ['Doc.Read'],
},
{
category: 'workspace_invitation',
workspaceId: 'w1',
docId: null,
userId: 'u2',
workspaceActions: ['Workspace.Read'],
docActions: null,
},
],
} as never,
{
permissionProjection: {
checkLegacyProjection: async () => ({}),
},
} as never,
{
load: async (input: { docs?: [{ docId: string }] }) => ({
version: 1,
workspace: { marker: 'legacy' },
docs: input.docs
? [{ docId: input.docs[0].docId, marker: 'legacy' }]
: [],
}),
loadFromNewTables: async (input: { docs?: [{ docId: string }] }) => ({
version: 1,
workspace: { marker: input.docs ? 'legacy' : 'projection' },
docs: input.docs
? [
{
docId: input.docs[0].docId,
marker:
input.docs[0].docId === 'doc1' ? 'legacy' : 'projection',
},
]
: [],
}),
} as never,
{
evaluate: (input: unknown) => input,
} as never
);
t.deepEqual(await checker.checkLegacyProjection(), {
oldNewDecisionMismatch: 2,
});
});
@@ -151,6 +151,22 @@ test('should not get inactive workspace role', async t => {
t.is(role, null);
});
test('should not activate a missing workspace invitation', async t => {
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await t.throwsAsync(
models.workspaceUser.setStatus(
workspace.id,
user.id,
WorkspaceMemberStatus.Accepted
),
{ message: 'Cannot activate a missing workspace invitation.' }
);
t.is(await models.workspaceUser.get(workspace.id, user.id), null);
});
test('should update user role', async t => {
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
@@ -215,6 +231,114 @@ test('should delete workspace user role', async t => {
t.is(role, null);
});
test('should delete legacy-only external workspace user role', async t => {
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.External, {
status: WorkspaceMemberStatus.Accepted,
});
t.truthy(await models.workspaceUser.get(workspace.id, u1.id));
await models.workspaceUser.delete(workspace.id, u1.id);
t.is(await models.workspaceUser.get(workspace.id, u1.id), null);
});
test('should convert existing workspace user role to legacy-only external role', async t => {
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(
workspace.id,
u1.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Accepted,
}
);
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.External, {
status: WorkspaceMemberStatus.Accepted,
});
const role = await models.workspaceUser.get(workspace.id, u1.id);
t.is(role?.type, WorkspaceRole.External);
t.is(
await db.workspaceMember.count({
where: {
workspaceId: workspace.id,
userId: u1.id,
state: 'active',
},
}),
0
);
});
test('should backfill legacy permission id for new workspace member writes', async t => {
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(
workspace.id,
u1.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Accepted,
}
);
const legacyRole = await db.workspaceUserRole.findUniqueOrThrow({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: u1.id,
},
},
});
const member = await db.workspaceMember.findFirstOrThrow({
where: {
workspaceId: workspace.id,
userId: u1.id,
state: 'active',
},
});
t.is(member.legacyPermissionId, legacyRole.id);
});
test('should backfill legacy permission id for new workspace invitation writes', async t => {
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(
workspace.id,
u1.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Pending,
}
);
const legacyRole = await db.workspaceUserRole.findUniqueOrThrow({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: u1.id,
},
},
});
const invitation = await db.workspaceInvitation.findFirstOrThrow({
where: {
workspaceId: workspace.id,
inviteeUserId: u1.id,
},
});
t.is(invitation.legacyPermissionId, legacyRole.id);
});
test('should get user workspace roles with filter', async t => {
const ws1 = await module.create(Mockers.Workspace);
const ws2 = await module.create(Mockers.Workspace);
@@ -0,0 +1,204 @@
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { CryptoHelper, EventBus } from '../../base';
import { EntitlementService } from '../../core/entitlement';
import { WorkspacePolicyService } from '../../core/permission';
import { QuotaStateService } from '../../core/quota/state';
import { WorkspaceService } from '../../core/workspaces';
import { Models } from '../../models';
import { LicenseService } from '../../plugins/license/service';
import { PaymentEventHandlers } from '../../plugins/payment/event';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionVariant,
} from '../../plugins/payment/types';
type Context = Record<string, never>;
const test = ava as TestFn<Context>;
test('workspace subscription activation only sends upgrade notification', async t => {
const events: Array<{ name: string; payload: unknown }> = [];
let reconciled = false;
const handler = new PaymentEventHandlers(
{
isTeamWorkspace: async () => true,
sendTeamWorkspaceUpgradedEmail: async () => {},
} as unknown as WorkspaceService,
{
reconcileWorkspaceQuotaState: async () => {
reconciled = true;
},
} as unknown as WorkspacePolicyService,
{
reconcileWorkspaceQuotaState: async () => ({ seatLimit: 7 }),
} as unknown as QuotaStateService,
{
emit: (name: string, payload: unknown) => events.push({ name, payload }),
} as unknown as EventBus
);
await handler.onWorkspaceSubscriptionUpdated({
workspaceId: 'ws',
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
quantity: 999,
});
t.deepEqual(events, []);
t.false(reconciled);
});
test('workspace entitlement change allocates seats from effective quota state', async t => {
const events: Array<{ name: string; payload: unknown }> = [];
const handler = new PaymentEventHandlers(
{} as unknown as WorkspaceService,
{} as unknown as WorkspacePolicyService,
{
reconcileWorkspaceQuotaState: async () => ({
plan: 'team',
seatLimit: 7,
}),
} as unknown as QuotaStateService,
{
emit: (name: string, payload: unknown) => events.push({ name, payload }),
} as unknown as EventBus
);
await handler.onEntitlementChanged({
targetType: 'workspace',
targetId: 'ws',
});
t.deepEqual(events, [
{
name: 'workspace.members.allocateSeats',
payload: { workspaceId: 'ws', quantity: 7 },
},
]);
});
test('onetime selfhost license seat allocation ignores projected license quantity', async t => {
const events: Array<{ name: string; payload: unknown }> = [];
const service = new LicenseService(
{
installedLicense: {
findUnique: async () => ({
key: 'license-key',
workspaceId: 'ws',
quantity: 999,
recurring: SubscriptionRecurring.Yearly,
variant: SubscriptionVariant.Onetime,
}),
},
} as unknown as PrismaClient,
{
emit: (name: string, payload: unknown) => events.push({ name, payload }),
} as unknown as EventBus,
{} as unknown as Models,
{} as unknown as CryptoHelper,
{} as unknown as WorkspacePolicyService,
{} as unknown as EntitlementService,
{
reconcileWorkspaceQuotaState: async () => ({ seatLimit: 4 }),
} as unknown as QuotaStateService
);
await service.updateTeamSeats({
workspaceId: 'ws',
} as Events['workspace.members.updated']);
t.deepEqual(events, [
{
name: 'workspace.members.allocateSeats',
payload: { workspaceId: 'ws', quantity: 4 },
},
]);
});
test('recurring selfhost license activation returns activation projection without remote health recheck', async t => {
const events: Array<{ name: string; payload: unknown }> = [];
const affineProRequests: string[] = [];
const upserts: unknown[] = [];
const entitlements: unknown[] = [];
const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000;
const service = new LicenseService(
{
installedLicense: {
findUnique: async () => null,
upsert: async (input: unknown) => {
upserts.push(input);
return {
workspaceId: 'ws',
key: 'license-key',
quantity: 3,
recurring: SubscriptionRecurring.Monthly,
variant: null,
};
},
},
} as unknown as PrismaClient,
{
emit: (name: string, payload: unknown) => events.push({ name, payload }),
} as unknown as EventBus,
{} as unknown as Models,
{} as unknown as CryptoHelper,
{} as unknown as WorkspacePolicyService,
{
upsertFromValidatedSelfhostLicense: async (input: unknown) => {
entitlements.push(input);
},
} as unknown as EntitlementService,
{} as unknown as QuotaStateService
);
(
service as unknown as {
fetchAffinePro: (path: string) => Promise<{
plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
quantity: number;
endAt: number;
res: Response;
}>;
}
).fetchAffinePro = async (path: string) => {
affineProRequests.push(path);
return {
plan: SubscriptionPlan.SelfHostedTeam,
recurring: SubscriptionRecurring.Monthly,
quantity: 3,
endAt: expiresAt,
res: new Response(null, {
headers: {
'x-next-validate-key': 'next-validate-key',
},
}),
};
};
const license = await service.activateTeamLicense('ws', 'license-key');
t.like(license, {
workspaceId: 'ws',
key: 'license-key',
quantity: 3,
recurring: SubscriptionRecurring.Monthly,
});
t.is(entitlements.length, 1);
t.is(upserts.length, 1);
t.deepEqual(affineProRequests, ['/api/team/licenses/license-key/activate']);
t.deepEqual(events, [
{
name: 'workspace.subscription.activated',
payload: {
workspaceId: 'ws',
plan: SubscriptionPlan.SelfHostedTeam,
recurring: SubscriptionRecurring.Monthly,
quantity: 3,
},
},
]);
});
@@ -86,7 +86,10 @@ test('should cleanup expired pending blobs', async t => {
],
});
const abortSpy = Sinon.spy(t.context.storage, 'abortMultipartUpload');
const abortSpy = Sinon.stub(
t.context.storage,
'abortMultipartUpload'
).resolves();
const deleteSpy = Sinon.spy(t.context.storage, 'delete');
t.teardown(() => {
abortSpy.restore();
@@ -9,7 +9,7 @@ import type { TestingApp } from './utils';
type TestContext = {
app: TestingApp;
};
const test = ava as TestFn<TestContext>;
const test = ava.serial as TestFn<TestContext>;
let safeFetchStub: Sinon.SinonStub | undefined;
let safeFetchHandler:
@@ -3,7 +3,8 @@ import { createHash } from 'node:crypto';
import test from 'ava';
import Sinon from 'sinon';
import { Config, StorageProviderFactory } from '../../base';
import { Config, ConfigFactory, StorageProviderFactory } from '../../base';
import { QuotaStateService } from '../../core/quota/state';
import { WorkspaceBlobStorage } from '../../core/storage/wrappers/blob';
import { BlobModel, WorkspaceFeatureModel } from '../../models';
import {
@@ -35,6 +36,18 @@ let model: WorkspaceFeatureModel;
test.before(async () => {
app = await createTestingApp();
model = app.get(WorkspaceFeatureModel);
app.get(ConfigFactory).override({
storages: {
blob: {
storage: {
provider: 'fs',
bucket: 'test',
config: { path: '/tmp/affine-test-storage' },
},
},
},
});
await app.get(WorkspaceBlobStorage).onConfigInit();
});
test.beforeEach(async () => {
@@ -45,6 +58,26 @@ test.after.always(async () => {
await app.close();
});
async function withRestrictedWorkspaceQuota(workspaceId: string) {
const quotaState = app.get(QuotaStateService);
const blobModel = app.get(BlobModel);
const base = await quotaState.reconcileWorkspaceQuotaState(workspaceId);
return Sinon.stub(quotaState, 'reconcileWorkspaceQuotaState').callsFake(
async id => {
if (id !== workspaceId) {
return base;
}
return {
...base,
blobLimit: BigInt(RESTRICTED_QUOTA.blobLimit),
storageQuota: BigInt(RESTRICTED_QUOTA.storageQuota),
usedStorageQuota: BigInt(await blobModel.totalSize(workspaceId)),
};
}
);
}
test('should set blobs', async t => {
await app.signupV1('u1@affine.pro');
@@ -233,7 +266,8 @@ test('should reject blob exceeded limit', async t => {
await app.signupV1('u1@affine.pro');
const workspace1 = await createWorkspace(app);
await model.add(workspace1.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA);
const quotaStub = await withRestrictedWorkspaceQuota(workspace1.id);
t.teardown(() => quotaStub.restore());
const buffer1 = Buffer.from(
Array.from({ length: RESTRICTED_QUOTA.blobLimit + 1 }, () => 0)
@@ -247,7 +281,8 @@ test('should reject blob exceeded storage quota', async t => {
await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app);
await model.add(workspace.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA);
const quotaStub = await withRestrictedWorkspaceQuota(workspace.id);
t.teardown(() => quotaStub.restore());
const buffer = Buffer.from(Array.from({ length: OneMB }, () => 0));
@@ -7,7 +7,9 @@ import Sinon from 'sinon';
import supertest from 'supertest';
import { applyUpdate, Doc as YDoc, Map as YMap } from 'yjs';
import { ConfigFactory } from '../../base';
import { PgWorkspaceDocStorageAdapter } from '../../core/doc';
import { PermissionReadModel } from '../../core/permission/config';
import { WorkspaceBlobStorage } from '../../core/storage';
import { Models, PublicDocMode, WorkspaceRole } from '../../models';
import {
@@ -152,6 +154,31 @@ test('should be able to get private workspace with public pages', async t => {
t.is(res.text, 'blob');
});
test('should be able to get private workspace with public pages using new permission model', async t => {
const { app, storage } = t.context;
const config = app.get(ConfigFactory);
config.override({
permission: {
readModel: PermissionReadModel.Projection,
},
});
try {
storage.get.resolves(blob());
const res = await app.GET('/api/workspaces/private/blobs/test');
t.is(res.status, HttpStatus.OK);
t.is(res.get('content-type'), 'text/plain');
t.is(res.text, 'blob');
} finally {
config.override({
permission: {
readModel: PermissionReadModel.Legacy,
},
});
}
});
test('should not be able to get private workspace with no public pages', async t => {
const { app } = t.context;