Skip to content
Merged
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
56 changes: 52 additions & 4 deletions bin/ra-console/examples/e2e_console.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ async fn main() {
.await
.unwrap();
let dsn = format!("{}/{name}", base.rsplit_once('/').unwrap().0);
let _ = oe_castore::Postgres::open(&dsn).await.unwrap();
let store: Arc<dyn oe_castore::Store> =
Arc::new(oe_castore::Postgres::open(&dsn).await.unwrap());
let registry = Registry::connect(&dsn).await.unwrap();

let (token, root) = SoftToken::new(true).unwrap();
Expand Down Expand Up @@ -116,8 +117,10 @@ async fn main() {
.private_key_to_pkcs8()
.unwrap();
let b64 = base64::engine::general_purpose::STANDARD;
let pending: Vec<String> = (1..=8).map(|i| format!("tx-e2e-{i}")).collect();
let out = serde_json::json!({
"origin": origin.as_str().trim_end_matches('/'),
"pending": pending,
"operator": "alice",
"role": "ra_operateur",
"credential": {
Expand All @@ -134,14 +137,50 @@ async fn main() {
}
std::fs::write(&fixture, serde_json::to_vec_pretty(&out).unwrap()).unwrap();

// Un lien vers ca-server injoignable : la connexion et le poste n'en ont
// pas besoin (seul /healthz s'en soucie).
// Des demandes d'enrôlement en attente, pour les écrans de décision (6b).
for (i, tx) in pending.iter().enumerate() {
store
.create_request(oe_castore::Request {
transaction_id: tx.clone(),
csr_fingerprint: format!("empreinte-{tx}"),
csr_der: vec![0x30, 0x00],
profile: "tsa_signer".to_string(),
subject_cn: format!("tsu-{}.example.test", i + 1),
state: oe_castore::RequestState::Pending,
created_at: now,
decided_at: None,
operator: String::new(),
comment: String::new(),
issued_at: None,
certificate_serial: None,
})
.await
.unwrap();
}

// Le vrai service d'actions de ca-server, derrière son routeur interne et le
// lien mTLS : les décisions signées dans le navigateur y sont vérifiées.
let service = Arc::new(oe_actions::Service::new(
registry.clone(),
verifier(),
store.clone(),
oe_raflow::Decider::new(oe_raflow::DeciderOptions {
store: store.clone(),
recorder: None,
clock: None,
}),
Arc::new(NullJournal),
Arc::new(time::OffsetDateTime::now_utc),
));
let pki = common::pki().await;
let internal = pki
.serve_router(ca_server::internal::router(service, 64 * 1024))
.await;
let dir = common::tempdir::Dir::new();
let client = pki
.cert(&oe_ca_core::profile::internal_client(), "ra-console")
.await;
let link = CaLink::new(&pki.files(&dir, &client, 9)).unwrap();
let link = CaLink::new(&pki.files(&dir, &client, internal)).unwrap();
let pool = PgPoolOptions::new().connect(&dsn).await.unwrap();
let console = app(
Arc::new(AppState {
Expand Down Expand Up @@ -209,3 +248,12 @@ fn bytes_of(values: &[serde_cbor_2::Value]) -> Vec<u8> {
})
.collect()
}

struct NullJournal;

#[async_trait::async_trait]
impl oe_raflow::Recorder for NullJournal {
async fn append(&self, _: &str, _: serde_json::Value) -> Result<(), String> {
Ok(())
}
}
114 changes: 114 additions & 0 deletions bin/ra-console/web/dist/console.css
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,117 @@ button.quiet {
color: var(--undeclared-text);
border-bottom: 1px dashed var(--undeclared-text);
}

/* --- 6b : file des demandes, inspecteur, modale de signature (UI-UX §3.1, §5) --- */

.mono {
font-family: var(--font-mono);
font-variant-numeric: slashed-zero tabular-nums;
}
.split {
display: grid;
grid-template-columns: 1fr minmax(420px, 34%);
gap: 16px;
align-items: start;
}
table.dense {
width: 100%;
border-collapse: collapse;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
}
table.dense th {
position: sticky;
top: 0;
background: var(--bg-surface-elevated);
text-align: left;
font-size: 12px;
color: var(--text-muted);
}
table.dense th,
table.dense td {
padding: 8px 10px;
height: 38px;
border-bottom: 1px solid var(--border-subtle);
}
table.dense tr[aria-selected="true"] td {
background: var(--bg-surface-elevated);
}
table.dense tr[aria-selected="true"] td:first-child {
box-shadow: inset 3px 0 0 var(--border-focus);
}
.badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
}
.badge.pending {
background: #451a03;
color: #fbbf24;
border: 1px dashed #d97706;
}
.inspector {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 6px;
padding: 16px;
}
.inspector h2,
dialog.signature h2 {
font-size: 16px;
margin: 0 0 12px;
}
dl {
display: grid;
grid-template-columns: max-content 1fr;
gap: 6px 16px;
margin: 0 0 16px;
}
dt {
color: var(--text-muted);
}
dd {
margin: 0;
overflow-wrap: anywhere;
}
.actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
dialog.signature {
width: min(640px, 92vw);
background: var(--bg-surface-elevated);
color: var(--text-main);
border: 1px solid var(--border-subtle);
border-radius: 8px;
padding: 24px;
}
dialog.signature::backdrop {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
}
dialog.signature textarea {
width: 100%;
font: inherit;
color: var(--text-main);
background: var(--bg-canvas);
border: 1px solid var(--border-subtle);
border-radius: 4px;
padding: 8px;
margin: 8px 0 16px;
}
.frozen pre {
background: var(--bg-canvas);
border: 1px solid var(--border-subtle);
border-radius: 4px;
padding: 12px;
overflow: auto;
max-height: 240px;
font-size: 12px;
}
.hash {
font-size: 13px;
word-spacing: 0.4em;
}
2 changes: 1 addition & 1 deletion bin/ra-console/web/dist/console.js

Large diffs are not rendered by default.

58 changes: 3 additions & 55 deletions bin/ra-console/web/e2e/console.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,60 +2,8 @@
// docs/UI-UX.md §6.3) : en-têtes de sécurité, connexion par clé FIDO2,
// déconnexion, verrouillage après inactivité.

import { expect, test, type Page } from "@playwright/test";
import { readFileSync } from "node:fs";
import { fixturePath } from "../playwright.config";

interface Fixture {
operator: string;
credential: Record<string, unknown> & { signCount: number };
}

const fixture = (): Fixture => JSON.parse(readFileSync(fixturePath, "utf8")) as Fixture;

// Chaque test recrée un authentificateur : son compteur doit dépasser celui
// déjà vu par la console, sans quoi elle détecte (à juste titre) un clone.
let signCountBase = 1000;

async function withOperatorKey(page: Page): Promise<void> {
const cdp = await page.context().newCDPSession(page);
await cdp.send("WebAuthn.enable");
const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
// La clé de test a été enregistrée par le SoftToken, qui s'annonce
// `internal` : le navigateur ne consulte que les authentificateurs de
// ce transport (les options relaient les transports enregistrés).
transport: "internal",
hasResidentKey: false,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
});
signCountBase += 1000;
await cdp.send("WebAuthn.addCredential", {
authenticatorId,
credential: { ...fixture().credential, signCount: fixture().credential.signCount + signCountBase },
} as never);
}

/// Exceptions de la page et violations de CSP : aucune n'est admise. Les
/// réponses d'erreur HTTP attendues (401 avant connexion) n'en sont pas.
function collectErrors(page: Page): string[] {
const errors: string[] = [];
page.on("console", (m) => {
if (m.type() === "error" && !m.text().startsWith("Failed to load resource")) errors.push(m.text());
});
page.on("pageerror", (e) => errors.push(e.message));
return errors;
}

async function logIn(page: Page): Promise<void> {
await page.getByTestId("login-name").fill(fixture().operator);
await page.getByTestId("login-submit").click();
await expect(page.getByTestId("operator")).toHaveText(fixture().operator);
}
import { expect, test } from "@playwright/test";
import { collectErrors, logIn, withOperatorKey } from "./helpers";

test("chaque réponse porte les en-têtes de sécurité, sans script en ligne", async ({ request }) => {
for (const path of ["/", "/assets/console.js", "/api/v1/console", "/api/v1/me"]) {
Expand All @@ -79,7 +27,7 @@ test("connexion par clé FIDO2, puis déconnexion qui révoque la session", asyn
await expect(page.getByTestId("env-banner")).toHaveText("STAGING");
await logIn(page);
await expect(page.getByTestId("role")).toHaveText("opérateur RA");
await expect(page.getByTestId("count-requests")).toHaveText("(0)");
await expect(page.getByTestId("count-requests")).toHaveText(/^\(\d+\)$/);
await expect(page.getByTestId("count-quorum")).toHaveText("(0)");

await page.getByTestId("logout").click();
Expand Down
60 changes: 60 additions & 0 deletions bin/ra-console/web/e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Aides communes aux parcours Playwright : la fixture écrite par
// examples/e2e_console.rs, la clé de l'opérateur confiée à l'authentificateur
// WebAuthn virtuel, la connexion.

import { expect, type Page } from "@playwright/test";
import { readFileSync } from "node:fs";
import { fixturePath } from "../playwright.config";

export interface Fixture {
operator: string;
pending: string[];
credential: Record<string, unknown> & { signCount: number };
}

export const fixture = (): Fixture => JSON.parse(readFileSync(fixturePath, "utf8")) as Fixture;

// Chaque test recrée un authentificateur : son compteur doit dépasser ceux
// déjà vus par la console et par ca-server, sans quoi ils détectent (à juste
// titre) un clone. Module partagé : le compteur croît d'un fichier à l'autre.
let signCountBase = 1000;

export async function withOperatorKey(page: Page): Promise<void> {
const cdp = await page.context().newCDPSession(page);
await cdp.send("WebAuthn.enable");
const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
// La clé de test a été enregistrée par le SoftToken, qui s'annonce
// `internal` : le navigateur ne consulte que les authentificateurs de
// ce transport (les options relaient les transports enregistrés).
transport: "internal",
hasResidentKey: false,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
});
signCountBase += 1000;
await cdp.send("WebAuthn.addCredential", {
authenticatorId,
credential: { ...fixture().credential, signCount: fixture().credential.signCount + signCountBase },
} as never);
}

/// Exceptions de la page et violations de CSP : aucune n'est admise. Les
/// réponses d'erreur HTTP attendues (401 avant connexion) n'en sont pas.
export function collectErrors(page: Page): string[] {
const errors: string[] = [];
page.on("console", (m) => {
if (m.type() === "error" && !m.text().startsWith("Failed to load resource")) errors.push(m.text());
});
page.on("pageerror", (e) => errors.push(e.message));
return errors;
}

export async function logIn(page: Page): Promise<void> {
await page.getByTestId("login-name").fill(fixture().operator);
await page.getByTestId("login-submit").click();
await expect(page.getByTestId("operator")).toHaveText(fixture().operator);
}
Loading
Loading