mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
1061 lines
49 KiB
TypeScript
1061 lines
49 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {AdminRepository} from '@app/api/admin/AdminRepository';
|
|
import {setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
|
import {
|
|
createAttachmentID,
|
|
createChannelID,
|
|
createGuildID,
|
|
createMessageID,
|
|
createStickerID,
|
|
createWebhookID,
|
|
} from '@app/api/BrandedTypes';
|
|
import {Config} from '@app/api/Config';
|
|
import {ChannelRepository} from '@app/api/channel/ChannelRepository';
|
|
import {enqueueCrosspostSync} from '@app/api/channel/services/message/CrosspostPropagation';
|
|
import {MessagePersistenceService} from '@app/api/channel/services/message/MessagePersistenceService';
|
|
import {MessageSearchService} from '@app/api/channel/services/message/MessageSearchService';
|
|
import {MessageWriteLock} from '@app/api/channel/services/message/MessageWriteLock';
|
|
import {
|
|
addGuildMember,
|
|
createAnnouncementTargetGuild,
|
|
createGuildChannel,
|
|
disableCrosspostWorker,
|
|
findWebhookRow,
|
|
follow,
|
|
publish,
|
|
} from '@app/api/channel/tests/AnnouncementTestUtils';
|
|
import {loadFixture} from '@app/api/channel/tests/AttachmentTestUtils';
|
|
import {createPermissionOverwrite, updateGuild} from '@app/api/channel/tests/ChannelTestUtils';
|
|
import {addGuildFeature, createStallGate, patchGuildRow} from '@app/api/channel/tests/CrosspostTestUtils';
|
|
import {
|
|
copiesOf,
|
|
deleteMessageRequest,
|
|
editRequest,
|
|
type FanoutWorld,
|
|
followInto,
|
|
listCopies,
|
|
mappingRow,
|
|
patchChannel,
|
|
patchChannelRow,
|
|
postAndPublish,
|
|
publishAndDrain,
|
|
readRow,
|
|
sendMessage,
|
|
sendWithImage,
|
|
setupFanoutWorld,
|
|
workerHelpers,
|
|
writeRow,
|
|
} from '@app/api/channel/tests/CrosspostWorkerTestUtils';
|
|
import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
|
|
import {Logger} from '@app/api/Logger';
|
|
import {getMessages} from '@app/api/message/tests/MessageTestUtils';
|
|
import {startContentBlocklistCaches, stopContentBlocklistCaches} from '@app/api/middleware/ContentBlocklistCaches';
|
|
import {fileShaCache} from '@app/api/middleware/FileShaCache';
|
|
import {getKVClient} from '@app/api/middleware/ServiceRegistry';
|
|
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
|
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
|
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
|
import crosspostMessageChunk from '@app/api/worker/tasks/CrosspostMessageChunk';
|
|
import syncCrosspostCopies from '@app/api/worker/tasks/SyncCrosspostCopies';
|
|
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
|
import {CROSSPOST_SOURCE_DELETED_CONTENT} from '@fluxer/constants/src/AnnouncementConstants';
|
|
import {
|
|
ChannelTypes,
|
|
MessageAttachmentFlags,
|
|
MessageFlags,
|
|
MessageTypes,
|
|
Permissions,
|
|
} from '@fluxer/constants/src/ChannelConstants';
|
|
import {
|
|
ContentWarningLevel,
|
|
GuildExplicitContentFilterTypes,
|
|
GuildFeatures,
|
|
GuildOperations,
|
|
} from '@fluxer/constants/src/GuildConstants';
|
|
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
|
import sharp from 'sharp';
|
|
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
|
|
|
|
function createBarrier(parties: number): () => Promise<void> {
|
|
let arrived = 0;
|
|
let open: () => void = () => {};
|
|
const opened = new Promise<void>((resolve) => {
|
|
open = resolve;
|
|
});
|
|
return async () => {
|
|
arrived++;
|
|
if (arrived >= parties) open();
|
|
await opened;
|
|
};
|
|
}
|
|
|
|
const fanoutSizes = vi.hoisted(() => ({chunk: null as number | null, page: null as number | null}));
|
|
|
|
vi.mock('@fluxer/constants/src/AnnouncementConstants', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@fluxer/constants/src/AnnouncementConstants')>();
|
|
return {
|
|
...actual,
|
|
get CROSSPOST_FANOUT_CHUNK_SIZE() {
|
|
return fanoutSizes.chunk ?? actual.CROSSPOST_FANOUT_CHUNK_SIZE;
|
|
},
|
|
get CROSSPOST_FANOUT_PAGE_SIZE() {
|
|
return fanoutSizes.page ?? actual.CROSSPOST_FANOUT_PAGE_SIZE;
|
|
},
|
|
};
|
|
});
|
|
|
|
describe('Crosspost fan-out', () => {
|
|
let harness: ApiTestHarness;
|
|
let world: FanoutWorld;
|
|
|
|
beforeAll(async () => {
|
|
harness = await createApiTestHarness();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await harness.reset();
|
|
harness.storageService.reset();
|
|
world = await setupFanoutWorld(harness);
|
|
});
|
|
|
|
afterEach(() => {
|
|
fanoutSizes.chunk = null;
|
|
fanoutSizes.page = null;
|
|
disableCrosspostWorker();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await harness?.shutdown();
|
|
});
|
|
|
|
async function createTextChannel(name: string): Promise<ChannelResponse> {
|
|
return createGuildChannel(harness, world.b.owner.token, world.b.guild.id, {name, type: ChannelTypes.GUILD_TEXT});
|
|
}
|
|
|
|
async function setSourceIcon(icon: string | null): Promise<string | null> {
|
|
const updated = await updateGuild(harness, world.a.owner.token, world.a.guild.id, {icon});
|
|
return updated.icon ?? null;
|
|
}
|
|
|
|
async function solidPngDataUrl(background: string): Promise<string> {
|
|
const png = await sharp({create: {width: 32, height: 32, channels: 3, background}})
|
|
.png()
|
|
.toBuffer();
|
|
return `data:image/png;base64,${png.toString('base64')}`;
|
|
}
|
|
|
|
function webhookAvatarCopies(webhookId: string): Array<{sourceKey: string; destinationKey: string}> {
|
|
return harness.storageService
|
|
.getCopiedObjects()
|
|
.filter((copy) => copy.destinationKey.startsWith(`avatars/${webhookId}/`));
|
|
}
|
|
|
|
function attachmentObjectCopies(): Array<string> {
|
|
return harness.storageService
|
|
.getCopiedObjects()
|
|
.filter((entry) => entry.sourceBucket === Config.s3.buckets.cdn && entry.sourceKey.startsWith('attachments/'))
|
|
.map((entry) => entry.destinationKey);
|
|
}
|
|
|
|
function deletedKeys(): Array<string> {
|
|
return harness.storageService.getDeletedObjects().map((entry) => entry.key);
|
|
}
|
|
|
|
function failNextCopyCreate(webhookId?: string): void {
|
|
const original = MessagePersistenceService.prototype.createMessage;
|
|
let failed = false;
|
|
vi.spyOn(MessagePersistenceService.prototype, 'createMessage').mockImplementation(async function (
|
|
this: MessagePersistenceService,
|
|
params,
|
|
) {
|
|
const matches = webhookId ? params.webhookId?.toString() === webhookId : params.webhookId != null;
|
|
if (!failed && matches) {
|
|
failed = true;
|
|
throw new Error('database hiccup');
|
|
}
|
|
return original.call(this, params);
|
|
});
|
|
}
|
|
|
|
function stallNextCopyCreate(): ReturnType<typeof createStallGate> {
|
|
const gate = createStallGate();
|
|
const original = MessagePersistenceService.prototype.createMessage;
|
|
let stalled = false;
|
|
vi.spyOn(MessagePersistenceService.prototype, 'createMessage').mockImplementation(async function (
|
|
this: MessagePersistenceService,
|
|
params,
|
|
) {
|
|
if (!stalled && params.webhookId != null) {
|
|
stalled = true;
|
|
await gate.hit();
|
|
}
|
|
return original.call(this, params);
|
|
});
|
|
return gate;
|
|
}
|
|
|
|
async function expectNoCopies(channelId: string): Promise<void> {
|
|
expect(await listCopies(harness, world.b.owner.token, channelId)).toHaveLength(0);
|
|
expect(world.worker.deadLetters).toHaveLength(0);
|
|
}
|
|
|
|
test('one follower receives a copy with the published wire shape', async () => {
|
|
const webhookId = await followInto(harness, world, world.b.t1.id);
|
|
const source = await postAndPublish(harness, world, {content: 'Version 2 is out'});
|
|
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, source.id);
|
|
expect(copy).toBeDefined();
|
|
expect(copy!.type).toBe(MessageTypes.DEFAULT);
|
|
expect(copy!.webhook_id).toBe(webhookId);
|
|
expect(copy!.author.id).toBe(webhookId);
|
|
expect(copy!.author.username).toBe(`${world.a.guild.name} #${world.a.ann.name}`);
|
|
expect(copy!.author.bot).toBe(true);
|
|
expect(copy!.flags).toBe(MessageFlags.IS_CROSSPOST);
|
|
expect(copy!.content).toBe('Version 2 is out');
|
|
expect(copy!.message_reference).toEqual({
|
|
type: 0,
|
|
guild_id: world.a.guild.id,
|
|
channel_id: world.a.ann.id,
|
|
message_id: source.id,
|
|
});
|
|
expect('referenced_message' in copy!).toBe(false);
|
|
const row = await mappingRow(source.id, webhookId);
|
|
expect(row?.state).toBe('delivered');
|
|
expect(row?.target_message_id.toString()).toBe(copy!.id);
|
|
expect(row?.target_guild_id.toString()).toBe(world.b.guild.id);
|
|
});
|
|
|
|
test('copies store no mentions and reading them does not repair any', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
const content = `@everyone <@&${world.a.guild.id}> <@${world.a.member.userId}> hello`;
|
|
const source = await postAndPublish(harness, world, {content});
|
|
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, source.id);
|
|
expect(copy!.content).toBe(content);
|
|
expect(copy!.mention_everyone).toBe(false);
|
|
expect(copy!.mentions).toEqual([]);
|
|
expect(copy!.mention_roles).toEqual([]);
|
|
const before = await readRow(world.b.t1.id, copy!.id);
|
|
for (let index = 0; index < 3; index++) {
|
|
await createBuilder(harness, world.b.owner.token)
|
|
.get(`/channels/${world.b.t1.id}/messages/${copy!.id}`)
|
|
.execute();
|
|
await getMessages(harness, world.b.owner.token, world.b.t1.id);
|
|
}
|
|
const after = await readRow(world.b.t1.id, copy!.id);
|
|
expect(after?.version).toBe(before?.version);
|
|
expect(after?.mentionEveryone).toBe(false);
|
|
expect(after?.mentionedUserIds.size).toBe(0);
|
|
expect(after?.mentionedRoleIds.size).toBe(0);
|
|
});
|
|
|
|
test('the copy keeps the sendable flags', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
const message = await sendMessage(harness, world.a.owner.token, world.a.ann.id, {content: 'quiet'});
|
|
const sendable = MessageFlags.SUPPRESS_EMBEDS | MessageFlags.SUPPRESS_NOTIFICATIONS | MessageFlags.VOICE_MESSAGE;
|
|
await writeRow((await readRow(world.a.ann.id, message.id))!, {flags: sendable});
|
|
await publishAndDrain(harness, world, message.id);
|
|
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id);
|
|
expect(copy!.flags).toBe(MessageFlags.IS_CROSSPOST | sendable);
|
|
});
|
|
|
|
test('copies have the source attachments and resolve to the source channel', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
const message = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'files'}, [
|
|
'first.png',
|
|
'second.gif',
|
|
]);
|
|
const row = (await readRow(world.a.ann.id, message.id))!;
|
|
await writeRow(row, {
|
|
attachments: row.attachments.map((attachment, index) => ({
|
|
...attachment.toMessageAttachment(),
|
|
flags: index === 0 ? MessageAttachmentFlags.IS_SPOILER : 0,
|
|
})),
|
|
});
|
|
await publishAndDrain(harness, world, message.id);
|
|
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id);
|
|
const sourceAttachments = (await readRow(world.a.ann.id, message.id))!.attachments;
|
|
const copyRow = (await readRow(world.b.t1.id, copy!.id))!;
|
|
expect(copyRow.attachments.map((attachment) => attachment.toMessageAttachment())).toEqual(
|
|
sourceAttachments.map((attachment) => attachment.toMessageAttachment()),
|
|
);
|
|
expect(copy!.attachments).toHaveLength(2);
|
|
copy!.attachments!.forEach((attachment, index) => {
|
|
const original = sourceAttachments[index]!;
|
|
expect(attachment.id).toBe(original.id.toString());
|
|
expect(attachment.url).toContain(`/attachments/${world.a.ann.id}/${original.id}/${original.filename}`);
|
|
expect(attachment.url).not.toContain(`/attachments/${world.b.t1.id}/`);
|
|
expect(attachment.proxy_url).toBe(attachment.url);
|
|
});
|
|
expect(attachmentObjectCopies()).toEqual([]);
|
|
});
|
|
|
|
test('rich embeds are copied with their source attachment urls', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
const message = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {
|
|
content: 'card',
|
|
embeds: [{title: 'Release card', description: 'All the details', image: {url: 'attachment://yeah.png'}}],
|
|
});
|
|
const sourceRow = (await readRow(world.a.ann.id, message.id))!;
|
|
expect(sourceRow.attachments).toHaveLength(0);
|
|
const sourceImageUrl = sourceRow.embeds[0]!.image!.url!;
|
|
expect(sourceImageUrl.startsWith(`${Config.endpoints.media}/attachments/${world.a.ann.id}/`)).toBe(true);
|
|
const sourceKey = sourceImageUrl.slice(`${Config.endpoints.media}/`.length);
|
|
await publishAndDrain(harness, world, message.id);
|
|
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id);
|
|
const copyRow = (await readRow(world.b.t1.id, copy!.id))!;
|
|
expect(copyRow.embeds[0]!.title).toBe('Release card');
|
|
expect(copyRow.embeds[0]!.description).toBe('All the details');
|
|
expect(copyRow.embeds[0]!.image!.url).toBe(sourceImageUrl);
|
|
expect(copy!.embeds?.[0]?.image?.url).toContain(`/${sourceKey}`);
|
|
expect(harness.storageService.hasObject(Config.s3.buckets.cdn, sourceKey)).toBe(true);
|
|
expect(attachmentObjectCopies()).toEqual([]);
|
|
await deleteMessageRequest(harness, world.a.owner.token, world.a.ann.id, message.id);
|
|
await world.worker.drain();
|
|
expect(deletedKeys().some((key) => key.startsWith(`attachments/${world.b.t1.id}/`))).toBe(false);
|
|
const marked = (await readRow(world.b.t1.id, copy!.id))!;
|
|
expect(marked.flags).toBe(MessageFlags.IS_CROSSPOST | MessageFlags.SOURCE_MESSAGE_DELETED);
|
|
});
|
|
|
|
test('favoriting the embed image of a copy by embed index copies the source object', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
const message = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {
|
|
content: 'card',
|
|
embeds: [{title: 'Release card', image: {url: 'attachment://yeah.png'}}],
|
|
});
|
|
const sourceRow = (await readRow(world.a.ann.id, message.id))!;
|
|
const sourceKey = sourceRow.embeds[0]!.image!.url!.slice(`${Config.endpoints.media}/`.length);
|
|
await publishAndDrain(harness, world, message.id);
|
|
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id);
|
|
await createBuilder(harness, world.b.owner.token)
|
|
.post(`/channels/${world.b.t1.id}/messages/${copy!.id}/memes`)
|
|
.body({embed_index: 0, name: 'Release card'})
|
|
.expect(201)
|
|
.execute();
|
|
const fromSource = harness.storageService
|
|
.getCopiedObjects()
|
|
.filter((entry) => entry.sourceBucket === Config.s3.buckets.cdn && entry.sourceKey === sourceKey);
|
|
expect(fromSource).toHaveLength(1);
|
|
});
|
|
|
|
test('stickers are copied', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
const message = await sendMessage(harness, world.a.owner.token, world.a.ann.id, {content: 'sticker'});
|
|
const stickerId = createStickerID(123456789012345678n);
|
|
await writeRow((await readRow(world.a.ann.id, message.id))!, {
|
|
sticker_items: [{sticker_id: stickerId, name: 'party', animated: false}],
|
|
});
|
|
await publishAndDrain(harness, world, message.id);
|
|
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id);
|
|
const copyRow = (await readRow(world.b.t1.id, copy!.id))!;
|
|
expect(copyRow.stickers.map((sticker) => [sticker.id, sticker.name])).toEqual([[stickerId, 'party']]);
|
|
});
|
|
|
|
test('several followers in several guilds each get one copy', async () => {
|
|
const c = await createAnnouncementTargetGuild(harness, 'Third Community');
|
|
await addGuildMember(harness, world.a.owner, world.a.guild, c.owner);
|
|
await followInto(harness, world, world.b.t1.id);
|
|
await followInto(harness, world, world.b.t2.id);
|
|
await followInto(harness, world, c.t1.id, c.owner.token);
|
|
const source = await postAndPublish(harness, world, {content: 'everyone gets this'});
|
|
expect(await copiesOf(harness, world.b.owner.token, world.b.t1.id, source.id)).toHaveLength(1);
|
|
expect(await copiesOf(harness, world.b.owner.token, world.b.t2.id, source.id)).toHaveLength(1);
|
|
expect(await copiesOf(harness, c.owner.token, c.t1.id, source.id)).toHaveLength(1);
|
|
});
|
|
|
|
test('small chunks and pages still give every follower exactly one copy', async () => {
|
|
const targets = [world.b.t1, world.b.t2];
|
|
for (const name of ['t3', 't4', 't5']) {
|
|
targets.push(await createTextChannel(name));
|
|
}
|
|
for (const target of targets) {
|
|
await followInto(harness, world, target.id);
|
|
}
|
|
fanoutSizes.chunk = 1;
|
|
fanoutSizes.page = 2;
|
|
const source = await postAndPublish(harness, world, {content: 'paged'});
|
|
for (const target of targets) {
|
|
expect(await copiesOf(harness, world.b.owner.token, target.id, source.id)).toHaveLength(1);
|
|
}
|
|
expect(world.worker.byTask('crosspostMessage')).toHaveLength(3);
|
|
expect(world.worker.byTask('crosspostMessageChunk')).toHaveLength(5);
|
|
});
|
|
|
|
test('running a chunk twice for one follower creates one copy', async () => {
|
|
const source = await postAndPublish(harness, world, {content: 'once'});
|
|
const webhookId = await followInto(harness, world, world.b.t1.id);
|
|
const payload = {channelId: world.a.ann.id, messageId: source.id, webhookIds: [webhookId], attempt: 0};
|
|
await crosspostMessageChunk(payload, workerHelpers(world.worker));
|
|
await crosspostMessageChunk(payload, workerHelpers(world.worker));
|
|
expect(await copiesOf(harness, world.b.owner.token, world.b.t1.id, source.id)).toHaveLength(1);
|
|
});
|
|
|
|
test('a stale pending reservation is reclaimed with a newer id, a fresh one waits', async () => {
|
|
const staleSource = await postAndPublish(harness, world, {content: 'stale'});
|
|
const freshSource = await postAndPublish(harness, world, {content: 'fresh'});
|
|
const webhookId = await followInto(harness, world, world.b.t1.id);
|
|
const repository = new ChannelRepository();
|
|
const staleTargetId = createMessageID(BigInt(staleSource.id));
|
|
const base = {
|
|
webhook_id: createWebhookID(BigInt(webhookId)),
|
|
source_channel_id: createChannelID(BigInt(world.a.ann.id)),
|
|
target_guild_id: createGuildID(BigInt(world.b.guild.id)),
|
|
target_channel_id: createChannelID(BigInt(world.b.t1.id)),
|
|
state: 'pending' as const,
|
|
source_fingerprint: null,
|
|
created_at: new Date(),
|
|
};
|
|
await repository.crossposts.insertPending({
|
|
...base,
|
|
source_message_id: createMessageID(BigInt(staleSource.id)),
|
|
target_message_id: staleTargetId,
|
|
reserved_at: new Date(Date.now() - 120_000),
|
|
});
|
|
await repository.crossposts.insertPending({
|
|
...base,
|
|
source_message_id: createMessageID(BigInt(freshSource.id)),
|
|
target_message_id: createMessageID(BigInt(freshSource.id)),
|
|
reserved_at: new Date(),
|
|
});
|
|
await crosspostMessageChunk(
|
|
{channelId: world.a.ann.id, messageId: staleSource.id, webhookIds: [webhookId], attempt: 0},
|
|
workerHelpers(world.worker),
|
|
);
|
|
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, staleSource.id);
|
|
expect(BigInt(copy!.id)).toBeGreaterThan(staleTargetId);
|
|
expect((await mappingRow(staleSource.id, webhookId))?.state).toBe('delivered');
|
|
const recordedBefore = world.worker.recorded.length;
|
|
await crosspostMessageChunk(
|
|
{channelId: world.a.ann.id, messageId: freshSource.id, webhookIds: [webhookId], attempt: 0},
|
|
workerHelpers(world.worker),
|
|
);
|
|
expect(await copiesOf(harness, world.b.owner.token, world.b.t1.id, freshSource.id)).toHaveLength(0);
|
|
const retries = world.worker.recorded.slice(recordedBefore);
|
|
expect(retries).toHaveLength(1);
|
|
expect(retries[0]!.taskType).toBe('crosspostMessageChunk');
|
|
expect(retries[0]!.payload).toMatchObject({attempt: 1, webhookIds: [webhookId]});
|
|
expect(retries[0]!.runAt!.getTime()).toBeGreaterThan(Date.now());
|
|
expect(retries[0]!.jobKey).toBe(`crosspost-chunk:${freshSource.id}:${webhookId}:1`);
|
|
});
|
|
|
|
test('a copy delivered on retry still lands after newer target messages', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
const message = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'late'});
|
|
failNextCopyCreate();
|
|
await publish(harness, world.a.owner.token, world.a.ann.id, message.id);
|
|
await world.worker.drain({maxJobs: 2});
|
|
expect(await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id)).toHaveLength(0);
|
|
const newer = await sendMessage(harness, world.b.owner.token, world.b.t1.id, {content: 'meanwhile'});
|
|
await world.worker.drain();
|
|
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id);
|
|
expect(BigInt(copy!.id)).toBeGreaterThan(BigInt(newer.id));
|
|
const channel = await createBuilder<ChannelResponse>(harness, world.b.owner.token)
|
|
.get(`/channels/${world.b.t1.id}`)
|
|
.execute();
|
|
expect(channel.last_message_id).toBe(copy!.id);
|
|
});
|
|
|
|
describe('skips without errors', () => {
|
|
test('a deleted follower webhook', async () => {
|
|
const webhookId = await followInto(harness, world, world.b.t1.id);
|
|
await createBuilder(harness, world.b.owner.token).delete(`/webhooks/${webhookId}`).expect(204).execute();
|
|
await postAndPublish(harness, world, {content: 'nobody'});
|
|
await expectNoCopies(world.b.t1.id);
|
|
});
|
|
|
|
test('a follower webhook removed while the chunk is queued', async () => {
|
|
const webhookId = await followInto(harness, world, world.b.t1.id);
|
|
const message = await sendMessage(harness, world.a.owner.token, world.a.ann.id, {content: 'raced'});
|
|
await publish(harness, world.a.owner.token, world.a.ann.id, message.id);
|
|
await world.worker.drain({maxJobs: 1});
|
|
await createBuilder(harness, world.b.owner.token).delete(`/webhooks/${webhookId}`).expect(204).execute();
|
|
await world.worker.drain();
|
|
await expectNoCopies(world.b.t1.id);
|
|
});
|
|
|
|
test('a deleted target channel', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
const message = await sendMessage(harness, world.a.owner.token, world.a.ann.id, {content: 'gone'});
|
|
await publish(harness, world.a.owner.token, world.a.ann.id, message.id);
|
|
await world.worker.drain({maxJobs: 1});
|
|
await createBuilder(harness, world.b.owner.token).delete(`/channels/${world.b.t1.id}`).expect(204).execute();
|
|
await world.worker.drain();
|
|
expect(world.worker.deadLetters).toHaveLength(0);
|
|
});
|
|
|
|
test('a target converted to a non-text type', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
await patchChannelRow(world.b.t1.id, {type: ChannelTypes.GUILD_ANNOUNCEMENT});
|
|
await postAndPublish(harness, world, {content: 'wrong type'});
|
|
await expectNoCopies(world.b.t1.id);
|
|
});
|
|
|
|
test('an age-restricted source into a target that lost its restriction', async () => {
|
|
await patchChannel(harness, world.a.owner.token, world.a.ann.id, {nsfw_override: true}).execute();
|
|
await followInto(harness, world, world.b.ageRestricted.id);
|
|
await patchChannel(harness, world.b.owner.token, world.b.ageRestricted.id, {nsfw_override: false}).execute();
|
|
await postAndPublish(harness, world, {content: 'restricted'});
|
|
await expectNoCopies(world.b.ageRestricted.id);
|
|
});
|
|
|
|
test('a content-warning source into a target that lost its warning', async () => {
|
|
await patchChannel(harness, world.a.owner.token, world.a.ann.id, {
|
|
content_warning_level: ContentWarningLevel.CONTENT_WARNING,
|
|
}).execute();
|
|
await followInto(harness, world, world.b.contentWarning.id);
|
|
await patchChannel(harness, world.b.owner.token, world.b.contentWarning.id, {
|
|
content_warning_level: ContentWarningLevel.INHERIT,
|
|
}).execute();
|
|
await postAndPublish(harness, world, {content: 'warned'});
|
|
await expectNoCopies(world.b.contentWarning.id);
|
|
});
|
|
|
|
test('a target guild with sending disabled', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
await patchGuildRow(world.b.guild.id, {disabled_operations: GuildOperations.SEND_MESSAGE});
|
|
await postAndPublish(harness, world, {content: 'muted target'});
|
|
await patchGuildRow(world.b.guild.id, {disabled_operations: 0});
|
|
await expectNoCopies(world.b.t1.id);
|
|
});
|
|
|
|
test('a source guild that disables announcement channels after publishing', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
const message = await sendMessage(harness, world.a.owner.token, world.a.ann.id, {content: 'switched off'});
|
|
await publish(harness, world.a.owner.token, world.a.ann.id, message.id);
|
|
await addGuildFeature(world.a.guild.id, GuildFeatures.ANNOUNCEMENT_CHANNELS_DISABLED);
|
|
await world.worker.drain();
|
|
await expectNoCopies(world.b.t1.id);
|
|
});
|
|
|
|
test('a follower creator who can no longer view the source', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
await createPermissionOverwrite(harness, world.a.owner.token, world.a.ann.id, world.b.owner.userId, {
|
|
type: 1,
|
|
allow: '0',
|
|
deny: Permissions.VIEW_CHANNEL.toString(),
|
|
});
|
|
const infoSpy = vi.spyOn(Logger.child({}), 'info');
|
|
await postAndPublish(harness, world, {content: 'private'});
|
|
await expectNoCopies(world.b.t1.id);
|
|
expect(infoSpy.mock.calls.some(([, message]) => String(message).includes('cannot view'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
test('flagged media is stripped for targets that do not allow it', async () => {
|
|
const c = await createAnnouncementTargetGuild(harness, 'Filtered Community');
|
|
await addGuildMember(harness, world.a.owner, world.a.guild, c.owner);
|
|
await patchGuildRow(world.a.guild.id, {explicit_content_filter: GuildExplicitContentFilterTypes.DISABLED});
|
|
await followInto(harness, world, world.b.t1.id);
|
|
await followInto(harness, world, world.b.ageRestricted.id);
|
|
await followInto(harness, world, c.t1.id, c.owner.token);
|
|
await addGuildFeature(world.b.guild.id, GuildFeatures.DISCOVERABLE);
|
|
await patchGuildRow(c.guild.id, {explicit_content_filter: GuildExplicitContentFilterTypes.ALL_MEMBERS});
|
|
const message = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {
|
|
content: 'mixed',
|
|
embeds: [{title: 'Safe card'}, {title: 'Flagged card'}],
|
|
});
|
|
const row = (await readRow(world.a.ann.id, message.id))!;
|
|
await writeRow(row, {
|
|
attachments: row.attachments.map((attachment) => ({...attachment.toMessageAttachment(), nsfw: true})),
|
|
embeds: row.embeds.map((embed, index) => ({...embed.toMessageEmbed(), nsfw: index === 1})),
|
|
});
|
|
await publishAndDrain(harness, world, message.id);
|
|
for (const [token, channelId] of [
|
|
[world.b.owner.token, world.b.t1.id],
|
|
[c.owner.token, c.t1.id],
|
|
] as const) {
|
|
const [copy] = await copiesOf(harness, token, channelId, message.id);
|
|
const copyRow = (await readRow(channelId, copy!.id))!;
|
|
expect(copyRow.attachments).toHaveLength(0);
|
|
expect(copyRow.embeds.map((embed) => embed.title)).toEqual(['Safe card']);
|
|
}
|
|
const [restricted] = await copiesOf(harness, world.b.owner.token, world.b.ageRestricted.id, message.id);
|
|
const restrictedRow = (await readRow(world.b.ageRestricted.id, restricted!.id))!;
|
|
expect(restrictedRow.attachments).toHaveLength(1);
|
|
expect(restrictedRow.embeds.map((embed) => embed.title)).toEqual(['Safe card', 'Flagged card']);
|
|
});
|
|
|
|
test('a sha blocklisted after publishing stops the fan-out', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
const message = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'image'});
|
|
await publish(harness, world.a.owner.token, world.a.ann.id, message.id);
|
|
const blockedSha = 'cd'.repeat(32);
|
|
const row = (await readRow(world.a.ann.id, message.id))!;
|
|
await writeRow(row, {
|
|
attachments: row.attachments.map((attachment) => ({
|
|
...attachment.toMessageAttachment(),
|
|
content_hash: blockedSha,
|
|
})),
|
|
});
|
|
fileShaCache.add(blockedSha);
|
|
const warnSpy = vi.spyOn(Logger.child({}), 'warn');
|
|
try {
|
|
await world.worker.drain();
|
|
} finally {
|
|
fileShaCache.remove(blockedSha);
|
|
}
|
|
await expectNoCopies(world.b.t1.id);
|
|
expect(warnSpy.mock.calls.some(([, text]) => String(text).includes('blocked content'))).toBe(true);
|
|
});
|
|
|
|
test('an edit that removes and adds attachments reaches the copies', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
const message = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'v1'}, [
|
|
'first.png',
|
|
'second.png',
|
|
]);
|
|
await publishAndDrain(harness, world, message.id);
|
|
const row = (await readRow(world.a.ann.id, message.id))!;
|
|
const [first, second] = row.attachments;
|
|
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id);
|
|
expect(copy!.attachments?.map((attachment) => attachment.id)).toEqual([
|
|
first!.id.toString(),
|
|
second!.id.toString(),
|
|
]);
|
|
await createBuilder(harness, world.a.owner.token)
|
|
.delete(`/channels/${world.a.ann.id}/messages/${message.id}/attachments/${second!.id}`)
|
|
.expect(204)
|
|
.execute();
|
|
await world.worker.drain();
|
|
let copyRow = (await readRow(world.b.t1.id, copy!.id))!;
|
|
expect(copyRow.attachments.map((attachment) => attachment.id)).toEqual([first!.id]);
|
|
const secondKey = `attachments/${world.a.ann.id}/${second!.id}/${second!.filename}`;
|
|
expect(deletedKeys().filter((key) => key === secondKey)).toHaveLength(1);
|
|
const extraId = first!.id + 1000n;
|
|
const edited = (await readRow(world.a.ann.id, message.id))!;
|
|
await writeRow(edited, {
|
|
content: 'v2',
|
|
attachments: [
|
|
first!.toMessageAttachment(),
|
|
{...first!.toMessageAttachment(), attachment_id: createAttachmentID(extraId), filename: 'extra.png'},
|
|
],
|
|
});
|
|
await enqueueCrosspostSync(world.worker, {
|
|
channelId: createChannelID(BigInt(world.a.ann.id)),
|
|
messageId: createMessageID(BigInt(message.id)),
|
|
mode: 'update',
|
|
});
|
|
await world.worker.drain();
|
|
copyRow = (await readRow(world.b.t1.id, copy!.id))!;
|
|
expect(copyRow.content).toBe('v2');
|
|
expect(copyRow.attachments.map((attachment) => [attachment.id, attachment.filename])).toEqual([
|
|
[first!.id, first!.filename],
|
|
[extraId, 'extra.png'],
|
|
]);
|
|
const [synced] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id);
|
|
expect(synced!.attachments?.[1]?.url).toContain(`/attachments/${world.a.ann.id}/${extraId}/extra.png`);
|
|
expect(attachmentObjectCopies()).toEqual([]);
|
|
expect(deletedKeys().some((key) => key.startsWith(`attachments/${world.b.t1.id}/`))).toBe(false);
|
|
});
|
|
|
|
test('a published message with large attachments reaches every copy without copying bytes', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
await followInto(harness, world, world.b.t2.id);
|
|
const message = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'big'});
|
|
const row = (await readRow(world.a.ann.id, message.id))!;
|
|
const large = 500n * 1024n * 1024n;
|
|
await writeRow(row, {
|
|
attachments: row.attachments.map((attachment) => ({...attachment.toMessageAttachment(), size: large})),
|
|
});
|
|
await publishAndDrain(harness, world, message.id);
|
|
for (const target of [world.b.t1, world.b.t2]) {
|
|
const [copy] = await copiesOf(harness, world.b.owner.token, target.id, message.id);
|
|
expect(copy!.attachments).toHaveLength(1);
|
|
expect(copy!.attachments![0]!.size).toBe(Number(large));
|
|
expect(copy!.attachments![0]!.id).toBe(row.attachments[0]!.id.toString());
|
|
}
|
|
expect(attachmentObjectCopies()).toEqual([]);
|
|
});
|
|
|
|
test('a failed target is retried with backoff and only the missing copy is created', async () => {
|
|
const webhookT1 = await followInto(harness, world, world.b.t1.id);
|
|
const webhookT2 = await followInto(harness, world, world.b.t2.id);
|
|
const message = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'retry'});
|
|
failNextCopyCreate();
|
|
await publishAndDrain(harness, world, message.id);
|
|
const retries = world.worker
|
|
.byTask('crosspostMessageChunk')
|
|
.filter((job) => (job.payload as {attempt: number}).attempt === 1);
|
|
expect(retries).toHaveLength(1);
|
|
expect(retries[0]!.runAt).toBeInstanceOf(Date);
|
|
expect((retries[0]!.payload as {webhookIds: Array<string>}).webhookIds).toHaveLength(1);
|
|
expect([webhookT1, webhookT2]).toContain((retries[0]!.payload as {webhookIds: Array<string>}).webhookIds[0]);
|
|
expect(await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id)).toHaveLength(1);
|
|
expect(await copiesOf(harness, world.b.owner.token, world.b.t2.id, message.id)).toHaveLength(1);
|
|
});
|
|
|
|
test('an edit racing the delivery ends in the copy', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
const message = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'draft'});
|
|
const gate = stallNextCopyCreate();
|
|
await publish(harness, world.a.owner.token, world.a.ann.id, message.id);
|
|
const draining = world.worker.drain();
|
|
await gate.reached;
|
|
await editRequest(harness, world.a.owner.token, world.a.ann.id, message.id, {content: 'final'})
|
|
.expect(200)
|
|
.execute();
|
|
gate.release();
|
|
await draining;
|
|
await world.worker.drain();
|
|
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id);
|
|
expect(copy!.content).toBe('final');
|
|
});
|
|
|
|
test('a delete racing the delivery leaves the copy marked as source deleted', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
const message = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'short lived'});
|
|
const gate = stallNextCopyCreate();
|
|
await publish(harness, world.a.owner.token, world.a.ann.id, message.id);
|
|
const draining = world.worker.drain();
|
|
await gate.reached;
|
|
await deleteMessageRequest(harness, world.a.owner.token, world.a.ann.id, message.id);
|
|
gate.release();
|
|
await draining;
|
|
await world.worker.drain();
|
|
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id);
|
|
expect(copy!.flags).toBe(MessageFlags.IS_CROSSPOST | MessageFlags.SOURCE_MESSAGE_DELETED);
|
|
expect(copy!.content).toBe(CROSSPOST_SOURCE_DELETED_CONTENT);
|
|
expect(copy!.attachments ?? []).toHaveLength(0);
|
|
});
|
|
|
|
test('a sync between reserve and create keeps the pending row and the copy converges', async () => {
|
|
const webhookId = await followInto(harness, world, world.b.t1.id);
|
|
const message = await sendMessage(harness, world.a.owner.token, world.a.ann.id, {content: 'one'});
|
|
const gate = createStallGate();
|
|
const original = MessagePersistenceService.prototype.createMessage;
|
|
vi.spyOn(MessagePersistenceService.prototype, 'createMessage').mockImplementation(async function (
|
|
this: MessagePersistenceService,
|
|
params,
|
|
) {
|
|
if (params.webhookId?.toString() === webhookId) {
|
|
await gate.hit();
|
|
}
|
|
return original.call(this, params);
|
|
});
|
|
await publish(harness, world.a.owner.token, world.a.ann.id, message.id);
|
|
const draining = world.worker.drain();
|
|
await gate.reached;
|
|
expect((await mappingRow(message.id, webhookId))?.state).toBe('pending');
|
|
await editRequest(harness, world.a.owner.token, world.a.ann.id, message.id, {content: 'two'}).expect(200).execute();
|
|
const syncCopies = (await import('@app/api/worker/tasks/SyncCrosspostCopies')).default;
|
|
await syncCopies(
|
|
{channelId: world.a.ann.id, messageId: message.id, mode: 'update', webhookIds: [webhookId]},
|
|
workerHelpers(world.worker),
|
|
);
|
|
expect((await mappingRow(message.id, webhookId))?.state).toBe('pending');
|
|
gate.release();
|
|
await draining;
|
|
await world.worker.drain();
|
|
let [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id);
|
|
expect(copy!.content).toBe('two');
|
|
await editRequest(harness, world.a.owner.token, world.a.ann.id, message.id, {content: 'three'})
|
|
.expect(200)
|
|
.execute();
|
|
await world.worker.drain();
|
|
[copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id);
|
|
expect(copy!.content).toBe('three');
|
|
await deleteMessageRequest(harness, world.a.owner.token, world.a.ann.id, message.id);
|
|
await world.worker.drain();
|
|
[copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id);
|
|
expect(copy!.flags & MessageFlags.SOURCE_MESSAGE_DELETED).toBe(MessageFlags.SOURCE_MESSAGE_DELETED);
|
|
});
|
|
|
|
test('a copy moves the target last message id, is indexed and is dispatched to the target guild', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
await patchChannelRow(world.b.t1.id, {indexed_at: new Date()});
|
|
const dispatchSpy = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
|
|
const indexSpy = vi.spyOn(MessageSearchService.prototype, 'indexMessage');
|
|
const source = await postAndPublish(harness, world, {content: 'indexed'});
|
|
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, source.id);
|
|
const channel = await createBuilder<ChannelResponse>(harness, world.b.owner.token)
|
|
.get(`/channels/${world.b.t1.id}`)
|
|
.execute();
|
|
expect(channel.last_message_id).toBe(copy!.id);
|
|
expect(indexSpy.mock.calls.some(([message]) => message.id.toString() === copy!.id)).toBe(true);
|
|
const created = dispatchSpy.mock.calls
|
|
.map(([params]) => params)
|
|
.filter((params) => params.event === 'MESSAGE_CREATE' && params.guildId.toString() === world.b.guild.id);
|
|
expect(created.some((params) => (params.data as {id: string}).id === copy!.id)).toBe(true);
|
|
});
|
|
|
|
test('messages published before a follow are not backfilled', async () => {
|
|
const source = await postAndPublish(harness, world, {content: 'before'});
|
|
await followInto(harness, world, world.b.t1.id);
|
|
await world.worker.drain();
|
|
expect(await copiesOf(harness, world.b.owner.token, world.b.t1.id, source.id)).toHaveLength(0);
|
|
await follow(harness, world.b.owner.token, world.a.ann.id, world.b.t2.id);
|
|
expect(await copiesOf(harness, world.b.owner.token, world.b.t2.id, source.id)).toHaveLength(0);
|
|
});
|
|
async function sendEmbeddedImage(content: string): Promise<{id: string; imageUrl: string}> {
|
|
const message = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {
|
|
content,
|
|
embeds: [{title: 'Card', image: {url: 'attachment://yeah.png'}}],
|
|
});
|
|
const row = (await readRow(world.a.ann.id, message.id))!;
|
|
expect(row.attachments).toHaveLength(0);
|
|
return {id: message.id, imageUrl: row.embeds[0]!.toMessageEmbed().image!.url!};
|
|
}
|
|
|
|
test('a sha blocklisted on embed media after publishing stops the fan-out', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
const source = await sendEmbeddedImage('blocked card');
|
|
await publish(harness, world.a.owner.token, world.a.ann.id, source.id);
|
|
const blockedSha = 'ef'.repeat(32);
|
|
const row = (await readRow(world.a.ann.id, source.id))!;
|
|
await writeRow(row, {
|
|
embeds: row.embeds.map((embed) => {
|
|
const value = embed.toMessageEmbed();
|
|
return {...value, image: value.image ? {...value.image, content_hash: blockedSha} : value.image};
|
|
}),
|
|
});
|
|
fileShaCache.add(blockedSha);
|
|
try {
|
|
await world.worker.drain();
|
|
} finally {
|
|
fileShaCache.remove(blockedSha);
|
|
}
|
|
await expectNoCopies(world.b.t1.id);
|
|
expect(attachmentObjectCopies()).toEqual([]);
|
|
});
|
|
|
|
test('the worker blocklist caches load entries added after publishing', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
const source = await sendMessage(harness, world.a.owner.token, world.a.ann.id, {
|
|
content: 'news with lateblockedphrase inside',
|
|
});
|
|
await publish(harness, world.a.owner.token, world.a.ann.id, source.id);
|
|
await new AdminRepository().banPhrase('lateblockedphrase');
|
|
await stopContentBlocklistCaches();
|
|
await startContentBlocklistCaches({kvClient: getKVClient(), storageService: harness.storageService});
|
|
try {
|
|
await world.worker.drain();
|
|
} finally {
|
|
await stopContentBlocklistCaches();
|
|
}
|
|
await expectNoCopies(world.b.t1.id);
|
|
});
|
|
|
|
test('removing a copy never deletes target objects that a source embed points at', async () => {
|
|
await followInto(harness, world, world.b.t1.id);
|
|
const victim = await sendWithImage(harness, world.b.owner.token, world.b.t1.id, {content: 'mine'});
|
|
const victimAttachment = (await readRow(world.b.t1.id, victim.id))!.attachments[0]!;
|
|
const victimKey = `attachments/${world.b.t1.id}/${victimAttachment.id}/${victimAttachment.filename}`;
|
|
expect(harness.storageService.hasObject(Config.s3.buckets.cdn, victimKey)).toBe(true);
|
|
const source = await sendMessage(harness, world.a.owner.token, world.a.ann.id, {
|
|
content: 'look',
|
|
embeds: [{title: 'Pointer', image: {url: 'https://example.com/pointer.png'}}],
|
|
});
|
|
const sourceRow = (await readRow(world.a.ann.id, source.id))!;
|
|
const pointerUrl = `${Config.endpoints.media}/attachments/${world.b.t1.id}/${victimAttachment.id}/x.png`;
|
|
await writeRow(sourceRow, {
|
|
embeds: sourceRow.embeds.map((embed) => {
|
|
const value = embed.toMessageEmbed();
|
|
return {...value, image: {...value.image!, url: pointerUrl}};
|
|
}),
|
|
});
|
|
await publishAndDrain(harness, world, source.id);
|
|
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, source.id);
|
|
expect((await readRow(world.b.t1.id, copy!.id))!.embeds[0]!.toMessageEmbed().image?.url).toBe(pointerUrl);
|
|
await deleteMessageRequest(harness, world.a.owner.token, world.a.ann.id, source.id);
|
|
await world.worker.drain();
|
|
expect((await readRow(world.b.t1.id, copy!.id))!.flags).toBe(
|
|
MessageFlags.IS_CROSSPOST | MessageFlags.SOURCE_MESSAGE_DELETED,
|
|
);
|
|
expect(deletedKeys().some((key) => key.startsWith(`attachments/${world.b.t1.id}/${victimAttachment.id}/`))).toBe(
|
|
false,
|
|
);
|
|
expect(harness.storageService.hasObject(Config.s3.buckets.cdn, victimKey)).toBe(true);
|
|
});
|
|
|
|
test('a delivered copy whose race check failed catches up on the retry', async () => {
|
|
const webhookId = await followInto(harness, world, world.b.t1.id);
|
|
const message = await sendMessage(harness, world.a.owner.token, world.a.ann.id, {content: 'one'});
|
|
const original = MessagePersistenceService.prototype.createMessage;
|
|
vi.spyOn(MessagePersistenceService.prototype, 'createMessage').mockImplementation(async function (
|
|
this: MessagePersistenceService,
|
|
params,
|
|
) {
|
|
if (params.webhookId?.toString() !== webhookId) {
|
|
return original.call(this, params);
|
|
}
|
|
await writeRow((await readRow(world.a.ann.id, message.id))!, {content: 'two'});
|
|
const created = await original.call(this, params);
|
|
vi.spyOn(MessageWriteLock.prototype, 'withFreshMessage').mockRejectedValueOnce(new Error('lock hiccup'));
|
|
return created;
|
|
});
|
|
await publish(harness, world.a.owner.token, world.a.ann.id, message.id);
|
|
await world.worker.drain();
|
|
expect(world.worker.byTask('crosspostMessageChunk').some((job) => job.payload.attempt === 1)).toBe(true);
|
|
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id);
|
|
expect(copy!.content).toBe('two');
|
|
});
|
|
|
|
test('an admin takedown racing the delivery deletes the copy instead of marking it', async () => {
|
|
const webhookId = await followInto(harness, world, world.b.t1.id);
|
|
const message = await sendMessage(harness, world.a.owner.token, world.a.ann.id, {content: 'illegal'});
|
|
const gate = createStallGate();
|
|
const original = MessagePersistenceService.prototype.createMessage;
|
|
vi.spyOn(MessagePersistenceService.prototype, 'createMessage').mockImplementation(async function (
|
|
this: MessagePersistenceService,
|
|
params,
|
|
) {
|
|
if (params.webhookId?.toString() === webhookId) {
|
|
await gate.hit();
|
|
}
|
|
return original.call(this, params);
|
|
});
|
|
await publish(harness, world.a.owner.token, world.a.ann.id, message.id);
|
|
const draining = world.worker.drain();
|
|
await gate.reached;
|
|
const admin = await setUserACLs(harness, world.a.member, [AdminACLs.AUTHENTICATE, AdminACLs.MESSAGE_DELETE]);
|
|
await createBuilder(harness, admin.token)
|
|
.delete(`/admin/channels/${world.a.ann.id}/messages/${message.id}`)
|
|
.expect(200)
|
|
.execute();
|
|
gate.release();
|
|
await draining;
|
|
await world.worker.drain();
|
|
await expectNoCopies(world.b.t1.id);
|
|
expect(await mappingRow(message.id, webhookId)).toBeNull();
|
|
});
|
|
|
|
test('a failed copy create leaves no copy behind and the retry delivers once', async () => {
|
|
const webhookId = await followInto(harness, world, world.b.t1.id);
|
|
const message = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'files'});
|
|
failNextCopyCreate(webhookId);
|
|
await publish(harness, world.a.owner.token, world.a.ann.id, message.id);
|
|
await crosspostMessageChunk(
|
|
{channelId: world.a.ann.id, messageId: message.id, webhookIds: [webhookId], attempt: 0},
|
|
workerHelpers(world.worker),
|
|
);
|
|
expect(await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id)).toHaveLength(0);
|
|
await world.worker.drain();
|
|
expect(await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id)).toHaveLength(1);
|
|
expect(attachmentObjectCopies()).toEqual([]);
|
|
expect(deletedKeys().filter((key) => key.startsWith('attachments/'))).toEqual([]);
|
|
});
|
|
|
|
test('two syncs of one edit converge on the source attachments', async () => {
|
|
const webhookId = await followInto(harness, world, world.b.t1.id);
|
|
const message = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'v1'});
|
|
await publishAndDrain(harness, world, message.id);
|
|
const row = (await readRow(world.a.ann.id, message.id))!;
|
|
const first = row.attachments[0]!;
|
|
const extraId = first.id + 1n;
|
|
await harness.storageService.uploadObject({
|
|
bucket: Config.s3.buckets.cdn,
|
|
key: `attachments/${world.a.ann.id}/${extraId}/extra.png`,
|
|
body: loadFixture('yeah.png'),
|
|
contentType: 'image/png',
|
|
});
|
|
await writeRow(row, {
|
|
content: 'v2',
|
|
attachments: [
|
|
first.toMessageAttachment(),
|
|
{...first.toMessageAttachment(), attachment_id: createAttachmentID(extraId), filename: 'extra.png'},
|
|
],
|
|
});
|
|
const arrive = createBarrier(2);
|
|
const originalLock = MessageWriteLock.prototype.withFreshMessage;
|
|
vi.spyOn(MessageWriteLock.prototype, 'withFreshMessage').mockImplementation(async function (
|
|
this: MessageWriteLock,
|
|
...args: Parameters<MessageWriteLock['withFreshMessage']>
|
|
) {
|
|
await arrive();
|
|
return originalLock.apply(this, args);
|
|
} as MessageWriteLock['withFreshMessage']);
|
|
const payload = {
|
|
channelId: world.a.ann.id,
|
|
messageId: message.id,
|
|
mode: 'update' as const,
|
|
webhookIds: [webhookId],
|
|
};
|
|
const results = await Promise.allSettled([
|
|
syncCrosspostCopies(payload, workerHelpers(world.worker)),
|
|
syncCrosspostCopies(payload, workerHelpers(world.worker)),
|
|
]);
|
|
vi.restoreAllMocks();
|
|
expect(results.some((result) => result.status === 'fulfilled')).toBe(true);
|
|
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id);
|
|
expect(copy!.content).toBe('v2');
|
|
const copyRow = (await readRow(world.b.t1.id, copy!.id))!;
|
|
expect(copyRow.attachments.map((attachment) => attachment.id)).toEqual([first.id, extraId]);
|
|
expect(attachmentObjectCopies()).toEqual([]);
|
|
expect(deletedKeys().filter((key) => key.startsWith('attachments/'))).toEqual([]);
|
|
});
|
|
|
|
describe('copy author avatar', () => {
|
|
test('a follow captures the source icon and the copy uses it', async () => {
|
|
const icon = await setSourceIcon(getPngDataUrl());
|
|
expect(icon).toBeTruthy();
|
|
const webhookId = await followInto(harness, world, world.b.t1.id);
|
|
expect((await findWebhookRow(webhookId))?.avatarHash).toBe(icon);
|
|
expect(webhookAvatarCopies(webhookId)).toEqual([
|
|
{
|
|
sourceBucket: Config.s3.buckets.cdn,
|
|
sourceKey: `icons/${world.a.guild.id}/${icon!.replace(/^a_/, '')}`,
|
|
destinationBucket: Config.s3.buckets.cdn,
|
|
destinationKey: `avatars/${webhookId}/${icon!.replace(/^a_/, '')}`,
|
|
},
|
|
]);
|
|
const source = await postAndPublish(harness, world, {content: 'with icon'});
|
|
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, source.id);
|
|
expect(copy!.author.avatar).toBe(icon);
|
|
expect(webhookAvatarCopies(webhookId)).toHaveLength(1);
|
|
});
|
|
|
|
test('a changed source icon reaches new copies while the webhook keeps its snapshot', async () => {
|
|
const oldIcon = await setSourceIcon(getPngDataUrl());
|
|
const webhookId = await followInto(harness, world, world.b.t1.id);
|
|
const before = await postAndPublish(harness, world, {content: 'before'});
|
|
const newIcon = await setSourceIcon(await solidPngDataUrl('#ff0000'));
|
|
expect(newIcon).toBeTruthy();
|
|
expect(newIcon).not.toBe(oldIcon);
|
|
const first = await postAndPublish(harness, world, {content: 'after one'});
|
|
const second = await postAndPublish(harness, world, {content: 'after two'});
|
|
expect((await findWebhookRow(webhookId))?.avatarHash).toBe(oldIcon);
|
|
const newKey = newIcon!.replace(/^a_/, '');
|
|
const [firstCopy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, first.id);
|
|
const [secondCopy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, second.id);
|
|
expect(firstCopy!.author.avatar).toBe(newIcon);
|
|
expect(secondCopy!.author.avatar).toBe(newIcon);
|
|
expect((await readRow(world.b.t1.id, firstCopy!.id))?.webhookAvatarHash).toBe(newIcon);
|
|
const newIconCopies = webhookAvatarCopies(webhookId).filter(
|
|
(copy) => copy.destinationKey === `avatars/${webhookId}/${newKey}`,
|
|
);
|
|
expect(newIconCopies).toEqual([expect.objectContaining({sourceKey: `icons/${world.a.guild.id}/${newKey}`})]);
|
|
expect(
|
|
await harness.storageService.getObjectMetadata(Config.s3.buckets.cdn, `avatars/${webhookId}/${newKey}`),
|
|
).not.toBeNull();
|
|
const [beforeCopy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, before.id);
|
|
expect(beforeCopy!.author.avatar).toBe(oldIcon);
|
|
await editRequest(harness, world.a.owner.token, world.a.ann.id, before.id, {content: 'before edited'})
|
|
.expect(200)
|
|
.execute();
|
|
await world.worker.drain();
|
|
const [editedCopy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, before.id);
|
|
expect(editedCopy!.content).toBe('before edited');
|
|
expect(editedCopy!.author.avatar).toBe(oldIcon);
|
|
});
|
|
|
|
test('a source guild without an icon gives a null avatar on the webhook and the copy', async () => {
|
|
const webhookId = await followInto(harness, world, world.b.t1.id);
|
|
expect((await findWebhookRow(webhookId))?.avatarHash ?? null).toBeNull();
|
|
const source = await postAndPublish(harness, world, {content: 'no icon'});
|
|
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, source.id);
|
|
expect(copy!.author.avatar ?? null).toBeNull();
|
|
expect(webhookAvatarCopies(webhookId)).toHaveLength(0);
|
|
});
|
|
|
|
test('removing the source icon after following gives new copies a null avatar', async () => {
|
|
const icon = await setSourceIcon(getPngDataUrl());
|
|
const webhookId = await followInto(harness, world, world.b.t1.id);
|
|
expect(await setSourceIcon(null)).toBeNull();
|
|
const source = await postAndPublish(harness, world, {content: 'icon removed'});
|
|
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, source.id);
|
|
expect(copy!.author.avatar ?? null).toBeNull();
|
|
expect((await findWebhookRow(webhookId))?.avatarHash).toBe(icon);
|
|
});
|
|
});
|
|
});
|