8ebc1da9e4
- Astro statique, EN par defaut + /fr/ + /nl/, detection langue navigateur - i18n par fichiers JSON, ajouter une langue = ajouter des traductions - Contenu original porte a l'identique (diff d'inventaire par langue) - Chronologie legislative corrigee sur sources primaires (PE, Conseil, votes) - Timeline 23 precedents + observatoire 35 items + annuaire 66 outils FOSS, chaque affirmation verifiee et sourcee - Zero requete tierce (teste), lisible JS coupe, axe WCAG AA x2 themes - Version offline monofichier par langue a chaque build - Docker multi-stage vers nginx + CSP stricte auto-generee - CI GitHub Actions (SHA-pinnees) + verification hebdo des liens - CONTRIBUTING : divulgation d'affiliation obligatoire, allowlist de domaines testee en CI
75 lines
2.6 KiB
TypeScript
75 lines
2.6 KiB
TypeScript
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { describe, expect, it } from 'vitest'
|
|
import allowlist from '../../src/data/domains-allowlist.json'
|
|
|
|
// The anti-backlink gate, run against the BUILT site (ground truth).
|
|
// PR #1 taught us the lesson: a smuggled external link must never be
|
|
// mergeable silently. Every external hostname in dist/ must be declared
|
|
// in src/data/domains-allowlist.json (a reviewable diff), and every
|
|
// external anchor must carry the full safe rel.
|
|
//
|
|
// Requires `pnpm build` first (CI builds before testing).
|
|
|
|
const DIST = 'dist'
|
|
|
|
function htmlFiles(dir: string): string[] {
|
|
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
const path = join(dir, entry.name)
|
|
if (entry.isDirectory()) return htmlFiles(path)
|
|
return entry.name.endsWith('.html') ? [path] : []
|
|
})
|
|
}
|
|
|
|
const built = existsSync(DIST)
|
|
|
|
describe.skipIf(!built)('built site external links', () => {
|
|
const allowed = new Set<string>(allowlist.domains)
|
|
const anchorRe = /<a\s[^>]*href="(https?:\/\/[^"]+)"[^>]*>/gi
|
|
|
|
const anchors: { file: string; href: string; tag: string }[] = []
|
|
for (const file of htmlFiles(DIST)) {
|
|
const html = readFileSync(file, 'utf8')
|
|
for (const m of html.matchAll(anchorRe)) {
|
|
anchors.push({ file, href: m[1]!, tag: m[0]! })
|
|
}
|
|
}
|
|
|
|
it('found external anchors to audit (sanity)', () => {
|
|
expect(anchors.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('every external hostname is on the allowlist', () => {
|
|
const offenders = anchors
|
|
.map((a) => ({ ...a, host: new URL(a.href).hostname }))
|
|
.filter((a) => !allowed.has(a.host) && a.host !== 'exitchatcontrol.org')
|
|
expect(
|
|
offenders.map((o) => `${o.host} (${o.file})`),
|
|
'unlisted external domain in built output — add it to domains-allowlist.json ONLY after review',
|
|
).toEqual([])
|
|
})
|
|
|
|
it('every external anchor is https and carries rel="noopener noreferrer"', () => {
|
|
const offenders = anchors.filter(
|
|
(a) =>
|
|
!a.href.startsWith('https://') ||
|
|
!/rel="[^"]*noopener[^"]*"/.test(a.tag) ||
|
|
!/rel="[^"]*noreferrer[^"]*"/.test(a.tag),
|
|
)
|
|
expect(offenders.map((o) => o.tag)).toEqual([])
|
|
})
|
|
|
|
it('never links the domains PR #1 tried to smuggle in', () => {
|
|
for (const { tag } of anchors) {
|
|
expect(tag).not.toMatch(/nika\.sh|vpn-gratuit\.fr/)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('allowlist hygiene', () => {
|
|
it('is sorted and unique (reviewable diffs)', () => {
|
|
const domains = allowlist.domains
|
|
expect([...new Set(domains)].sort()).toEqual(domains)
|
|
})
|
|
})
|