mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-19 11:06:25 +08:00
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 --> [](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:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user