Merge branch 'develop' into fetch-outbox

This commit is contained in:
Kagami Sascha Rosylight
2023-08-06 00:04:26 +02:00
1698 changed files with 17392 additions and 4601 deletions

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';

View File

@@ -1,10 +1,15 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { signup, api, startServer, successfulApiCall, failedApiCall, uploadFile, waitFire, connectStream } from '../utils.js';
import { IncomingMessage } from 'http';
import { signup, api, startServer, successfulApiCall, failedApiCall, uploadFile, waitFire, connectStream, relativeFetch } from '../utils.js';
import type { INestApplicationContext } from '@nestjs/common';
import type * as misskey from 'misskey-js';
import { IncomingMessage } from 'http';
describe('API', () => {
let app: INestApplicationContext;
@@ -218,6 +223,42 @@ describe('API', () => {
assert.ok(result.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="invalid_request", error_description'));
});
// TODO: insufficient_scope test (authテストが全然なくて書けない)
describe('invalid bearer format', () => {
test('No preceding bearer', async () => {
const result = await relativeFetch('api/notes/create', {
method: 'POST',
headers: {
Authorization: alice.token,
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: 'test' }),
});
assert.strictEqual(result.status, 401);
});
test('Lowercase bearer', async () => {
const result = await relativeFetch('api/notes/create', {
method: 'POST',
headers: {
Authorization: `bearer ${alice.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: 'test' }),
});
assert.strictEqual(result.status, 401);
});
test('No space after bearer', async () => {
const result = await relativeFetch('api/notes/create', {
method: 'POST',
headers: {
Authorization: `Bearer${alice.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: 'test' }),
});
assert.strictEqual(result.status, 401);
});
});
});
});

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';

View File

@@ -1,7 +1,13 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { Note } from '@/models/entities/Note.js';
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { signup, post, uploadUrl, startServer, initTestDb, api, uploadFile } from '../utils.js';
import type { INestApplicationContext } from '@nestjs/common';
import type * as misskey from 'misskey-js';
@@ -164,7 +170,7 @@ describe('Note', () => {
test('文字数ぎりぎりで怒られない', async () => {
const post = {
text: '!'.repeat(3000),
text: '!'.repeat(MAX_NOTE_TEXT_LENGTH), // 3000文字
};
const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 200);
@@ -172,7 +178,7 @@ describe('Note', () => {
test('文字数オーバーで怒られる', async () => {
const post = {
text: '!'.repeat(3001),
text: '!'.repeat(MAX_NOTE_TEXT_LENGTH + 1), // 3001文字
};
const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 400);
@@ -546,8 +552,8 @@ describe('Note', () => {
test('センシティブな投稿はhomeになる (単語指定)', async () => {
const sensitive = await api('admin/update-meta', {
sensitiveWords: [
"test",
]
'test',
],
}, alice);
assert.strictEqual(sensitive.status, 204);
@@ -560,14 +566,13 @@ describe('Note', () => {
assert.strictEqual(note1.status, 200);
assert.strictEqual(note1.body.createdNote.visibility, 'home');
});
test('センシティブな投稿はhomeになる (正規表現)', async () => {
const sensitive = await api('admin/update-meta', {
sensitiveWords: [
"/Test/i",
]
'/Test/i',
],
}, alice);
assert.strictEqual(sensitive.status, 204);
@@ -583,8 +588,8 @@ describe('Note', () => {
test('センシティブな投稿はhomeになる (スペースアンド)', async () => {
const sensitive = await api('admin/update-meta', {
sensitiveWords: [
"Test hoge"
]
'Test hoge',
],
}, alice);
assert.strictEqual(sensitive.status, 204);
@@ -595,7 +600,6 @@ describe('Note', () => {
assert.strictEqual(note2.status, 200);
assert.strictEqual(note2.body.createdNote.visibility, 'home');
});
});

View File

@@ -0,0 +1,944 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Basic OAuth tests to make sure the library is correctly integrated to Misskey
* and not regressed by version updates or potential migration to another library.
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { AuthorizationCode, ResourceOwnerPassword, type AuthorizationTokenConfig, ClientCredentials, ModuleOptions } from 'simple-oauth2';
import pkceChallenge from 'pkce-challenge';
import { JSDOM } from 'jsdom';
import Fastify, { type FastifyReply, type FastifyInstance } from 'fastify';
import { api, port, signup, startServer } from '../utils.js';
import type * as misskey from 'misskey-js';
import type { INestApplicationContext } from '@nestjs/common';
const host = `http://127.0.0.1:${port}`;
const clientPort = port + 1;
const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`;
const basicAuthParams: AuthorizationParamsExtended = {
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
};
interface AuthorizationParamsExtended {
redirect_uri: string;
scope: string | string[];
state: string;
code_challenge?: string;
code_challenge_method?: string;
}
interface AuthorizationTokenConfigExtended extends AuthorizationTokenConfig {
code_verifier: string | undefined;
}
interface GetTokenError {
data: {
payload: {
error: string;
}
}
}
const clientConfig: ModuleOptions<'client_id'> = {
client: {
id: `http://127.0.0.1:${clientPort}/`,
secret: '',
},
auth: {
tokenHost: host,
tokenPath: '/oauth/token',
authorizePath: '/oauth/authorize',
},
options: {
authorizationMethod: 'body',
},
};
function getMeta(html: string): { transactionId: string | undefined, clientName: string | undefined } {
const fragment = JSDOM.fragment(html);
return {
transactionId: fragment.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]')?.content,
clientName: fragment.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content,
};
}
function fetchDecision(transactionId: string, user: misskey.entities.MeSignup, { cancel }: { cancel?: boolean } = {}): Promise<Response> {
return fetch(new URL('/oauth/decision', host), {
method: 'post',
body: new URLSearchParams({
transaction_id: transactionId,
login_token: user.token,
cancel: cancel ? 'cancel' : '',
}),
redirect: 'manual',
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
});
}
async function fetchDecisionFromResponse(response: Response, user: misskey.entities.MeSignup, { cancel }: { cancel?: boolean } = {}): Promise<Response> {
const { transactionId } = getMeta(await response.text());
assert.ok(transactionId);
return await fetchDecision(transactionId, user, { cancel });
}
async function fetchAuthorizationCode(user: misskey.entities.MeSignup, scope: string, code_challenge: string): Promise<{ client: AuthorizationCode, code: string }> {
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope,
state: 'state',
code_challenge,
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
assert.strictEqual(response.status, 200);
const decisionResponse = await fetchDecisionFromResponse(response, user);
assert.strictEqual(decisionResponse.status, 302);
const locationHeader = decisionResponse.headers.get('location');
assert.ok(locationHeader);
const location = new URL(locationHeader);
assert.ok(location.searchParams.has('code'));
const code = new URL(location).searchParams.get('code');
assert.ok(code);
return { client, code };
}
function assertIndirectError(response: Response, error: string): void {
assert.strictEqual(response.status, 302);
const locationHeader = response.headers.get('location');
assert.ok(locationHeader);
const location = new URL(locationHeader);
assert.strictEqual(location.searchParams.get('error'), error);
// https://datatracker.ietf.org/doc/html/rfc9207#name-response-parameter-iss
assert.strictEqual(location.searchParams.get('iss'), 'http://misskey.local');
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2.1
assert.ok(location.searchParams.has('state'));
}
async function assertDirectError(response: Response, status: number, error: string): Promise<void> {
assert.strictEqual(response.status, status);
const data = await response.json();
assert.strictEqual(data.error, error);
}
describe('OAuth', () => {
let app: INestApplicationContext;
let fastify: FastifyInstance;
let alice: misskey.entities.MeSignup;
let bob: misskey.entities.MeSignup;
let sender: (reply: FastifyReply) => void;
beforeAll(async () => {
app = await startServer();
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
fastify = Fastify();
fastify.get('/', async (request, reply) => {
sender(reply);
});
await fastify.listen({ port: clientPort });
}, 1000 * 60 * 2);
beforeEach(async () => {
process.env.MISSKEY_TEST_CHECK_IP_RANGE = '';
sender = (reply): void => {
reply.send(`
<!DOCTYPE html>
<link rel="redirect_uri" href="/redirect" />
<div class="h-app"><a href="/" class="u-url p-name">Misklient
`);
};
});
afterAll(async () => {
await fastify.close();
await app.close();
});
test('Full flow', async () => {
const { code_challenge, code_verifier } = await pkceChallenge(128);
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge,
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
assert.strictEqual(response.status, 200);
const meta = getMeta(await response.text());
assert.strictEqual(typeof meta.transactionId, 'string');
assert.ok(meta.transactionId);
assert.strictEqual(meta.clientName, 'Misklient');
const decisionResponse = await fetchDecision(meta.transactionId, alice);
assert.strictEqual(decisionResponse.status, 302);
assert.ok(decisionResponse.headers.has('location'));
const locationHeader = decisionResponse.headers.get('location');
assert.ok(locationHeader);
const location = new URL(locationHeader);
assert.strictEqual(location.origin + location.pathname, redirect_uri);
assert.ok(location.searchParams.has('code'));
assert.strictEqual(location.searchParams.get('state'), 'state');
// https://datatracker.ietf.org/doc/html/rfc9207#name-response-parameter-iss
assert.strictEqual(location.searchParams.get('iss'), 'http://misskey.local');
const code = new URL(location).searchParams.get('code');
assert.ok(code);
const token = await client.getToken({
code,
redirect_uri,
code_verifier,
} as AuthorizationTokenConfigExtended);
assert.strictEqual(typeof token.token.access_token, 'string');
assert.strictEqual(token.token.token_type, 'Bearer');
assert.strictEqual(token.token.scope, 'write:notes');
const createResult = await api('notes/create', { text: 'test' }, {
token: token.token.access_token as string,
bearer: true,
});
assert.strictEqual(createResult.status, 200);
const createResultBody = createResult.body as misskey.Endpoints['notes/create']['res'];
assert.strictEqual(createResultBody.createdNote.text, 'test');
});
test('Two concurrent flows', async () => {
const client = new AuthorizationCode(clientConfig);
const pkceAlice = await pkceChallenge(128);
const pkceBob = await pkceChallenge(128);
const responseAlice = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge: pkceAlice.code_challenge,
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
assert.strictEqual(responseAlice.status, 200);
const responseBob = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge: pkceBob.code_challenge,
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
assert.strictEqual(responseBob.status, 200);
const decisionResponseAlice = await fetchDecisionFromResponse(responseAlice, alice);
assert.strictEqual(decisionResponseAlice.status, 302);
const decisionResponseBob = await fetchDecisionFromResponse(responseBob, bob);
assert.strictEqual(decisionResponseBob.status, 302);
const locationHeaderAlice = decisionResponseAlice.headers.get('location');
assert.ok(locationHeaderAlice);
const locationAlice = new URL(locationHeaderAlice);
const locationHeaderBob = decisionResponseBob.headers.get('location');
assert.ok(locationHeaderBob);
const locationBob = new URL(locationHeaderBob);
const codeAlice = locationAlice.searchParams.get('code');
assert.ok(codeAlice);
const codeBob = locationBob.searchParams.get('code');
assert.ok(codeBob);
const tokenAlice = await client.getToken({
code: codeAlice,
redirect_uri,
code_verifier: pkceAlice.code_verifier,
} as AuthorizationTokenConfigExtended);
const tokenBob = await client.getToken({
code: codeBob,
redirect_uri,
code_verifier: pkceBob.code_verifier,
} as AuthorizationTokenConfigExtended);
const createResultAlice = await api('notes/create', { text: 'test' }, {
token: tokenAlice.token.access_token as string,
bearer: true,
});
assert.strictEqual(createResultAlice.status, 200);
const createResultBob = await api('notes/create', { text: 'test' }, {
token: tokenBob.token.access_token as string,
bearer: true,
});
assert.strictEqual(createResultAlice.status, 200);
const createResultBodyAlice = await createResultAlice.body as misskey.Endpoints['notes/create']['res'];
assert.strictEqual(createResultBodyAlice.createdNote.user.username, 'alice');
const createResultBodyBob = await createResultBob.body as misskey.Endpoints['notes/create']['res'];
assert.strictEqual(createResultBodyBob.createdNote.user.username, 'bob');
});
// https://datatracker.ietf.org/doc/html/rfc7636.html
describe('PKCE', () => {
// https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.4.1
// '... the authorization endpoint MUST return the authorization
// error response with the "error" value set to "invalid_request".'
test('Require PKCE', async () => {
const client = new AuthorizationCode(clientConfig);
// Pattern 1: No PKCE fields at all
let response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
}), { redirect: 'manual' });
assertIndirectError(response, 'invalid_request');
// Pattern 2: Only code_challenge
response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
} as AuthorizationParamsExtended), { redirect: 'manual' });
assertIndirectError(response, 'invalid_request');
// Pattern 3: Only code_challenge_method
response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended), { redirect: 'manual' });
assertIndirectError(response, 'invalid_request');
// Pattern 4: Unsupported code_challenge_method
response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'SSSS',
} as AuthorizationParamsExtended), { redirect: 'manual' });
assertIndirectError(response, 'invalid_request');
});
// Use precomputed challenge/verifier set here for deterministic test
const code_challenge = '4w2GDuvaxXlw2l46k5PFIoIcTGHdzw2i3hrn-C_Q6f7u0-nTYKd-beVEYy9XinYsGtAix.Nnvr.GByD3lAii2ibPRsSDrZgIN0YQb.kfevcfR9aDKoTLyOUm4hW4ABhs';
const code_verifier = 'Ew8VSBiH59JirLlg7ocFpLQ6NXuFC1W_rn8gmRzBKc8';
const tests: Record<string, string | undefined> = {
'Code followed by some junk code': code_verifier + 'x',
'Clipped code': code_verifier.slice(0, 80),
'Some part of code is replaced': code_verifier.slice(0, -10) + 'x'.repeat(10),
'No verifier': undefined,
};
describe('Verify PKCE', () => {
for (const [title, wrong_verifier] of Object.entries(tests)) {
test(title, async () => {
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
await assert.rejects(client.getToken({
code,
redirect_uri,
code_verifier: wrong_verifier,
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
assert.strictEqual(err.data.payload.error, 'invalid_grant');
return true;
});
});
}
});
});
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2
// "If an authorization code is used more than once, the authorization server
// MUST deny the request and SHOULD revoke (when possible) all tokens
// previously issued based on that authorization code."
describe('Revoking authorization code', () => {
test('On success', async () => {
const { code_challenge, code_verifier } = await pkceChallenge(128);
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
await client.getToken({
code,
redirect_uri,
code_verifier,
} as AuthorizationTokenConfigExtended);
await assert.rejects(client.getToken({
code,
redirect_uri,
code_verifier,
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
assert.strictEqual(err.data.payload.error, 'invalid_grant');
return true;
});
});
test('On failure', async () => {
const { code_challenge, code_verifier } = await pkceChallenge(128);
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
await assert.rejects(client.getToken({ code, redirect_uri }), (err: GetTokenError) => {
assert.strictEqual(err.data.payload.error, 'invalid_grant');
return true;
});
await assert.rejects(client.getToken({
code,
redirect_uri,
code_verifier,
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
assert.strictEqual(err.data.payload.error, 'invalid_grant');
return true;
});
});
test('Revoke the already granted access token', async () => {
const { code_challenge, code_verifier } = await pkceChallenge(128);
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
const token = await client.getToken({
code,
redirect_uri,
code_verifier,
} as AuthorizationTokenConfigExtended);
const createResult = await api('notes/create', { text: 'test' }, {
token: token.token.access_token as string,
bearer: true,
});
assert.strictEqual(createResult.status, 200);
await assert.rejects(client.getToken({
code,
redirect_uri,
code_verifier,
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
assert.strictEqual(err.data.payload.error, 'invalid_grant');
return true;
});
const createResult2 = await api('notes/create', { text: 'test' }, {
token: token.token.access_token as string,
bearer: true,
});
assert.strictEqual(createResult2.status, 401);
});
});
test('Cancellation', async () => {
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 decisionResponse = await fetchDecisionFromResponse(response, alice, { cancel: true });
assert.strictEqual(decisionResponse.status, 302);
const locationHeader = decisionResponse.headers.get('location');
assert.ok(locationHeader);
const location = new URL(locationHeader);
assert.ok(!location.searchParams.has('code'));
assert.ok(location.searchParams.has('error'));
});
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-3.3
describe('Scope', () => {
// "If the client omits the scope parameter when requesting
// authorization, the authorization server MUST either process the
// request using a pre-defined default value or fail the request
// indicating an invalid scope."
// (And Misskey does the latter)
test('Missing scope', async () => {
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended), { redirect: 'manual' });
assertIndirectError(response, 'invalid_scope');
});
test('Empty scope', async () => {
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: '',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended), { redirect: 'manual' });
assertIndirectError(response, 'invalid_scope');
});
test('Unknown scopes', async () => {
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'test:unknown test:unknown2',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended), { redirect: 'manual' });
assertIndirectError(response, 'invalid_scope');
});
// "If the issued access token scope
// is different from the one requested by the client, the authorization
// server MUST include the "scope" response parameter to inform the
// client of the actual scope granted."
// (Although Misskey always return scope, which is also fine)
test('Partially known scopes', async () => {
const { code_challenge, code_verifier } = await pkceChallenge(128);
// Just get the known scope for this case for backward compatibility
const { client, code } = await fetchAuthorizationCode(
alice,
'write:notes test:unknown test:unknown2',
code_challenge,
);
const token = await client.getToken({
code,
redirect_uri,
code_verifier,
} as AuthorizationTokenConfigExtended);
assert.strictEqual(token.token.scope, 'write:notes');
});
test('Known scopes', async () => {
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes read:account',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
assert.strictEqual(response.status, 200);
});
test('Duplicated scopes', async () => {
const { code_challenge, code_verifier } = await pkceChallenge(128);
const { client, code } = await fetchAuthorizationCode(
alice,
'write:notes write:notes read:account read:account',
code_challenge,
);
const token = await client.getToken({
code,
redirect_uri,
code_verifier,
} as AuthorizationTokenConfigExtended);
assert.strictEqual(token.token.scope, 'write:notes read:account');
});
test('Scope check by API', async () => {
const { code_challenge, code_verifier } = await pkceChallenge(128);
const { client, code } = await fetchAuthorizationCode(alice, 'read:account', code_challenge);
const token = await client.getToken({
code,
redirect_uri,
code_verifier,
} as AuthorizationTokenConfigExtended);
assert.strictEqual(typeof token.token.access_token, 'string');
const createResult = await api('notes/create', { text: 'test' }, {
token: token.token.access_token as string,
bearer: true,
});
assert.strictEqual(createResult.status, 403);
assert.ok(createResult.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="insufficient_scope", error_description'));
});
});
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-3.1.2.4
// "If an authorization request fails validation due to a missing,
// invalid, or mismatching redirection URI, the authorization server
// SHOULD inform the resource owner of the error and MUST NOT
// automatically redirect the user-agent to the invalid redirection URI."
describe('Redirection', () => {
test('Invalid redirect_uri at authorization endpoint', async () => {
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri: 'http://127.0.0.2/',
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
await assertDirectError(response, 400, 'invalid_request');
});
test('Invalid redirect_uri including the valid one at authorization endpoint', async () => {
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri: 'http://127.0.0.1/redirection',
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
await assertDirectError(response, 400, 'invalid_request');
});
test('No redirect_uri at authorization endpoint', async () => {
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
scope: 'write:notes',
state: 'state',
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
await assertDirectError(response, 400, 'invalid_request');
});
test('Invalid redirect_uri at token endpoint', async () => {
const { code_challenge, code_verifier } = await pkceChallenge(128);
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
await assert.rejects(client.getToken({
code,
redirect_uri: 'http://127.0.0.2/',
code_verifier,
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
assert.strictEqual(err.data.payload.error, 'invalid_grant');
return true;
});
});
test('Invalid redirect_uri including the valid one at token endpoint', async () => {
const { code_challenge, code_verifier } = await pkceChallenge(128);
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
await assert.rejects(client.getToken({
code,
redirect_uri: 'http://127.0.0.1/redirection',
code_verifier,
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
assert.strictEqual(err.data.payload.error, 'invalid_grant');
return true;
});
});
test('No redirect_uri at token endpoint', async () => {
const { code_challenge, code_verifier } = await pkceChallenge(128);
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
await assert.rejects(client.getToken({
code,
code_verifier,
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
assert.strictEqual(err.data.payload.error, 'invalid_grant');
return true;
});
});
});
// https://datatracker.ietf.org/doc/html/rfc8414
test('Server metadata', async () => {
const response = await fetch(new URL('.well-known/oauth-authorization-server', host));
assert.strictEqual(response.status, 200);
const body = await response.json();
assert.strictEqual(body.issuer, 'http://misskey.local');
assert.ok(body.scopes_supported.includes('write:notes'));
});
// Any error on decision endpoint is solely on Misskey side and nothing to do with the client.
// Do not use indirect error here.
describe('Decision endpoint', () => {
test('No login token', async () => {
const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL(basicAuthParams));
assert.strictEqual(response.status, 200);
const { transactionId } = getMeta(await response.text());
assert.ok(transactionId);
const decisionResponse = await fetch(new URL('/oauth/decision', host), {
method: 'post',
body: new URLSearchParams({
transaction_id: transactionId,
}),
redirect: 'manual',
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
});
await assertDirectError(decisionResponse, 400, 'invalid_request');
});
test('No transaction ID', async () => {
const decisionResponse = await fetch(new URL('/oauth/decision', host), {
method: 'post',
body: new URLSearchParams({
login_token: alice.token,
}),
redirect: 'manual',
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
});
await assertDirectError(decisionResponse, 400, 'invalid_request');
});
test('Invalid transaction ID', async () => {
const decisionResponse = await fetch(new URL('/oauth/decision', host), {
method: 'post',
body: new URLSearchParams({
login_token: alice.token,
transaction_id: 'invalid_id',
}),
redirect: 'manual',
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
});
await assertDirectError(decisionResponse, 403, 'access_denied');
});
});
// Only authorization code grant is supported
describe('Grant type', () => {
test('Implicit grant is not supported', async () => {
const url = new URL('/oauth/authorize', host);
url.searchParams.append('response_type', 'token');
const response = await fetch(url);
assertDirectError(response, 501, 'unsupported_response_type');
});
test('Resource owner grant is not supported', async () => {
const client = new ResourceOwnerPassword({
...clientConfig,
auth: {
tokenHost: host,
tokenPath: '/oauth/token',
},
});
await assert.rejects(client.getToken({
username: 'alice',
password: 'test',
}), (err: GetTokenError) => {
assert.strictEqual(err.data.payload.error, 'unsupported_grant_type');
return true;
});
});
test('Client credential grant is not supported', async () => {
const client = new ClientCredentials({
...clientConfig,
auth: {
tokenHost: host,
tokenPath: '/oauth/token',
},
});
await assert.rejects(client.getToken({}), (err: GetTokenError) => {
assert.strictEqual(err.data.payload.error, 'unsupported_grant_type');
return true;
});
});
});
// 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
`);
},
};
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);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
state: 'state',
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 () => {
process.env.MISSKEY_TEST_CHECK_IP_RANGE = '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();
};
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}/`);
});
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 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('Unknown OAuth endpoint', async () => {
const response = await fetch(new URL('/oauth/foo', host));
assert.strictEqual(response.status, 404);
});
});

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Config } from '@/config.js';
import type { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
import type { ApRendererService } from '@/core/activitypub/ApRendererService.js';

View File

@@ -1,11 +1,16 @@
import { Schema } from '@/misc/schema';
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import Ajv from 'ajv';
import { Schema } from '@/misc/schema';
export const getValidator = (paramDef: Schema) => {
const ajv = new Ajv({
useDefaults: true,
});
ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/);
const ajv = new Ajv({
useDefaults: true,
});
ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/);
return ajv.compile(paramDef);
}
return ajv.compile(paramDef);
};

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as assert from 'assert';
import { just, nothing } from '../../src/misc/prelude/maybe.js';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as assert from 'assert';
import { query } from '../../src/misc/prelude/url.js';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import { Test } from '@nestjs/testing';

View File

@@ -1,8 +1,14 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals';
import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing';
import { Redis } from 'ioredis';
import { GlobalModule } from '@/GlobalModule.js';
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
@@ -13,15 +19,14 @@ import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
import type { TestingModule } from '@nestjs/testing';
import type { MockFunctionMetadata } from 'jest-mock';
import { Redis } from 'ioredis'
function mockRedis() {
const hash = {};
const set = jest.fn((key, value) => {
const ret = hash[key];
hash[key] = value;
return ret;
});
const ret = hash[key];
hash[key] = value;
return ret;
});
return set;
}
@@ -47,7 +52,7 @@ describe('FetchInstanceMetadataService', () => {
})
.useMocker((token) => {
if (token === HttpRequestService) {
return { getJson: jest.fn(), getHtml: jest.fn(), send: jest.fn(), };
return { getJson: jest.fn(), getHtml: jest.fn(), send: jest.fn() };
} else if (token === FederatedInstanceService) {
return { fetch: jest.fn() };
} else if (token === DI.redis) {
@@ -74,20 +79,20 @@ describe('FetchInstanceMetadataService', () => {
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: "example.com" });
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' });
expect(tryLockSpy).toHaveBeenCalledTimes(1);
expect(unlockSpy).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
expect(httpRequestService.getJson).toHaveBeenCalled();
});
test("Lock and don't update", async () => {
test('Lock and don\'t update', async () => {
redisClient.set = mockRedis();
const now = Date.now();
federatedInstanceService.fetch.mockReturnValue({ infoUpdatedAt: { getTime: () => now } });
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: "example.com" });
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' });
expect(tryLockSpy).toHaveBeenCalledTimes(1);
expect(unlockSpy).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
@@ -99,8 +104,8 @@ describe('FetchInstanceMetadataService', () => {
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
await fetchInstanceMetadataService.tryLock("example.com");
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: "example.com" });
await fetchInstanceMetadataService.tryLock('example.com');
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' });
expect(tryLockSpy).toHaveBeenCalledTimes(2);
expect(unlockSpy).toHaveBeenCalledTimes(0);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0);

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
@@ -5,12 +10,12 @@ import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing';
import { describe, beforeAll, afterAll, test } from '@jest/globals';
import { GlobalModule } from '@/GlobalModule.js';
import { FileInfoService } from '@/core/FileInfoService.js';
//import { DI } from '@/di-symbols.js';
import { AiService } from '@/core/AiService.js';
import type { TestingModule } from '@nestjs/testing';
import { describe, beforeAll, afterAll, test } from '@jest/globals';
import type { MockFunctionMetadata } from 'jest-mock';
const _filename = fileURLToPath(import.meta.url);

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as assert from 'assert';
import * as mfm from 'mfm-js';
import { Test } from '@nestjs/testing';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as assert from 'assert';
import { Test } from '@nestjs/testing';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import { Test } from '@nestjs/testing';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as assert from 'assert';
import httpSignature from '@peertube/http-signature';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as assert from 'assert';
import { parse } from 'mfm-js';

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { checkWordMute } from '@/misc/check-word-mute.js';
describe(checkWordMute, () => {

View File

@@ -1,44 +1,49 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ulid } from 'ulid';
import { describe, test, expect } from '@jest/globals';
import { aidRegExp, genAid, parseAid } from '@/misc/id/aid.js';
import { genMeid, meidRegExp, parseMeid } from '@/misc/id/meid.js';
import { genMeidg, meidgRegExp, parseMeidg } from '@/misc/id/meidg.js';
import { genObjectId, objectIdRegExp, parseObjectId } from '@/misc/id/object-id.js';
import { ulidRegExp, parseUlid } from '@/misc/id/ulid.js';
import { ulid } from 'ulid';
import { describe, test, expect } from '@jest/globals';
describe('misc:id', () => {
test('aid', () => {
const date = new Date();
const gotAid = genAid(date);
expect(gotAid).toMatch(aidRegExp);
expect(parseAid(gotAid).date.getTime()).toBe(date.getTime());
});
test('aid', () => {
const date = new Date();
const gotAid = genAid(date);
expect(gotAid).toMatch(aidRegExp);
expect(parseAid(gotAid).date.getTime()).toBe(date.getTime());
});
test('meid', () => {
const date = new Date();
const gotMeid = genMeid(date);
expect(gotMeid).toMatch(meidRegExp);
expect(parseMeid(gotMeid).date.getTime()).toBe(date.getTime());
});
test('meid', () => {
const date = new Date();
const gotMeid = genMeid(date);
expect(gotMeid).toMatch(meidRegExp);
expect(parseMeid(gotMeid).date.getTime()).toBe(date.getTime());
});
test('meidg', () => {
const date = new Date();
const gotMeidg = genMeidg(date);
expect(gotMeidg).toMatch(meidgRegExp);
expect(parseMeidg(gotMeidg).date.getTime()).toBe(date.getTime());
});
test('meidg', () => {
const date = new Date();
const gotMeidg = genMeidg(date);
expect(gotMeidg).toMatch(meidgRegExp);
expect(parseMeidg(gotMeidg).date.getTime()).toBe(date.getTime());
});
test('objectid', () => {
const date = new Date();
const gotObjectId = genObjectId(date);
expect(gotObjectId).toMatch(objectIdRegExp);
expect(Math.floor(parseObjectId(gotObjectId).date.getTime() / 1000)).toBe(Math.floor(date.getTime() / 1000));
});
test('objectid', () => {
const date = new Date();
const gotObjectId = genObjectId(date);
expect(gotObjectId).toMatch(objectIdRegExp);
expect(Math.floor(parseObjectId(gotObjectId).date.getTime() / 1000)).toBe(Math.floor(date.getTime() / 1000));
});
test('ulid', () => {
const date = new Date();
const gotUlid = ulid(date.getTime());
expect(gotUlid).toMatch(ulidRegExp);
expect(parseUlid(gotUlid).date.getTime()).toBe(date.getTime());
});
test('ulid', () => {
const date = new Date();
const gotUlid = ulid(date.getTime());
expect(gotUlid).toMatch(ulidRegExp);
expect(parseUlid(gotUlid).date.getTime()).toBe(date.getTime());
});
});

View File

@@ -1,42 +1,47 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, test, expect } from '@jest/globals';
import { contentDisposition } from '@/misc/content-disposition.js';
import { correctFilename } from '@/misc/correct-filename.js';
describe('misc:content-disposition', () => {
test('inline', () => {
expect(contentDisposition('inline', 'foo bar')).toBe('inline; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar');
});
test('attachment', () => {
expect(contentDisposition('attachment', 'foo bar')).toBe('attachment; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar');
});
test('non ascii', () => {
expect(contentDisposition('attachment', 'ファイル名')).toBe('attachment; filename=\"_____\"; filename*=UTF-8\'\'%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E5%90%8D');
});
test('inline', () => {
expect(contentDisposition('inline', 'foo bar')).toBe('inline; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar');
});
test('attachment', () => {
expect(contentDisposition('attachment', 'foo bar')).toBe('attachment; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar');
});
test('non ascii', () => {
expect(contentDisposition('attachment', 'ファイル名')).toBe('attachment; filename=\"_____\"; filename*=UTF-8\'\'%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E5%90%8D');
});
});
describe('misc:correct-filename', () => {
test('simple', () => {
expect(correctFilename('filename', 'jpg')).toBe('filename.jpg');
});
test('with same ext', () => {
expect(correctFilename('filename.jpg', 'jpg')).toBe('filename.jpg');
});
test('.ext', () => {
expect(correctFilename('filename.jpg', '.jpg')).toBe('filename.jpg');
});
test('with different ext', () => {
expect(correctFilename('filename.webp', 'jpg')).toBe('filename.webp.jpg');
});
test('non ascii with space', () => {
expect(correctFilename('ファイル 名前', 'jpg')).toBe('ファイル 名前.jpg');
});
test('jpeg', () => {
expect(correctFilename('filename.jpeg', 'jpg')).toBe('filename.jpeg');
});
test('tiff', () => {
expect(correctFilename('filename.tiff', 'tif')).toBe('filename.tiff');
});
test('null ext', () => {
expect(correctFilename('filename', null)).toBe('filename.unknown');
});
test('simple', () => {
expect(correctFilename('filename', 'jpg')).toBe('filename.jpg');
});
test('with same ext', () => {
expect(correctFilename('filename.jpg', 'jpg')).toBe('filename.jpg');
});
test('.ext', () => {
expect(correctFilename('filename.jpg', '.jpg')).toBe('filename.jpg');
});
test('with different ext', () => {
expect(correctFilename('filename.webp', 'jpg')).toBe('filename.webp.jpg');
});
test('non ascii with space', () => {
expect(correctFilename('ファイル 名前', 'jpg')).toBe('ファイル 名前.jpg');
});
test('jpeg', () => {
expect(correctFilename('filename.jpeg', 'jpg')).toBe('filename.jpeg');
});
test('tiff', () => {
expect(correctFilename('filename.tiff', 'tif')).toBe('filename.tiff');
});
test('null ext', () => {
expect(correctFilename('filename', null)).toBe('filename.unknown');
});
});

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as assert from 'node:assert';
import { readFile } from 'node:fs/promises';
import { isAbsolute, basename } from 'node:path';
@@ -90,7 +95,7 @@ const request = async (path: string, params: any, me?: UserToken): Promise<{ sta
};
};
const relativeFetch = async (path: string, init?: RequestInit | undefined) => {
export const relativeFetch = async (path: string, init?: RequestInit | undefined) => {
return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init);
};