Merge remote-tracking branch 'parent/stable-4.3' into upstream-20241005
This commit is contained in:
commit
cc857e57c6
132 changed files with 775 additions and 312 deletions
|
@ -247,7 +247,7 @@ RSpec.describe Auth::SessionsController do
|
|||
context 'when using two-factor authentication' do
|
||||
context 'with OTP enabled as second factor' do
|
||||
let!(:user) do
|
||||
Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', otp_required_for_login: true, otp_secret: User.generate_otp_secret(32))
|
||||
Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', otp_required_for_login: true, otp_secret: User.generate_otp_secret)
|
||||
end
|
||||
|
||||
let!(:recovery_codes) do
|
||||
|
@ -269,7 +269,7 @@ RSpec.describe Auth::SessionsController do
|
|||
|
||||
context 'when using email and password after an unfinished log-in attempt to a 2FA-protected account' do
|
||||
let!(:other_user) do
|
||||
Fabricate(:user, email: 'z@y.com', password: 'abcdefgh', otp_required_for_login: true, otp_secret: User.generate_otp_secret(32))
|
||||
Fabricate(:user, email: 'z@y.com', password: 'abcdefgh', otp_required_for_login: true, otp_secret: User.generate_otp_secret)
|
||||
end
|
||||
|
||||
before do
|
||||
|
@ -381,7 +381,7 @@ RSpec.describe Auth::SessionsController do
|
|||
|
||||
context 'with WebAuthn and OTP enabled as second factor' do
|
||||
let!(:user) do
|
||||
Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', otp_required_for_login: true, otp_secret: User.generate_otp_secret(32))
|
||||
Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', otp_required_for_login: true, otp_secret: User.generate_otp_secret)
|
||||
end
|
||||
|
||||
let!(:webauthn_credential) do
|
||||
|
|
|
@ -18,7 +18,7 @@ RSpec.describe Settings::TwoFactorAuthentication::ConfirmationsController do
|
|||
def qr_code_markup
|
||||
RQRCode::QRCode.new(
|
||||
'otpauth://totp/cb6e6126.ngrok.io:local-part%40domain?secret=thisisasecretforthespecofnewview&issuer=cb6e6126.ngrok.io'
|
||||
).as_svg(padding: 0, module_size: 4)
|
||||
).as_svg(padding: 0, module_size: 4, use_path: true)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -2,5 +2,5 @@
|
|||
|
||||
Fabricator(:account_domain_block) do
|
||||
account { Fabricate.build(:account) }
|
||||
domain 'example.com'
|
||||
domain { sequence { |n| "host-#{n}.example" } }
|
||||
end
|
||||
|
|
|
@ -74,6 +74,24 @@ RSpec.describe ActivityPub::Activity::Create do
|
|||
}
|
||||
end
|
||||
|
||||
let(:invalid_mention_json) do
|
||||
{
|
||||
id: [ActivityPub::TagManager.instance.uri_for(sender), 'post2'].join('/'),
|
||||
type: 'Note',
|
||||
to: [
|
||||
'https://www.w3.org/ns/activitystreams#Public',
|
||||
ActivityPub::TagManager.instance.uri_for(follower),
|
||||
],
|
||||
content: '@bob lorem ipsum',
|
||||
published: 1.hour.ago.utc.iso8601,
|
||||
updated: 1.hour.ago.utc.iso8601,
|
||||
tag: {
|
||||
type: 'Mention',
|
||||
href: 'http://notexisting.dontexistingtld/actor',
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
def activity_for_object(json)
|
||||
{
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
|
@ -128,6 +146,25 @@ RSpec.describe ActivityPub::Activity::Create do
|
|||
# Creates two notifications
|
||||
expect(Notification.count).to eq 2
|
||||
end
|
||||
|
||||
it 'ignores unprocessable mention', :aggregate_failures do
|
||||
stub_request(:get, invalid_mention_json[:tag][:href]).to_raise(HTTP::ConnectionError)
|
||||
# When receiving the post that contains an invalid mention…
|
||||
described_class.new(activity_for_object(invalid_mention_json), sender, delivery: true).perform
|
||||
|
||||
# NOTE: Refering explicitly to the workers is a bit awkward
|
||||
DistributionWorker.drain
|
||||
FeedInsertWorker.drain
|
||||
|
||||
# …it creates a status
|
||||
status = Status.find_by(uri: invalid_mention_json[:id])
|
||||
|
||||
# Check the process did not crash
|
||||
expect(status.nil?).to be false
|
||||
|
||||
# It has queued a mention resolve job
|
||||
expect(MentionResolveWorker).to have_enqueued_sidekiq_job(status.id, invalid_mention_json[:tag][:href], anything)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
|
|
|
@ -3,66 +3,175 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Export do
|
||||
subject { described_class.new(account) }
|
||||
|
||||
let(:account) { Fabricate(:account) }
|
||||
let(:target_accounts) do
|
||||
[{}, { username: 'one', domain: 'local.host' }].map(&method(:Fabricate).curry(2).call(:account))
|
||||
[
|
||||
Fabricate(:account),
|
||||
Fabricate(:account, username: 'one', domain: 'local.host'),
|
||||
]
|
||||
end
|
||||
|
||||
describe 'to_csv' do
|
||||
it 'returns a csv of the blocked accounts' do
|
||||
target_accounts.each { |target_account| account.block!(target_account) }
|
||||
describe '#to_bookmarks_csv' do
|
||||
before { Fabricate.times(2, :bookmark, account: account) }
|
||||
|
||||
export = described_class.new(account).to_blocked_accounts_csv
|
||||
results = export.strip.split
|
||||
let(:export) { CSV.parse(subject.to_bookmarks_csv) }
|
||||
|
||||
expect(results.size).to eq 2
|
||||
expect(results.first).to eq 'one@local.host'
|
||||
it 'returns a csv of bookmarks' do
|
||||
expect(export)
|
||||
.to contain_exactly(
|
||||
include(/statuses/),
|
||||
include(/statuses/)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#to_blocked_accounts_csv' do
|
||||
before { target_accounts.each { |target_account| account.block!(target_account) } }
|
||||
|
||||
let(:export) { CSV.parse(subject.to_blocked_accounts_csv) }
|
||||
|
||||
it 'returns a csv of the blocked accounts' do
|
||||
expect(export)
|
||||
.to contain_exactly(
|
||||
include('one@local.host'),
|
||||
include(be_present)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#to_muted_accounts_csv' do
|
||||
before { target_accounts.each { |target_account| account.mute!(target_account) } }
|
||||
|
||||
let(:export) { CSV.parse(subject.to_muted_accounts_csv) }
|
||||
|
||||
it 'returns a csv of the muted accounts' do
|
||||
target_accounts.each { |target_account| account.mute!(target_account) }
|
||||
|
||||
export = described_class.new(account).to_muted_accounts_csv
|
||||
results = export.strip.split("\n")
|
||||
|
||||
expect(results.size).to eq 3
|
||||
expect(results.first).to eq 'Account address,Hide notifications'
|
||||
expect(results.second).to eq 'one@local.host,true'
|
||||
expect(export)
|
||||
.to contain_exactly(
|
||||
contain_exactly('Account address', 'Hide notifications'),
|
||||
include('one@local.host', 'true'),
|
||||
include(be_present)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#to_following_accounts_csv' do
|
||||
before { target_accounts.each { |target_account| account.follow!(target_account) } }
|
||||
|
||||
let(:export) { CSV.parse(subject.to_following_accounts_csv) }
|
||||
|
||||
it 'returns a csv of the following accounts' do
|
||||
target_accounts.each { |target_account| account.follow!(target_account) }
|
||||
|
||||
export = described_class.new(account).to_following_accounts_csv
|
||||
results = export.strip.split("\n")
|
||||
|
||||
expect(results.size).to eq 3
|
||||
expect(results.first).to eq 'Account address,Show boosts,Notify on new posts,Languages'
|
||||
expect(results.second).to eq 'one@local.host,true,false,'
|
||||
expect(export)
|
||||
.to contain_exactly(
|
||||
contain_exactly('Account address', 'Show boosts', 'Notify on new posts', 'Languages'),
|
||||
include('one@local.host', 'true', 'false', be_blank),
|
||||
include(be_present)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'total_storage' do
|
||||
describe '#to_lists_csv' do
|
||||
before do
|
||||
target_accounts.each do |target_account|
|
||||
account.follow!(target_account)
|
||||
Fabricate(:list, account: account).accounts << target_account
|
||||
end
|
||||
end
|
||||
|
||||
let(:export) { CSV.parse(subject.to_lists_csv) }
|
||||
|
||||
it 'returns a csv of the lists' do
|
||||
expect(export)
|
||||
.to contain_exactly(
|
||||
include('one@local.host'),
|
||||
include(be_present)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#to_blocked_domains_csv' do
|
||||
before { Fabricate.times(2, :account_domain_block, account: account) }
|
||||
|
||||
let(:export) { CSV.parse(subject.to_blocked_domains_csv) }
|
||||
|
||||
it 'returns a csv of the blocked domains' do
|
||||
expect(export)
|
||||
.to contain_exactly(
|
||||
include(/example/),
|
||||
include(/example/)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#total_storage' do
|
||||
it 'returns the total size of the media attachments' do
|
||||
media_attachment = Fabricate(:media_attachment, account: account)
|
||||
expect(described_class.new(account).total_storage).to eq media_attachment.file_file_size || 0
|
||||
expect(subject.total_storage).to eq media_attachment.file_file_size || 0
|
||||
end
|
||||
end
|
||||
|
||||
describe 'total_follows' do
|
||||
it 'returns the total number of the followed accounts' do
|
||||
target_accounts.each { |target_account| account.follow!(target_account) }
|
||||
expect(described_class.new(account.reload).total_follows).to eq 2
|
||||
describe '#total_statuses' do
|
||||
before { Fabricate.times(2, :status, account: account) }
|
||||
|
||||
it 'returns the total number of statuses' do
|
||||
expect(subject.total_statuses).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#total_bookmarks' do
|
||||
before { Fabricate.times(2, :bookmark, account: account) }
|
||||
|
||||
it 'returns the total number of bookmarks' do
|
||||
expect(subject.total_bookmarks).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#total_follows' do
|
||||
before { target_accounts.each { |target_account| account.follow!(target_account) } }
|
||||
|
||||
it 'returns the total number of the followed accounts' do
|
||||
expect(subject.total_follows).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#total_lists' do
|
||||
before { Fabricate.times(2, :list, account: account) }
|
||||
|
||||
it 'returns the total number of lists' do
|
||||
expect(subject.total_lists).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#total_followers' do
|
||||
before { target_accounts.each { |target_account| target_account.follow!(account) } }
|
||||
|
||||
it 'returns the total number of the follower accounts' do
|
||||
expect(subject.total_followers).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#total_blocks' do
|
||||
before { target_accounts.each { |target_account| account.block!(target_account) } }
|
||||
|
||||
it 'returns the total number of the blocked accounts' do
|
||||
target_accounts.each { |target_account| account.block!(target_account) }
|
||||
expect(described_class.new(account.reload).total_blocks).to eq 2
|
||||
expect(subject.total_blocks).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#total_mutes' do
|
||||
before { target_accounts.each { |target_account| account.mute!(target_account) } }
|
||||
|
||||
it 'returns the total number of the muted accounts' do
|
||||
target_accounts.each { |target_account| account.mute!(target_account) }
|
||||
expect(described_class.new(account.reload).total_mutes).to eq 2
|
||||
expect(subject.total_mutes).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#total_domain_blocks' do
|
||||
before { Fabricate.times(2, :account_domain_block, account: account) }
|
||||
|
||||
it 'returns the total number of account domain blocks' do
|
||||
expect(subject.total_domain_blocks).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -30,4 +30,17 @@ RSpec.describe ReportFilter do
|
|||
expect(Report).to have_received(:resolved)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when given remote target_origin and also by_target_domain' do
|
||||
let!(:matching_report) { Fabricate :report, target_account: Fabricate(:account, domain: 'match.example') }
|
||||
let!(:non_matching_report) { Fabricate :report, target_account: Fabricate(:account, domain: 'other.example') }
|
||||
|
||||
it 'preserves the domain value' do
|
||||
filter = described_class.new(by_target_domain: 'match.example', target_origin: 'remote')
|
||||
|
||||
expect(filter.results)
|
||||
.to include(matching_report)
|
||||
.and not_include(non_matching_report)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -161,6 +161,11 @@ RSpec.configure do |config|
|
|||
host! Rails.configuration.x.local_domain
|
||||
end
|
||||
|
||||
config.before :each, type: :system do
|
||||
# Align with capybara config so that rails helpers called from rspec use matching host
|
||||
host! 'localhost:3000'
|
||||
end
|
||||
|
||||
config.after do
|
||||
Rails.cache.clear
|
||||
redis.del(redis.keys)
|
||||
|
|
|
@ -6,7 +6,7 @@ require 'webauthn/fake_client'
|
|||
RSpec.describe 'Security Key Options' do
|
||||
describe 'GET /auth/sessions/security_key_options' do
|
||||
let!(:user) do
|
||||
Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', otp_required_for_login: true, otp_secret: User.generate_otp_secret(32))
|
||||
Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', otp_required_for_login: true, otp_secret: User.generate_otp_secret)
|
||||
end
|
||||
|
||||
context 'with WebAuthn and OTP enabled as second factor' do
|
||||
|
|
|
@ -9,19 +9,39 @@ RSpec.describe 'The /.well-known/host-meta request' do
|
|||
expect(response)
|
||||
.to have_http_status(200)
|
||||
.and have_attributes(
|
||||
media_type: 'application/xrd+xml',
|
||||
body: host_meta_xml_template
|
||||
media_type: 'application/xrd+xml'
|
||||
)
|
||||
|
||||
doc = Nokogiri::XML(response.parsed_body)
|
||||
expect(doc.at_xpath('/xrd:XRD/xrd:Link[@rel="lrdd"]/@template', 'xrd' => 'http://docs.oasis-open.org/ns/xri/xrd-1.0').value)
|
||||
.to eq 'https://cb6e6126.ngrok.io/.well-known/webfinger?resource={uri}'
|
||||
end
|
||||
|
||||
it 'returns http success with valid JSON response with .json extension' do
|
||||
get '/.well-known/host-meta.json'
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(200)
|
||||
.and have_attributes(
|
||||
media_type: 'application/json'
|
||||
)
|
||||
|
||||
expect(response.parsed_body)
|
||||
.to include(
|
||||
links: [
|
||||
'rel' => 'lrdd',
|
||||
'template' => 'https://cb6e6126.ngrok.io/.well-known/webfinger?resource={uri}',
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
it 'returns http success with valid JSON response with Accept header' do
|
||||
get '/.well-known/host-meta', headers: { 'Accept' => 'application/json' }
|
||||
|
||||
def host_meta_xml_template
|
||||
<<~XML
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
|
||||
<Link rel="lrdd" template="https://cb6e6126.ngrok.io/.well-known/webfinger?resource={uri}"/>
|
||||
</XRD>
|
||||
XML
|
||||
expect(response)
|
||||
.to have_http_status(200)
|
||||
.and have_attributes(
|
||||
media_type: 'application/json'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -4,9 +4,14 @@ require 'rails_helper'
|
|||
|
||||
RSpec.describe 'Well Known routes' do
|
||||
describe 'the host-meta route' do
|
||||
it 'routes to correct place with xml format' do
|
||||
it 'routes to correct place' do
|
||||
expect(get('/.well-known/host-meta'))
|
||||
.to route_to('well_known/host_meta#show', format: 'xml')
|
||||
.to route_to('well_known/host_meta#show')
|
||||
end
|
||||
|
||||
it 'routes to correct place with json format' do
|
||||
expect(get('/.well-known/host-meta.json'))
|
||||
.to route_to('well_known/host_meta#show', format: 'json')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -9,7 +9,6 @@ RSpec.describe 'Invites' do
|
|||
|
||||
before do
|
||||
UserRole.everyone.update(permissions: UserRole::FLAGS[:invite_users])
|
||||
host! 'localhost:3000' # TODO: Move into before for all system specs?
|
||||
sign_in user
|
||||
end
|
||||
|
||||
|
|
|
@ -179,7 +179,7 @@ RSpec.describe 'Using OAuth from an external app' do
|
|||
end
|
||||
|
||||
context 'when the user has set up TOTP' do
|
||||
let(:user) { Fabricate(:user, email: email, password: password, otp_required_for_login: true, otp_secret: User.generate_otp_secret(32)) }
|
||||
let(:user) { Fabricate(:user, email: email, password: password, otp_required_for_login: true, otp_secret: User.generate_otp_secret) }
|
||||
|
||||
it 'when accepting the authorization request' do
|
||||
params = { client_id: client_app.uid, response_type: 'code', redirect_uri: client_app.redirect_uri, scope: 'read' }
|
||||
|
|
38
spec/workers/mention_resolve_worker_spec.rb
Normal file
38
spec/workers/mention_resolve_worker_spec.rb
Normal file
|
@ -0,0 +1,38 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe MentionResolveWorker do
|
||||
let(:status_id) { -42 }
|
||||
let(:uri) { 'https://example.com/users/unknown' }
|
||||
|
||||
describe '#perform' do
|
||||
subject { described_class.new.perform(status_id, uri, {}) }
|
||||
|
||||
context 'with a non-existent status' do
|
||||
it 'returns nil' do
|
||||
expect(subject).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a valid user' do
|
||||
let(:status) { Fabricate(:status) }
|
||||
let(:status_id) { status.id }
|
||||
|
||||
let(:service_double) { instance_double(ActivityPub::FetchRemoteAccountService) }
|
||||
|
||||
before do
|
||||
allow(ActivityPub::FetchRemoteAccountService).to receive(:new).and_return(service_double)
|
||||
|
||||
allow(service_double).to receive(:call).with(uri, anything) { Fabricate(:account, domain: 'example.com', uri: uri) }
|
||||
end
|
||||
|
||||
it 'resolves the account and adds a new mention', :aggregate_failures do
|
||||
expect { subject }
|
||||
.to change { status.reload.mentions }.from([]).to(a_collection_including(having_attributes(account: having_attributes(uri: uri), silent: false)))
|
||||
|
||||
expect(service_double).to have_received(:call).once
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -22,27 +22,48 @@ RSpec.describe Web::PushNotificationWorker do
|
|||
let(:payload) { { ciphertext: ciphertext, salt: salt, server_public_key: server_public_key, shared_secret: shared_secret } }
|
||||
|
||||
describe 'perform' do
|
||||
around do |example|
|
||||
original_private = Rails.configuration.x.vapid_private_key
|
||||
original_public = Rails.configuration.x.vapid_public_key
|
||||
Rails.configuration.x.vapid_private_key = vapid_private_key
|
||||
Rails.configuration.x.vapid_public_key = vapid_public_key
|
||||
example.run
|
||||
Rails.configuration.x.vapid_private_key = original_private
|
||||
Rails.configuration.x.vapid_public_key = original_public
|
||||
end
|
||||
|
||||
before do
|
||||
allow(subscription).to receive_messages(contact_email: contact_email, vapid_key: vapid_key)
|
||||
allow(Web::PushSubscription).to receive(:find).with(subscription.id).and_return(subscription)
|
||||
Setting.site_contact_email = contact_email
|
||||
|
||||
allow(Webpush::Encryption).to receive(:encrypt).and_return(payload)
|
||||
allow(JWT).to receive(:encode).and_return('jwt.encoded.payload')
|
||||
|
||||
stub_request(:post, endpoint).to_return(status: 201, body: '')
|
||||
|
||||
subject.perform(subscription.id, notification.id)
|
||||
end
|
||||
|
||||
it 'calls the relevant service with the correct headers' do
|
||||
expect(a_request(:post, endpoint).with(headers: {
|
||||
'Content-Encoding' => 'aesgcm',
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Crypto-Key' => "dh=BAgtUks5d90kFmxGevk9tH7GEmvz9DB0qcEMUsOBgKwMf-TMjsKIIG6LQvGcFAf6jcmAod15VVwmYwGIIxE4VWE;p256ecdsa=#{vapid_public_key.delete('=')}",
|
||||
'Encryption' => 'salt=WJeVM-RY-F9351SVxTFx_g',
|
||||
'Ttl' => '172800',
|
||||
'Urgency' => 'normal',
|
||||
'Authorization' => 'WebPush jwt.encoded.payload',
|
||||
}, body: "+\xB8\xDBT}\u0013\xB6\xDD.\xF9\xB0\xA7\xC8Ҁ\xFD\x99#\xF7\xAC\x83\xA4\xDB,\u001F\xB5\xB9w\x85>\xF7\xADr")).to have_been_made
|
||||
subject.perform(subscription.id, notification.id)
|
||||
|
||||
expect(web_push_endpoint_request)
|
||||
.to have_been_made
|
||||
end
|
||||
|
||||
def web_push_endpoint_request
|
||||
a_request(
|
||||
:post,
|
||||
endpoint
|
||||
).with(
|
||||
headers: {
|
||||
'Content-Encoding' => 'aesgcm',
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Crypto-Key' => "dh=BAgtUks5d90kFmxGevk9tH7GEmvz9DB0qcEMUsOBgKwMf-TMjsKIIG6LQvGcFAf6jcmAod15VVwmYwGIIxE4VWE;p256ecdsa=#{vapid_public_key.delete('=')}",
|
||||
'Encryption' => 'salt=WJeVM-RY-F9351SVxTFx_g',
|
||||
'Ttl' => '172800',
|
||||
'Urgency' => 'normal',
|
||||
'Authorization' => 'WebPush jwt.encoded.payload',
|
||||
},
|
||||
body: "+\xB8\xDBT}\u0013\xB6\xDD.\xF9\xB0\xA7\xC8Ҁ\xFD\x99#\xF7\xAC\x83\xA4\xDB,\u001F\xB5\xB9w\x85>\xF7\xADr"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue