fix(post transfer): publish trash with public key

This commit is contained in:
Tommaso Casaburi
2026-07-05 00:11:48 +07:00
parent 716d8d93a5
commit d6f83e8abe
6 changed files with 201 additions and 22 deletions
@@ -161,6 +161,58 @@ describe('PostTransferModal', () => {
expect(document.body.querySelector('select')).toBeNull();
});
it('publishes hidden trash board transfers with the board public key identity', async () => {
await renderTransferModal();
expect(document.body.querySelector('select')).toBeNull();
const form = document.body.querySelector<HTMLFormElement>('form');
expect(form).not.toBeNull();
await act(async () => {
form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
await Promise.resolve();
});
const [publishOptions] = testState.publishCommentMock.mock.calls[0] as [Record<string, unknown>, string];
expect(publishOptions).toMatchObject({
communityAddress: TRASH_BOARD_PUBLIC_KEY,
communityPublicKey: TRASH_BOARD_PUBLIC_KEY,
});
expect(publishOptions).not.toHaveProperty('communityName');
});
it('unlocks after hidden trash board challenge verification fails', async () => {
await renderTransferModal();
const form = document.body.querySelector<HTMLFormElement>('form');
expect(form).not.toBeNull();
await act(async () => {
form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
await Promise.resolve();
});
const [publishOptions] = testState.publishCommentMock.mock.calls[0] as [Record<string, unknown>, string];
(publishOptions._onPendingCommentIndex as (index: number) => void)(12);
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
await act(async () => {
await (publishOptions.onChallengeVerification as (verification: unknown, comment: unknown) => Promise<void>)(
{ challengeSuccess: false, reason: 'try again' },
{ cid: 'challenge-comment' },
);
await Promise.resolve();
});
expect(alertSpy).toHaveBeenCalledTimes(1);
expect(testState.deleteCommentMock).toHaveBeenCalledWith(12, expect.stringMatching(/^5chan-transfer-comment-/));
expect(testState.deleteAccountMock).toHaveBeenCalledWith(expect.stringMatching(/^5chan-transfer-comment-/));
expect(document.body.textContent).toContain('try again');
const closeButton = Array.from(document.body.querySelectorAll<HTMLButtonElement>('button')).find((button) => button.textContent === 'close');
const submitButton = Array.from(document.body.querySelectorAll<HTMLButtonElement>('button')).find((button) => button.type === 'submit');
expect(closeButton?.disabled).toBe(false);
expect(submitButton?.disabled).toBe(false);
alertSpy.mockRestore();
});
it('disables submission when the hidden trash board is the transfer source', async () => {
await renderTransferModal({ ...baseComment, communityAddress: TRASH_BOARD_ADDRESS });
@@ -19,6 +19,7 @@ import {
getInitialTransferFields,
getTargetTransferModerationFlairs,
getTransferBoardReference,
getTransferPublishIdentity,
getTransferPublishPayload,
getTransferSourceBoardReference,
getTransferSourceBoardRulesLink,
@@ -333,7 +334,9 @@ const PostTransferModal = ({ comment, onClose, onTransferStateChange, onTransfer
try {
await createAccount(temporaryAccountName);
temporaryAccountCreated = true;
const payload = getTransferPublishPayload(comment, selectedFields, resolvedTargetBoardAddress);
const targetPublishIdentity = getTransferPublishIdentity(resolvedTargetBoard, resolvedTargetBoardAddress);
const sourcePublishIdentity = sourceCommunityAddress ? getTransferPublishIdentity(sourceBoard, sourceCommunityAddress) : undefined;
const payload = getTransferPublishPayload(comment, selectedFields, resolvedTargetBoardAddress, resolvedTargetBoard);
await publishComment(
{
...payload,
@@ -351,6 +354,9 @@ const PostTransferModal = ({ comment, onClose, onTransferStateChange, onTransfer
try {
alertChallengeVerificationFailed(challengeVerification, challengeComment);
if (challengeVerification?.challengeSuccess !== true) {
await cleanupTemporaryAccount({ deletePendingComment: true });
onTransferStateChange?.('failed');
dispatchModalState({ type: 'publishFailed', error: new Error(challengeVerification?.reason || 'Transfer challenge verification failed.') });
return;
}
if (finalizedTransferRef.current) return;
@@ -360,11 +366,14 @@ const PostTransferModal = ({ comment, onClose, onTransferStateChange, onTransfer
if (!targetCommentCid) {
throw new Error('Transferred post was accepted, but no target CID was returned.');
}
if (!sourcePublishIdentity) {
throw new Error('Transferred post was accepted, but no source board was resolved.');
}
// Queue the target marker first so a target moderation failure does not remove the original post.
await publishCommentModeration({
commentCid: targetCommentCid,
communityAddress: resolvedTargetBoardAddress,
...targetPublishIdentity,
commentModeration: {
flairs: getTargetTransferModerationFlairs(comment, selectedFields),
},
@@ -372,7 +381,7 @@ const PostTransferModal = ({ comment, onClose, onTransferStateChange, onTransfer
});
await publishCommentModeration({
commentCid: sourceCommentCid,
communityAddress: sourceCommunityAddress,
...sourcePublishIdentity,
commentModeration: getTransferSourceModeration(
comment,
getTransferBoardReference(resolvedTargetBoard, resolvedTargetBoardAddress),
@@ -3,6 +3,7 @@ import {
canMoveCommentToTrash,
canTransferComment,
getTargetTransferModerationFlairs,
getTransferPublishIdentity,
getTransferPublishPayload,
getTransferSourceBoardReference,
getTransferSourceBoardRulesLink,
@@ -66,6 +67,36 @@ describe('comment-transfer', () => {
});
});
it('uses the public key identity when publishing transfers to a special board', () => {
expect(getTransferPublishIdentity({ address: 'tech-posting.eth', directoryCode: 'g' }, 'tech-posting.eth')).toEqual({
communityAddress: 'tech-posting.eth',
});
expect(getTransferPublishIdentity({ address: TRASH_BOARD_ADDRESS, directoryCode: TRASH_BOARD_CODE, publicKey: TRASH_BOARD_PUBLIC_KEY }, TRASH_BOARD_ADDRESS)).toEqual(
{
communityAddress: TRASH_BOARD_PUBLIC_KEY,
communityPublicKey: TRASH_BOARD_PUBLIC_KEY,
},
);
const payload = getTransferPublishPayload(
{
cid: 'source',
communityAddress: 'music-posting.eth',
content: 'wrong board',
} as never,
selectedFields,
TRASH_BOARD_ADDRESS,
{ address: TRASH_BOARD_ADDRESS, directoryCode: TRASH_BOARD_CODE, publicKey: TRASH_BOARD_PUBLIC_KEY },
);
expect(payload).toMatchObject({
communityAddress: TRASH_BOARD_PUBLIC_KEY,
communityPublicKey: TRASH_BOARD_PUBLIC_KEY,
content: 'wrong board',
});
expect(payload).not.toHaveProperty('communityName');
});
it('copies selected comment body text verbatim while still ignoring blank bodies', () => {
expect(
getTransferPublishPayload(
+30 -2
View File
@@ -11,9 +11,16 @@ export type PostTransferFields = Record<PostTransferField, boolean>;
export type TransferBoardLike = {
address?: string;
directoryCode?: string;
publicKey?: string;
title?: string;
};
export type TransferPublishIdentity = {
communityAddress: string;
communityPublicKey?: string;
communityName?: string;
};
export const TRANSFERRED_COMMENT_FLAIR_TEXT = '5chan:transferred';
export const TRANSFERRED_COMMENT_FLAIR = { text: TRANSFERRED_COMMENT_FLAIR_TEXT } as const;
export const TRANSFER_FIELD_KEYS: PostTransferField[] = ['displayName', 'title', 'content', 'link', 'spoiler', 'flairs'];
@@ -67,9 +74,30 @@ export const getAvailableTransferFields = (comment: Comment): PostTransferField[
export const hasSelectedTransferFields = (fields: PostTransferFields, availableFields: PostTransferField[]) => availableFields.some((field) => fields[field]);
export const getTransferPublishPayload = (comment: Comment, fields: PostTransferFields, targetBoardAddress: string): Record<string, unknown> => {
export const getTransferPublishIdentity = (targetBoard: TransferBoardLike | undefined, targetBoardAddress: string): TransferPublishIdentity => {
const boardAddress = getTextField(targetBoard?.address) ?? targetBoardAddress;
const publicKey = getTextField(targetBoard?.publicKey);
if (publicKey && isSpecialBoardCode(targetBoard?.directoryCode)) {
return {
communityAddress: publicKey,
communityPublicKey: publicKey,
};
}
return {
communityAddress: boardAddress,
};
};
export const getTransferPublishPayload = (
comment: Comment,
fields: PostTransferFields,
targetBoardAddress: string,
targetBoard?: TransferBoardLike,
): Record<string, unknown> => {
const payload: Record<string, unknown> = {
communityAddress: targetBoardAddress,
...getTransferPublishIdentity(targetBoard, targetBoardAddress),
};
const displayName = getCommentDisplayName(comment);
const title = getTextField(comment.title);
+4 -2
View File
@@ -3,6 +3,7 @@ export interface SpecialBoard {
aliases?: string[];
directoryCode: string;
nsfw?: boolean;
publicKey?: string;
title: string;
}
@@ -14,14 +15,15 @@ export const TRASH_BOARD_TITLE = '/trash/ - Off-topic';
export const SPECIAL_BOARDS: SpecialBoard[] = [
{
address: TRASH_BOARD_ADDRESS,
aliases: [TRASH_BOARD_PUBLIC_KEY, 'off-topic.eth'],
aliases: ['off-topic.eth'],
directoryCode: TRASH_BOARD_CODE,
nsfw: true,
publicKey: TRASH_BOARD_PUBLIC_KEY,
title: TRASH_BOARD_TITLE,
},
];
const getSpecialBoardLookupAddresses = (board: SpecialBoard): string[] => [board.address, ...(board.aliases ?? [])];
const getSpecialBoardLookupAddresses = (board: SpecialBoard): string[] => [board.address, ...(board.publicKey ? [board.publicKey] : []), ...(board.aliases ?? [])];
export const getSpecialBoardByCode = (code: string | undefined): SpecialBoard | undefined =>
code ? SPECIAL_BOARDS.find((board) => board.directoryCode === code) : undefined;
@@ -4,7 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { Link, MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ModQueueView from '../mod-queue';
import { TRASH_BOARD_ADDRESS } from '../../../lib/special-boards';
import { TRASH_BOARD_ADDRESS, TRASH_BOARD_PUBLIC_KEY } from '../../../lib/special-boards';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (callback: () => void | Promise<void>) => void | Promise<void> }).act as (callback: () => void | Promise<void>) => void | Promise<void>;
@@ -640,7 +640,8 @@ describe('ModQueueView', () => {
const [payload, accountName] = testState.publishCommentMock.mock.calls[0];
expect(accountName).toBe(temporaryAccountName);
expect(payload).toMatchObject({
communityAddress: TRASH_BOARD_ADDRESS,
communityAddress: TRASH_BOARD_PUBLIC_KEY,
communityPublicKey: TRASH_BOARD_PUBLIC_KEY,
content: 'belongs on tech',
flairs: [{ text: 'flash:loop' }],
link: 'https://example.com/image.png',
@@ -648,20 +649,9 @@ describe('ModQueueView', () => {
title: 'Wrong board',
});
expect(payload.author).toBeUndefined();
expect(payload).not.toHaveProperty('communityName');
expect(typeof payload.onChallengeVerification).toBe('function');
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
await act(async () => {
await payload.onChallengeVerification({ challengeSuccess: false, reason: 'try again' }, { cid: 'retry-post', communityAddress: TRASH_BOARD_ADDRESS });
await Promise.resolve();
});
expect(alertSpy).toHaveBeenCalledTimes(1);
expect(testState.publishCommentModerationActionMock).not.toHaveBeenCalled();
expect(testState.deleteAccountMock).not.toHaveBeenCalled();
expect(dialog?.textContent).toContain('publishing');
alertSpy.mockRestore();
await act(async () => {
await payload.onChallengeVerification({ challengeSuccess: true, commentUpdate: { cid: 'transferred-post' } }, { cid: 'transferred-post' });
await Promise.resolve();
@@ -670,7 +660,8 @@ describe('ModQueueView', () => {
expect(testState.publishCommentModerationActionMock).toHaveBeenCalledTimes(2);
expect(testState.publishCommentModerationActionMock.mock.calls[0][0]).toMatchObject({
commentCid: 'transferred-post',
communityAddress: TRASH_BOARD_ADDRESS,
communityAddress: TRASH_BOARD_PUBLIC_KEY,
communityPublicKey: TRASH_BOARD_PUBLIC_KEY,
commentModeration: {
flairs: [{ text: 'flash:loop' }, { text: '5chan:transferred' }],
},
@@ -709,6 +700,72 @@ describe('ModQueueView', () => {
expect(testState.publishCommentModerationActionMock).toHaveBeenCalledTimes(2);
});
it('unlocks the transfer modal when trash challenge verification fails', async () => {
testState.feed = [
{
cid: 'wrong-board-post',
communityAddress: 'music-posting.eth',
content: 'belongs on trash',
number: 8,
pendingApproval: true,
timestamp: 90_000,
},
];
let resolveDeleteAccount: () => void = () => undefined;
testState.deleteAccountMock.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveDeleteAccount = resolve;
}),
);
await renderModQueue();
const transferButton = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).find((button) => button.textContent === 'Trash');
await act(async () => {
transferButton?.click();
});
const dialog = document.body.querySelector<HTMLElement>('[role="dialog"][aria-labelledby="post-transfer-title"]');
const submitButton = Array.from(dialog?.querySelectorAll<HTMLButtonElement>('button') ?? []).find((button) => button.type === 'submit');
const closeButton = Array.from(dialog?.querySelectorAll<HTMLButtonElement>('button') ?? []).find((button) => button.textContent === 'close');
await act(async () => {
submitButton?.click();
await Promise.resolve();
});
const temporaryAccountName = testState.createAccountMock.mock.calls[0][0];
const [payload] = testState.publishCommentMock.mock.calls[0];
payload._onPendingCommentIndex(12);
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
let verificationPromise: Promise<void> | undefined;
await act(async () => {
verificationPromise = payload.onChallengeVerification({ challengeSuccess: false, reason: 'try again' }, { cid: 'failed-trash-post' });
await Promise.resolve();
});
expect(alertSpy).toHaveBeenCalledTimes(1);
expect(testState.deleteCommentMock).toHaveBeenCalledWith(12, temporaryAccountName);
expect(testState.deleteAccountMock).toHaveBeenCalledWith(temporaryAccountName);
expect(container.textContent).toContain('publishing');
expect(closeButton?.disabled).toBe(true);
expect(submitButton?.disabled).toBe(true);
await act(async () => {
resolveDeleteAccount();
await verificationPromise;
await Promise.resolve();
});
expect(dialog?.querySelector('[data-testid="error-display"]')?.textContent).toBe('try again');
expect(container.textContent).not.toContain('publishing');
expect(closeButton?.disabled).toBe(false);
expect(submitButton?.disabled).toBe(false);
expect(testState.publishCommentModerationActionMock).not.toHaveBeenCalled();
alertSpy.mockRestore();
});
it('keeps post-acceptance transfer finalization failures from being retried', async () => {
testState.directories = [
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },