Add 'Show Key' prompt on private rooms to prevent key leakage. Closes #36

This commit is contained in:
Jack Kingsman
2026-03-03 21:39:03 -08:00
parent d6611e8518
commit a9472870f3
4 changed files with 290 additions and 33 deletions
@@ -0,0 +1,114 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ChannelInfoPane } from '../components/ChannelInfoPane';
import type { Channel, ChannelDetail, Favorite } from '../types';
// Mock the api module
vi.mock('../api', () => ({
api: {
getChannelDetail: vi.fn(),
},
}));
import { api } from '../api';
const mockGetChannelDetail = vi.mocked(api.getChannelDetail);
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null };
}
function makeDetail(channel: Channel): ChannelDetail {
return {
channel,
message_counts: { last_1h: 0, last_24h: 0, last_48h: 0, last_7d: 0, all_time: 0 },
first_message_at: null,
unique_sender_count: 0,
top_senders_24h: [],
};
}
const noop = () => {};
const baseProps = {
onClose: noop,
favorites: [] as Favorite[],
onToggleFavorite: noop,
};
describe('ChannelInfoPane key visibility', () => {
it('shows key directly for hashtag channels', async () => {
const key = 'AA'.repeat(16);
const channel = makeChannel(key, '#general', true);
mockGetChannelDetail.mockResolvedValue(makeDetail(channel));
render(<ChannelInfoPane {...baseProps} channelKey={key} channels={[channel]} />);
await waitFor(() => {
expect(screen.getByText(key.toLowerCase())).toBeInTheDocument();
});
expect(screen.queryByText('Show Key')).not.toBeInTheDocument();
});
it('hides key behind "Show Key" button for private channels', async () => {
const key = 'BB'.repeat(16);
const channel = makeChannel(key, 'Secret', false);
mockGetChannelDetail.mockResolvedValue(makeDetail(channel));
render(<ChannelInfoPane {...baseProps} channelKey={key} channels={[channel]} />);
await waitFor(() => {
expect(screen.getByText('Secret')).toBeInTheDocument();
});
expect(screen.queryByText(key.toLowerCase())).not.toBeInTheDocument();
expect(screen.getByText('Show Key')).toBeInTheDocument();
});
it('reveals key when "Show Key" is clicked', async () => {
const key = 'CC'.repeat(16);
const channel = makeChannel(key, 'Private', false);
mockGetChannelDetail.mockResolvedValue(makeDetail(channel));
render(<ChannelInfoPane {...baseProps} channelKey={key} channels={[channel]} />);
await waitFor(() => {
expect(screen.getByText('Show Key')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Show Key'));
expect(screen.getByText(key.toLowerCase())).toBeInTheDocument();
expect(screen.queryByText('Show Key')).not.toBeInTheDocument();
});
it('resets key visibility when channel changes', async () => {
const key1 = 'DD'.repeat(16);
const key2 = 'EE'.repeat(16);
const ch1 = makeChannel(key1, 'Room1', false);
const ch2 = makeChannel(key2, 'Room2', false);
mockGetChannelDetail.mockImplementation((key) =>
Promise.resolve(key === key1 ? makeDetail(ch1) : makeDetail(ch2))
);
const { rerender } = render(
<ChannelInfoPane {...baseProps} channelKey={key1} channels={[ch1, ch2]} />
);
await waitFor(() => {
expect(screen.getByText('Show Key')).toBeInTheDocument();
});
// Reveal key for first channel
fireEvent.click(screen.getByText('Show Key'));
expect(screen.getByText(key1.toLowerCase())).toBeInTheDocument();
// Switch channel — key should be hidden again
rerender(<ChannelInfoPane {...baseProps} channelKey={key2} channels={[ch1, ch2]} />);
await waitFor(() => {
expect(screen.getByText('Room2')).toBeInTheDocument();
});
expect(screen.queryByText(key2.toLowerCase())).not.toBeInTheDocument();
expect(screen.getByText('Show Key')).toBeInTheDocument();
});
});
@@ -0,0 +1,108 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ChatHeader } from '../components/ChatHeader';
import type { Channel, Conversation, Favorite } from '../types';
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null };
}
const noop = () => {};
const baseProps = {
contacts: [],
config: null,
favorites: [] as Favorite[],
onTrace: noop,
onToggleFavorite: noop,
onDeleteChannel: noop,
onDeleteContact: noop,
};
describe('ChatHeader key visibility', () => {
it('shows key directly for hashtag channels', () => {
const key = 'AA'.repeat(16);
const channel = makeChannel(key, '#general', true);
const conversation: Conversation = { type: 'channel', id: key, name: '#general' };
render(<ChatHeader {...baseProps} conversation={conversation} channels={[channel]} />);
expect(screen.getByText(key.toLowerCase())).toBeInTheDocument();
expect(screen.queryByText('Show Key')).not.toBeInTheDocument();
});
it('hides key behind "Show Key" button for private channels', () => {
const key = 'BB'.repeat(16);
const channel = makeChannel(key, 'Secret Room', false);
const conversation: Conversation = { type: 'channel', id: key, name: 'Secret Room' };
render(<ChatHeader {...baseProps} conversation={conversation} channels={[channel]} />);
expect(screen.queryByText(key.toLowerCase())).not.toBeInTheDocument();
expect(screen.getByText('Show Key')).toBeInTheDocument();
});
it('reveals key when "Show Key" is clicked', () => {
const key = 'CC'.repeat(16);
const channel = makeChannel(key, 'Private', false);
const conversation: Conversation = { type: 'channel', id: key, name: 'Private' };
render(<ChatHeader {...baseProps} conversation={conversation} channels={[channel]} />);
fireEvent.click(screen.getByText('Show Key'));
expect(screen.getByText(key.toLowerCase())).toBeInTheDocument();
expect(screen.queryByText('Show Key')).not.toBeInTheDocument();
});
it('resets key visibility when conversation changes', () => {
const key1 = 'DD'.repeat(16);
const key2 = 'EE'.repeat(16);
const ch1 = makeChannel(key1, 'Room1', false);
const ch2 = makeChannel(key2, 'Room2', false);
const conv1: Conversation = { type: 'channel', id: key1, name: 'Room1' };
const conv2: Conversation = { type: 'channel', id: key2, name: 'Room2' };
const { rerender } = render(
<ChatHeader {...baseProps} conversation={conv1} channels={[ch1, ch2]} />
);
// Reveal key for first conversation
fireEvent.click(screen.getByText('Show Key'));
expect(screen.getByText(key1.toLowerCase())).toBeInTheDocument();
// Switch conversation — key should be hidden again
rerender(<ChatHeader {...baseProps} conversation={conv2} channels={[ch1, ch2]} />);
expect(screen.queryByText(key2.toLowerCase())).not.toBeInTheDocument();
expect(screen.getByText('Show Key')).toBeInTheDocument();
});
it('shows key directly for contacts', () => {
const pubKey = '11'.repeat(32);
const conversation: Conversation = { type: 'contact', id: pubKey, name: 'Alice' };
render(<ChatHeader {...baseProps} conversation={conversation} channels={[]} />);
expect(screen.getByText(pubKey)).toBeInTheDocument();
expect(screen.queryByText('Show Key')).not.toBeInTheDocument();
});
it('copies key to clipboard when revealed key is clicked', async () => {
const key = 'FF'.repeat(16);
const channel = makeChannel(key, 'Priv', false);
const conversation: Conversation = { type: 'channel', id: key, name: 'Priv' };
const writeText = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });
render(<ChatHeader {...baseProps} conversation={conversation} channels={[channel]} />);
// Reveal key then click to copy
fireEvent.click(screen.getByText('Show Key'));
fireEvent.click(screen.getByText(key.toLowerCase()));
expect(writeText).toHaveBeenCalledWith(key);
});
});