-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpublic-api.test.ts
More file actions
182 lines (164 loc) · 6.52 KB
/
Copy pathpublic-api.test.ts
File metadata and controls
182 lines (164 loc) · 6.52 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
import { validateBlockKit } from '@tightknitai/slack-block-kit-validator';
import { BlockKitchen, SendDialog, SlackSignInButton, TemplatePicker, useSlackSignIn } from '../src/index';
import { defaultPalette, extraAlertVariant, legacyInputVariants } from '../src/lib/default-blocks';
import { toSlackBlocks } from '../src/lib/to-slack-blocks';
import { decodeBlocksFromString, encodeBlocksToString } from '../src/lib/url-state';
import type { SupportedBlock } from '../src/types';
describe('toSlackBlocks', () => {
it('strips the builder-only `level` field from header blocks', () => {
const input: SupportedBlock[] = [
{
type: 'header',
level: 2,
text: { type: 'plain_text', text: 'Heading', emoji: true }
} as SupportedBlock
];
const [out] = toSlackBlocks(input);
expect(out.type).toBe('header');
expect('level' in out).toBe(false);
});
it('passes non-header blocks through unchanged', () => {
const input: SupportedBlock[] = [
{ type: 'divider' },
{
type: 'section',
text: { type: 'mrkdwn', text: 'hello' }
}
];
expect(toSlackBlocks(input)).toEqual(input);
});
});
describe('url-state', () => {
it('roundtrips an arbitrary block list through encode/decode', () => {
const blocks: SupportedBlock[] = [
{ type: 'divider' },
{
type: 'section',
text: { type: 'mrkdwn', text: 'hello *world*' }
},
{
type: 'header',
text: { type: 'plain_text', text: 'Heading', emoji: true }
}
];
const encoded = encodeBlocksToString(blocks);
expect(encoded).not.toBe('');
expect(encoded).not.toMatch(/[+/=]/);
const decoded = decodeBlocksFromString(encoded);
expect(decoded).toEqual(blocks);
});
it('encodes an empty list as the empty string', () => {
expect(encodeBlocksToString([])).toBe('');
});
it.each([null, undefined, '', 'not-base64!!!', 'eyJub3QiOiJhbiBhcnJheSJ9'])(
'decodes invalid input %p to null',
(input) => {
expect(decodeBlocksFromString(input)).toBeNull();
}
);
it('preserves multibyte (utf-8) text through the roundtrip', () => {
const blocks: SupportedBlock[] = [
{
type: 'section',
text: { type: 'mrkdwn', text: 'café 北京 🚀' }
}
];
expect(decodeBlocksFromString(encodeBlocksToString(blocks))).toEqual(blocks);
});
it('rejects encoded input larger than 1 MiB without invoking atob', () => {
// 1 MiB + 1 byte of arbitrary characters. We never construct a real
// payload of this size; the guard exists so a pathological URL hash
// returns null promptly instead of stalling the tab inside atob
// and JSON.parse.
const oversized = 'a'.repeat(1024 * 1024 + 1);
expect(decodeBlocksFromString(oversized)).toBeNull();
});
});
describe('toSlackBlocks URL sanitization', () => {
it('scrubs javascript: from a section button url', () => {
const input = [
{
type: 'section',
text: { type: 'mrkdwn', text: 'hi' },
accessory: {
type: 'button',
text: { type: 'plain_text', text: 'Click' },
url: 'javascript:alert(1)',
action_id: 'a'
}
}
] as unknown as SupportedBlock[];
const [out] = toSlackBlocks(input);
const url = (out as { accessory: { url: string } }).accessory.url;
expect(url).toBe('');
});
it('scrubs data:image/svg+xml from image_url', () => {
const input = [
{ type: 'image', image_url: 'data:image/svg+xml,<svg onload=alert(1)>', alt_text: 'evil' }
] as unknown as SupportedBlock[];
const [out] = toSlackBlocks(input);
expect((out as { image_url: string }).image_url).toBe('');
});
});
describe('palette factories', () => {
it('every variant factory returns a valid block payload', () => {
for (const section of defaultPalette) {
for (const variant of section.variants) {
const block = variant.factory();
// Sections like "Structure" and "Card and Carousel" intentionally
// mix multiple block types, so we no longer assert that a
// section maps to a single `type`. A truthy `type` is enough to
// confirm the factory built a block-shaped payload.
expect(typeof block.type).toBe('string');
expect(block.type.length).toBeGreaterThan(0);
}
}
});
it('variant ids are unique across the palette', () => {
const ids = defaultPalette.flatMap((s) => s.variants.map((v) => v.id));
expect(new Set(ids).size).toBe(ids.length);
});
it('legacy input variants keep building input blocks', () => {
for (const variant of legacyInputVariants) {
expect(variant.factory().type).toBe('input');
}
expect(extraAlertVariant.factory().type).toBe('alert');
});
// Catches the failure mode where a palette default drifts out of sync with
// the Slack Block Kit schema — users add the block, see nothing wrong in
// the builder, and Slack rejects the payload on send. Validating every
// factory's output through the same path the runtime uses
// (`toSlackBlocks` → `validateBlockKit`) keeps factories honest.
it('every default palette factory produces a payload that validates', () => {
for (const section of defaultPalette) {
for (const variant of section.variants) {
const block = variant.factory();
const result = validateBlockKit(toSlackBlocks([block]), { target: 'blocks' });
if (!result.valid) {
throw new Error(`Palette variant "${variant.id}" produced an invalid block:\n${result.errors.join('\n')}`);
}
}
}
});
it('legacy input + extra alert factories also validate', () => {
for (const variant of [...legacyInputVariants, extraAlertVariant]) {
const block = variant.factory();
const result = validateBlockKit(toSlackBlocks([block]), { target: 'blocks' });
if (!result.valid) {
throw new Error(`Legacy variant "${variant.id}" produced an invalid block:\n${result.errors.join('\n')}`);
}
}
});
});
describe('package entry point', () => {
// The send-flow primitives are public API for hosts building a bespoke
// send UI on top of compose-only mode; a rename or dropped export is a
// breaking change that should fail here, not in a consumer's build.
it('exports the components and send-flow primitives', () => {
expect(typeof BlockKitchen).toBe('function');
expect(typeof TemplatePicker).toBe('function');
expect(typeof SendDialog).toBe('function');
expect(typeof useSlackSignIn).toBe('function');
expect(typeof SlackSignInButton).toBe('function');
});
});