feat(server): impl storage runtime (#15181)

#### PR Dependency Tree


* **PR #15181** 👈

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**
* Added an additional storage backend option: asset-pack based storage
(provider for avatar, blob, and copilot).
* Introduced a dedicated storage runtime with provider capability
reporting and expanded object operations (put/head/get/list/delete),
including presigned and multipart flows where supported.
* Cloudflare R2 `jurisdiction` now uses an explicit default when
omitted.
* **Bug Fixes**
  * Broadened avatar access to allow both fs and asset-pack providers.
* Improved workspace blob upload completion validation and handling when
stored objects are missing or mismatched.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-07-01 22:24:10 +08:00
committed by GitHub
parent da7d438377
commit 8ebdb7452f
102 changed files with 6487 additions and 4508 deletions
@@ -1,13 +1,16 @@
import { randomUUID } from 'node:crypto';
import { Global, Module } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import type { ExecutionContext, TestFn } from 'ava';
import ava from 'ava';
import Sinon from 'sinon';
import { z } from 'zod';
import { AppModuleBuilder, FunctionalityModules } from '../../app.module';
import { JobModule, JobQueue } from '../../base';
import { ServerFeature, ServerService } from '../../core';
import { AuthService } from '../../core/auth';
import { AuthModule, AuthService } from '../../core/auth';
import { QuotaModule } from '../../core/quota';
import { Models } from '../../models';
import { llmImageDispatchPlan } from '../../native';
@@ -25,6 +28,7 @@ import { ChatSession, ChatSessionService } from '../../plugins/copilot/session';
import { TranscriptPayloadSchema } from '../../plugins/copilot/transcript/schema';
import { CopilotTranscriptionService } from '../../plugins/copilot/transcript/service';
import { TestingPromptService } from '../mocks/prompt-service.mock';
import { MockJobQueue } from '../mocks/queue.mock';
import { createTestingModule, TestingModule } from '../utils';
import { TestAssets } from '../utils/copilot';
import {
@@ -48,6 +52,13 @@ type Tester = {
const test = ava as TestFn<Tester>;
@Global()
@Module({
providers: [{ provide: JobQueue, useClass: MockJobQueue }],
exports: [JobQueue],
})
class MockJobModule {}
let isCopilotConfigured = false;
const runIfCopilotConfigured = test.macro(
async (
@@ -64,8 +75,20 @@ const runIfCopilotConfigured = test.macro(
);
test.serial.before(async t => {
const appModule = new AppModuleBuilder()
.use(
...FunctionalityModules.filter(module => {
const moduleType = 'module' in module ? module.module : module;
return moduleType !== JobModule;
}),
MockJobModule,
AuthModule,
QuotaModule,
CopilotModule
)
.compile();
const module = await createTestingModule({
imports: [QuotaModule, CopilotModule],
imports: [appModule],
tapModule: builder => {
builder.overrideProvider(PromptService).useClass(TestingPromptService);
},
@@ -156,8 +156,6 @@ test.before(async t => {
CopilotModule,
],
tapModule: builder => {
// use real JobQueue for testing
builder.overrideProvider(JobQueue).useClass(JobQueue);
builder.overrideProvider(RequestMutex).useValue({
acquire: async () => ({
async [Symbol.asyncDispose]() {},
@@ -811,7 +809,9 @@ test('should schedule title generation as a background job', async t => {
const chatSession = await session.get(sessionId);
t.truthy(chatSession);
const addJob = Sinon.stub(jobs, 'add').resolves();
const addJob = jobs.add as Sinon.SinonStub;
addJob.resetHistory();
addJob.resolves();
chatSession!.pushTurn(
buildTurn(sessionId, {
@@ -1835,6 +1835,14 @@ test('should be able to manage workspace embedding', async t => {
fileId: file.fileId,
fileName: file.fileName,
});
await jobs.embedPendingFile({
userId,
workspaceId: ws.id,
contextId: undefined,
blobId,
fileId: file.fileId,
fileName: file.fileName,
});
let ret = 0;
while (!ret) {
@@ -2059,7 +2067,9 @@ test('should handle copilot cron jobs correctly', async t => {
copilotSession,
'toBeGenerateTitle'
).resolves(mockSessions);
const jobAddStub = Sinon.stub(cronJobs['jobs'], 'add').resolves();
const jobAddStub = cronJobs['jobs'].add as Sinon.SinonStub;
jobAddStub.resetHistory();
jobAddStub.resolves();
// daily cleanup job scheduling
{
@@ -2107,7 +2117,7 @@ test('should handle copilot cron jobs correctly', async t => {
cleanupStub.restore();
toBeGenerateStub.restore();
jobAddStub.restore();
jobAddStub.resetHistory();
});
test('model selection policy should resolve requested optional models consistently', async t => {
@@ -7,8 +7,13 @@ import {
import { PrismaClient } from '@prisma/client';
import { FunctionalityModules } from '../app.module';
import { AFFiNELogger, EventBus, JobQueue } from '../base';
import { createFactory, MockEventBus, MockJobQueue } from './mocks';
import { AFFiNELogger, EventBus, JobModule, JobQueue } from '../base';
import {
createFactory,
MockEventBus,
MockJobModule,
MockJobQueue,
} from './mocks';
import { TEST_LOG_LEVEL } from './utils';
interface TestingModuleMetadata extends ModuleMetadata {
@@ -26,10 +31,17 @@ export async function createModule(
metadata: TestingModuleMetadata = {}
): Promise<TestingModule> {
const { tapModule, ...meta } = metadata;
const functionalityModules = [
...FunctionalityModules.filter(module => {
const moduleType = 'module' in module ? module.module : module;
return moduleType !== JobModule;
}),
MockJobModule,
];
const builder = Test.createTestingModule({
...meta,
imports: [...FunctionalityModules, ...(meta.imports ?? [])],
imports: [...functionalityModules, ...(meta.imports ?? [])],
});
builder
@@ -13,6 +13,7 @@ import {
AFFiNELogger,
CacheInterceptor,
CloudThrottlerGuard,
ConfigFactory,
EventBus,
GlobalExceptionFilter,
JobQueue,
@@ -250,6 +251,31 @@ export async function createApp(
}
const module = await builder.compile();
module.get(ConfigFactory).override({
storages: {
avatar: {
storage: {
provider: 'assetpack',
bucket: 'avatars',
config: { path: '/tmp/affine-test-storage' },
},
},
blob: {
storage: {
provider: 'assetpack',
bucket: 'blobs',
config: { path: '/tmp/affine-test-storage' },
},
},
},
copilot: {
storage: {
provider: 'assetpack',
bucket: 'copilot',
config: { path: '/tmp/affine-test-storage' },
},
},
});
module.useCustomApplicationConstructor(TestingApp);
@@ -1,4 +1,4 @@
import { createHash } from 'node:crypto';
import { createHash, createHmac } from 'node:crypto';
import { mock } from 'node:test';
import {
@@ -6,22 +6,13 @@ import {
ConfigFactory,
PROXY_MULTIPART_PATH,
PROXY_UPLOAD_PATH,
StorageProviderConfig,
StorageProviderFactory,
toBuffer,
type R2StorageConfig,
SIGNED_URL_EXPIRED,
type StorageProviderConfig,
} from '../../../base';
import {
R2StorageConfig,
R2StorageProvider,
} from '../../../base/storage/providers/r2';
import { SIGNED_URL_EXPIRED } from '../../../base/storage/providers/utils';
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 { StorageRuntimeProvider } from '../../../core/storage-runtime';
import {
SubscriptionPlan,
SubscriptionRecurring,
@@ -29,7 +20,7 @@ import {
} from '../../../plugins/payment/types';
import { app, e2e, Mockers } from '../test';
class MockR2Provider extends R2StorageProvider {
class MockStorageRuntime {
createMultipartCalls = 0;
putCalls: {
key: string;
@@ -46,45 +37,72 @@ class MockR2Provider extends R2StorageProvider {
contentLength?: number;
}[] = [];
constructor(config: R2StorageConfig, bucket: string) {
super(config, bucket);
async providerCapabilities() {
const storage = app.get(Config).storages.blob.storage;
const usePresignedURL = (storage.config as R2StorageConfig).usePresignedURL;
if (storage.provider !== 'cloudflare-r2') {
return {
put: true,
get: true,
head: true,
list: true,
delete: true,
presignPut: false,
presignGet: false,
multipartDirect: false,
proxyUpload: false,
assetpack: false,
serverMediatedOnly: true,
};
}
return {
put: true,
get: true,
head: true,
list: true,
delete: true,
presignPut: true,
presignGet: false,
multipartDirect: true,
proxyUpload: !!usePresignedURL?.enabled,
assetpack: false,
serverMediatedOnly: false,
};
}
destroy() {}
override async proxyPutObject(
async presignPut(
_scope: string,
key: string,
body: any,
options: { contentType?: string; contentLength?: number } = {}
metadata: { contentType?: string; contentLength?: number } = {}
) {
this.putCalls.push({
key,
body: await toBuffer(body),
contentType: options.contentType,
contentLength: options.contentLength,
});
const storage = app.get(Config).storages.blob.storage;
const r2 = storage.config as R2StorageConfig;
if (!r2.usePresignedURL?.enabled) {
return {
url: 'https://test-bucket.r2.example.com/object?X-Amz-Algorithm=AWS4-HMAC-SHA256',
headers: {},
expiresAt: new Date(Date.now() + SIGNED_URL_EXPIRED * 1000),
};
}
const [workspaceId, blobKey] = key.split('/');
return createProxyUrl(
PROXY_UPLOAD_PATH,
[
workspaceId,
blobKey,
metadata.contentType ?? 'application/octet-stream',
metadata.contentLength,
],
{
workspaceId,
key: blobKey,
contentType: metadata.contentType ?? 'application/octet-stream',
contentLength: metadata.contentLength,
}
);
}
override async proxyUploadPart(
key: string,
uploadId: string,
partNumber: number,
body: any,
options: { contentLength?: number } = {}
) {
const etag = `etag-${partNumber}`;
this.partCalls.push({
key,
uploadId,
partNumber,
etag,
body: await toBuffer(body),
contentLength: options.contentLength,
});
return etag;
}
override async createMultipartUpload() {
async createMultipartUpload() {
this.createMultipartCalls += 1;
return {
uploadId: 'upload-id',
@@ -92,7 +110,30 @@ class MockR2Provider extends R2StorageProvider {
};
}
override async listMultipartUploadParts(key: string, uploadId: string) {
async presignUploadPart(
_scope: string,
key: string,
uploadId: string,
partNumber: number
) {
const [workspaceId, blobKey] = key.split('/');
return createProxyUrl(
PROXY_MULTIPART_PATH,
[workspaceId, blobKey, uploadId, partNumber],
{
workspaceId,
key: blobKey,
uploadId,
partNumber,
}
);
}
async listMultipartUploadParts(
_scope: string,
key: string,
uploadId: string
) {
const latest = new Map<number, string>();
for (const part of this.partCalls) {
if (part.key !== key || part.uploadId !== uploadId) {
@@ -104,6 +145,45 @@ class MockR2Provider extends R2StorageProvider {
.sort((left, right) => left[0] - right[0])
.map(([partNumber, etag]) => ({ partNumber, etag }));
}
async putObject(
_scope: string,
key: string,
body: Buffer,
options: { contentType?: string; contentLength?: number } = {}
) {
this.putCalls.push({
key,
body,
contentType: options.contentType,
contentLength: options.contentLength,
});
return {
contentType: options.contentType ?? 'application/octet-stream',
contentLength: options.contentLength ?? body.length,
lastModified: new Date(),
};
}
async proxyUploadPart(
_scope: string,
key: string,
uploadId: string,
partNumber: number,
body: Buffer,
contentLength?: number
) {
const etag = `etag-${partNumber}`;
this.partCalls.push({
key,
uploadId,
partNumber,
etag,
body,
contentLength,
});
return etag;
}
}
const baseR2Storage: StorageProviderConfig = {
@@ -125,55 +205,40 @@ const baseR2Storage: StorageProviderConfig = {
};
let defaultBlobStorage: StorageProviderConfig;
let provider: MockR2Provider | null = null;
let factoryCreateUnmocked: StorageProviderFactory['create'];
let runtime: MockStorageRuntime;
e2e.before(() => {
defaultBlobStorage = structuredClone(app.get(Config).storages.blob.storage);
const factory = app.get(StorageProviderFactory);
factoryCreateUnmocked = factory.create.bind(factory);
});
e2e.beforeEach(async () => {
provider?.destroy();
provider = null;
const factory = app.get(StorageProviderFactory);
mock.method(factory, 'create', (config: StorageProviderConfig) => {
if (config.provider === 'cloudflare-r2') {
if (!provider) {
provider = new MockR2Provider(
config.config as R2StorageConfig,
config.bucket
);
}
return provider;
}
return factoryCreateUnmocked(config);
});
runtime = new MockStorageRuntime();
const rt = app.get(StorageRuntimeProvider);
for (const method of [
'providerCapabilities',
'presignPut',
'createMultipartUpload',
'presignUploadPart',
'listMultipartUploadParts',
'putObject',
'proxyUploadPart',
] as const) {
mock.method(rt, method, (...args: any[]) =>
(runtime[method] as any)(...args)
);
}
await useR2Storage();
});
e2e.afterEach.always(async () => {
await setBlobStorage(defaultBlobStorage);
provider?.destroy();
provider = null;
mock.reset();
});
async function setBlobStorage(storage: StorageProviderConfig) {
provider?.destroy();
provider = null;
const configFactory = app.get(ConfigFactory);
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;
}
async function useR2Storage(
@@ -193,11 +258,8 @@ async function useR2Storage(
return storage;
}
function getProvider(): MockR2Provider {
if (!provider) {
throw new Error('R2 provider is not initialized');
}
return provider;
function getRuntime(): MockStorageRuntime {
return runtime;
}
async function createBlobUpload(
@@ -285,7 +347,7 @@ async function gql<QueryData = any>(
return res.body.data;
}
e2e('should proxy single upload with valid signature', async t => {
e2e.serial('should proxy single upload with valid signature', async t => {
const { workspace } = await setupWorkspace();
const buffer = Buffer.from('r2-proxy');
const key = sha256Base64urlWithPadding(buffer);
@@ -300,6 +362,7 @@ e2e('should proxy single upload with valid signature', async t => {
t.is(init.method, 'PRESIGNED');
t.truthy(init.uploadUrl);
const uploadUrl = new URL(init.uploadUrl, app.url);
t.is(uploadUrl.origin, 'https://cdn.example.com');
t.is(uploadUrl.pathname, PROXY_UPLOAD_PATH);
const res = await app
@@ -309,7 +372,7 @@ e2e('should proxy single upload with valid signature', async t => {
.send(buffer);
t.is(res.status, 200);
const calls = getProvider().putCalls;
const calls = getRuntime().putCalls;
t.is(calls.length, 1);
t.is(calls[0].key, `${workspace.id}/${key}`);
t.is(calls[0].contentType, 'text/plain');
@@ -317,7 +380,7 @@ e2e('should proxy single upload with valid signature', async t => {
t.deepEqual(calls[0].body, buffer);
});
e2e('should proxy multipart upload and return etag', async t => {
e2e.serial('should proxy multipart upload and return etag', async t => {
const { workspace } = await setupWorkspace();
const key = 'multipart-object';
const totalSize = MULTIPART_THRESHOLD + 1024;
@@ -329,6 +392,7 @@ e2e('should proxy multipart upload and return etag', async t => {
const part = await getBlobUploadPartUrl(workspace.id, key, init.uploadId, 1);
const partUrl = new URL(part.uploadUrl, app.url);
t.is(partUrl.origin, 'https://cdn.example.com');
t.is(partUrl.pathname, PROXY_MULTIPART_PATH);
const payload = Buffer.from('part-body');
@@ -340,7 +404,7 @@ e2e('should proxy multipart upload and return etag', async t => {
t.is(res.status, 200);
t.is(res.get('etag'), 'etag-1');
const calls = getProvider().partCalls;
const calls = getRuntime().partCalls;
t.is(calls.length, 1);
t.is(calls[0].key, `${workspace.id}/${key}`);
t.is(calls[0].uploadId, 'upload-id');
@@ -349,34 +413,42 @@ e2e('should proxy multipart upload and return etag', async t => {
t.deepEqual(calls[0].body, payload);
});
e2e('should resume multipart upload and return uploaded parts', async t => {
const { workspace } = await setupWorkspace();
const key = 'multipart-resume';
const totalSize = MULTIPART_THRESHOLD + 1024;
e2e.serial(
'should resume multipart upload and return uploaded parts',
async t => {
const { workspace } = await setupWorkspace();
const key = 'multipart-resume';
const totalSize = MULTIPART_THRESHOLD + 1024;
const init1 = await createBlobUpload(workspace.id, key, totalSize, 'bin');
t.is(init1.method, 'MULTIPART');
t.is(init1.uploadId, 'upload-id');
t.deepEqual(init1.uploadedParts, []);
t.is(getProvider().createMultipartCalls, 1);
const init1 = await createBlobUpload(workspace.id, key, totalSize, 'bin');
t.is(init1.method, 'MULTIPART');
t.is(init1.uploadId, 'upload-id');
t.deepEqual(init1.uploadedParts, []);
t.is(getRuntime().createMultipartCalls, 1);
const part = await getBlobUploadPartUrl(workspace.id, key, init1.uploadId, 1);
const payload = Buffer.from('part-body');
const partUrl = new URL(part.uploadUrl, app.url);
await app
.PUT(partUrl.pathname + partUrl.search)
.set('content-length', payload.length.toString())
.send(payload)
.expect(200);
const part = await getBlobUploadPartUrl(
workspace.id,
key,
init1.uploadId,
1
);
const payload = Buffer.from('part-body');
const partUrl = new URL(part.uploadUrl, app.url);
await app
.PUT(partUrl.pathname + partUrl.search)
.set('content-length', payload.length.toString())
.send(payload)
.expect(200);
const init2 = await createBlobUpload(workspace.id, key, totalSize, 'bin');
t.is(init2.method, 'MULTIPART');
t.is(init2.uploadId, 'upload-id');
t.deepEqual(init2.uploadedParts, [{ partNumber: 1, etag: 'etag-1' }]);
t.is(getProvider().createMultipartCalls, 1);
});
const init2 = await createBlobUpload(workspace.id, key, totalSize, 'bin');
t.is(init2.method, 'MULTIPART');
t.is(init2.uploadId, 'upload-id');
t.deepEqual(init2.uploadedParts, [{ partNumber: 1, etag: 'etag-1' }]);
t.is(getRuntime().createMultipartCalls, 1);
}
);
e2e('should reject upload when token is invalid', async t => {
e2e.serial('should reject upload when token is invalid', async t => {
const { workspace } = await setupWorkspace();
const buffer = Buffer.from('payload');
const init = await createBlobUpload(
@@ -396,10 +468,10 @@ e2e('should reject upload when token is invalid', async t => {
t.is(res.status, 400);
t.is(res.body.message, 'Invalid upload token');
t.is(getProvider().putCalls.length, 0);
t.is(getRuntime().putCalls.length, 0);
});
e2e('should reject upload when url is expired', async t => {
e2e.serial('should reject upload when url is expired', async t => {
const { workspace } = await setupWorkspace();
const buffer = Buffer.from('expired');
const init = await createBlobUpload(
@@ -422,10 +494,10 @@ e2e('should reject upload when url is expired', async t => {
t.is(res.status, 400);
t.is(res.body.message, 'Upload URL expired');
t.is(getProvider().putCalls.length, 0);
t.is(getRuntime().putCalls.length, 0);
});
e2e(
e2e.serial(
'should fall back to direct presign when custom domain is disabled',
async t => {
await useR2Storage({
@@ -449,7 +521,7 @@ e2e(
}
);
e2e(
e2e.serial(
'should still fallback to graphql when provider does not support presign',
async t => {
await setBlobStorage({
@@ -473,6 +545,40 @@ e2e(
}
);
function createProxyUrl(
path: string,
canonicalFields: (string | number | undefined)[],
query: Record<string, string | number | undefined>
) {
const signKey = (
app.get(Config).storages.blob.storage.config as R2StorageConfig
).usePresignedURL?.signKey;
if (!signKey) {
throw new Error('missing R2 proxy sign key');
}
const exp = Math.floor(Date.now() / 1000) + SIGNED_URL_EXPIRED;
const canonical = [
path,
...canonicalFields.map(field =>
field === undefined ? '' : field.toString()
),
exp.toString(),
].join('\n');
const token = createHmac('sha256', signKey)
.update(canonical)
.digest('base64');
const url = new URL(`http://localhost${path}`);
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) {
url.searchParams.set(key, value.toString());
}
}
url.searchParams.set('exp', exp.toString());
url.searchParams.set('token', `${exp}-${token}`);
return { url: url.pathname + url.search, expiresAt: new Date(exp * 1000) };
}
function sha256Base64urlWithPadding(buffer: Buffer) {
return createHash('sha256')
.update(buffer)
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto';
import { Readable } from 'node:stream';
import { mock } from 'node:test';
import {
@@ -7,6 +8,8 @@ import {
type StorageProviderConfig,
} from '../../../base';
import { CommentAttachmentStorage } from '../../../core/storage';
import { StorageRuntimeProvider } from '../../../core/storage-runtime';
import { getMime } from '../../../native';
import { Mockers } from '../../mocks';
import { app, e2e } from '../test';
@@ -26,14 +29,68 @@ e2e.afterEach.always(() => {
mock.reset();
});
const objects = new Map<
string,
{
body: Buffer;
metadata?: {
contentLength?: number;
contentType?: string;
checksumCRC32?: string;
lastModified?: Date;
};
}
>();
e2e.beforeEach(() => {
objects.clear();
const rt = app.get(StorageRuntimeProvider);
mock.method(
rt,
'putObject',
async (
_scope: string,
key: string,
body: Buffer,
metadata?: {
contentLength?: number;
contentType?: string;
checksumCRC32?: string;
}
) => {
const object = {
body,
metadata: {
...metadata,
contentType: metadata?.contentType ?? getMime(body),
contentLength: metadata?.contentLength ?? body.length,
lastModified: new Date(),
},
};
objects.set(key, object);
return object.metadata;
}
);
mock.method(rt, 'getObject', async (_scope: string, key: string) => {
const object = objects.get(key);
if (!object) {
return {};
}
return {
body: Readable.from(object.body),
metadata: object.metadata,
};
});
mock.method(rt, 'presignGet', async () => undefined);
});
async function useCommentAttachmentBlobStorage(storage: StorageProviderConfig) {
app.get(ConfigFactory).override({ storages: { blob: { storage } } });
await app.get(CommentAttachmentStorage).onConfigInit();
}
// #region comment attachment
e2e(
e2e.serial(
'should get comment attachment not found when key is not exists',
async t => {
const { owner, workspace } = await createWorkspace();
@@ -50,7 +107,7 @@ e2e(
}
);
e2e(
e2e.serial(
'should get comment attachment no permission when user is not member',
async t => {
const { workspace } = await createWorkspace();
@@ -117,7 +174,7 @@ e2e.serial('should get comment attachment body', async t => {
}
});
e2e('should get comment attachment redirect url', async t => {
e2e.serial('should get comment attachment redirect url', async t => {
const { owner, workspace } = await createWorkspace();
await app.login(owner);
@@ -12,7 +12,7 @@ import { MockDocSnapshot } from './doc-snapshot.mock';
import { MockDocUser } from './doc-user.mock';
import { MockEventBus } from './eventbus.mock';
import { MockMailer } from './mailer.mock';
import { MockJobQueue } from './queue.mock';
import { MockJobModule, MockJobQueue } from './queue.mock';
import { MockTeamWorkspace } from './team-workspace.mock';
import { MockUser } from './user.mock';
import { MockUserSettings } from './user-settings.mock';
@@ -35,6 +35,7 @@ export {
installMockCopilotRuntime,
MockCopilotProvider,
MockEventBus,
MockJobModule,
MockJobQueue,
MockMailer,
};
@@ -1,12 +1,16 @@
import { Global, Module } from '@nestjs/common';
import { interval, map, take, takeUntil } from 'rxjs';
import Sinon from 'sinon';
import { JobQueue } from '../../base';
export class MockJobQueue {
add = Sinon.createStubInstance(JobQueue).add.resolves();
remove = Sinon.createStubInstance(JobQueue).remove.resolves();
removeWhere = Sinon.createStubInstance(JobQueue).removeWhere.resolves([]);
private readonly sandbox = Sinon.createSandbox();
add = this.sandbox.stub().resolves();
get = this.sandbox.stub().resolves();
remove = this.sandbox.stub().resolves();
removeWhere = this.sandbox.stub().resolves([]);
last<Job extends JobName>(name: Job): { name: Job; payload: Jobs[Job] } {
const addJobName = this.add.lastCall?.args[0];
@@ -57,3 +61,10 @@ export class MockJobQueue {
return this.add.getCalls().filter(call => call.args[0] === name).length;
}
}
@Global()
@Module({
providers: [{ provide: JobQueue, useClass: MockJobQueue }],
exports: [JobQueue],
})
export class MockJobModule {}
@@ -4,9 +4,9 @@ import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { OneDay } from '../../base';
import { BackendRuntimeProvider } from '../../core/backend-runtime';
import { StorageModule, WorkspaceBlobStorage } from '../../core/storage';
import { BlobUploadCleanupJob } from '../../core/storage/job';
import { StorageRuntimeProvider } from '../../core/storage-runtime';
import { MockUser, MockWorkspace } from '../mocks';
import { createTestingModule, TestingModule } from '../utils';
@@ -28,7 +28,7 @@ test.before(async t => {
imports: [ScheduleModule.forRoot(), StorageModule],
tapModule: builder => {
builder
.overrideProvider(BackendRuntimeProvider)
.overrideProvider(StorageRuntimeProvider)
.useValue(t.context.runtime);
},
});
@@ -9,7 +9,7 @@ import {
import { PrismaClient } from '@prisma/client';
import { buildAppModule, FunctionalityModules } from '../../app.module';
import { AFFiNELogger, JobQueue } from '../../base';
import { AFFiNELogger, ConfigFactory, JobModule, JobQueue } from '../../base';
import { GqlModule } from '../../base/graphql';
import { ServerConfigModule } from '../../core';
import { AuthGuard, AuthModule } from '../../core/auth';
@@ -18,7 +18,7 @@ import { ModelsModule } from '../../models';
// for jsdoc inference
// oxlint-disable-next-line no-unused-vars
import type { createModule } from '../create-module';
import { createFactory, MockJobQueue } from '../mocks';
import { createFactory, MockJobModule, MockJobQueue } from '../mocks';
import { MockMailer } from '../mocks/mailer.mock';
import { initTestingDB, TEST_LOG_LEVEL } from './utils';
@@ -48,6 +48,16 @@ function dedupeModules(modules: NonNullable<ModuleMetadata['imports']>) {
return Array.from(map.values());
}
function testingFunctionalityModules() {
return [
...FunctionalityModules.filter(module => {
const moduleType = 'module' in module ? module.module : module;
return moduleType !== JobModule;
}),
MockJobModule,
];
}
@Resolver(() => String)
class MockResolver {
@Query(() => String)
@@ -70,7 +80,7 @@ export async function createTestingModule(
imports[0].module?.name === 'AppModule'
? imports
: dedupeModules([
...FunctionalityModules,
...testingFunctionalityModules(),
ModelsModule,
AuthModule,
GqlModule,
@@ -99,6 +109,31 @@ export async function createTestingModule(
}
const module = await builder.compile();
module.get(ConfigFactory).override({
storages: {
avatar: {
storage: {
provider: 'assetpack',
bucket: 'avatars',
config: { path: '/tmp/affine-test-storage' },
},
},
blob: {
storage: {
provider: 'assetpack',
bucket: 'blobs',
config: { path: '/tmp/affine-test-storage' },
},
},
},
copilot: {
storage: {
provider: 'assetpack',
bucket: 'copilot',
config: { path: '/tmp/affine-test-storage' },
},
},
});
const testingModule = module as TestingModule;
@@ -1,12 +1,15 @@
import { createHash } from 'node:crypto';
import { Readable } from 'node:stream';
import test from 'ava';
import Sinon from 'sinon';
import { Config, ConfigFactory, StorageProviderFactory } from '../../base';
import { ConfigFactory } from '../../base';
import { QuotaStateService } from '../../core/quota/state';
import { WorkspaceBlobStorage } from '../../core/storage/wrappers/blob';
import { StorageRuntimeProvider } from '../../core/storage-runtime';
import { BlobModel, WorkspaceFeatureModel } from '../../models';
import { getMime } from '../../native';
import {
collectAllBlobSizes,
completeBlobUpload,
@@ -32,9 +35,117 @@ const RESTRICTED_QUOTA = {
let app: TestingApp;
let model: WorkspaceFeatureModel;
type CompleteResult =
| {
ok: true;
contentType: string;
contentLength: number;
lastModifiedMs: number;
}
| {
ok: false;
reason:
| 'not_found'
| 'size_mismatch'
| 'mime_mismatch'
| 'checksum_mismatch'
| 'size_too_large';
};
const objects = new Map<
string,
{
body: Buffer;
metadata: {
contentType: string;
contentLength: number;
lastModified: Date;
};
}
>();
const completeResults = new Map<string, CompleteResult>();
const storageRuntime = {
providerCapabilities: async () => ({
put: true,
get: true,
head: true,
list: true,
delete: true,
presignPut: false,
presignGet: false,
multipartDirect: false,
proxyUpload: false,
assetpack: false,
serverMediatedOnly: true,
}),
putObject: async (
_scope: string,
key: string,
body: Buffer,
metadata?: { contentType?: string; contentLength?: number }
) => {
const object = {
body,
metadata: {
contentType: metadata?.contentType ?? getMime(body),
contentLength: metadata?.contentLength ?? body.length,
lastModified: new Date(),
},
};
objects.set(key, object);
return object.metadata;
},
headObject: async (_scope: string, key: string) => {
return objects.get(key)?.metadata;
},
getObject: async (_scope: string, key: string) => {
const object = objects.get(key);
return object
? { body: Readable.from(object.body), metadata: object.metadata }
: {};
},
listObjects: async (_scope: string, prefix?: string) => {
return Array.from(objects.entries())
.filter(([key]) => !prefix || key.startsWith(prefix))
.map(([key, object]) => ({ key, ...object.metadata }));
},
deleteObject: async (_scope: string, key: string) => {
objects.delete(key);
},
presignPut: async () => undefined,
presignGet: async () => undefined,
createMultipartUpload: async () => undefined,
presignUploadPart: async () => undefined,
listMultipartUploadParts: async () => undefined,
completeMultipartUpload: async () => undefined,
completeWorkspaceBlobUpload: async (workspaceId: string, key: string) => {
const objectKey = `${workspaceId}/${key}`;
const configured = completeResults.get(objectKey);
if (configured) return configured;
const object = objects.get(objectKey);
if (!object) return { ok: false, reason: 'not_found' };
await app.get(BlobModel).upsert({
workspaceId,
key,
mime: object.metadata.contentType,
size: object.metadata.contentLength,
status: 'completed',
uploadId: null,
});
return {
ok: true,
contentType: object.metadata.contentType,
contentLength: object.metadata.contentLength,
lastModifiedMs: object.metadata.lastModified.getTime(),
};
},
};
test.before(async () => {
app = await createTestingApp();
app = await createTestingApp({
tapModule: builder => {
builder.overrideProvider(StorageRuntimeProvider).useValue(storageRuntime);
},
});
model = app.get(WorkspaceFeatureModel);
app.get(ConfigFactory).override({
storages: {
@@ -47,11 +158,12 @@ test.before(async () => {
},
},
});
await app.get(WorkspaceBlobStorage).onConfigInit();
});
test.beforeEach(async () => {
await app.initTestingDB();
objects.clear();
completeResults.clear();
});
test.after.always(async () => {
@@ -124,22 +236,19 @@ test('should keep partial blob metadata listing on DB path without storage scan'
const workspace = await createWorkspace(app);
const storage = app.get(WorkspaceBlobStorage);
const rawProvider = (storage as any).provider;
const listSpy = Sinon.spy(rawProvider, 'list');
const rt = app.get(StorageRuntimeProvider);
const listSpy = Sinon.spy(rt, 'listObjects');
t.teardown(() => listSpy.restore());
const buffer1 = Buffer.from('with metadata');
const buffer2 = Buffer.from('without metadata');
const key1 = sha256Base64urlWithPadding(buffer1);
const key2 = sha256Base64urlWithPadding(buffer2);
const config = app.get(Config);
const factory = app.get(StorageProviderFactory);
const provider = factory.create(config.storages.blob.storage);
await provider.put(`${workspace.id}/${key1}`, buffer1, {
await rt.putObject('blob', `${workspace.id}/${key1}`, buffer1, {
contentType: 'text/plain',
contentLength: buffer1.length,
});
await provider.put(`${workspace.id}/${key2}`, buffer2, {
await rt.putObject('blob', `${workspace.id}/${key2}`, buffer2, {
contentType: 'text/plain',
contentLength: buffer2.length,
});
@@ -194,11 +303,9 @@ test('should complete pending blob upload', async t => {
await createBlobUpload(app, workspace.id, key, buffer.length, mime);
const config = app.get(Config);
const factory = app.get(StorageProviderFactory);
const provider = factory.create(config.storages.blob.storage);
const rt = app.get(StorageRuntimeProvider);
await provider.put(`${workspace.id}/${key}`, buffer, {
await rt.putObject('blob', `${workspace.id}/${key}`, buffer, {
contentType: mime,
contentLength: buffer.length,
});
@@ -225,14 +332,16 @@ test('should reject complete when blob key mismatched', async t => {
const wrongKey = sha256Base64urlWithPadding(Buffer.from('other'));
await createBlobUpload(app, workspace.id, wrongKey, buffer.length, mime);
const config = app.get(Config);
const factory = app.get(StorageProviderFactory);
const provider = factory.create(config.storages.blob.storage);
const rt = app.get(StorageRuntimeProvider);
await provider.put(`${workspace.id}/${wrongKey}`, buffer, {
await rt.putObject('blob', `${workspace.id}/${wrongKey}`, buffer, {
contentType: mime,
contentLength: buffer.length,
});
completeResults.set(`${workspace.id}/${wrongKey}`, {
ok: false,
reason: 'checksum_mismatch',
});
await t.throwsAsync(() => completeBlobUpload(app, workspace.id, wrongKey), {
message: 'Blob key mismatch',
@@ -265,9 +374,8 @@ test('should auto delete blobs when workspace is deleted', async t => {
const blobs = await listBlobs(app, workspace.id);
t.is(blobs.length, 2);
const storage = app.get(WorkspaceBlobStorage);
const rawProvider = (storage as any).provider;
const listSpy = Sinon.spy(rawProvider, 'list');
const rt = app.get(StorageRuntimeProvider);
const listSpy = Sinon.spy(rt, 'listObjects');
t.teardown(() => listSpy.restore());
await deleteWorkspace(app, workspace.id);
@@ -212,8 +212,7 @@ test('should be able to get permission granted workspace', async t => {
test('should return 404 if blob not found', async t => {
const { app, storage } = t.context;
// @ts-expect-error mock
storage.get.resolves({ body: null });
storage.get.resolves({ body: undefined });
const res = await app.GET('/api/workspaces/public/blobs/test');
t.is(res.status, HttpStatus.NOT_FOUND);