Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,43 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
fortress-test-bundle:
name: FORTRESS TEST - isolated public config bundle
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'test/fortress-drive-execution-20261026'
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Checkout exact PR head
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Configure Node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1
with:
node-version: '22'
cache: npm
- name: Install pinned dependencies
run: npm ci --no-audit --no-fund
- id: fortress
name: Build and attest Google-enabled fake-only TEST
env:
BUILD_SHA: ${{ github.event.pull_request.head.sha }}
FORTRESS_TEST_GOOGLE_CONFIG: ${{ vars.FORTRESS_TEST_GOOGLE_CONFIG }}
GOOGLE_DRIVE_CONFIG: ${{ vars.FORTRESS_TEST_GOOGLE_CONFIG }}
run: |
node scripts/build-drive-test.mjs > "$RUNNER_TEMP/fortress-build-receipt.json"
node scripts/verify-fortress-artifact.mjs "$RUNNER_TEMP/fortress-build-receipt.json"
- name: Archive attested synthetic TEST only
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: fortress-control-vault-test-${{ github.event.pull_request.head.sha }}
path: ${{ steps.fortress.outputs.site_dir }}/
include-hidden-files: true
if-no-files-found: error
retention-days: 14
codeql:
name: CodeQL JavaScript/TypeScript
runs-on: ubuntu-24.04
Expand Down
8 changes: 7 additions & 1 deletion public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
// CacheStorage est partagé par toutes les applications d'une même origine GitHub Pages.
// Ne jamais supprimer les caches Race Control ou les caches tiers.
const CACHE_PREFIX = 'cv-shell-';
const VERSION = `${CACHE_PREFIX}v3`;
// Renouveler le namespace protège contre un ancien shell déjà remplacé par une page annexe.
// La migration efface uniquement les anciens caches cv-shell-, jamais les autres applications.
const VERSION = `${CACHE_PREFIX}v4`;
const APP_PATH = new URL(self.registration.scope).pathname;
const cached = (request) => caches.open(VERSION).then((cache) => cache.match(request));
async function remember(request, response) {
Expand Down Expand Up @@ -37,6 +39,10 @@ self.addEventListener('fetch', (event) => {

// Navigation : réseau d'abord (toujours la dernière version), cache en secours hors ligne.
if (req.mode === 'navigate') {
// Une page de reprise, une configuration ou tout autre document HTML ne doit
// jamais écraser ./index.html dans CacheStorage. Seuls les alias du shell
// bénéficient du repli hors ligne.
if (![APP_PATH, APP_PATH + 'index', APP_PATH + 'index.html'].includes(url.pathname)) return;
event.respondWith(
fetch(req)
.then((res) => {
Expand Down
2 changes: 1 addition & 1 deletion scripts/build-drive-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ let cfg = null;
try { if (text) cfg = GoogleDriveSession.config(JSON.parse(text), VAULT); } catch { throw new Error('Configuration publique TEST invalide.'); }
if (cfg && (cfg.raceOrigin !== RACE || cfg.vaultOrigin !== VAULT)) throw new Error('Origines TEST exactes requises.');
const base = fs.mkdtempSync(path.join(ROOT, '.drive-test-profile-')), site = path.join(base, 'site');
try { execFileSync(process.execPath, ['node_modules/vite/bin/vite.js', 'build'], { cwd: ROOT, env: { ...process.env, VAULT_OUT: site, BUILD_SHA: sha, VAULT_DEDICATED_BUILD: '1', GOOGLE_DRIVE_CONFIG: text }, stdio: 'pipe', timeout: 120000 }); }
try { execFileSync(process.execPath, ['node_modules/vite/bin/vite.js', 'build', '--mode', 'fortress-test'], { cwd: ROOT, env: { ...process.env, VAULT_OUT: site, BUILD_SHA: sha, VAULT_DEDICATED_BUILD: '1', GOOGLE_DRIVE_CONFIG: text }, stdio: 'pipe', timeout: 120000 }); }
catch { throw new Error('Build TEST refusé ; aucune sortie brute affichée.'); }
const p = name => path.join(site, name);
let html = fs.readFileSync(p('index.html'), 'utf8');
Expand Down
33 changes: 33 additions & 0 deletions scripts/verify-fortress-artifact.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {readFileSync,readdirSync,lstatSync,appendFileSync} from 'node:fs';
import {resolve,relative,join} from 'node:path';
import {createHash} from 'node:crypto';
const check=(yes,why)=>{if(!yes)throw new Error('FORTRESS TEST: '+why)};
const root=resolve(process.cwd());
check(process.argv[2] && /^[0-9a-f]{40}$/.test(process.env.BUILD_SHA||''),'receipt and source SHA required');
const receipt=JSON.parse(readFileSync(process.argv[2],'utf8'));
check(typeof receipt.site==='string','site missing');
const site=resolve(receipt.site);
check(/^\.drive-test-profile-[^/]+\/site$/.test(relative(root,site).replaceAll('\\','/')),'unexpected site folder');
check(lstatSync(site).isDirectory() && !lstatSync(site).isSymbolicLink(),'site not directory');
const v=JSON.parse(readFileSync(join(site,'version.json'),'utf8'));
const m=JSON.parse(readFileSync(join(site,'fixture-manifest.json'),'utf8'));
const sha=process.env.BUILD_SHA;
check(receipt.sourceSha===sha && v.sha===sha && m.sourceSha===sha,'source SHA mismatch');
check(receipt.googleEnabled===Boolean(process.env.FORTRESS_TEST_GOOGLE_CONFIG),'configuration mismatch');
// The owner configured TEST OAuth: fail closed instead of certifying Google-off artifacts.
check(receipt.googleEnabled===true,'Google TEST config missing or disabled: refused');
check(v.googleEnabled===receipt.googleEnabled && m.googleEnabled===receipt.googleEnabled,'Google config mismatch');
for(const obj of [v,m])check(obj.environment==='isolated-test' && obj.fixtureOnly===true,'not synthetic TEST');
check(m.raceOrigin==='https://rc-drive-test-ipoower.pages.dev' && m.vaultOrigin==='https://cv-drive-test-ipoower.pages.dev','origin mismatch');
check(m.files && typeof m.files==='object' && !Array.isArray(m.files),'no manifest');
const files=[];
function walk(dir,prefix=''){for(const e of readdirSync(dir,{withFileTypes:true})){const n=prefix+e.name,full=join(dir,e.name),s=lstatSync(full);check(!s.isSymbolicLink(),'symlink: '+n);if(s.isDirectory())walk(full,n+'/');else{check(s.isFile(),'nonfile: '+n);files.push(n)}}}
walk(site);
const actual=files.filter(n=>n!=='fixture-manifest.json').sort();
check(JSON.stringify(actual)===JSON.stringify(Object.keys(m.files).sort()),'manifest entries differ');
for(const name of actual){check(createHash('sha256').update(readFileSync(join(site,name))).digest('hex')===m.files[name],'file SHA mismatch: '+name);check(!/(^|\/)(encrypted|private|personal|\.env|\.git|node_modules)(\/|$)/i.test(name),'private path: '+name)}
for(const name of ['index.html','drive-config.json','version.json'])check(actual.includes(name),'missing: '+name);
check(actual.length>=6,'implausibly small build');
check(typeof process.env.GITHUB_OUTPUT==='string','CI output missing');
appendFileSync(process.env.GITHUB_OUTPUT,'site_dir='+site+'\n');
console.log('FORTRESS TEST: verified '+actual.length+' synthetic files, origin, version and SHA-256.');
4 changes: 2 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,9 @@ const shell = h(
h('nav', { class: 'app-links', 'aria-label': 'Mes applications' },
h('strong', null, 'Mes applications'),
h('div', null,
h('a', { href: 'https://race-control-ipoower.pages.dev/', target: '_blank', rel: 'noopener noreferrer', 'aria-label': 'Race Control (nouvel onglet)' }, 'Race Control ↗'),
h('a', { href: import.meta.env.MODE === 'fortress-test' ? 'https://rc-drive-test-ipoower.pages.dev/' : 'https://race-control-ipoower.pages.dev/', target: '_blank', rel: 'noopener noreferrer', 'aria-label': 'Race Control (nouvel onglet)' }, 'Race Control ↗'),
h('span', { 'aria-current': 'page' }, 'Control Vault'),
h('a', { href: 'https://ipoower.github.io/Reconversion-Control/', target: '_blank', rel: 'noopener noreferrer', 'aria-label': 'Reconversion Control (nouvel onglet)' }, 'Reconversion Control ↗'),
import.meta.env.MODE !== 'fortress-test' && h('a', { href: 'https://ipoower.github.io/Reconversion-Control/', target: '_blank', rel: 'noopener noreferrer', 'aria-label': 'Reconversion Control (nouvel onglet)' }, 'Reconversion Control ↗'),
),
),
),
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/drive-test-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ it('publie uniquement un profil TEST identifié, vérifiable et fermé sans clie
const manifest = JSON.parse(readFileSync(join(data.site, 'fixture-manifest.json'), 'utf8'));
expect(manifest.fixtureOnly).toBe(true); expect(manifest.sourceSha).toBe('a'.repeat(40)); expect(manifest.googleEnabled).toBe(false);
expect(readFileSync(join(data.site, 'index.html'), 'utf8')).toContain('TEST — DONNÉES FICTIVES UNIQUEMENT');
const scripts = Object.keys(manifest.files).filter(name => name.endsWith('.js'));
const code = scripts.map(name => readFileSync(join(data.site, name), 'utf8')).join('\n');
expect(code).not.toMatch(/https:\/\/(?:race-control-ipoower\.pages\.dev|ipoower\.github\.io\/Reconversion-Control)\//);
expect(code).toContain('https://rc-drive-test-ipoower.pages.dev/');
expect(readFileSync(join(data.site, 'drive-config.json'), 'utf8')).toBe('null');
for (const [name, hash] of Object.entries(manifest.files)) expect(createHash('sha256').update(readFileSync(join(data.site, name))).digest('hex')).toBe(hash);
expect(run(data.site)).toEqual([]);
Expand Down
59 changes: 47 additions & 12 deletions tests/unit/service-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type TestEvent = {
request?: { method: string; mode: string; url: string };
};

function harness(initial: string[], options: { fetch?: () => Promise<unknown>; hit?: boolean; failPut?: boolean } = {}) {
function harness(initial: string[], options: { fetch?: () => Promise<unknown>; hit?: boolean; failPut?: boolean; origin?: string; scope?: string } = {}) {
const names = new Set(initial);
const deleted: string[] = [];
const listeners = new Map<string, (event: TestEvent) => void>();
Expand All @@ -20,8 +20,8 @@ function harness(initial: string[], options: { fetch?: () => Promise<unknown>; h
let claimed = false;

const self = {
location: { origin: 'https://ipoower.github.io' },
registration: { scope: 'https://ipoower.github.io/Control-Vault/' },
location: { origin: options.origin ?? 'https://ipoower.github.io' },
registration: { scope: options.scope ?? 'https://ipoower.github.io/Control-Vault/' },
addEventListener: (type: string, fn: (event: TestEvent) => void) => listeners.set(type, fn),
skipWaiting: () => {},
clients: { claim: async () => { claimed = true; } },
Expand Down Expand Up @@ -57,16 +57,16 @@ function harness(initial: string[], options: { fetch?: () => Promise<unknown>; h

describe('Service Worker : isolation interprojets', () => {
it('ne supprime que les ANCIENS caches Control Vault et préserve Race Control et les autres applications', async () => {
const h = harness(['cv-shell-v0', 'cv-shell-v1', 'cv-shell-v2', 'cv-shell-v3', 'twrc-shell-v17', 'twrc-weather-v4', 'unrelated-cache']);
const h = harness(['cv-shell-v0', 'cv-shell-v1', 'cv-shell-v2', 'cv-shell-v3', 'cv-shell-v4', 'twrc-shell-v17', 'twrc-weather-v4', 'unrelated-cache']);
const activate = h.listeners.get('activate');
expect(activate).toBeTypeOf('function');
let pending: Promise<unknown> | undefined;
activate?.({ waitUntil: (promise) => { pending = promise; } });
expect(pending).toBeDefined();
await pending;

expect(h.deleted.sort()).toEqual(['cv-shell-v0', 'cv-shell-v1', 'cv-shell-v2']);
expect([...h.names].sort()).toEqual(['cv-shell-v3', 'twrc-shell-v17', 'twrc-weather-v4', 'unrelated-cache'].sort());
expect(h.deleted.sort()).toEqual(['cv-shell-v0', 'cv-shell-v1', 'cv-shell-v2', 'cv-shell-v3']);
expect([...h.names].sort()).toEqual(['cv-shell-v4', 'twrc-shell-v17', 'twrc-weather-v4', 'unrelated-cache'].sort());
expect(h.isClaimed()).toBe(true);
});

Expand All @@ -80,20 +80,20 @@ describe('Service Worker : isolation interprojets', () => {
});

it('utilise encore la page hors ligne en cas d’échec réseau', async () => {
const h = harness(['cv-shell-v3', 'twrc-shell-v17']);
const h = harness(['cv-shell-v4', 'twrc-shell-v17']);
let response: Promise<unknown> | undefined;
h.listeners.get('fetch')?.({
request: { method: 'GET', mode: 'navigate', url: 'https://ipoower.github.io/Control-Vault/' },
respondWith: (promise) => { response = promise; },
});
expect(response).toBeDefined();
await expect(response).resolves.toEqual({ offlineShell: true });
expect(h.matched).toEqual(['cv-shell-v3:./index.html']);
expect(h.matched).toEqual(['cv-shell-v4:./index.html']);
expect(h.deleted).toEqual([]);
});

it('ignore les requêtes externes et non GET', () => {
const h = harness(['cv-shell-v3', 'twrc-shell-v17']);
const h = harness(['cv-shell-v4', 'twrc-shell-v17']);
let responded = false;
for (const request of [
{ method: 'POST', mode: 'navigate', url: 'https://ipoower.github.io/Control-Vault/' },
Expand All @@ -117,7 +117,7 @@ describe('Service Worker : isolation interprojets', () => {
it('attend la mise en cache de l’interface réseau dans son seul cache', async () => {
const res = response(), h = harness(['twrc-shell-v17'], { fetch: async () => res });
expect(await request(h)).toBe(res);
expect(h.written).toEqual([{ cache: 'cv-shell-v3', request: './index.html' }]);
expect(h.written).toEqual([{ cache: 'cv-shell-v4', request: './index.html' }]);
expect(h.deleted).toEqual([]);
});
it('ne remplace pas son interface hors ligne par une erreur HTTP ou une réponse JSON', async () => {
Expand All @@ -136,7 +136,42 @@ describe('Service Worker : isolation interprojets', () => {
const res = response(true, 'text/javascript'), h = harness(['twrc-shell-v17'], { fetch: async () => res, hit: false });
const asset = { ...navigation, mode: 'cors', url: 'https://ipoower.github.io/Control-Vault/assets/fiction.js' };
expect(await request(h, asset)).toBe(res);
expect(h.written).toEqual([{ cache: 'cv-shell-v3', request: asset }]);
expect(h.matched.every(k => k.startsWith('cv-shell-v3:'))).toBe(true);
expect(h.written).toEqual([{ cache: 'cv-shell-v4', request: asset }]);
expect(h.matched.every(k => k.startsWith('cv-shell-v4:'))).toBe(true);
});
it('ne remplace jamais le shell par une navigation vers /recovery ou un JSON de configuration', () => {
const h = harness(['cv-shell-v4'], { fetch: async () => response(true, 'text/html') });
const paths = ['recovery', 'recovery.html', 'drive-config.json', 'fixture-manifest.json'];
for (const path of paths) {
let intercepted = false;
h.listeners.get('fetch')?.({
request: { method: 'GET', mode: 'navigate', url: 'https://ipoower.github.io/Control-Vault/' + path },
respondWith: () => { intercepted = true; },
});
expect(intercepted, 'le navigateur doit aller directement au réseau pour ' + path).toBe(false);
}
expect(h.written).toEqual([]);
});

it('applique aussi les frontières TEST Cloudflare à la racine, sans bloquer le shell', () => {
const origin = 'https://cv-drive-test-ipoower.pages.dev';
const h = harness(['cv-shell-v3'], { origin, scope: origin + '/' });
for (const pathname of ['/recovery', '/recovery.html', '/drive-config.json', '/fixture-manifest.json', '/autre-page.html']) {
let intercepted = false;
h.listeners.get('fetch')?.({
request: { method: 'GET', mode: 'navigate', url: origin + pathname },
respondWith: () => { intercepted = true; },
});
expect(intercepted, 'ne pas intercepter la route secondaire ' + pathname).toBe(false);
}
for (const pathname of ['/', '/index', '/index.html']) {
let intercepted = false;
h.listeners.get('fetch')?.({
request: { method: 'GET', mode: 'navigate', url: origin + pathname },
respondWith: () => { intercepted = true; },
});
expect(intercepted, 'la route shell doit rester interceptée ' + pathname).toBe(true);
}
});

});
Loading