mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(post transfer): publish trash with public key
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user