Merge tag '2023.9.0' into merge-upstream
This commit is contained in:
@@ -60,10 +60,12 @@ describe('2要素認証', () => {
|
||||
};
|
||||
|
||||
const keyDoneParam = (param: {
|
||||
token: string,
|
||||
keyName: string,
|
||||
credentialId: Buffer,
|
||||
creationOptions: PublicKeyCredentialCreationOptionsJSON,
|
||||
}): {
|
||||
token: string,
|
||||
password: string,
|
||||
name: string,
|
||||
credential: RegistrationResponseJSON,
|
||||
@@ -94,6 +96,7 @@ describe('2要素認証', () => {
|
||||
|
||||
return {
|
||||
password,
|
||||
token: param.token,
|
||||
name: param.keyName,
|
||||
credential: <RegistrationResponseJSON>{
|
||||
id: param.credentialId.toString('base64url'),
|
||||
@@ -218,6 +221,12 @@ describe('2要素認証', () => {
|
||||
});
|
||||
assert.strictEqual(signinResponse.status, 200);
|
||||
assert.notEqual(signinResponse.body.i, undefined);
|
||||
|
||||
// 後片付け
|
||||
await api('/i/2fa/unregister', {
|
||||
password,
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
}, alice);
|
||||
});
|
||||
|
||||
test('が設定でき、セキュリティキーでログインできる。', async () => {
|
||||
@@ -233,6 +242,7 @@ describe('2要素認証', () => {
|
||||
|
||||
const registerKeyResponse = await api('/i/2fa/register-key', {
|
||||
password,
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
}, alice);
|
||||
assert.strictEqual(registerKeyResponse.status, 200);
|
||||
assert.notEqual(registerKeyResponse.body.rp, undefined);
|
||||
@@ -241,6 +251,7 @@ describe('2要素認証', () => {
|
||||
const keyName = 'example-key';
|
||||
const credentialId = crypto.randomBytes(0x41);
|
||||
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
keyName,
|
||||
credentialId,
|
||||
creationOptions: registerKeyResponse.body,
|
||||
@@ -271,6 +282,12 @@ describe('2要素認証', () => {
|
||||
}));
|
||||
assert.strictEqual(signinResponse2.status, 200);
|
||||
assert.notEqual(signinResponse2.body.i, undefined);
|
||||
|
||||
// 後片付け
|
||||
await api('/i/2fa/unregister', {
|
||||
password,
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
}, alice);
|
||||
});
|
||||
|
||||
test('が設定でき、セキュリティキーでパスワードレスログインできる。', async () => {
|
||||
@@ -285,6 +302,7 @@ describe('2要素認証', () => {
|
||||
assert.strictEqual(doneResponse.status, 200);
|
||||
|
||||
const registerKeyResponse = await api('/i/2fa/register-key', {
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
password,
|
||||
}, alice);
|
||||
assert.strictEqual(registerKeyResponse.status, 200);
|
||||
@@ -292,6 +310,7 @@ describe('2要素認証', () => {
|
||||
const keyName = 'example-key';
|
||||
const credentialId = crypto.randomBytes(0x41);
|
||||
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
keyName,
|
||||
credentialId,
|
||||
creationOptions: registerKeyResponse.body,
|
||||
@@ -326,6 +345,12 @@ describe('2要素認証', () => {
|
||||
});
|
||||
assert.strictEqual(signinResponse2.status, 200);
|
||||
assert.notEqual(signinResponse2.body.i, undefined);
|
||||
|
||||
// 後片付け
|
||||
await api('/i/2fa/unregister', {
|
||||
password,
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
}, alice);
|
||||
});
|
||||
|
||||
test('が設定でき、設定したセキュリティキーの名前を変更できる。', async () => {
|
||||
@@ -340,6 +365,7 @@ describe('2要素認証', () => {
|
||||
assert.strictEqual(doneResponse.status, 200);
|
||||
|
||||
const registerKeyResponse = await api('/i/2fa/register-key', {
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
password,
|
||||
}, alice);
|
||||
assert.strictEqual(registerKeyResponse.status, 200);
|
||||
@@ -347,6 +373,7 @@ describe('2要素認証', () => {
|
||||
const keyName = 'example-key';
|
||||
const credentialId = crypto.randomBytes(0x41);
|
||||
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
keyName,
|
||||
credentialId,
|
||||
creationOptions: registerKeyResponse.body,
|
||||
@@ -367,6 +394,12 @@ describe('2要素認証', () => {
|
||||
assert.strictEqual(securityKeys.length, 1);
|
||||
assert.strictEqual(securityKeys[0].name, renamedKey);
|
||||
assert.notEqual(securityKeys[0].lastUsed, undefined);
|
||||
|
||||
// 後片付け
|
||||
await api('/i/2fa/unregister', {
|
||||
password,
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
}, alice);
|
||||
});
|
||||
|
||||
test('が設定でき、設定したセキュリティキーを削除できる。', async () => {
|
||||
@@ -381,6 +414,7 @@ describe('2要素認証', () => {
|
||||
assert.strictEqual(doneResponse.status, 200);
|
||||
|
||||
const registerKeyResponse = await api('/i/2fa/register-key', {
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
password,
|
||||
}, alice);
|
||||
assert.strictEqual(registerKeyResponse.status, 200);
|
||||
@@ -388,6 +422,7 @@ describe('2要素認証', () => {
|
||||
const keyName = 'example-key';
|
||||
const credentialId = crypto.randomBytes(0x41);
|
||||
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
keyName,
|
||||
credentialId,
|
||||
creationOptions: registerKeyResponse.body,
|
||||
@@ -400,6 +435,7 @@ describe('2要素認証', () => {
|
||||
assert.strictEqual(iResponse.status, 200);
|
||||
for (const key of iResponse.body.securityKeysList) {
|
||||
const removeKeyResponse = await api('/i/2fa/remove-key', {
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
password,
|
||||
credentialId: key.id,
|
||||
}, alice);
|
||||
@@ -418,6 +454,12 @@ describe('2要素認証', () => {
|
||||
});
|
||||
assert.strictEqual(signinResponse.status, 200);
|
||||
assert.notEqual(signinResponse.body.i, undefined);
|
||||
|
||||
// 後片付け
|
||||
await api('/i/2fa/unregister', {
|
||||
password,
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
}, alice);
|
||||
});
|
||||
|
||||
test('が設定でき、設定解除できる。(パスワードのみでログインできる。)', async () => {
|
||||
@@ -438,6 +480,7 @@ describe('2要素認証', () => {
|
||||
assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true);
|
||||
|
||||
const unregisterResponse = await api('/i/2fa/unregister', {
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
password,
|
||||
}, alice);
|
||||
assert.strictEqual(unregisterResponse.status, 204);
|
||||
@@ -447,5 +490,11 @@ describe('2要素認証', () => {
|
||||
});
|
||||
assert.strictEqual(signinResponse.status, 200);
|
||||
assert.notEqual(signinResponse.body.i, undefined);
|
||||
|
||||
// 後片付け
|
||||
await api('/i/2fa/unregister', {
|
||||
password,
|
||||
token: otpToken(registerResponse.body.secret),
|
||||
}, alice);
|
||||
});
|
||||
});
|
||||
|
@@ -7,7 +7,7 @@ process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { signup, api, startServer, successfulApiCall, failedApiCall, uploadFile, waitFire, connectStream } from '../utils.js';
|
||||
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';
|
||||
|
||||
@@ -223,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -721,7 +721,7 @@ describe('クリップ', () => {
|
||||
await addNote({ clipId: aliceClip.id, noteId: aliceNote.id });
|
||||
const res = await show({ clipId: aliceClip.id });
|
||||
assert.strictEqual(res.lastClippedAt, new Date(res.lastClippedAt ?? '').toISOString());
|
||||
assert.deepStrictEqual(await notes({ clipId: aliceClip.id }), [aliceNote]);
|
||||
assert.deepStrictEqual((await notes({ clipId: aliceClip.id })).map(x => x.id), [aliceNote.id]);
|
||||
|
||||
// 他人の非公開ノートも突っ込める
|
||||
await addNote({ clipId: aliceClip.id, noteId: bobHomeNote.id });
|
||||
@@ -861,8 +861,8 @@ describe('クリップ', () => {
|
||||
bobNote, bobHomeNote,
|
||||
];
|
||||
assert.deepStrictEqual(
|
||||
res.sort(compareBy(s => s.id)),
|
||||
expects.sort(compareBy(s => s.id)));
|
||||
res.sort(compareBy(s => s.id)).map(x => x.id),
|
||||
expects.sort(compareBy(s => s.id)).map(x => x.id));
|
||||
});
|
||||
|
||||
test('を始端IDとlimitで取得できる。', async () => {
|
||||
@@ -881,8 +881,8 @@ describe('クリップ', () => {
|
||||
// Promise.allで返ってくる配列はID順で並んでないのでソートして厳密比較
|
||||
const expects = [noteList[3], noteList[4], noteList[5]];
|
||||
assert.deepStrictEqual(
|
||||
res.sort(compareBy(s => s.id)),
|
||||
expects.sort(compareBy(s => s.id)));
|
||||
res.sort(compareBy(s => s.id)).map(x => x.id),
|
||||
expects.sort(compareBy(s => s.id)).map(x => x.id));
|
||||
});
|
||||
|
||||
test('をID範囲指定で取得できる。', async () => {
|
||||
@@ -901,8 +901,8 @@ describe('クリップ', () => {
|
||||
// Promise.allで返ってくる配列はID順で並んでないのでソートして厳密比較
|
||||
const expects = [noteList[2], noteList[3]];
|
||||
assert.deepStrictEqual(
|
||||
res.sort(compareBy(s => s.id)),
|
||||
expects.sort(compareBy(s => s.id)));
|
||||
res.sort(compareBy(s => s.id)).map(x => x.id),
|
||||
expects.sort(compareBy(s => s.id)).map(x => x.id));
|
||||
});
|
||||
|
||||
test.todo('Remoteのノートもクリップできる。どうテストしよう?');
|
||||
@@ -911,7 +911,7 @@ describe('クリップ', () => {
|
||||
const bobClip = await create({ isPublic: true }, { user: bob } );
|
||||
await addNote({ clipId: bobClip.id, noteId: aliceNote.id }, { user: bob });
|
||||
const res = await notes({ clipId: bobClip.id });
|
||||
assert.deepStrictEqual(res, [aliceNote]);
|
||||
assert.deepStrictEqual(res.map(x => x.id), [aliceNote.id]);
|
||||
});
|
||||
|
||||
test('はPublicなクリップなら認証なしでも取得できる。(非公開ノートはhideされて返ってくる)', async () => {
|
||||
@@ -928,8 +928,8 @@ describe('クリップ', () => {
|
||||
hiddenNote(aliceFollowersNote), hiddenNote(aliceSpecifiedNote),
|
||||
];
|
||||
assert.deepStrictEqual(
|
||||
res.sort(compareBy(s => s.id)),
|
||||
expects.sort(compareBy(s => s.id)));
|
||||
res.sort(compareBy(s => s.id)).map(x => x.id),
|
||||
expects.sort(compareBy(s => s.id)).map(x => x.id));
|
||||
});
|
||||
|
||||
test.todo('ブロック、ミュートされたユーザーからの設定&取得etc.');
|
||||
|
@@ -9,7 +9,7 @@ import * as assert from 'assert';
|
||||
// node-fetch only supports it's own Blob yet
|
||||
// https://github.com/node-fetch/node-fetch/pull/1664
|
||||
import { Blob } from 'node-fetch';
|
||||
import { MiUser } from '@/models/index.js';
|
||||
import { MiUser } from '@/models/_.js';
|
||||
import { startServer, signup, post, api, uploadFile, simpleGet, initTestDb } from '../utils.js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
@@ -34,6 +34,8 @@ describe('Webリソース', () => {
|
||||
let aliceGalleryPost: any;
|
||||
let aliceChannel: any;
|
||||
|
||||
let bob: misskey.entities.MeSignup;
|
||||
|
||||
type Request = {
|
||||
path: string,
|
||||
accept?: string,
|
||||
@@ -90,6 +92,8 @@ describe('Webリソース', () => {
|
||||
fileIds: [aliceUploadedFile.body.id],
|
||||
});
|
||||
aliceChannel = await channel(alice, {});
|
||||
|
||||
bob = await signup({ username: 'alice' });
|
||||
}, 1000 * 60 * 2);
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -163,9 +167,15 @@ describe('Webリソース', () => {
|
||||
});
|
||||
|
||||
describe.each([{ path: '/queue' }])('$path', ({ path }) => {
|
||||
test('はログインしないとGETできない。', async () => await notOk({
|
||||
path,
|
||||
status: 401,
|
||||
}));
|
||||
|
||||
test('はadminでなければGETできない。', async () => await notOk({
|
||||
path,
|
||||
status: 500, // FIXME? 403ではない。
|
||||
cookie: cookie(bob),
|
||||
status: 403,
|
||||
}));
|
||||
|
||||
test('はadminならGETできる。', async () => await ok({
|
||||
|
@@ -7,7 +7,7 @@ process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { loadConfig } from '@/config.js';
|
||||
import { MiUser, UsersRepository } from '@/models/index.js';
|
||||
import { MiUser, UsersRepository } from '@/models/_.js';
|
||||
import { jobQueue } from '@/boot/common.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
import { uploadFile, signup, startServer, initTestDb, api, sleep, successfulApiCall } from '../utils.js';
|
||||
|
@@ -6,7 +6,7 @@
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { MiNote } from '@/models/entities/Note.js';
|
||||
import { MiNote } from '@/models/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';
|
||||
|
944
packages/backend/test/e2e/oauth.ts
Normal file
944
packages/backend/test/e2e/oauth.ts
Normal 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);
|
||||
});
|
||||
});
|
@@ -6,7 +6,7 @@
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { MiFollowing } from '@/models/entities/Following.js';
|
||||
import { MiFollowing } from '@/models/Following.js';
|
||||
import { connectStream, signup, api, post, startServer, initTestDb, waitFire } from '../utils.js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
@@ -103,6 +103,7 @@ describe('ユーザー', () => {
|
||||
birthday: user.birthday,
|
||||
lang: user.lang,
|
||||
fields: user.fields,
|
||||
verifiedLinks: user.verifiedLinks,
|
||||
followersCount: user.followersCount,
|
||||
followingCount: user.followingCount,
|
||||
notesCount: user.notesCount,
|
||||
@@ -132,6 +133,7 @@ describe('ユーザー', () => {
|
||||
isBlocked: user.isBlocked ?? false,
|
||||
isMuted: user.isMuted ?? false,
|
||||
isRenoteMuted: user.isRenoteMuted ?? false,
|
||||
notify: user.notify ?? 'none',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -153,7 +155,7 @@ describe('ユーザー', () => {
|
||||
preventAiLearning: user.preventAiLearning,
|
||||
isExplorable: user.isExplorable,
|
||||
isDeleted: user.isDeleted,
|
||||
twoFactorBackupCodes: user.twoFactorBackupCodes,
|
||||
twoFactorBackupCodesStock: user.twoFactorBackupCodesStock,
|
||||
hideOnlineStatus: user.hideOnlineStatus,
|
||||
hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes,
|
||||
hasUnreadMentions: user.hasUnreadMentions,
|
||||
@@ -371,6 +373,7 @@ describe('ユーザー', () => {
|
||||
assert.strictEqual(response.birthday, null);
|
||||
assert.strictEqual(response.lang, null);
|
||||
assert.deepStrictEqual(response.fields, []);
|
||||
assert.deepStrictEqual(response.verifiedLinks, []);
|
||||
assert.strictEqual(response.followersCount, 0);
|
||||
assert.strictEqual(response.followingCount, 0);
|
||||
assert.strictEqual(response.notesCount, 0);
|
||||
@@ -401,7 +404,7 @@ describe('ユーザー', () => {
|
||||
assert.strictEqual(response.preventAiLearning, true);
|
||||
assert.strictEqual(response.isExplorable, true);
|
||||
assert.strictEqual(response.isDeleted, false);
|
||||
assert.strictEqual(response.twoFactorBackupCodes, 'none');
|
||||
assert.strictEqual(response.twoFactorBackupCodesStock, 'none');
|
||||
assert.strictEqual(response.hideOnlineStatus, false);
|
||||
assert.strictEqual(response.hasUnreadSpecifiedNotes, false);
|
||||
assert.strictEqual(response.hasUnreadMentions, false);
|
||||
@@ -494,7 +497,7 @@ describe('ユーザー', () => {
|
||||
{ parameters: (): object => ({ mutedWords: [] }) },
|
||||
{ parameters: (): object => ({ mutedInstances: ['xxxx.xxxxx'] }) },
|
||||
{ parameters: (): object => ({ mutedInstances: [] }) },
|
||||
{ parameters: (): object => ({ mutingNotificationTypes: ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] }) },
|
||||
{ parameters: (): object => ({ mutingNotificationTypes: ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] }) },
|
||||
{ parameters: (): object => ({ mutingNotificationTypes: [] }) },
|
||||
{ parameters: (): object => ({ emailNotificationTypes: ['mention', 'reply', 'quote', 'follow', 'receiveFollowRequest'] }) },
|
||||
{ parameters: (): object => ({ emailNotificationTypes: [] }) },
|
||||
|
@@ -15,7 +15,7 @@ import type { LoggerService } from '@/core/LoggerService.js';
|
||||
import type { MetaService } from '@/core/MetaService.js';
|
||||
import type { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { NoteReactionsRepository, NotesRepository, PollsRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { NoteReactionsRepository, NotesRepository, PollsRepository, UsersRepository, FollowRequestsRepository } from '@/models/_.js';
|
||||
|
||||
type MockResponse = {
|
||||
type: string;
|
||||
@@ -33,6 +33,7 @@ export class MockResolver extends Resolver {
|
||||
{} as NotesRepository,
|
||||
{} as PollsRepository,
|
||||
{} as NoteReactionsRepository,
|
||||
{} as FollowRequestsRepository,
|
||||
{} as UtilityService,
|
||||
{} as InstanceActorService,
|
||||
{} as MetaService,
|
||||
|
@@ -10,8 +10,8 @@
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"target": "ES2022",
|
||||
"module": "es2020",
|
||||
"moduleResolution": "node16",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"removeComments": false,
|
||||
"noLib": false,
|
||||
|
@@ -5,19 +5,19 @@
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import { jest } from '@jest/globals';
|
||||
import { ModuleMocker } from 'jest-mock';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { jest } from '@jest/globals';
|
||||
import type { MiAnnouncement, AnnouncementsRepository, AnnouncementReadsRepository, UsersRepository, MiUser } from '@/models/index.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
|
||||
import { AnnouncementService } from '@/core/AnnouncementService.js';
|
||||
import type { MiAnnouncement, AnnouncementsRepository, AnnouncementReadsRepository, UsersRepository, MiUser } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { genAidx } from '@/misc/id/aidx.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { genAid } from '@/misc/id/aid.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import type { MockFunctionMetadata } from 'jest-mock';
|
||||
|
||||
@@ -30,11 +30,12 @@ describe('AnnouncementService', () => {
|
||||
let announcementsRepository: AnnouncementsRepository;
|
||||
let announcementReadsRepository: AnnouncementReadsRepository;
|
||||
let globalEventService: jest.Mocked<GlobalEventService>;
|
||||
let moderationLogService: jest.Mocked<ModerationLogService>;
|
||||
|
||||
function createUser(data: Partial<MiUser> = {}) {
|
||||
const un = secureRndstr(16);
|
||||
return usersRepository.insert({
|
||||
id: genAid(new Date()),
|
||||
id: genAidx(new Date()),
|
||||
createdAt: new Date(),
|
||||
username: un,
|
||||
usernameLower: un,
|
||||
@@ -45,7 +46,7 @@ describe('AnnouncementService', () => {
|
||||
|
||||
function createAnnouncement(data: Partial<MiAnnouncement> = {}) {
|
||||
return announcementsRepository.insert({
|
||||
id: genAid(new Date()),
|
||||
id: genAidx(new Date()),
|
||||
createdAt: new Date(),
|
||||
updatedAt: null,
|
||||
title: 'Title',
|
||||
@@ -61,7 +62,6 @@ describe('AnnouncementService', () => {
|
||||
GlobalModule,
|
||||
],
|
||||
providers: [
|
||||
AnnouncementEntityService,
|
||||
AnnouncementService,
|
||||
CacheService,
|
||||
IdService,
|
||||
@@ -73,8 +73,11 @@ describe('AnnouncementService', () => {
|
||||
publishMainStream: jest.fn(),
|
||||
publishBroadcastStream: jest.fn(),
|
||||
};
|
||||
}
|
||||
if (typeof token === 'function') {
|
||||
} else if (token === ModerationLogService) {
|
||||
return {
|
||||
log: jest.fn(),
|
||||
};
|
||||
} else if (typeof token === 'function') {
|
||||
const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata<any, any>;
|
||||
const Mock = moduleMocker.generateFromMetadata(mockMetadata);
|
||||
return new Mock();
|
||||
@@ -89,6 +92,7 @@ describe('AnnouncementService', () => {
|
||||
announcementsRepository = app.get<AnnouncementsRepository>(DI.announcementsRepository);
|
||||
announcementReadsRepository = app.get<AnnouncementReadsRepository>(DI.announcementReadsRepository);
|
||||
globalEventService = app.get<GlobalEventService>(GlobalEventService) as jest.Mocked<GlobalEventService>;
|
||||
moderationLogService = app.get<ModerationLogService>(ModerationLogService) as jest.Mocked<ModerationLogService>;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -157,10 +161,11 @@ describe('AnnouncementService', () => {
|
||||
|
||||
describe('create', () => {
|
||||
test('通常', async () => {
|
||||
const me = await createUser();
|
||||
const result = await announcementService.create({
|
||||
title: 'Title',
|
||||
text: 'Text',
|
||||
});
|
||||
}, me);
|
||||
|
||||
expect(result.raw.title).toBe('Title');
|
||||
expect(result.packed.title).toBe('Title');
|
||||
@@ -168,15 +173,17 @@ describe('AnnouncementService', () => {
|
||||
expect(globalEventService.publishBroadcastStream).toHaveBeenCalled();
|
||||
expect(globalEventService.publishBroadcastStream.mock.lastCall![0]).toBe('announcementCreated');
|
||||
expect((globalEventService.publishBroadcastStream.mock.lastCall![1] as any).announcement).toBe(result.packed);
|
||||
expect(moderationLogService.log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('ユーザー指定', async () => {
|
||||
const me = await createUser();
|
||||
const user = await createUser();
|
||||
const result = await announcementService.create({
|
||||
title: 'Title',
|
||||
text: 'Text',
|
||||
userId: user.id,
|
||||
});
|
||||
}, me);
|
||||
|
||||
expect(result.raw.title).toBe('Title');
|
||||
expect(result.packed.title).toBe('Title');
|
||||
@@ -186,6 +193,7 @@ describe('AnnouncementService', () => {
|
||||
expect(globalEventService.publishMainStream.mock.lastCall![0]).toBe(user.id);
|
||||
expect(globalEventService.publishMainStream.mock.lastCall![1]).toBe('announcementCreated');
|
||||
expect((globalEventService.publishMainStream.mock.lastCall![2] as any).announcement).toBe(result.packed);
|
||||
expect(moderationLogService.log).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
@@ -6,7 +6,6 @@
|
||||
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';
|
||||
@@ -18,7 +17,6 @@ import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import type { MockFunctionMetadata } from 'jest-mock';
|
||||
|
||||
function mockRedis() {
|
||||
const hash = {};
|
||||
@@ -35,9 +33,9 @@ describe('FetchInstanceMetadataService', () => {
|
||||
let fetchInstanceMetadataService: jest.Mocked<FetchInstanceMetadataService>;
|
||||
let federatedInstanceService: jest.Mocked<FederatedInstanceService>;
|
||||
let httpRequestService: jest.Mocked<HttpRequestService>;
|
||||
let redisClient: jest.Mocked<Redis.Redis>;
|
||||
let redisClient: jest.Mocked<Redis>;
|
||||
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
app = await Test
|
||||
.createTestingModule({
|
||||
imports: [
|
||||
@@ -64,11 +62,11 @@ describe('FetchInstanceMetadataService', () => {
|
||||
|
||||
fetchInstanceMetadataService = app.get<FetchInstanceMetadataService>(FetchInstanceMetadataService);
|
||||
federatedInstanceService = app.get<FederatedInstanceService>(FederatedInstanceService) as jest.Mocked<FederatedInstanceService>;
|
||||
redisClient = app.get<Redis.Redis>(DI.redis) as jest.Mocked<Redis.Redis>;
|
||||
redisClient = app.get<Redis>(DI.redis) as jest.Mocked<Redis>;
|
||||
httpRequestService = app.get<HttpRequestService>(HttpRequestService) as jest.Mocked<HttpRequestService>;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -85,6 +83,7 @@ describe('FetchInstanceMetadataService', () => {
|
||||
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(httpRequestService.getJson).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Lock and don\'t update', async () => {
|
||||
redisClient.set = mockRedis();
|
||||
const now = Date.now();
|
||||
@@ -98,6 +97,7 @@ describe('FetchInstanceMetadataService', () => {
|
||||
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(httpRequestService.getJson).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test('Do nothing when lock not acquired', async () => {
|
||||
redisClient.set = mockRedis();
|
||||
federatedInstanceService.fetch.mockReturnValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } });
|
||||
|
@@ -9,7 +9,7 @@ import { jest } from '@jest/globals';
|
||||
import { ModuleMocker } from 'jest-mock';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import type { MetasRepository } from '@/models/index.js';
|
||||
import type { MetasRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
|
@@ -15,7 +15,7 @@ import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { RelaysRepository } from '@/models/index.js';
|
||||
import type { RelaysRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import type { MockFunctionMetadata } from 'jest-mock';
|
||||
|
@@ -11,10 +11,10 @@ import { Test } from '@nestjs/testing';
|
||||
import * as lolex from '@sinonjs/fake-timers';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import type { MiRole, RolesRepository, RoleAssignmentsRepository, UsersRepository, MiUser } from '@/models/index.js';
|
||||
import type { MiRole, RolesRepository, RoleAssignmentsRepository, UsersRepository, MiUser } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { genAid } from '@/misc/id/aid.js';
|
||||
import { genAidx } from '@/misc/id/aidx.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
@@ -37,7 +37,7 @@ describe('RoleService', () => {
|
||||
function createUser(data: Partial<MiUser> = {}) {
|
||||
const un = secureRndstr(16);
|
||||
return usersRepository.insert({
|
||||
id: genAid(new Date()),
|
||||
id: genAidx(new Date()),
|
||||
createdAt: new Date(),
|
||||
username: un,
|
||||
usernameLower: un,
|
||||
@@ -48,7 +48,7 @@ describe('RoleService', () => {
|
||||
|
||||
function createRole(data: Partial<MiRole> = {}) {
|
||||
return rolesRepository.insert({
|
||||
id: genAid(new Date()),
|
||||
id: genAidx(new Date()),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
lastUsedAt: new Date(),
|
||||
@@ -204,7 +204,7 @@ describe('RoleService', () => {
|
||||
createdAt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 365)),
|
||||
followersCount: 10,
|
||||
});
|
||||
const role = await createRole({
|
||||
await createRole({
|
||||
name: 'a',
|
||||
policies: {
|
||||
canManageCustomEmojis: {
|
||||
|
@@ -10,8 +10,8 @@ import { UploadPartCommand, CompleteMultipartUploadCommand, CreateMultipartUploa
|
||||
import { mockClient } from 'aws-sdk-client-mock';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
import { S3Service } from '@/core/S3Service';
|
||||
import { MiMeta } from '@/models';
|
||||
import { S3Service } from '@/core/S3Service.js';
|
||||
import { MiMeta } from '@/models/_.js';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
|
||||
describe('S3Service', () => {
|
||||
|
@@ -18,11 +18,11 @@ import { CoreModule } from '@/core/CoreModule.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type { IActor, IApDocument, ICollection, IPost } from '@/core/activitypub/type.js';
|
||||
import { MiMeta, MiNote } from '@/models/index.js';
|
||||
import { MiMeta, MiNote } from '@/models/_.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
import { DownloadService } from '@/core/DownloadService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import type { MiRemoteUser } from '@/models/entities/User.js';
|
||||
import type { MiRemoteUser } from '@/models/User.js';
|
||||
import { MockResolver } from '../misc/mock-resolver.js';
|
||||
|
||||
const host = 'https://host1.test';
|
||||
@@ -259,6 +259,21 @@ describe('ActivityPub', () => {
|
||||
assert.strictEqual(note.text, 'test test foo');
|
||||
assert.strictEqual(note.uri, actor2Note.id);
|
||||
});
|
||||
|
||||
test('Fetch a note that is a featured note of the attributed actor', async () => {
|
||||
const actor = createRandomActor();
|
||||
actor.featured = `${actor.id}/collections/featured`;
|
||||
|
||||
const featured = createRandomFeaturedCollection(actor, 5);
|
||||
const firstNote = (featured.items as NonTransientIPost[])[0];
|
||||
|
||||
resolver.register(actor.id, actor);
|
||||
resolver.register(actor.featured, featured);
|
||||
resolver.register(firstNote.id, firstNote);
|
||||
|
||||
const note = await noteService.createNote(firstNote.id as string, resolver);
|
||||
assert.strictEqual(note?.uri, firstNote.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Images', () => {
|
||||
|
@@ -18,7 +18,7 @@ import { entity as TestGroupedChartEntity } from '@/core/chart/charts/entities/t
|
||||
import { entity as TestUniqueChartEntity } from '@/core/chart/charts/entities/test-unique.js';
|
||||
import { entity as TestIntersectionChartEntity } from '@/core/chart/charts/entities/test-intersection.js';
|
||||
import { loadConfig } from '@/config.js';
|
||||
import type { AppLockService } from '@/core/AppLockService';
|
||||
import type { AppLockService } from '@/core/AppLockService.js';
|
||||
import Logger from '@/logger.js';
|
||||
|
||||
describe('Chart', () => {
|
||||
|
48
packages/backend/test/unit/misc/correct-filename.ts
Normal file
48
packages/backend/test/unit/misc/correct-filename.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { correctFilename } from '@/misc/correct-filename.js';
|
||||
|
||||
describe(correctFilename, () => {
|
||||
it('no ext to null', () => {
|
||||
expect(correctFilename('test', null)).toBe('test.unknown');
|
||||
});
|
||||
it('no ext to jpg', () => {
|
||||
expect(correctFilename('test', 'jpg')).toBe('test.jpg');
|
||||
});
|
||||
it('jpg to webp', () => {
|
||||
expect(correctFilename('test.jpg', 'webp')).toBe('test.jpg.webp');
|
||||
});
|
||||
it('jpg to .webp', () => {
|
||||
expect(correctFilename('test.jpg', '.webp')).toBe('test.jpg.webp');
|
||||
});
|
||||
it('jpeg to jpg', () => {
|
||||
expect(correctFilename('test.jpeg', 'jpg')).toBe('test.jpeg');
|
||||
});
|
||||
it('JPEG to jpg', () => {
|
||||
expect(correctFilename('test.JPEG', 'jpg')).toBe('test.JPEG');
|
||||
});
|
||||
it('jpg to jpg', () => {
|
||||
expect(correctFilename('test.jpg', 'jpg')).toBe('test.jpg');
|
||||
});
|
||||
it('JPG to jpg', () => {
|
||||
expect(correctFilename('test.JPG', 'jpg')).toBe('test.JPG');
|
||||
});
|
||||
it('tiff to tif', () => {
|
||||
expect(correctFilename('test.tiff', 'tif')).toBe('test.tiff');
|
||||
});
|
||||
it('skip gz', () => {
|
||||
expect(correctFilename('test.unitypackage', 'gz')).toBe('test.unitypackage');
|
||||
});
|
||||
it('skip text file', () => {
|
||||
expect(correctFilename('test.txt', null)).toBe('test.txt');
|
||||
});
|
||||
it('unknown', () => {
|
||||
expect(correctFilename('test.hoge', null)).toBe('test.hoge');
|
||||
});
|
||||
test('non ascii with space', () => {
|
||||
expect(correctFilename('ファイル 名前', 'jpg')).toBe('ファイル 名前.jpg');
|
||||
});
|
||||
});
|
@@ -6,6 +6,7 @@
|
||||
import { ulid } from 'ulid';
|
||||
import { describe, test, expect } from '@jest/globals';
|
||||
import { aidRegExp, genAid, parseAid } from '@/misc/id/aid.js';
|
||||
import { aidxRegExp, genAidx, parseAidx } from '@/misc/id/aidx.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';
|
||||
@@ -19,6 +20,13 @@ describe('misc:id', () => {
|
||||
expect(parseAid(gotAid).date.getTime()).toBe(date.getTime());
|
||||
});
|
||||
|
||||
test('aidx', () => {
|
||||
const date = new Date();
|
||||
const gotAidx = genAidx(date);
|
||||
expect(gotAidx).toMatch(aidxRegExp);
|
||||
expect(parseAidx(gotAidx).date.getTime()).toBe(date.getTime());
|
||||
});
|
||||
|
||||
test('meid', () => {
|
||||
const date = new Date();
|
||||
const gotMeid = genMeid(date);
|
||||
|
@@ -5,7 +5,6 @@
|
||||
|
||||
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', () => {
|
||||
@@ -18,30 +17,3 @@ describe('misc:content-disposition', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
@@ -8,7 +8,7 @@ import { readFile } from 'node:fs/promises';
|
||||
import { isAbsolute, basename } from 'node:path';
|
||||
import { inspect } from 'node:util';
|
||||
import WebSocket, { ClientOptions } from 'ws';
|
||||
import fetch, { Blob, File, RequestInit } from 'node-fetch';
|
||||
import fetch, { File, RequestInit } from 'node-fetch';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
||||
@@ -95,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);
|
||||
};
|
||||
|
||||
|
Reference in New Issue
Block a user