-
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathhtmlTemplates.test.ts
More file actions
1665 lines (1478 loc) · 61.3 KB
/
Copy pathhtmlTemplates.test.ts
File metadata and controls
1665 lines (1478 loc) · 61.3 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
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* HTML Templates Module Unit Tests
*
* Tests for htmlTemplates.ts pure functions (no VS Code dependency).
*
* ## CSP Tests - 2 test functions:
* 1. testBuildCspString - CSP directive construction
* 2. testBuildCspStringNonceVariation - Different nonces produce different CSP
*
* ## Shared Styles Tests - 1 test function:
* 3. testGetBaseStyles - Common CSS properties
*
* ## Document HTML Tests - 3 test functions:
* 4. testBuildDocumentHtmlStructure - Basic HTML structure and a11y
* 5. testBuildDocumentHtmlNavigation - Navigation bar and back button
* 6. testBuildDocumentHtmlContentEscaping - XSS prevention via escaping
*
* ## Loading HTML Tests - 2 test functions:
* 7. testBuildLoadingHtmlStructure - Loading state structure and a11y
* 8. testBuildLoadingHtmlAccessibility - ARIA attributes on spinner/status
*
* ## Error HTML Tests - 3 test functions:
* 9. testBuildErrorHtmlNetwork - Network error message
* 10. testBuildErrorHtmlNotFound - Not found error message
* 11. testBuildErrorHtmlServer - Server error message
*
* ## Lang Attribute Tests - 1 test function:
* 12. testLangAttributes - lang attribute presence across all templates
*
* ## Link Intercept Tests (Issue-00195) - 3 test functions:
* 13. testIsHrefAllowed - href scheme allowlist validation
* 14. testAllowedHrefSchemes - Allowlist constant pinning
* 15. testBuildLinkInterceptScript - Script structure and security guards
*
* Total: 15 test functions
* Coverage: buildCspString, getBaseStyles, buildDocumentHtml,
* buildLoadingHtml, buildErrorHtml, isHrefAllowed,
* buildLinkInterceptScript, ALLOWED_HREF_SCHEMES
*/
import * as assert from 'node:assert';
import {
type BodyClass,
type ErrorType,
type SanitizedHtml,
ALLOWED_HREF_SCHEMES,
assertValidLang,
assertValidNonce,
buildCspString,
buildHtmlShell,
buildLinkInterceptScript,
CspValidationError,
getBaseStyles,
buildDocumentHtml,
buildLoadingHtml,
buildErrorHtml,
isHrefAllowed,
} from '../ui/htmlTemplates';
/**
* Test-only brand lift. Production code MUST obtain `SanitizedHtml` exclusively
* through `renderMarkdown` (see documentationInternal.ts), which is the single
* sanctioned origin of the brand. Tests bypass the sanitizer because they
* exercise `buildDocumentHtml`'s structural contract (shell, nav, escaping),
* not the sanitizer's correctness — `renderMarkdown` has its own dedicated
* test suite in documentation.test.ts.
*/
const asSanitizedHtml = (s: string): SanitizedHtml => s as SanitizedHtml;
// ============================================================================
// Test Constants
// ============================================================================
const TEST_CSP_SOURCE = 'https://test.vscode-resource.vscode-cdn.net';
// 24 chars = base64 of 16 random bytes (what generateNonce() actually produces).
// Shorter values are rejected by NONCE_PATTERN's ≥22-char minimum.
const TEST_NONCE = 'dGVzdC1ub25jZS0xMjM0NTY=';
// ============================================================================
// CSP Tests: buildCspString()
// ============================================================================
/**
* Test CSP directive construction
*/
function testBuildCspString(): void {
console.log('Testing buildCspString...');
const csp = buildCspString(TEST_CSP_SOURCE, TEST_NONCE);
// Should include default-src 'none'
assert.ok(
csp.includes("default-src 'none'"),
'CSP should have default-src none'
);
// img-src: extract the directive to scope assertions precisely.
const imgSrc = csp.split('; ').find(d => d.startsWith('img-src')) ?? '';
assert.ok(
imgSrc.includes(`img-src ${TEST_CSP_SOURCE}`),
'img-src should include cspSource'
);
assert.ok(
imgSrc.includes('https://assets.nullvariant.com'),
'img-src should include CDN'
);
assert.ok(
imgSrc.includes('https://img.shields.io'),
'img-src should include shields.io'
);
assert.ok(
imgSrc.includes('https://avatars.githubusercontent.com'),
'img-src should include avatars.githubusercontent.com'
);
// img-src absence checks — wildcard *.githubusercontent.com must not
// appear; only the avatars subdomain is needed. raw.githubusercontent.com
// would allow loading arbitrary files from attacker-controlled
// repositories (Issue-00196).
assert.ok(
!imgSrc.includes('*.githubusercontent.com'),
'img-src must not contain wildcard *.githubusercontent.com'
);
assert.ok(
!imgSrc.includes('raw.githubusercontent.com'),
'img-src must not include raw.githubusercontent.com'
);
// style-src must be nonce-only — cspSource removed to close
// the `<link rel="stylesheet" href="${cspSource}/…">` bypass.
assert.ok(
csp.includes(`style-src 'nonce-${TEST_NONCE}'`),
'CSP should include nonce in style-src'
);
assert.ok(
!csp.includes(`style-src ${TEST_CSP_SOURCE}`),
'style-src must not include cspSource'
);
assert.ok(
csp.includes(`script-src 'nonce-${TEST_NONCE}'`),
'CSP should include nonce in script-src'
);
// Defense-in-depth directives not covered by default-src 'none'.
assert.ok(csp.includes("base-uri 'none'"), 'CSP should have base-uri none');
assert.ok(csp.includes("form-action 'none'"), 'CSP should have form-action none');
assert.ok(
csp.includes("frame-ancestors 'none'"),
'CSP should have frame-ancestors none'
);
// Should include connect-src and font-src
assert.ok(
csp.includes('connect-src https://assets.nullvariant.com'),
'CSP should include connect-src'
);
assert.ok(
csp.includes(`font-src ${TEST_CSP_SOURCE}`),
'CSP should include font-src'
);
// Directives should be semicolon-separated
assert.ok(
csp.includes('; '),
'CSP directives should be semicolon-separated'
);
console.log(' buildCspString passed!');
}
/**
* Test that different nonces produce different CSP strings
*/
function testBuildCspStringNonceVariation(): void {
console.log('Testing buildCspString (nonce variation)...');
// Two distinct 24-char base64 nonces (≥22 chars required by NONCE_PATTERN).
const nonce1 = 'abcdefghijklmnopqrstuvw=';
const nonce2 = 'ABCDEFGHIJKLMNOPQRSTUVW=';
const csp1 = buildCspString(TEST_CSP_SOURCE, nonce1);
const csp2 = buildCspString(TEST_CSP_SOURCE, nonce2);
assert.notStrictEqual(csp1, csp2, 'Different nonces should produce different CSP');
// Each should contain its own nonce (without double nonce- prefix)
assert.ok(csp1.includes(`'nonce-${nonce1}'`), 'CSP1 should contain its nonce');
assert.ok(csp2.includes(`'nonce-${nonce2}'`), 'CSP2 should contain its nonce');
assert.ok(!csp1.includes('nonce-nonce-'), 'Should not have double nonce- prefix');
console.log(' buildCspString (nonce variation) passed!');
}
/**
* Test CSP input validation — nonce / cspSource format hardening.
*
* Attribute breakout via malformed nonce/cspSource must be rejected
* fail-closed. Checks the full set of dangerous characters that could
* terminate the `content="…"` attribute or start a new directive.
*/
function testBuildCspStringValidation(): void {
console.log('Testing buildCspString (input validation)...');
// Error message assertions use anchored regexes that match the exact
// "buildCspString: nonce " / "buildCspString: cspSource " prefix so a
// future rewording that conflates the two parameters is caught.
// The `CspValidationError:` prefix lets renderWithFallback
// narrow its catch via `instanceof` instead of swallowing every throw.
// Nonce validation was de-duplicated into assertValidNonce (
// SSOT consolidation), so the error prefix is `assertValidNonce:` rather
// than `buildCspString:`. Thescrub invariant (static message,
// no attacker bytes) and the `instanceof CspValidationError` narrowing
// used by renderWithFallback remain unchanged.
const NONCE_ERR = /^CspValidationError: assertValidNonce: nonce /;
const SOURCE_ERR = /^CspValidationError: buildCspString: cspSource /;
// Nonce breakout and boundary payloads.
// Covers attribute breakout (quote/angle), directive injection (semicolon),
// control-character / whitespace smuggling (newline, tab, NBSP, null byte),
// non-ASCII and length-floor violations (short nonce passes character
// class but is below the 22-char entropy minimum).
const badNonces = [
`${TEST_NONCE}' ; script-src *`, // quote + directive injection
`${TEST_NONCE}"`, // double quote
`${TEST_NONCE} `, // trailing whitespace
`${TEST_NONCE};`, // semicolon
`${TEST_NONCE}<script>`, // angle bracket
`${TEST_NONCE}\n; script-src *`, // newline (CRLF-like smuggling)
`${TEST_NONCE}\t`, // tab
`${TEST_NONCE}\u00A0`, // non-breaking space (Unicode)
`${TEST_NONCE}\0`, // null byte
'日本語テスト字列xxxxxxxxx', // non-ASCII (also ≥22 chars)
'tooShort', // character class OK but <22 chars
'', // empty
];
for (const bad of badNonces) {
assert.throws(
() => buildCspString(TEST_CSP_SOURCE, bad),
NONCE_ERR,
`Malformed nonce should throw: ${JSON.stringify(bad)}`
);
}
// cspSource breakout payloads
const badSources = [
`${TEST_CSP_SOURCE}' ; script-src *`,
`${TEST_CSP_SOURCE} https://evil.example`, // whitespace → extra source
`${TEST_CSP_SOURCE};`,
`${TEST_CSP_SOURCE}"`,
`${TEST_CSP_SOURCE}\n`, // newline
'no-scheme', // missing scheme
'',
];
for (const bad of badSources) {
assert.throws(
() => buildCspString(bad, TEST_NONCE),
SOURCE_ERR,
`Malformed cspSource should throw: ${JSON.stringify(bad)}`
);
}
// Valid shapes must continue to pass (regression guard).
// Covers: percent-encoded resource URI, vscode-webview scheme, wildcard host,
// and port number — all shapes VS Code may legitimately hand us.
const validSources = [
'https://file%2B.vscode-resource.vscode-cdn.net',
'vscode-webview://abc-123',
'https://*.vscode-cdn.net',
'https://example.com:8080',
];
for (const good of validSources) {
assert.doesNotThrow(
() => buildCspString(good, TEST_NONCE),
`Valid cspSource must be accepted: ${good}`
);
}
//scrub contract: the CspValidationError message MUST be
// static — it must not interpolate any caller-supplied nonce/cspSource
// substring. renderWithFallback logs `error.message` verbatim, so any
// future "helpful" error builder that echoes raw input would silently
// leak attacker-chosen bytes into extension logs. Lock the contract
// here (at the throw site) rather than at the log site.
const SCRUB_SENTINEL = 'LEAK_SENTINEL_ZZZ';
try {
buildCspString(TEST_CSP_SOURCE, `${SCRUB_SENTINEL}' ; x`);
assert.fail('expected throw');
} catch (error) {
assert.ok(error instanceof CspValidationError);
assert.ok(
!error.message.includes(SCRUB_SENTINEL),
'buildCspString error must not echo raw nonce input'
);
}
try {
buildCspString(`https://${SCRUB_SENTINEL}' ; x`, TEST_NONCE);
assert.fail('expected throw');
} catch (error) {
assert.ok(error instanceof CspValidationError);
assert.ok(
!error.message.includes(SCRUB_SENTINEL),
'buildCspString error must not echo raw cspSource input'
);
}
// : thrown error must be a `CspValidationError` instance so
// `renderWithFallback` can narrow its catch. A plain `Error` would still
// match the regex above but break the instanceof guard silently.
assert.throws(
() => buildCspString(TEST_CSP_SOURCE, ''),
(err: unknown) => err instanceof CspValidationError,
'nonce validation must throw CspValidationError'
);
assert.throws(
() => buildCspString('no-scheme', TEST_NONCE),
(err: unknown) => err instanceof CspValidationError,
'cspSource validation must throw CspValidationError'
);
console.log(' buildCspString (input validation) passed!');
}
// ============================================================================
// Shared Styles Tests: getBaseStyles()
// ============================================================================
/**
* Test common CSS properties
*/
function testGetBaseStyles(): void {
console.log('Testing getBaseStyles...');
const styles = getBaseStyles();
// Should include VS Code CSS variables for theming
assert.ok(
styles.includes('var(--vscode-font-family)'),
'Should use VS Code font family variable'
);
assert.ok(
styles.includes('var(--vscode-foreground)'),
'Should use VS Code foreground color variable'
);
assert.ok(
styles.includes('var(--vscode-editor-background)'),
'Should use VS Code background color variable'
);
// Should include link styles
assert.ok(
styles.includes('var(--vscode-textLink-foreground)'),
'Should use VS Code link color variable'
);
assert.ok(
styles.includes('var(--vscode-textLink-activeForeground)'),
'Should use VS Code active link color variable'
);
// Should define design tokens for border-radius
// Values matter: they form the SSOT contract, not just the names.
assert.match(
styles, /--gis-radius-sm:\s*3px/,
'--gis-radius-sm must equal 3px'
);
assert.match(
styles, /--gis-radius-md:\s*5px/,
'--gis-radius-md must equal 5px'
);
console.log(' getBaseStyles passed!');
}
/**
* Count `border-radius: Npx` literal occurrences (excluding `%` values
* like `50%` which are intentionally not tokenised, and excluding
* `var(--...)` references).
*/
function countBorderRadiusPxLiterals(html: string): string[] {
const matches = [...html.matchAll(/border-radius:\s*(\d+px)/g)];
return matches.map(m => m[1]);
}
/**
* Test CSS/a11y quality fixes from .
*
* Applies to ALL templates (document/loading/error) so the SSOT
* guarantees cannot regress in one template while passing in another.
*/
function testAllTemplatesCssQuality(): void {
console.log('Testing all templates (CSS/a11y quality — )...');
const templates: ReadonlyArray<readonly [string, () => string]> = [
['document', (): string => buildDocumentHtml(
TEST_CSP_SOURCE, asSanitizedHtml('<p>Content</p>'), 'en', 'docs/README.md', TEST_NONCE, false
)],
['loading', (): string => buildLoadingHtml(TEST_CSP_SOURCE, TEST_NONCE)],
['error', (): string => buildErrorHtml(TEST_CSP_SOURCE, 'network', TEST_NONCE)],
];
for (const [name, build] of templates) {
const html = build();
// No raw `border-radius: Npx` literals allowed. Spinner uses
// `50%` which does not match the `\d+px` pattern so it is exempt.
const literals = countBorderRadiusPxLiterals(html);
assert.strictEqual(
literals.length, 0,
`${name}: border-radius must use design tokens, found literals: ${literals.join(', ')}`
);
}
// Document template uses both tokens explicitly.
const docHtml = buildDocumentHtml(
TEST_CSP_SOURCE, asSanitizedHtml('<p>Content</p>'), 'en', 'docs/README.md', TEST_NONCE, false
);
assert.ok(
docHtml.includes('border-radius: var(--gis-radius-sm)'),
'Document template should reference --gis-radius-sm via border-radius'
);
assert.ok(
docHtml.includes('border-radius: var(--gis-radius-md)'),
'Document template should reference --gis-radius-md via border-radius'
);
// External link arrow must be scoped to the correct selector and
// must announce its purpose via CSS alt-text (not silenced).
const arrowRule =
/a\[href\^="http"\][^{]*::after\s*\{[^}]*content:\s*" ↗"\s*\/\s*" \(opens externally\)"\s*;[^}]*\}/;
assert.match(
docHtml, arrowRule,
'External link ::after must declare "(opens externally)" CSS alt text'
);
// Table cell overflow rules must live inside the `th, td` block,
// not just anywhere in the stylesheet.
const cellBlock = /th,\s*td\s*\{([^}]*)\}/.exec(docHtml)?.[1] ?? '';
assert.ok(
cellBlock.includes('overflow-wrap: anywhere'),
'th/td block must declare overflow-wrap: anywhere'
);
// word-break: break-word is a non-standard alias of overflow-wrap
// and is intentionally omitted to avoid duplication.
assert.ok(
!cellBlock.includes('word-break: break-word'),
'th/td should not duplicate word-break: break-word (use overflow-wrap only)'
);
console.log(' all templates (CSS/a11y quality) passed!');
}
// ============================================================================
// Cross-Template Invariants: shell skeleton equality
// ============================================================================
/**
* Strip template-specific content (lang, CSP, title, styles, body class,
* body inner HTML) from a rendered template so only the shared shell
* skeleton — DOCTYPE, html/head/body tag layout, meta tags, their order —
* remains. Used to prove all three templates emit a byte-identical shell
* after buildHtmlShell() extraction.
*/
function extractShell(html: string): string {
return html
.replace(/lang="[^"]*"/, 'lang="__LANG__"')
.replace(/content="default-src[^"]*"/, 'content="__CSP__"')
.replace(/<title>[^<]*<\/title>/, '<title>__TITLE__</title>')
.replace(
/<style nonce="[^"]*">[\s\S]*?<\/style>/,
'<style nonce="__NONCE__">__STYLES__</style>'
)
// Mask only the class attribute value (so additions of non-class body
// attributes surface as shell differences), then non-greedily strip
// body inner HTML. Non-greedy `*?` prevents runaway matching if a
// template ever embeds the literal `</body>` in its content.
.replace(/<body class="[^"]*">/, '<body class="__BODY_CLASS__">')
.replace(
/<body class="__BODY_CLASS__">[\s\S]*?<\/body>/,
'<body class="__BODY_CLASS__">__BODY__</body>'
);
}
/**
* All three templates must share a byte-identical shell skeleton.
*
* Regression guard for(buildHtmlShell extraction): if any
* future change adds a meta tag, reorders head children, or drops the
* viewport meta from just one template, this assertion fails immediately
* instead of silently diverging across templates.
*/
function testShellSkeletonIsShared(): void {
console.log('Testing shell skeleton invariance across templates...');
const docShell = extractShell(
buildDocumentHtml(
TEST_CSP_SOURCE, asSanitizedHtml('<p>Content</p>'), 'en', 'docs/README.md', TEST_NONCE, false
)
);
const loadingShell = extractShell(buildLoadingHtml(TEST_CSP_SOURCE, TEST_NONCE));
const errorShell = extractShell(buildErrorHtml(TEST_CSP_SOURCE, 'network', TEST_NONCE));
assert.strictEqual(
loadingShell, docShell,
'Loading shell skeleton must match document shell skeleton'
);
assert.strictEqual(
errorShell, docShell,
'Error shell skeleton must match document shell skeleton'
);
// Positive check — the stripped shell must still contain the shared
// skeleton markers; otherwise extractShell masked too aggressively and
// equality would be trivially true.
for (const shell of [docShell, loadingShell, errorShell]) {
assert.ok(shell.includes('<!DOCTYPE html>'), 'Shell should retain DOCTYPE');
assert.ok(shell.includes('<meta charset="UTF-8">'), 'Shell should retain charset meta');
assert.ok(
shell.includes('<meta name="viewport"'),
'Shell should retain viewport meta'
);
assert.ok(
shell.includes('http-equiv="Content-Security-Policy"'),
'Shell should retain CSP meta'
);
}
console.log(' shell skeleton invariance passed!');
}
/**
* Body class-based override invariant.
*
* Each template must declare its body layout under a `body.gis-*` class
* selector, not a bare `body { … }` override. The base body rule in
* getBaseStyles() remains unscoped, so class-based selectors win on
* specificity regardless of style-block order.
*/
function testBodyClassOverrides(): void {
console.log('Testing body class overrides...');
const cases: ReadonlyArray<readonly [string, string, () => string]> = [
['document', 'gis-doc', (): string => buildDocumentHtml(
TEST_CSP_SOURCE, asSanitizedHtml('<p>Content</p>'), 'en', 'docs/README.md', TEST_NONCE, false
)],
['loading', 'gis-loading', (): string => buildLoadingHtml(TEST_CSP_SOURCE, TEST_NONCE)],
['error', 'gis-error', (): string => buildErrorHtml(TEST_CSP_SOURCE, 'network', TEST_NONCE)],
];
for (const [name, cls, build] of cases) {
const html = build();
assert.ok(
html.includes(`<body class="${cls}">`),
`${name}: <body> must carry class="${cls}"`
);
// Guard: no bare `<body>` without class.
assert.ok(
!/<body>\s/.test(html),
`${name}: must not emit a bare <body> without class`
);
}
// All three templates must scope their overrides via the class selector.
// Loading's <p> override was bare (`p { margin-top: ... }`) prior to
//and has since been pinned under `body.gis-loading p` to stay
// symmetric with document/error; asserting it here prevents the scoping
// from silently regressing to a bare element selector again.
const docHtml = buildDocumentHtml(
TEST_CSP_SOURCE, asSanitizedHtml('<p>Content</p>'), 'en', 'docs/README.md', TEST_NONCE, false
);
assert.match(
docHtml, /body\.gis-doc\s*\{/,
'Document template must scope override under body.gis-doc'
);
const errorHtml = buildErrorHtml(TEST_CSP_SOURCE, 'network', TEST_NONCE);
assert.match(
errorHtml, /body\.gis-error\s*\{/,
'Error template must scope override under body.gis-error'
);
const loadingHtml = buildLoadingHtml(TEST_CSP_SOURCE, TEST_NONCE);
assert.match(
loadingHtml, /body\.gis-loading\s+p\s*\{/,
'Loading template must scope <p> override under body.gis-loading'
);
// Negative guard: within the loading <style> block, no bare `p {` selector
// may remain. Slice between the style tags so the assertion ignores any
// future <p> in the body markup itself.
const styleOpen = loadingHtml.indexOf('<style');
const styleClose = loadingHtml.indexOf('</style>', styleOpen);
const loadingStyleBlock = loadingHtml.slice(styleOpen, styleClose);
assert.ok(
!/(^|\n)\s*p\s*\{/.test(loadingStyleBlock),
'Loading style block must not contain a bare `p {` selector'
);
console.log(' body class overrides passed!');
}
/**
* Design token coverage.
*
* Verify magic numbers previously scattered across templates are now
* token references. Presence of the tokens in :root is a necessary
* condition; the negative check ensures literal `1px solid var(--vscode-panel-border)`
* no longer recurs (SSOT enforcement for core-values #4).
*/
function testDesignTokenCoverage(): void {
console.log('Testing design token coverage...');
const styles = getBaseStyles();
// Values matter: tokens form the SSOT contract, not just the names.
// `--gis-space-xs: 999em` would pass a name-only check and silently
// break every consumer.
const tokenSpec: ReadonlyArray<readonly [string, RegExp]> = [
['--gis-border-subtle', /--gis-border-subtle:\s*1px solid var\(--vscode-panel-border\)/],
['--gis-space-xs', /--gis-space-xs:\s*0\.3em\b/],
['--gis-space-sm', /--gis-space-sm:\s*0\.5em\b/],
['--gis-space-md', /--gis-space-md:\s*1em\b/],
['--gis-space-lg', /--gis-space-lg:\s*1\.5em\b/],
['--gis-space-xl', /--gis-space-xl:\s*2em\b/],
['--gis-pad-btn', /--gis-pad-btn:\s*4px 12px\b/],
['--gis-pad-body', /--gis-pad-body:\s*20px\b/],
['--gis-pad-body-lg', /--gis-pad-body-lg:\s*40px\b/],
['--gis-font-sm', /--gis-font-sm:\s*0\.9em\b/],
['--gis-font-xs', /--gis-font-xs:\s*0\.8em\b/],
];
for (const [name, re] of tokenSpec) {
assert.match(styles, re, `${name} value contract violated`);
}
// The literal `1px solid var(--vscode-panel-border)` must exist exactly
// ONCE per template — inside the --gis-border-subtle token definition
// — and nowhere else. Checked across all three templates because
// getBaseStyles() is inlined into each, and SSOT enforcement must hold
// uniformly.
const literalPattern = /1px solid var\(--vscode-panel-border\)/g;
const allTemplates: ReadonlyArray<readonly [string, string]> = [
['document', buildDocumentHtml(
TEST_CSP_SOURCE, asSanitizedHtml('<p>Content</p>'), 'en', 'docs/README.md', TEST_NONCE, false
)],
['loading', buildLoadingHtml(TEST_CSP_SOURCE, TEST_NONCE)],
['error', buildErrorHtml(TEST_CSP_SOURCE, 'network', TEST_NONCE)],
];
for (const [name, html] of allTemplates) {
const matches = html.match(literalPattern) ?? [];
assert.strictEqual(
matches.length, 1,
`${name}: literal "1px solid var(--vscode-panel-border)" must appear exactly once (in token definition), found ${matches.length}`
);
}
// Only the document template actually consumes --gis-border-subtle
// (loading/error have no panel-border rules). Asserted separately to
// confirm the token is wired up, not merely defined.
const docHtml = allTemplates[0][1];
assert.ok(
docHtml.includes('var(--gis-border-subtle)'),
'Document template must reference --gis-border-subtle'
);
console.log(' design token coverage passed!');
}
// ============================================================================
// Document HTML Tests: buildDocumentHtml()
// ============================================================================
/**
* Test basic HTML structure and a11y
*/
function testBuildDocumentHtmlStructure(): void {
console.log('Testing buildDocumentHtml (structure)...');
const html = buildDocumentHtml(
TEST_CSP_SOURCE,
asSanitizedHtml('<p>Test content</p>'),
'en',
'docs/README.md',
TEST_NONCE,
false
);
// DOCTYPE and basic structure
assert.ok(html.includes('<!DOCTYPE html>'), 'Should have DOCTYPE');
assert.ok(html.includes('<html lang="en">'), 'Should have lang attribute on html element');
assert.ok(html.includes('<meta charset="UTF-8">'), 'Should have charset meta');
assert.ok(html.includes('Content-Security-Policy'), 'Should have CSP meta');
// Content should be included
assert.ok(html.includes('<p>Test content</p>'), 'Should include content');
// Title
assert.ok(
html.includes('<title>Git ID Switcher Documentation</title>'),
'Should have title'
);
// Nonce on style and script
assert.ok(
html.includes(`<style nonce="${TEST_NONCE}">`),
'Style should have nonce'
);
assert.ok(
html.includes(`<script nonce="${TEST_NONCE}">`),
'Script should have nonce'
);
// Footer links
assert.ok(html.includes('View on GitHub'), 'Should have GitHub link');
assert.ok(html.includes('VS Code Marketplace'), 'Should have Marketplace link');
console.log(' buildDocumentHtml (structure) passed!');
}
/**
* Test navigation bar and back button
*/
function testBuildDocumentHtmlNavigation(): void {
console.log('Testing buildDocumentHtml (navigation)...');
// nav element with aria-label (semantic HTML)
const htmlWithBack = buildDocumentHtml(
TEST_CSP_SOURCE, asSanitizedHtml('<p>Content</p>'), 'ja', 'docs/test.md', TEST_NONCE, true
);
assert.ok(
htmlWithBack.includes('<nav class="nav-bar" aria-label="Document navigation">'),
'Should use nav element with aria-label'
);
// Back button enabled: aria-disabled="false" (not the [disabled] attribute),
// so Safari/VoiceOver keep it in focus order.
assert.ok(
htmlWithBack.includes('aria-disabled="false"'),
'Back button should have aria-disabled="false" when canGoBack is true'
);
assert.ok(
!/<button id="back-btn"[^>]*\sdisabled(>|\s)/.test(htmlWithBack),
'Back button must NOT use the native [disabled] attribute'
);
// Back button disabled when canGoBack=false — aria-disabled="true"
const htmlNoBack = buildDocumentHtml(
TEST_CSP_SOURCE, asSanitizedHtml('<p>Content</p>'), 'en', 'docs/test.md', TEST_NONCE, false
);
assert.ok(
htmlNoBack.includes('aria-disabled="true"'),
'Back button should have aria-disabled="true" when canGoBack is false'
);
assert.ok(
!/<button id="back-btn"[^>]*\sdisabled(>|\s)/.test(htmlNoBack),
'Back button must NOT use the native [disabled] attribute when disabled'
);
// Current path should be displayed (escaped)
assert.ok(
htmlWithBack.includes('docs/test.md'),
'Should display current path'
);
// Locale should be reflected in lang attribute
assert.ok(
htmlWithBack.includes('<html lang="ja">'),
'Should use provided locale in lang attribute'
);
console.log(' buildDocumentHtml (navigation) passed!');
}
/**
* Test XSS prevention via HTML escaping
*/
function testBuildDocumentHtmlContentEscaping(): void {
console.log('Testing buildDocumentHtml (escaping)...');
// : XSS payloads in `locale` no longer rely on downstream
// escaping — they are rejected fail-closed at the shell boundary via
// assertValidLang. This is strictly stronger than escaping because the
// error surfaces immediately instead of depending on the escaper staying
// correct forever.
assert.throws(
() =>
buildDocumentHtml(
TEST_CSP_SOURCE,
asSanitizedHtml('<p>Safe content</p>'),
'"><script>alert(1)</script>',
'docs/README.md',
TEST_NONCE,
false
),
(err: unknown) => err instanceof CspValidationError,
'Locale XSS payload must throw CspValidationError at the shell boundary'
);
// Path still flows through escapeHtmlEntities (it is markdown-derived
// free text, not an allowlisted attribute) so the escape contract on
// that field must keep working.
const html = buildDocumentHtml(
TEST_CSP_SOURCE,
asSanitizedHtml('<p>Safe content</p>'),
'en',
'docs/<script>alert(2)</script>.md',
TEST_NONCE,
false
);
assert.ok(
html.includes('<script>alert(2)</script>'),
'Script tags in path should be HTML-escaped'
);
console.log(' buildDocumentHtml (escaping) passed!');
}
/**
* : defense-in-depth validation of nonce / lang at the
* buildHtmlShell boundary via the exported assertValid* helpers.
*
* Covered:
* - assertValidNonce rejects the same breakout payloads as buildCspString
* (quote, angle bracket, semicolon, whitespace, length floor)
* - assertValidLang rejects XSS payloads and accepts every entry in
* SUPPORTED_LOCALES (including `x-*` private-use tags)
* - assertValidLang's errors are scrubbed (no attacker bytes in message)
* - buildDocumentHtml propagates nonce rejection to the shell boundary
* - buildLoadingHtml / buildErrorHtml also validate nonce at the shell
*/
function testShellInputValidation(): void {
console.log('Testing buildHtmlShell input validation...');
// --- assertValidNonce ---
const badNonces = [
`${TEST_NONCE}"`,
`${TEST_NONCE}>`,
`${TEST_NONCE}<script>`,
`${TEST_NONCE} `,
`${TEST_NONCE};`,
`${TEST_NONCE}\n`,
`${TEST_NONCE}\r`, // CR
`${TEST_NONCE}\t`, // tab
`${TEST_NONCE}\u00A0`, // NBSP
`${TEST_NONCE}\u2028`, // line separator (JS-specific hazard)
`${TEST_NONCE}\u0000`, // NUL
'tooShort',
'',
];
for (const bad of badNonces) {
assert.throws(
() => assertValidNonce(bad),
(err: unknown) => err instanceof CspValidationError,
`assertValidNonce must reject: ${JSON.stringify(bad)}`
);
}
assert.doesNotThrow(
() => assertValidNonce(TEST_NONCE),
'assertValidNonce must accept a valid 24-char base64 nonce'
);
// --- assertValidLang happy path ---
// Every entry in the extension's SUPPORTED_LOCALES must pass. Hardcoded
// rather than imported so a future accidental narrowing of LANG_PATTERN
// that silently drops `x-*` tags is caught here even if the import path
// changes.
const validLangs = [
'en', 'ja', 'zh-CN', 'zh-TW', 'ko', 'de', 'fr', 'es', 'it', 'pt-BR',
'ru', 'pl', 'tr', 'uk', 'cs', 'hu', 'bg',
'ain', 'ryu', 'haw', 'eo', 'tlh', 'tok',
'x-pirate', 'x-shakespeare', 'x-lolcat',
];
for (const good of validLangs) {
assert.doesNotThrow(
() => assertValidLang(good),
`assertValidLang must accept SUPPORTED_LOCALES entry: ${good}`
);
}
// --- assertValidLang rejection ---
const badLangs = [
'"><script>alert(1)</script>',
'en"',
'en>',
'en ',
'en;',
'en\n',
'a', // 1-char primary subtag (not x-*)
'toolong', // 7-char primary subtag
'en-', // trailing hyphen
'en--US', // empty subtag between hyphens
'', // empty (coerce happens in shell, not here)
'x', // bare x without private-use subtag
'x-', // trailing hyphen after x
'en\u0000', // NUL smuggling
'en\u00A0', // NBSP
'en\r\n', // CRLF
'en\u2028', // line separator
];
for (const bad of badLangs) {
assert.throws(
() => assertValidLang(bad),
(err: unknown) => err instanceof CspValidationError,
`assertValidLang must reject: ${JSON.stringify(bad)}`
);
}
// --- scrub contract: message must not echo attacker bytes ---
const SCRUB_SENTINEL = 'LEAK_SENTINEL_LANG_ZZZ';
try {
assertValidLang(`"><script>${SCRUB_SENTINEL}`);
assert.fail('expected throw');
} catch (error) {
assert.ok(error instanceof CspValidationError);
assert.ok(
!error.message.includes(SCRUB_SENTINEL),
'assertValidLang error must not echo raw lang input'
);
}
// --- buildDocumentHtml propagates nonce rejection ---
assert.throws(
() =>
buildDocumentHtml(
TEST_CSP_SOURCE,
asSanitizedHtml('<p>x</p>'),
'en',
'docs/README.md',
`${TEST_NONCE}"><script>`,
false
),
(err: unknown) => err instanceof CspValidationError,
'buildDocumentHtml must reject nonce breakout at the shell boundary'
);
// --- buildLoadingHtml / buildErrorHtml also validate nonce ---
assert.throws(
() => buildLoadingHtml(TEST_CSP_SOURCE, 'short'),
(err: unknown) => err instanceof CspValidationError,
'buildLoadingHtml must reject malformed nonce'
);
assert.throws(
() => buildErrorHtml(TEST_CSP_SOURCE, 'network', 'short'),
(err: unknown) => err instanceof CspValidationError,
'buildErrorHtml must reject malformed nonce'
);
// --- empty lang is coerced to 'en' inside the shell ---
// Passed through buildDocumentHtml to exercise the shell path, not the
// raw assertValidLang (which stays fail-closed on empty).
const coercedHtml = buildDocumentHtml(
TEST_CSP_SOURCE,
asSanitizedHtml('<p>x</p>'),
'',
'docs/README.md',
TEST_NONCE,
false
);
assert.ok(
coercedHtml.includes('<html lang="en">'),
'Empty locale must be coerced to lang="en" at the shell boundary'
);
console.log(' buildHtmlShell input validation passed!');
}
// ============================================================================
// Loading HTML Tests: buildLoadingHtml()
// ============================================================================
/**
* Test loading state structure and a11y
*/
function testBuildLoadingHtmlStructure(): void {
console.log('Testing buildLoadingHtml (structure)...');
const html = buildLoadingHtml(TEST_CSP_SOURCE, TEST_NONCE);
// Basic structure
assert.ok(html.includes('<!DOCTYPE html>'), 'Should have DOCTYPE');
assert.ok(html.includes('<meta charset="UTF-8">'), 'Should have charset meta');
assert.ok(html.includes('Content-Security-Policy'), 'Should have CSP meta');
assert.ok(html.includes(`<style nonce="${TEST_NONCE}">`), 'Style should have nonce');
// Loading content
assert.ok(html.includes('Loading documentation...'), 'Should have loading text');
assert.ok(html.includes('class="spinner"'), 'Should have spinner element');
assert.ok(html.includes('class="loading"'), 'Should have loading container');
// Animation
assert.ok(html.includes('@keyframes spin'), 'Should have spinner animation');
console.log(' buildLoadingHtml (structure) passed!');
}
/**
* Test ARIA attributes on spinner and status
*/
function testBuildLoadingHtmlAccessibility(): void {
console.log('Testing buildLoadingHtml (accessibility)...');
const html = buildLoadingHtml(TEST_CSP_SOURCE, TEST_NONCE);
// Spinner should be hidden from screen readers (decorative)
assert.ok(
html.includes('aria-hidden="true"'),
'Spinner should have aria-hidden="true"'
);
assert.ok(