summaryrefslogtreecommitdiff
path: root/packages/backend/test
diff options
context:
space:
mode:
authormisskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com>2026-03-05 10:56:50 +0000
committerGitHub <noreply@github.com>2026-03-05 10:56:50 +0000
commitfe3dd8edb5f30104cd0a7ed755eb254feda2922d (patch)
treeaf6cf5fa4ca75302ac2de5db742cead00bc13d21 /packages/backend/test
parentMerge pull request #16998 from misskey-dev/develop (diff)
parentRelease: 2026.3.0 (diff)
downloadmisskey-fe3dd8edb5f30104cd0a7ed755eb254feda2922d.tar.gz
misskey-fe3dd8edb5f30104cd0a7ed755eb254feda2922d.tar.bz2
misskey-fe3dd8edb5f30104cd0a7ed755eb254feda2922d.zip
Merge pull request #17217 from misskey-dev/develop
Release: 2026.3.0
Diffstat (limited to 'packages/backend/test')
-rw-r--r--packages/backend/test/compose.yml8
-rw-r--r--packages/backend/test/e2e/oauth.ts422
-rw-r--r--packages/backend/test/resources/dummy-for-file-server-service.pngbin0 -> 6285 bytes
-rw-r--r--packages/backend/test/tsconfig.json1
-rw-r--r--packages/backend/test/unit/SearchService.ts483
-rw-r--r--packages/backend/test/unit/entities/DriveFileEntityService.ts227
-rw-r--r--packages/backend/test/unit/entities/DriveFolderEntityService.ts171
-rw-r--r--packages/backend/test/unit/server/FileServerService.ts770
-rw-r--r--packages/backend/test/utils.ts43
9 files changed, 1952 insertions, 173 deletions
diff --git a/packages/backend/test/compose.yml b/packages/backend/test/compose.yml
index fe96616fc0..4f1dba6428 100644
--- a/packages/backend/test/compose.yml
+++ b/packages/backend/test/compose.yml
@@ -11,3 +11,11 @@ services:
environment:
POSTGRES_DB: "test-misskey"
POSTGRES_HOST_AUTH_METHOD: trust
+
+ meilisearchtest:
+ image: getmeili/meilisearch:v1.3.4
+ ports:
+ - "127.0.0.1:57712:7700"
+ environment:
+ - MEILI_NO_ANALYTICS=true
+ - MEILI_ENV=development
diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts
index 96a6311a5a..67a9026eb5 100644
--- a/packages/backend/test/e2e/oauth.ts
+++ b/packages/backend/test/e2e/oauth.ts
@@ -28,6 +28,7 @@ const host = `http://127.0.0.1:${port}`;
const clientPort = port + 1;
const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`;
+const redirect_uri2 = `http://127.0.0.1:${clientPort}/redirect2`;
const basicAuthParams: AuthorizationParamsExtended = {
redirect_uri,
@@ -807,45 +808,193 @@ describe('OAuth', () => {
});
});
- // https://indieauth.spec.indieweb.org/#client-information-discovery
describe('Client Information Discovery', () => {
- describe('Redirection', () => {
- const tests: Record<string, (reply: FastifyReply) => void> = {
- 'Read HTTP header': reply => {
- reply.header('Link', '</redirect>; rel="redirect_uri"');
- reply.send(`
- <!DOCTYPE html>
- <div class="h-app"><a href="/" class="u-url p-name">Misklient
- `);
- },
- 'Mixed links': reply => {
- reply.header('Link', '</redirect>; rel="redirect_uri"');
- reply.send(`
- <!DOCTYPE html>
- <link rel="redirect_uri" href="/redirect2" />
- <div class="h-app"><a href="/" class="u-url p-name">Misklient
- `);
- },
- 'Multiple items in Link header': reply => {
- reply.header('Link', '</redirect2>; rel="redirect_uri",</redirect>; rel="redirect_uri"');
- reply.send(`
- <!DOCTYPE html>
- <div class="h-app"><a href="/" class="u-url p-name">Misklient
- `);
- },
- 'Multiple items in HTML': reply => {
- reply.send(`
- <!DOCTYPE html>
- <link rel="redirect_uri" href="/redirect2" />
- <link rel="redirect_uri" href="/redirect" />
- <div class="h-app"><a href="/" class="u-url p-name">Misklient
- `);
- },
- };
+ // https://indieauth.spec.indieweb.org/#client-information-discovery
+ describe('JSON client metadata (11 July 2024)', () => {
+ test('Read JSON document', async () => {
+ sender = (reply): void => {
+ reply.header('content-type', 'application/json');
+ reply.send({
+ client_id: `http://127.0.0.1:${clientPort}/`,
+ client_uri: `http://127.0.0.1:${clientPort}/`,
+ client_name: 'Misklient JSON',
+ logo_uri: '/logo.png',
+ redirect_uris: ['/redirect'],
+ });
+ };
- for (const [title, replyFunc] of Object.entries(tests)) {
- test(title, async () => {
- sender = replyFunc;
+ const client = new AuthorizationCode(clientConfig);
+
+ const response = await fetch(client.authorizeURL({
+ redirect_uri,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ assert.strictEqual(response.status, 200);
+ const meta = getMeta(await response.text());
+ assert.strictEqual(meta.clientName, 'Misklient JSON');
+ assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`);
+ });
+
+ test('Merge Link header redirect_uri with JSON redirect_uris', async () => {
+ sender = (reply): void => {
+ reply.header('Link', '</redirect2>; rel="redirect_uri"');
+ reply.header('content-type', 'application/json');
+ reply.send({
+ client_id: `http://127.0.0.1:${clientPort}/`,
+ client_uri: `http://127.0.0.1:${clientPort}/`,
+ client_name: 'Misklient JSON',
+ redirect_uris: ['/redirect'],
+ });
+ };
+
+ const client = new AuthorizationCode(clientConfig);
+
+ const ok1 = await fetch(client.authorizeURL({
+ redirect_uri,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ assert.strictEqual(ok1.status, 200);
+
+ const ok2 = await fetch(client.authorizeURL({
+ redirect_uri: redirect_uri2,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ assert.strictEqual(ok2.status, 200);
+ });
+
+ test('Reject when client_id does not match retrieved URL', async () => {
+ sender = (reply): void => {
+ reply.header('content-type', 'application/json');
+ reply.send({
+ client_id: `http://127.0.0.1:${clientPort}/mismatch`,
+ client_uri: `http://127.0.0.1:${clientPort}/`,
+ redirect_uris: ['/redirect'],
+ });
+ };
+
+ const client = new AuthorizationCode(clientConfig);
+ const response = await fetch(client.authorizeURL({
+ redirect_uri,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ await assertDirectError(response, 400, 'invalid_request');
+ });
+
+ test('Reject when client_uri is not a prefix of client_id', async () => {
+ sender = (reply): void => {
+ reply.header('content-type', 'application/json');
+ reply.send({
+ client_id: `http://127.0.0.1:${clientPort}/`,
+ client_uri: `http://127.0.0.1:${clientPort}/no-prefix/`,
+ redirect_uris: ['/redirect'],
+ });
+ };
+
+ const client = new AuthorizationCode(clientConfig);
+ const response = await fetch(client.authorizeURL({
+ redirect_uri,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ await assertDirectError(response, 400, 'invalid_request');
+ });
+
+ test('Reject when JSON metadata has no redirect_uris and no Link header', async () => {
+ sender = (reply): void => {
+ reply.header('content-type', 'application/json');
+ reply.send({
+ client_id: `http://127.0.0.1:${clientPort}/`,
+ client_uri: `http://127.0.0.1:${clientPort}/`,
+ client_name: 'Misklient JSON',
+ });
+ };
+
+ const client = new AuthorizationCode(clientConfig);
+ const response = await fetch(client.authorizeURL({
+ redirect_uri,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ await assertDirectError(response, 400, 'invalid_request');
+ });
+ });
+
+ // https://indieauth.spec.indieweb.org/20220212/#client-information-discovery
+ describe('HTML link client metadata (12 Feb 2022)', () => {
+ describe('Redirection', () => {
+ const tests: Record<string, (reply: FastifyReply) => void> = {
+ 'Read HTTP header': reply => {
+ reply.header('Link', '</redirect>; rel="redirect_uri"');
+ reply.send(`
+ <!DOCTYPE html>
+ <div class="h-app"><a href="/" class="u-url p-name">Misklient
+ `);
+ },
+ 'Mixed links': reply => {
+ reply.header('Link', '</redirect>; rel="redirect_uri"');
+ reply.send(`
+ <!DOCTYPE html>
+ <link rel="redirect_uri" href="/redirect2" />
+ <div class="h-app"><a href="/" class="u-url p-name">Misklient
+ `);
+ },
+ 'Multiple items in Link header': reply => {
+ reply.header('Link', '</redirect2>; rel="redirect_uri",</redirect>; rel="redirect_uri"');
+ reply.send(`
+ <!DOCTYPE html>
+ <div class="h-app"><a href="/" class="u-url p-name">Misklient
+ `);
+ },
+ 'Multiple items in HTML': reply => {
+ reply.send(`
+ <!DOCTYPE html>
+ <link rel="redirect_uri" href="/redirect2" />
+ <link rel="redirect_uri" href="/redirect" />
+ <div class="h-app"><a href="/" class="u-url p-name">Misklient
+ `);
+ },
+ };
+
+ for (const [title, replyFunc] of Object.entries(tests)) {
+ test(title, async () => {
+ sender = replyFunc;
+
+ const client = new AuthorizationCode(clientConfig);
+
+ const response = await fetch(client.authorizeURL({
+ redirect_uri,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ assert.strictEqual(response.status, 200);
+ });
+ }
+
+ test('No item', async () => {
+ sender = (reply): void => {
+ reply.send(`
+ <!DOCTYPE html>
+ <div class="h-app"><a href="/" class="u-url p-name">Misklient
+ `);
+ };
const client = new AuthorizationCode(clientConfig);
@@ -856,20 +1005,17 @@ describe('OAuth', () => {
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
- assert.strictEqual(response.status, 200);
+
+ // direct error because there's no redirect URI to ping
+ await assertDirectError(response, 400, 'invalid_request');
});
- }
+ });
- test('No item', async () => {
- sender = (reply): void => {
- reply.send(`
- <!DOCTYPE html>
- <div class="h-app"><a href="/" class="u-url p-name">Misklient
- `);
- };
- const client = new AuthorizationCode(clientConfig);
+ test('Disallow loopback', async () => {
+ await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '1' });
+ const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
@@ -877,119 +1023,103 @@ describe('OAuth', () => {
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
-
- // direct error because there's no redirect URI to ping
await assertDirectError(response, 400, 'invalid_request');
});
- });
- test('Disallow loopback', async () => {
- await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '1' });
-
- const client = new AuthorizationCode(clientConfig);
- const response = await fetch(client.authorizeURL({
- redirect_uri,
- scope: 'write:notes',
- state: 'state',
- code_challenge: 'code',
- code_challenge_method: 'S256',
- } as AuthorizationParamsExtended));
- await assertDirectError(response, 400, 'invalid_request');
- });
-
- test('Missing name', async () => {
- sender = (reply): void => {
- reply.header('Link', '</redirect>; rel="redirect_uri"');
- reply.send();
- };
+ test('Missing name', async () => {
+ sender = (reply): void => {
+ reply.header('Link', '</redirect>; rel="redirect_uri"');
+ reply.send();
+ };
- const client = new AuthorizationCode(clientConfig);
+ const client = new AuthorizationCode(clientConfig);
- const response = await fetch(client.authorizeURL({
- redirect_uri,
- scope: 'write:notes',
- state: 'state',
- code_challenge: 'code',
- code_challenge_method: 'S256',
- } as AuthorizationParamsExtended));
- assert.strictEqual(response.status, 200);
- assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
- });
+ const response = await fetch(client.authorizeURL({
+ redirect_uri,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ assert.strictEqual(response.status, 200);
+ assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
+ });
- test('With Logo', async () => {
- sender = (reply): void => {
- reply.header('Link', '</redirect>; rel="redirect_uri"');
- reply.send(`
- <!DOCTYPE html>
- <div class="h-app">
- <a href="/" class="u-url p-name">Misklient</a>
- <img src="/logo.png" class="u-logo" />
- </div>
- `);
- reply.send();
- };
+ test('With Logo', async () => {
+ sender = (reply): void => {
+ reply.header('Link', '</redirect>; rel="redirect_uri"');
+ reply.send(`
+ <!DOCTYPE html>
+ <div class="h-app">
+ <a href="/" class="u-url p-name">Misklient</a>
+ <img src="/logo.png" class="u-logo" />
+ </div>
+ `);
+ reply.send();
+ };
- const client = new AuthorizationCode(clientConfig);
+ const client = new AuthorizationCode(clientConfig);
- const response = await fetch(client.authorizeURL({
- redirect_uri,
- scope: 'write:notes',
- state: 'state',
- code_challenge: 'code',
- code_challenge_method: 'S256',
- } as AuthorizationParamsExtended));
- assert.strictEqual(response.status, 200);
- const meta = getMeta(await response.text());
- assert.strictEqual(meta.clientName, 'Misklient');
- assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`);
- });
+ const response = await fetch(client.authorizeURL({
+ redirect_uri,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ assert.strictEqual(response.status, 200);
+ const meta = getMeta(await response.text());
+ assert.strictEqual(meta.clientName, 'Misklient');
+ assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`);
+ });
- test('Missing Logo', async () => {
- sender = (reply): void => {
- reply.header('Link', '</redirect>; rel="redirect_uri"');
- reply.send(`
- <!DOCTYPE html>
- <div class="h-app"><a href="/" class="u-url p-name">Misklient
- `);
- reply.send();
- };
+ test('Missing Logo', async () => {
+ sender = (reply): void => {
+ reply.header('Link', '</redirect>; rel="redirect_uri"');
+ reply.send(`
+ <!DOCTYPE html>
+ <div class="h-app"><a href="/" class="u-url p-name">Misklient
+ `);
+ reply.send();
+ };
- const client = new AuthorizationCode(clientConfig);
+ const client = new AuthorizationCode(clientConfig);
- const response = await fetch(client.authorizeURL({
- redirect_uri,
- scope: 'write:notes',
- state: 'state',
- code_challenge: 'code',
- code_challenge_method: 'S256',
- } as AuthorizationParamsExtended));
- assert.strictEqual(response.status, 200);
- const meta = getMeta(await response.text());
- assert.strictEqual(meta.clientName, 'Misklient');
- assert.strictEqual(meta.clientLogo, undefined);
- });
+ const response = await fetch(client.authorizeURL({
+ redirect_uri,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ assert.strictEqual(response.status, 200);
+ const meta = getMeta(await response.text());
+ assert.strictEqual(meta.clientName, 'Misklient');
+ assert.strictEqual(meta.clientLogo, undefined);
+ });
- test('Mismatching URL in h-app', async () => {
- sender = (reply): void => {
- reply.header('Link', '</redirect>; rel="redirect_uri"');
- reply.send(`
- <!DOCTYPE html>
- <div class="h-app"><a href="/foo" class="u-url p-name">Misklient
- `);
- reply.send();
- };
+ test('Mismatching URL in h-app', async () => {
+ sender = (reply): void => {
+ reply.header('Link', '</redirect>; rel="redirect_uri"');
+ reply.send(`
+ <!DOCTYPE html>
+ <div class="h-app"><a href="/foo" class="u-url p-name">Misklient
+ `);
+ reply.send();
+ };
- const client = new AuthorizationCode(clientConfig);
+ const client = new AuthorizationCode(clientConfig);
- const response = await fetch(client.authorizeURL({
- redirect_uri,
- scope: 'write:notes',
- state: 'state',
- code_challenge: 'code',
- code_challenge_method: 'S256',
- } as AuthorizationParamsExtended));
- assert.strictEqual(response.status, 200);
- assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
+ const response = await fetch(client.authorizeURL({
+ redirect_uri,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ assert.strictEqual(response.status, 200);
+ assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
+ });
});
});
diff --git a/packages/backend/test/resources/dummy-for-file-server-service.png b/packages/backend/test/resources/dummy-for-file-server-service.png
new file mode 100644
index 0000000000..39332b0c1b
--- /dev/null
+++ b/packages/backend/test/resources/dummy-for-file-server-service.png
Binary files differ
diff --git a/packages/backend/test/tsconfig.json b/packages/backend/test/tsconfig.json
index c6754c4802..a2a86c696e 100644
--- a/packages/backend/test/tsconfig.json
+++ b/packages/backend/test/tsconfig.json
@@ -25,7 +25,6 @@
"isolatedModules": true,
"jsx": "react-jsx",
"jsxImportSource": "@kitajs/html",
- "baseUrl": "./",
"paths": {
"@/*": ["../src/*"]
},
diff --git a/packages/backend/test/unit/SearchService.ts b/packages/backend/test/unit/SearchService.ts
new file mode 100644
index 0000000000..6e17bef1c3
--- /dev/null
+++ b/packages/backend/test/unit/SearchService.ts
@@ -0,0 +1,483 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { afterAll, afterEach, beforeAll, describe, expect, test } from '@jest/globals';
+import { Test, TestingModule } from '@nestjs/testing';
+import type { Index, MeiliSearch } from 'meilisearch';
+import { type Config, loadConfig } from '@/config.js';
+import { GlobalModule } from '@/GlobalModule.js';
+import { CoreModule } from '@/core/CoreModule.js';
+import { SearchService } from '@/core/SearchService.js';
+import { CacheService } from '@/core/CacheService.js';
+import { IdService } from '@/core/IdService.js';
+import { DI } from '@/di-symbols.js';
+import {
+ type BlockingsRepository,
+ type ChannelsRepository,
+ type FollowingsRepository,
+ type MutingsRepository,
+ type NotesRepository,
+ type UserProfilesRepository,
+ type UsersRepository,
+ type MiChannel,
+ type MiNote,
+ type MiUser,
+} from '@/models/_.js';
+
+describe('SearchService', () => {
+ type TestContext = {
+ app: TestingModule;
+ service: SearchService;
+ cacheService: CacheService;
+ idService: IdService;
+ mutingsRepository: MutingsRepository;
+ blockingsRepository: BlockingsRepository;
+ usersRepository: UsersRepository;
+ userProfilesRepository: UserProfilesRepository;
+ notesRepository: NotesRepository;
+ channelsRepository: ChannelsRepository;
+ followingsRepository: FollowingsRepository;
+ indexer?: (note: MiNote) => Promise<void>;
+ };
+
+ const meilisearchSettings = {
+ searchableAttributes: [
+ 'text',
+ 'cw',
+ ],
+ sortableAttributes: [
+ 'createdAt',
+ ],
+ filterableAttributes: [
+ 'createdAt',
+ 'userId',
+ 'userHost',
+ 'channelId',
+ 'tags',
+ ],
+ typoTolerance: {
+ enabled: false,
+ },
+ pagination: {
+ maxTotalHits: 10000,
+ },
+ };
+
+ async function buildContext(configOverride?: Config): Promise<TestContext> {
+ const builder = Test.createTestingModule({
+ imports: [
+ GlobalModule,
+ CoreModule,
+ ],
+ });
+
+ if (configOverride) {
+ builder.overrideProvider(DI.config).useValue(configOverride);
+ }
+
+ const app = await builder.compile();
+
+ app.enableShutdownHooks();
+
+ return {
+ app,
+ service: app.get(SearchService),
+ cacheService: app.get(CacheService),
+ idService: app.get(IdService),
+ mutingsRepository: app.get(DI.mutingsRepository),
+ blockingsRepository: app.get(DI.blockingsRepository),
+ usersRepository: app.get(DI.usersRepository),
+ userProfilesRepository: app.get(DI.userProfilesRepository),
+ notesRepository: app.get(DI.notesRepository),
+ channelsRepository: app.get(DI.channelsRepository),
+ followingsRepository: app.get(DI.followingsRepository),
+ };
+ }
+
+ async function cleanupContext(ctx: TestContext) {
+ await ctx.notesRepository.createQueryBuilder().delete().execute();
+ await ctx.mutingsRepository.createQueryBuilder().delete().execute();
+ await ctx.blockingsRepository.createQueryBuilder().delete().execute();
+ await ctx.followingsRepository.createQueryBuilder().delete().execute();
+ await ctx.channelsRepository.createQueryBuilder().delete().execute();
+ await ctx.userProfilesRepository.createQueryBuilder().delete().execute();
+ await ctx.usersRepository.createQueryBuilder().delete().execute();
+ }
+
+ async function createUser(ctx: TestContext, data: Partial<MiUser> = {}) {
+ const id = ctx.idService.gen();
+ const username = data.username ?? `user_${id}`;
+ const usernameLower = data.usernameLower ?? username.toLowerCase();
+
+ const user = await ctx.usersRepository
+ .insert({
+ id,
+ username,
+ usernameLower,
+ ...data,
+ })
+ .then(x => ctx.usersRepository.findOneByOrFail(x.identifiers[0]));
+
+ await ctx.userProfilesRepository.insert({
+ userId: id,
+ });
+
+ return user;
+ }
+
+ async function createChannel(ctx: TestContext, user: MiUser, data: Partial<MiChannel> = {}) {
+ const id = ctx.idService.gen();
+ const channel = await ctx.channelsRepository
+ .insert({
+ id,
+ userId: user.id,
+ name: data.name ?? `channel_${id}`,
+ ...data,
+ })
+ .then(x => ctx.channelsRepository.findOneByOrFail(x.identifiers[0]));
+
+ return channel;
+ }
+
+ async function createNote(ctx: TestContext, user: MiUser, data: Partial<MiNote> = {}, time?: number) {
+ const id = time == null ? ctx.idService.gen() : ctx.idService.gen(time);
+ const note = await ctx.notesRepository
+ .insert({
+ id,
+ text: 'hello',
+ userId: user.id,
+ userHost: user.host,
+ visibility: 'public',
+ tags: [],
+ ...data,
+ })
+ .then(x => ctx.notesRepository.findOneByOrFail(x.identifiers[0]));
+
+ if (ctx.indexer) {
+ await ctx.indexer(note);
+ }
+
+ return note;
+ }
+
+ async function createFollowing(ctx: TestContext, follower: MiUser, followee: MiUser) {
+ await ctx.followingsRepository.insert({
+ id: ctx.idService.gen(),
+ followerId: follower.id,
+ followeeId: followee.id,
+ followerHost: follower.host,
+ followeeHost: followee.host,
+ });
+ }
+
+ function clearUserCaches(ctx: TestContext, userId: MiUser['id']) {
+ ctx.cacheService.userMutingsCache.delete(userId);
+ ctx.cacheService.userBlockedCache.delete(userId);
+ ctx.cacheService.userBlockingCache.delete(userId);
+ }
+
+ async function createMuting(ctx: TestContext, muter: MiUser, mutee: MiUser) {
+ await ctx.mutingsRepository.insert({
+ id: ctx.idService.gen(),
+ muterId: muter.id,
+ muteeId: mutee.id,
+ });
+ clearUserCaches(ctx, muter.id);
+ }
+
+ async function createBlocking(ctx: TestContext, blocker: MiUser, blockee: MiUser) {
+ await ctx.blockingsRepository.insert({
+ id: ctx.idService.gen(),
+ blockerId: blocker.id,
+ blockeeId: blockee.id,
+ });
+ clearUserCaches(ctx, blocker.id);
+ clearUserCaches(ctx, blockee.id);
+ }
+
+ function defineSearchNoteTests(
+ getCtx: () => TestContext,
+ {
+ supportsFollowersVisibility,
+ sinceIdOrder,
+ }: {
+ supportsFollowersVisibility: boolean;
+ sinceIdOrder: 'asc' | 'desc';
+ },
+ ) {
+ describe('searchNote', () => {
+ test('filters notes by visibility (followers only visible to followers)', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
+
+ const publicNote = await createNote(ctx, author, { text: 'hello public', visibility: 'public' });
+ const followersNote = await createNote(ctx, author, { text: 'hello followers', visibility: 'followers' });
+
+ const beforeFollow = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
+ expect(beforeFollow.map(note => note.id)).toEqual([publicNote.id]);
+
+ await createFollowing(ctx, me, author);
+
+ const afterFollow = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
+ const expectedIds = supportsFollowersVisibility
+ ? [followersNote.id, publicNote.id]
+ : [publicNote.id];
+ expect(afterFollow.map(note => note.id).sort()).toEqual(expectedIds.sort());
+ });
+
+ test('filters out suspended users via base note filtering', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const active = await createUser(ctx, { username: 'active', usernameLower: 'active', host: null });
+ const suspended = await createUser(ctx, { username: 'suspended', usernameLower: 'suspended', host: null, isSuspended: true });
+
+ const activeNote = await createNote(ctx, active, { text: 'hello active', visibility: 'public' });
+ await createNote(ctx, suspended, { text: 'hello suspended', visibility: 'public' });
+
+ const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
+ expect(result.map(note => note.id)).toEqual([activeNote.id]);
+ });
+
+ test('filters by userId', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const alice = await createUser(ctx, { username: 'alice', usernameLower: 'alice', host: null });
+ const bob = await createUser(ctx, { username: 'bob', usernameLower: 'bob', host: null });
+
+ const aliceNote = await createNote(ctx, alice, { text: 'hello alice', visibility: 'public' });
+ await createNote(ctx, bob, { text: 'hello bob', visibility: 'public' });
+
+ const result = await ctx.service.searchNote('hello', me, { userId: alice.id }, { limit: 10 });
+ expect(result.map(note => note.id)).toEqual([aliceNote.id]);
+ });
+
+ test('filters by channelId', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
+ const channelA = await createChannel(ctx, author, { name: 'channel-a' });
+ const channelB = await createChannel(ctx, author, { name: 'channel-b' });
+
+ const channelNote = await createNote(ctx, author, { text: 'hello channel', channelId: channelA.id, visibility: 'public' });
+ await createNote(ctx, author, { text: 'hello other', channelId: channelB.id, visibility: 'public' });
+
+ const result = await ctx.service.searchNote('hello', me, { channelId: channelA.id }, { limit: 10 });
+ expect(result.map(note => note.id)).toEqual([channelNote.id]);
+ });
+
+ test('filters by host', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const local = await createUser(ctx, { username: 'local', usernameLower: 'local', host: null });
+ const remote = await createUser(ctx, { username: 'remote', usernameLower: 'remote', host: 'example.com' });
+
+ const localNote = await createNote(ctx, local, { text: 'hello local', visibility: 'public' });
+ const remoteNote = await createNote(ctx, remote, { text: 'hello remote', visibility: 'public', userHost: 'example.com' });
+
+ const localResult = await ctx.service.searchNote('hello', me, { host: '.' }, { limit: 10 });
+ expect(localResult.map(note => note.id)).toEqual([localNote.id]);
+
+ const remoteResult = await ctx.service.searchNote('hello', me, { host: 'example.com' }, { limit: 10 });
+ expect(remoteResult.map(note => note.id)).toEqual([remoteNote.id]);
+ });
+
+ describe('muting and blocking', () => {
+ test('filters out muted users', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const muted = await createUser(ctx, { username: 'muted', usernameLower: 'muted', host: null });
+ const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null });
+
+ await createNote(ctx, muted, { text: 'hello muted', visibility: 'public' });
+ const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' });
+
+ await createMuting(ctx, me, muted);
+
+ const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
+
+ expect(result.map(note => note.id)).toEqual([otherNote.id]);
+ });
+
+ test('filters out users who block me', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const blocker = await createUser(ctx, { username: 'blocker', usernameLower: 'blocker', host: null });
+ const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null });
+
+ await createNote(ctx, blocker, { text: 'hello blocker', visibility: 'public' });
+ const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' });
+
+ await createBlocking(ctx, blocker, me);
+
+ const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
+
+ expect(result.map(note => note.id)).toEqual([otherNote.id]);
+ });
+
+ test('filters no out users I block', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const blocked = await createUser(ctx, { username: 'blocked', usernameLower: 'blocked', host: null });
+ const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null });
+
+ const blockedNote = await createNote(ctx, blocked, { text: 'hello blocked', visibility: 'public' });
+ const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' });
+
+ await createBlocking(ctx, me, blocked);
+
+ const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
+ expect(result.map(note => note.id).sort()).toEqual([otherNote.id, blockedNote.id].sort());
+ });
+ });
+
+ describe('pagination', () => {
+ test('paginates with sinceId', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
+
+ const t1 = Date.now() - 3000;
+ const t2 = Date.now() - 2000;
+ const t3 = Date.now() - 1000;
+
+ const note1 = await createNote(ctx, author, { text: 'hello' }, t1);
+ const note2 = await createNote(ctx, author, { text: 'hello' }, t2);
+ const note3 = await createNote(ctx, author, { text: 'hello' }, t3);
+
+ const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, sinceId: note1.id });
+
+ const expected = sinceIdOrder === 'asc'
+ ? [note2.id, note3.id]
+ : [note3.id, note2.id];
+ expect(result.map(note => note.id)).toEqual(expected);
+ });
+
+ test('paginates with untilId', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
+
+ const t1 = Date.now() - 3000;
+ const t2 = Date.now() - 2000;
+ const t3 = Date.now() - 1000;
+
+ const note1 = await createNote(ctx, author, { text: 'hello' }, t1);
+ const note2 = await createNote(ctx, author, { text: 'hello' }, t2);
+ const note3 = await createNote(ctx, author, { text: 'hello' }, t3);
+
+ const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, untilId: note3.id });
+
+ expect(result.map(note => note.id)).toEqual([note2.id, note1.id]);
+ });
+
+ test('paginates with sinceId and untilId together', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
+
+ const t1 = Date.now() - 4000;
+ const t2 = Date.now() - 3000;
+ const t3 = Date.now() - 2000;
+ const t4 = Date.now() - 1000;
+
+ const note1 = await createNote(ctx, author, { text: 'hello' }, t1);
+ const note2 = await createNote(ctx, author, { text: 'hello' }, t2);
+ const note3 = await createNote(ctx, author, { text: 'hello' }, t3);
+ const note4 = await createNote(ctx, author, { text: 'hello' }, t4);
+
+ const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, sinceId: note1.id, untilId: note4.id });
+
+ expect(result.map(note => note.id)).toEqual([note3.id, note2.id]);
+ });
+ });
+ });
+ }
+
+ describe('sqlLike', () => {
+ let ctx: TestContext;
+
+ beforeAll(async () => {
+ ctx = await buildContext();
+ });
+
+ afterAll(async () => {
+ await ctx.app.close();
+ });
+
+ afterEach(async () => {
+ await cleanupContext(ctx);
+ });
+
+ defineSearchNoteTests(() => ctx, { supportsFollowersVisibility: true, sinceIdOrder: 'asc' });
+ });
+
+ describe('meilisearch', () => {
+ let ctx: TestContext;
+ let meilisearch: MeiliSearch;
+ let meilisearchIndex: Index;
+ let meiliConfig: Config;
+
+ beforeAll(async () => {
+ const baseConfig = loadConfig();
+ meiliConfig = {
+ ...baseConfig,
+ fulltextSearch: {
+ provider: 'meilisearch',
+ },
+ meilisearch: {
+ host: '127.0.0.1',
+ port: '57712',
+ apiKey: '',
+ index: 'test-search-service',
+ scope: 'global',
+ ssl: false,
+ },
+ };
+
+ ctx = await buildContext(meiliConfig);
+ meilisearch = ctx.app.get(DI.meilisearch) as MeiliSearch;
+ meilisearchIndex = meilisearch.index(`${meiliConfig.meilisearch!.index}---notes`);
+
+ const settingsTask = await meilisearchIndex.updateSettings(meilisearchSettings);
+ await meilisearch.tasks.waitForTask(settingsTask.taskUid);
+
+ const clearTask = await meilisearchIndex.deleteAllDocuments();
+ await meilisearch.tasks.waitForTask(clearTask.taskUid);
+
+ ctx.indexer = async (note: MiNote) => {
+ if (note.text == null && note.cw == null) return;
+ if (!['home', 'public'].includes(note.visibility)) return;
+ if (meiliConfig.meilisearch?.scope === 'local' && note.userHost != null) return;
+
+ const task = await meilisearchIndex.addDocuments([{
+ id: note.id,
+ createdAt: ctx.idService.parse(note.id).date.getTime(),
+ userId: note.userId,
+ userHost: note.userHost,
+ channelId: note.channelId,
+ cw: note.cw,
+ text: note.text,
+ tags: note.tags,
+ }], {
+ primaryKey: 'id',
+ });
+ await meilisearch.tasks.waitForTask(task.taskUid);
+ };
+ });
+
+ afterAll(async () => {
+ await ctx.app.close();
+ });
+
+ afterEach(async () => {
+ await cleanupContext(ctx);
+ const clearTask = await meilisearchIndex.deleteAllDocuments();
+ await meilisearch.tasks.waitForTask(clearTask.taskUid);
+ });
+
+ defineSearchNoteTests(() => ctx, { supportsFollowersVisibility: false, sinceIdOrder: 'desc' });
+ });
+});
diff --git a/packages/backend/test/unit/entities/DriveFileEntityService.ts b/packages/backend/test/unit/entities/DriveFileEntityService.ts
new file mode 100644
index 0000000000..2e416326ee
--- /dev/null
+++ b/packages/backend/test/unit/entities/DriveFileEntityService.ts
@@ -0,0 +1,227 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+process.env.NODE_ENV = 'test';
+
+import { afterAll, beforeAll, beforeEach, describe, expect, jest, test } from '@jest/globals';
+import { Test } from '@nestjs/testing';
+import type { TestingModule } from '@nestjs/testing';
+import type { DriveFilesRepository, DriveFoldersRepository, UsersRepository } from '@/models/_.js';
+import { GlobalModule } from '@/GlobalModule.js';
+import { CoreModule } from '@/core/CoreModule.js';
+import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
+import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import { DI } from '@/di-symbols.js';
+import { genAidx } from '@/misc/id/aidx.js';
+import { secureRndstr } from '@/misc/secure-rndstr.js';
+
+const describeBenchmark = process.env.RUN_BENCHMARKS === '1' ? describe : describe.skip;
+
+describe('DriveFileEntityService', () => {
+ let app: TestingModule;
+ let service: DriveFileEntityService;
+ let driveFolderEntityService: DriveFolderEntityService;
+ let driveFilesRepository: DriveFilesRepository;
+ let driveFoldersRepository: DriveFoldersRepository;
+ let usersRepository: UsersRepository;
+ let idCounter = 0;
+
+ const userEntityServiceMock = {
+ packMany: jest.fn(async (users: Array<string | { id: string }>) => {
+ return users.map(u => ({
+ id: typeof u === 'string' ? u : u.id,
+ username: 'user',
+ }));
+ }),
+ pack: jest.fn(async (user: string | { id: string }) => {
+ return {
+ id: typeof user === 'string' ? user : user.id,
+ username: 'user',
+ };
+ }),
+ };
+
+ const nextId = () => genAidx(Date.now() + (idCounter++));
+
+ const createUser = async () => {
+ const un = secureRndstr(16);
+ const id = nextId();
+ await usersRepository.insert({
+ id,
+ username: un,
+ usernameLower: un.toLowerCase(),
+ });
+ return usersRepository.findOneByOrFail({ id });
+ };
+
+ const createFolder = async (name: string, parentId: string | null) => {
+ const id = nextId();
+ await driveFoldersRepository.insert({
+ id,
+ name,
+ userId: null,
+ parentId,
+ });
+ return driveFoldersRepository.findOneByOrFail({ id });
+ };
+
+ const createFile = async (folderId: string | null, userId: string | null) => {
+ const id = nextId();
+ await driveFilesRepository.insert({
+ id,
+ userId,
+ userHost: null,
+ md5: secureRndstr(32),
+ name: `file-${id}`,
+ type: 'text/plain',
+ size: 1,
+ comment: null,
+ blurhash: null,
+ properties: {},
+ storedInternal: true,
+ url: `https://example.com/${id}`,
+ thumbnailUrl: null,
+ webpublicUrl: null,
+ webpublicType: null,
+ accessKey: null,
+ thumbnailAccessKey: null,
+ webpublicAccessKey: null,
+ uri: null,
+ src: null,
+ folderId,
+ isSensitive: false,
+ maybeSensitive: false,
+ maybePorn: false,
+ isLink: false,
+ requestHeaders: null,
+ requestIp: null,
+ });
+ return driveFilesRepository.findOneByOrFail({ id });
+ };
+
+ beforeAll(async () => {
+ const moduleBuilder = Test.createTestingModule({
+ imports: [GlobalModule, CoreModule],
+ });
+ moduleBuilder.overrideProvider(UserEntityService).useValue(userEntityServiceMock as any);
+
+ app = await moduleBuilder.compile();
+ await app.init();
+ app.enableShutdownHooks();
+
+ service = app.get<DriveFileEntityService>(DriveFileEntityService);
+ driveFolderEntityService = app.get<DriveFolderEntityService>(DriveFolderEntityService);
+ driveFilesRepository = app.get<DriveFilesRepository>(DI.driveFilesRepository);
+ driveFoldersRepository = app.get<DriveFoldersRepository>(DI.driveFoldersRepository);
+ usersRepository = app.get<UsersRepository>(DI.usersRepository);
+ });
+
+ beforeEach(() => {
+ userEntityServiceMock.packMany.mockClear();
+ userEntityServiceMock.pack.mockClear();
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ describe('pack', () => {
+ test('detail: false', async () => {
+ const user = await createUser();
+ const folder = await createFolder('pack-root', null);
+ const file = await createFile(folder.id, user.id);
+
+ const packed = await service.pack(file, { detail: false, self: true }) as any;
+ expect(packed.id).toBe(file.id);
+ expect(packed.folder).toBeNull();
+ expect(packed.user).toBeNull();
+ expect(packed.userId).toBeNull();
+ });
+
+ test('detail: true', async () => {
+ const folder = await createFolder('pack-parent', null);
+ const child = await createFolder('pack-child', folder.id);
+ const file = await createFile(child.id, null);
+
+ const packed = await service.pack(file, { detail: true, self: true }) as any;
+ expect(packed.folder?.id).toBe(child.id);
+ expect(packed.folder?.parent?.id).toBe(folder.id);
+ });
+ });
+
+ describe('packNullable', () => {
+ test('returns null for missing', async () => {
+ const packed = await service.packNullable('non-existent' as any, { detail: false });
+ expect(packed).toBeNull();
+ });
+
+ test('uses packedUser hint when withUser', async () => {
+ const user = await createUser();
+ const file = await createFile(null, user.id);
+
+ const packed = await service.packNullable(file, { withUser: true, self: true }, {
+ packedUser: { id: user.id, username: 'hint' } as any,
+ });
+ expect(packed?.user?.id).toBe(user.id);
+ expect(packed?.user?.username).toBe('hint');
+ });
+ });
+
+ describe('packMany', () => {
+ test('withUser: true uses deduped packMany', async () => {
+ const user = await createUser();
+ const fileA = await createFile(null, user.id);
+ const fileB = await createFile(null, user.id);
+
+ const packed = await service.packMany([fileA, fileB], { withUser: true, self: true });
+ expect(packed.length).toBe(2);
+ expect(userEntityServiceMock.packMany).toHaveBeenCalledTimes(1);
+ expect(userEntityServiceMock.packMany.mock.calls[0]?.[0]?.length).toBe(1);
+ expect(packed[0]?.user?.id).toBe(user.id);
+ });
+
+ test('detail: true packs folder', async () => {
+ const folder = await createFolder('packmany-root', null);
+ const file = await createFile(folder.id, null);
+
+ const packed = await service.packMany([file], { detail: true, self: true });
+ expect(packed[0]?.folder?.id).toBe(folder.id);
+ expect(packed[0]?.folder?.parent).toBeUndefined();
+ });
+
+ test('detail: true uses DriveFolderEntityService pack', async () => {
+ const folder = await createFolder('packmany-folder', null);
+ const file = await createFile(folder.id, null);
+ const packSpy = jest.spyOn(driveFolderEntityService, 'pack');
+
+ await service.packMany([file], { detail: true, self: true });
+ expect(packSpy).toHaveBeenCalled();
+ packSpy.mockRestore();
+ });
+ });
+
+ describeBenchmark('benchmark', () => {
+ test('packMany', async () => {
+ const user = await createUser();
+ const folders = [];
+ for (let i = 0; i < 100; i++) {
+ folders.push(await createFolder(`bench-${i}`, null));
+ }
+ const files = [];
+ for (const folder of folders) {
+ for (let j = 0; j < 20; j++) {
+ files.push(await createFile(folder.id, user.id));
+ }
+ }
+
+ const start = Date.now();
+ await service.packMany(files, { detail: true, withUser: true, self: true });
+ const elapsed = Date.now() - start;
+
+ console.log(`DriveFileEntityService.packMany benchmark: ${elapsed}ms`);
+ });
+ });
+});
diff --git a/packages/backend/test/unit/entities/DriveFolderEntityService.ts b/packages/backend/test/unit/entities/DriveFolderEntityService.ts
new file mode 100644
index 0000000000..299ee5f42b
--- /dev/null
+++ b/packages/backend/test/unit/entities/DriveFolderEntityService.ts
@@ -0,0 +1,171 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+process.env.NODE_ENV = 'test';
+
+import { afterAll, beforeAll, describe, expect, test } from '@jest/globals';
+import { Test } from '@nestjs/testing';
+import type { TestingModule } from '@nestjs/testing';
+import type { DriveFilesRepository, DriveFoldersRepository } from '@/models/_.js';
+import { GlobalModule } from '@/GlobalModule.js';
+import { CoreModule } from '@/core/CoreModule.js';
+import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js';
+import { DI } from '@/di-symbols.js';
+import { genAidx } from '@/misc/id/aidx.js';
+import { secureRndstr } from '@/misc/secure-rndstr.js';
+
+const describeBenchmark = process.env.RUN_BENCHMARKS === '1' ? describe : describe.skip;
+
+describe('DriveFolderEntityService', () => {
+ let app: TestingModule;
+ let service: DriveFolderEntityService;
+ let driveFoldersRepository: DriveFoldersRepository;
+ let driveFilesRepository: DriveFilesRepository;
+ let idCounter = 0;
+
+ const nextId = () => genAidx(Date.now() + (idCounter++));
+
+ const createFolder = async (name: string, parentId: string | null) => {
+ const id = nextId();
+ await driveFoldersRepository.insert({
+ id,
+ name,
+ userId: null,
+ parentId,
+ });
+ return driveFoldersRepository.findOneByOrFail({ id });
+ };
+
+ const createFile = async (folderId: string | null) => {
+ const id = nextId();
+ await driveFilesRepository.insert({
+ id,
+ userId: null,
+ userHost: null,
+ md5: secureRndstr(32),
+ name: `file-${id}`,
+ type: 'text/plain',
+ size: 1,
+ comment: null,
+ blurhash: null,
+ properties: {},
+ storedInternal: true,
+ url: `https://example.com/${id}`,
+ thumbnailUrl: null,
+ webpublicUrl: null,
+ webpublicType: null,
+ accessKey: null,
+ thumbnailAccessKey: null,
+ webpublicAccessKey: null,
+ uri: null,
+ src: null,
+ folderId,
+ isSensitive: false,
+ maybeSensitive: false,
+ maybePorn: false,
+ isLink: false,
+ requestHeaders: null,
+ requestIp: null,
+ });
+ };
+
+ beforeAll(async () => {
+ app = await Test.createTestingModule({
+ imports: [GlobalModule, CoreModule],
+ }).compile();
+ await app.init();
+ app.enableShutdownHooks();
+
+ service = app.get<DriveFolderEntityService>(DriveFolderEntityService);
+ driveFoldersRepository = app.get<DriveFoldersRepository>(DI.driveFoldersRepository);
+ driveFilesRepository = app.get<DriveFilesRepository>(DI.driveFilesRepository);
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ describe('pack', () => {
+ test('detail: false', async () => {
+ const root = await createFolder('root', null);
+ const child = await createFolder('child', root.id);
+
+ const packed = await service.pack(child, { detail: false }) as any;
+ expect(packed.id).toBe(child.id);
+ expect(packed.parentId).toBe(root.id);
+ expect(packed.parent).toBeUndefined();
+ expect(packed.foldersCount).toBeUndefined();
+ expect(packed.filesCount).toBeUndefined();
+ });
+
+ test('detail: true', async () => {
+ const root = await createFolder('root-detail', null);
+ const child = await createFolder('child-detail', root.id);
+ await createFolder('grandchild-detail', child.id);
+ await createFile(child.id);
+ await createFile(child.id);
+
+ const packed = await service.pack(child, { detail: true }) as any;
+ expect(packed.id).toBe(child.id);
+ expect(packed.foldersCount).toBe(1);
+ expect(packed.filesCount).toBe(2);
+ expect(packed.parent?.id).toBe(root.id);
+ expect(packed.parent?.parent).toBeUndefined();
+ });
+
+ test('detail: true reaches root for deep hierarchy', async () => {
+ const root = await createFolder('root-deep', null);
+ const level1 = await createFolder('level-1', root.id);
+ const level2 = await createFolder('level-2', level1.id);
+ const level3 = await createFolder('level-3', level2.id);
+ const level4 = await createFolder('level-4', level3.id);
+ const level5 = await createFolder('level-5', level4.id);
+
+ const packed = await service.pack(level5, { detail: true }) as any;
+ expect(packed.id).toBe(level5.id);
+ expect(packed.parent?.id).toBe(level4.id);
+ expect(packed.parent?.parent?.id).toBe(level3.id);
+ expect(packed.parent?.parent?.parent?.id).toBe(level2.id);
+ expect(packed.parent?.parent?.parent?.parent?.id).toBe(level1.id);
+ expect(packed.parent?.parent?.parent?.parent?.parent?.id).toBe(root.id);
+ expect(packed.parent?.parent?.parent?.parent?.parent?.parent).toBeUndefined();
+ });
+ });
+
+ describe('packMany', () => {
+ test('preserves order and packs parents', async () => {
+ const root = await createFolder('root-many', null);
+ const childA = await createFolder('child-a', root.id);
+ const childB = await createFolder('child-b', root.id);
+ await createFolder('child-a-sub', childA.id);
+ await createFile(childA.id);
+
+ const packed = await service.packMany([childB, childA], { detail: true }) as any;
+ expect(packed[0].id).toBe(childB.id);
+ expect(packed[1].id).toBe(childA.id);
+ expect(packed[0].parent?.id).toBe(root.id);
+ expect(packed[1].parent?.id).toBe(root.id);
+ expect(packed[0].filesCount).toBe(0);
+ expect(packed[1].filesCount).toBe(1);
+ expect(packed[0].foldersCount).toBe(0);
+ expect(packed[1].foldersCount).toBe(1);
+ });
+ });
+
+ describeBenchmark('benchmark', () => {
+ test('packMany', async () => {
+ const root = await createFolder('bench-root', null);
+ const folders = [];
+ for (let i = 0; i < 200; i++) {
+ folders.push(await createFolder(`bench-${i}`, root.id));
+ }
+
+ const start = Date.now();
+ await service.packMany(folders, { detail: true });
+ const elapsed = Date.now() - start;
+ console.log(`DriveFolderEntityService.packMany benchmark: ${elapsed}ms`);
+ });
+ });
+});
diff --git a/packages/backend/test/unit/server/FileServerService.ts b/packages/backend/test/unit/server/FileServerService.ts
new file mode 100644
index 0000000000..c88175c5c7
--- /dev/null
+++ b/packages/backend/test/unit/server/FileServerService.ts
@@ -0,0 +1,770 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import fastifyStatic from '@fastify/static';
+import Fastify, { type FastifyInstance } from 'fastify';
+import { describe, expect, test } from '@jest/globals';
+import sharp from 'sharp';
+import { DataSource, type Repository } from 'typeorm';
+import { initTestDb, randomString } from '../../utils.js';
+import type { AiService } from '@/core/AiService.js';
+import { DownloadService } from '@/core/DownloadService.js';
+import { FileInfoService } from '@/core/FileInfoService.js';
+import { HttpRequestService } from '@/core/HttpRequestService.js';
+import { ImageProcessingService } from '@/core/ImageProcessingService.js';
+import { InternalStorageService } from '@/core/InternalStorageService.js';
+import { IdService } from '@/core/IdService.js';
+import { LoggerService } from '@/core/LoggerService.js';
+import { VideoProcessingService } from '@/core/VideoProcessingService.js';
+import { loadConfig, type Config } from '@/config.js';
+import { MiDriveFile } from '@/models/DriveFile.js';
+import { FileServerService } from '@/server/FileServerService.js';
+
+const dummyPath = path.resolve('test/resources/dummy-for-file-server-service.png');
+const dummySize = fs.statSync(dummyPath).size;
+const dummyBuffer = fs.readFileSync(dummyPath);
+const svgBuffer = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"></svg>', 'utf8');
+const textBuffer = Buffer.from('dummy text', 'utf8');
+
+async function createRemoteFileServer() {
+ const flatPngBuffer = await sharp({
+ create: { width: 8, height: 8, channels: 3, background: { r: 0, g: 0, b: 0 } },
+ }).png().toBuffer();
+ const server = Fastify();
+
+ server.get('/dummy.png', async (_request, reply) => {
+ reply.header('Content-Type', 'image/png');
+ reply.header('Content-Length', String(dummyBuffer.length));
+ return reply.send(dummyBuffer);
+ });
+
+ server.get('/dummy.svg', async (_request, reply) => {
+ reply.header('Content-Type', 'image/svg+xml');
+ reply.header('Content-Length', String(svgBuffer.length));
+ return reply.send(svgBuffer);
+ });
+
+ server.get('/dummy.txt', async (_request, reply) => {
+ reply.header('Content-Type', 'text/plain');
+ reply.header('Content-Length', String(textBuffer.length));
+ return reply.send(textBuffer);
+ });
+
+ server.get('/flat.png', async (_request, reply) => {
+ reply.header('Content-Type', 'image/png');
+ reply.header('Content-Length', String(flatPngBuffer.length));
+ return reply.send(flatPngBuffer);
+ });
+
+ const baseUrl = await server.listen({ port: 0, host: '127.0.0.1' });
+
+ return {
+ server,
+ pngUrl: `${baseUrl}/dummy.png`,
+ svgUrl: `${baseUrl}/dummy.svg`,
+ textUrl: `${baseUrl}/dummy.txt`,
+ flatPngUrl: `${baseUrl}/flat.png`,
+ };
+}
+
+describe('FileServerService', () => {
+ let db: DataSource;
+ let fastify: FastifyInstance;
+ let externalFastify: FastifyInstance;
+ let driveFilesRepository: Repository<MiDriveFile>;
+ let internalStorageService: InternalStorageService;
+ let idService: IdService;
+ let config: Config;
+ let fileServerService: FileServerService;
+ let externalFileServerService: FileServerService;
+ let remoteServer: FastifyInstance;
+ let remotePngUrl: string;
+ let remoteSvgUrl: string;
+ let remoteTextUrl: string;
+ let remoteFlatPngUrl: string;
+ const storedPaths: string[] = [];
+ let createdFallbackAssets = false;
+ let fallbackAssetsDir = '';
+
+ function writeInternalFile(key: string) {
+ const dest = internalStorageService.resolvePath(key);
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+ fs.copyFileSync(dummyPath, dest);
+ storedPaths.push(dest);
+ }
+
+ async function insertDriveFile(params: {
+ accessKey: string;
+ thumbnailAccessKey?: string | null;
+ webpublicAccessKey?: string | null;
+ storedInternal: boolean;
+ isLink: boolean;
+ uri?: string | null;
+ name?: string;
+ type?: string;
+ size?: number;
+ }) {
+ const accessKey = params.accessKey;
+ const url = params.uri ?? `${config.url}/files/${accessKey}`;
+ await driveFilesRepository.insert({
+ id: idService.gen(),
+ userId: null,
+ userHost: null,
+ md5: '00000000000000000000000000000000',
+ name: params.name ?? 'dummy.png',
+ type: params.type ?? 'image/png',
+ size: params.size ?? dummySize,
+ comment: null,
+ blurhash: null,
+ properties: {},
+ storedInternal: params.storedInternal,
+ url,
+ thumbnailUrl: null,
+ webpublicUrl: null,
+ webpublicType: null,
+ accessKey,
+ thumbnailAccessKey: params.thumbnailAccessKey ?? null,
+ webpublicAccessKey: params.webpublicAccessKey ?? null,
+ uri: params.uri ?? null,
+ src: null,
+ folderId: null,
+ isSensitive: false,
+ maybeSensitive: false,
+ maybePorn: false,
+ isLink: params.isLink,
+ requestHeaders: {},
+ requestIp: null,
+ });
+ }
+
+ beforeAll(async () => {
+ config = loadConfig();
+ db = await initTestDb(false);
+ driveFilesRepository = db.getRepository(MiDriveFile);
+
+ const loggerService = new LoggerService();
+ const aiService = {
+ detectSensitive: async () => null,
+ } as unknown as AiService;
+ const fileInfoService = new FileInfoService(aiService, loggerService);
+ const httpRequestService = new HttpRequestService(config);
+ const downloadService = new DownloadService(config, httpRequestService, loggerService);
+ const imageProcessingService = new ImageProcessingService();
+ const videoProcessingService = new VideoProcessingService(config, imageProcessingService);
+ internalStorageService = new InternalStorageService(config);
+ idService = new IdService(config);
+ fileServerService = new FileServerService(
+ config,
+ driveFilesRepository as any,
+ fileInfoService,
+ downloadService,
+ imageProcessingService,
+ videoProcessingService,
+ internalStorageService,
+ loggerService,
+ );
+
+ fastify = Fastify();
+ await fastify.register(fastifyStatic, {
+ root: path.resolve('src/server/assets'),
+ serve: false,
+ });
+ fileServerService.createServer(fastify, {}, () => {});
+ await fastify.ready();
+
+ const externalConfig = {
+ ...config,
+ mediaProxy: 'https://media-proxy.test',
+ externalMediaProxyEnabled: true,
+ } as Config;
+ externalFileServerService = new FileServerService(
+ externalConfig,
+ driveFilesRepository as any,
+ fileInfoService,
+ downloadService,
+ imageProcessingService,
+ videoProcessingService,
+ internalStorageService,
+ loggerService,
+ );
+ externalFastify = Fastify();
+ await externalFastify.register(fastifyStatic, {
+ root: path.resolve('src/server/assets'),
+ serve: false,
+ });
+ externalFileServerService.createServer(externalFastify, {}, () => {});
+ await externalFastify.ready();
+
+ const remoteServerInfo = await createRemoteFileServer();
+ remoteServer = remoteServerInfo.server;
+ remotePngUrl = remoteServerInfo.pngUrl;
+ remoteSvgUrl = remoteServerInfo.svgUrl;
+ remoteTextUrl = remoteServerInfo.textUrl;
+ remoteFlatPngUrl = remoteServerInfo.flatPngUrl;
+
+ fallbackAssetsDir = path.resolve('src/server/file/assets');
+ if (!fs.existsSync(fallbackAssetsDir)) {
+ fs.mkdirSync(fallbackAssetsDir, { recursive: true });
+ fs.copyFileSync(dummyPath, path.join(fallbackAssetsDir, 'dummy.png'));
+ createdFallbackAssets = true;
+ }
+ });
+
+ afterEach(async () => {
+ await driveFilesRepository.createQueryBuilder().delete().execute();
+ for (const filePath of storedPaths) {
+ try {
+ fs.unlinkSync(filePath);
+ } catch {
+ // NOP
+ }
+ }
+ storedPaths.length = 0;
+ });
+
+ afterAll(async () => {
+ await fastify.close();
+ await externalFastify.close();
+ await remoteServer.close();
+ await db.destroy();
+ if (createdFallbackAssets) {
+ fs.rmSync(fallbackAssetsDir, { recursive: true, force: true });
+ }
+ });
+
+ describe('GET /files/app-default.jpg', () => {
+ test('GET /files/app-default.jpg ヘッダを検証する', async () => {
+ const prevNodeEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'test';
+
+ try {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: '/files/app-default.jpg',
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-type']).toBe('image/jpeg');
+ expect(res.headers['access-control-allow-origin']).toBeUndefined();
+ } finally {
+ process.env.NODE_ENV = prevNodeEnv;
+ }
+ });
+
+ test('GET /files/app-default.jpg development で CORS を許可する', async () => {
+ const prevNodeEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'development';
+
+ try {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: '/files/app-default.jpg',
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['access-control-allow-origin']).toBe('*');
+ } finally {
+ process.env.NODE_ENV = prevNodeEnv;
+ }
+ });
+
+ test('GET /files/app-default.jpg?x=1 クエリを除去してリダイレクトする', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: '/files/app-default.jpg?x=1',
+ });
+
+ expect(res.statusCode).toBe(301);
+ expect(res.headers.location).toBe('/files/app-default.jpg');
+ expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
+ });
+ });
+
+ describe('GET /files/:key', () => {
+ test('GET /files/:key 404 のときダミー画像を返す', async () => {
+ const accessKey = randomString();
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ });
+
+ expect(res.statusCode).toBe(404);
+ expect(res.headers['cache-control']).toBe('max-age=86400');
+ });
+
+ test('GET /files/:key 画像配信ヘッダを検証する', async () => {
+ const accessKey = randomString();
+ writeInternalFile(accessKey);
+ await insertDriveFile({
+ accessKey,
+ storedInternal: true,
+ isLink: false,
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['content-length']).toBe(String(dummySize));
+ expect(res.headers['content-disposition'] ?? '').toMatch(/^inline;/);
+ });
+
+ test('GET /files/:key Range で部分配信する', async () => {
+ const accessKey = randomString();
+ writeInternalFile(accessKey);
+ await insertDriveFile({
+ accessKey,
+ storedInternal: true,
+ isLink: false,
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ headers: {
+ range: 'bytes=0-3',
+ },
+ });
+
+ expect(res.statusCode).toBe(206);
+ expect(res.headers['content-range']).toBe(`bytes 0-3/${dummySize}`);
+ expect(res.headers['accept-ranges']).toBe('bytes');
+ expect(res.headers['content-length']).toBe('4');
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ });
+
+ test('GET /files/:key Range の終端を補正する', async () => {
+ const accessKey = randomString();
+ writeInternalFile(accessKey);
+ await insertDriveFile({
+ accessKey,
+ storedInternal: true,
+ isLink: false,
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ headers: {
+ range: 'bytes=0-999999',
+ },
+ });
+
+ expect(res.statusCode).toBe(206);
+ expect(res.headers['content-range']).toBe(`bytes 0-${dummySize - 1}/${dummySize}`);
+ expect(res.headers['accept-ranges']).toBe('bytes');
+ expect(res.headers['content-length']).toBe(String(dummySize));
+ });
+
+ test('GET /files/:key thumbnail の Range で部分配信する', async () => {
+ const accessKey = randomString();
+ const thumbnailKey = randomString();
+ writeInternalFile(thumbnailKey);
+ await insertDriveFile({
+ accessKey,
+ thumbnailAccessKey: thumbnailKey,
+ storedInternal: true,
+ isLink: false,
+ name: 'sample.png',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${thumbnailKey}`,
+ headers: {
+ range: 'bytes=0-3',
+ },
+ });
+
+ expect(res.statusCode).toBe(206);
+ expect(res.headers['content-range']).toBe(`bytes 0-3/${dummySize}`);
+ expect(res.headers['accept-ranges']).toBe('bytes');
+ expect(res.headers['content-length']).toBe('4');
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ });
+
+ test('GET /files/:key thumbnail のファイル名を整形する', async () => {
+ const accessKey = randomString();
+ const thumbnailKey = randomString();
+ writeInternalFile(thumbnailKey);
+ await insertDriveFile({
+ accessKey,
+ thumbnailAccessKey: thumbnailKey,
+ storedInternal: true,
+ isLink: false,
+ name: 'sample.png',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${thumbnailKey}`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('sample-thumb.png');
+ });
+
+ test('GET /files/:key webpublic のファイル名を整形する', async () => {
+ const accessKey = randomString();
+ const webpublicKey = randomString();
+ writeInternalFile(webpublicKey);
+ await insertDriveFile({
+ accessKey,
+ webpublicAccessKey: webpublicKey,
+ storedInternal: true,
+ isLink: false,
+ name: 'sample.png',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${webpublicKey}`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('sample-web.png');
+ });
+
+ test('GET /files/:key browsersafe でない MIME は octet-stream になる', async () => {
+ const accessKey = randomString();
+ writeInternalFile(accessKey);
+ await insertDriveFile({
+ accessKey,
+ storedInternal: true,
+ isLink: false,
+ type: 'application/x-msdownload',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('application/octet-stream');
+ });
+
+ test('GET /files/:key 204 のときキャッシュ制御を返す', async () => {
+ const accessKey = randomString();
+ await insertDriveFile({
+ accessKey,
+ storedInternal: false,
+ isLink: false,
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ });
+
+ expect(res.statusCode).toBe(204);
+ expect(res.headers['cache-control']).toBe('max-age=86400');
+ });
+
+ test('GET /files/:key 外部リンクを取得して配信する', async () => {
+ const accessKey = randomString();
+ await insertDriveFile({
+ accessKey,
+ storedInternal: false,
+ isLink: true,
+ uri: remotePngUrl,
+ name: 'remote.png',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-length']).toBe(String(dummyBuffer.length));
+ expect(res.headers['content-disposition'] ?? '').toContain('remote.png');
+ });
+
+ test('GET /files/:key 外部リンクを Range で部分配信する', async () => {
+ const accessKey = randomString();
+ await insertDriveFile({
+ accessKey,
+ storedInternal: false,
+ isLink: true,
+ uri: remotePngUrl,
+ name: 'remote.png',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ headers: {
+ range: 'bytes=0-3',
+ },
+ });
+
+ expect(res.statusCode).toBe(206);
+ expect(res.headers['content-range']).toBe(`bytes 0-3/${dummyBuffer.length}`);
+ expect(res.headers['accept-ranges']).toBe('bytes');
+ expect(res.headers['content-length']).toBe(String(dummyBuffer.length));
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ });
+
+ test('GET /files/:key thumbnail は mediaProxy/static.webp にリダイレクトする', async () => {
+ const accessKey = randomString();
+ const thumbnailKey = randomString();
+ await insertDriveFile({
+ accessKey,
+ thumbnailAccessKey: thumbnailKey,
+ storedInternal: false,
+ isLink: true,
+ uri: remotePngUrl,
+ name: 'remote.png',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${thumbnailKey}`,
+ });
+
+ expect(res.statusCode).toBe(301);
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers.location).toContain(`${config.mediaProxy}/static.webp`);
+ expect(res.headers.location).toContain('static=1');
+ });
+
+ test('GET /files/:key webpublic svg は mediaProxy/svg.webp にリダイレクトする', async () => {
+ const accessKey = randomString();
+ const webpublicKey = randomString();
+ await insertDriveFile({
+ accessKey,
+ webpublicAccessKey: webpublicKey,
+ storedInternal: false,
+ isLink: true,
+ uri: remoteSvgUrl,
+ name: 'vector.svg',
+ type: 'image/svg+xml',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${webpublicKey}`,
+ });
+
+ expect(res.statusCode).toBe(301);
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers.location).toContain(`${config.mediaProxy}/svg.webp`);
+ });
+ });
+
+ describe('GET /files/:key/*', () => {
+ test('GET /files/:key/* 正規の /files/:key にリダイレクトする', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: '/files/testkey/extra/path',
+ });
+
+ expect(res.statusCode).toBe(301);
+ expect(res.headers.location).toBe(`${config.url}/files/testkey`);
+ expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
+ });
+ });
+
+ describe('GET /proxy/:url*', () => {
+ test('GET /proxy/:url* 外部メディアプロキシへリダイレクトする', async () => {
+ const res = await externalFastify.inject({
+ method: 'GET',
+ url: '/proxy/path-part?url=https%3A%2F%2Fexample.com%2Fimg.png&static=1',
+ });
+
+ expect(res.statusCode).toBe(301);
+ expect(res.headers['cache-control']).toBe('public, max-age=259200');
+ expect(res.headers.location).toContain('https://media-proxy.test/');
+ expect(res.headers.location).toContain('url=https%3A%2F%2Fexample.com%2Fimg.png');
+ expect(res.headers.location).toContain('static=1');
+ expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
+ });
+
+ test('GET /proxy/:url* misskey User-Agent を拒否する', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: '/proxy/any?url=https%3A%2F%2Fexample.com%2Fimg.png',
+ headers: {
+ 'user-agent': 'misskey/1.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(403);
+ expect(res.headers['cache-control']).toBe('max-age=300');
+ });
+
+ test('GET /proxy/:url* origin 指定時は User-Agent 必須を検証する', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: '/proxy/any?url=https%3A%2F%2Fexample.com%2Fimg.png&origin=1',
+ headers: {
+ 'user-agent': '',
+ },
+ });
+
+ expect(res.statusCode).toBe(400);
+ expect(res.headers['cache-control']).toBe('max-age=300');
+ expect(res.headers.location).toBeUndefined();
+ expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
+ });
+
+ test('GET /proxy/:url* emoji 指定で非画像は 404 を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remoteTextUrl)}&emoji=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(404);
+ expect(res.headers['cache-control']).toBe('max-age=300');
+ });
+
+ test('GET /proxy/:url* 非画像は 403 を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remoteTextUrl)}`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(403);
+ expect(res.headers['cache-control']).toBe('max-age=300');
+ });
+
+ test('GET /proxy/:url* emoji static で webp を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&emoji=1&static=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/webp');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('dummy.png.webp');
+ });
+
+ test('GET /proxy/:url* avatar static で webp を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&avatar=1&static=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/webp');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('dummy.png.webp');
+ });
+
+ test('GET /proxy/:url* static で webp を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&static=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/webp');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('dummy.png.webp');
+ });
+
+ test('GET /proxy/:url* preview で webp を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&preview=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/webp');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('dummy.png.webp');
+ });
+
+ test('GET /proxy/:url* svg を webp に変換する', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remoteSvgUrl)}`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/webp');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('dummy.svg.webp');
+ });
+
+ test('GET /proxy/:url* badge で低エントロピー画像は 404 を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remoteFlatPngUrl)}&badge=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(404);
+ expect(res.headers['cache-control']).toBe('max-age=300');
+ });
+
+ test('GET /proxy/:url* 画像をそのまま返す', async () => {
+ const accessKey = randomString();
+ writeInternalFile(accessKey);
+ await insertDriveFile({
+ accessKey,
+ storedInternal: true,
+ isLink: false,
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(`${config.url}/files/${accessKey}`)}&origin=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('dummy.png');
+ });
+ });
+});
diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts
index ecca28b5af..f91fb7f9b1 100644
--- a/packages/backend/test/utils.ts
+++ b/packages/backend/test/utils.ts
@@ -404,37 +404,28 @@ export function connectStream<C extends keyof misskey.Channels>(user: UserToken,
}
export const waitFire = async <C extends keyof misskey.Channels>(user: UserToken, channel: C, trgr: () => any, cond: (msg: Record<string, any>) => boolean, params?: misskey.Channels[C]['params']) => {
- return new Promise<boolean>(async (res, rej) => {
- let timer: NodeJS.Timeout | null = null;
+ let ws: WebSocket | undefined;
- let ws: WebSocket;
- try {
- ws = await connectStream(user, channel, msg => {
+ try {
+ let callback: (msg: Record<string, unknown>) => void;
+ const receivedPromise = new Promise<boolean>((resolve) => {
+ callback = (msg: Record<string, unknown>) => {
if (cond(msg)) {
- ws.close();
- if (timer) clearTimeout(timer);
- res(true);
+ resolve(true);
}
- }, params);
- } catch (e) {
- rej(e);
- }
-
- if (!ws!) return;
+ };
+ });
- timer = setTimeout(() => {
- ws.close();
- res(false);
- }, 3000);
+ ws = await connectStream(user, channel, callback!, params);
+ await trgr();
- try {
- await trgr();
- } catch (e) {
- ws.close();
- if (timer) clearTimeout(timer);
- rej(e);
- }
- });
+ return await Promise.race([
+ receivedPromise,
+ new Promise<void>((r) => setTimeout(() => r(), 3000)).then(() => false),
+ ]);
+ } finally {
+ if (ws) ws.close();
+ }
};
/**