@@ -5,7 +5,7 @@ import { createHash, randomUUID } from "node:crypto";
55import { IntegrationContext } from "../context" ;
66import type { ExternalOidcFixture , ManagedHost , VolumeRecord } from "../types" ;
77import { SshHost } from "../remote/ssh" ;
8- import { expectHttpBodyContains , expectHttpsJson , waitForHttpStatusResolved } from "../assertions/http" ;
8+ import { expectHttpBodyContains , readHttpsResponse , waitForHttpStatusResolved , type HttpsResponse } from "../assertions/http" ;
99import { expectLxdUi , expectManagementSurfaces , expectManagementUi , expectProtectedRoute } from "../assertions/browser" ;
1010import { expectRemoteContains , expectSystemdActive } from "../assertions/host" ;
1111import { collectHostArtifacts } from "../cleanup" ;
@@ -330,36 +330,95 @@ export async function verifyManagementSurfaces(
330330export async function verifyLxdApi ( host : ManagedHost , context ?: IntegrationContext ) : Promise < void > {
331331 await withStepTimeout ( `LXD API verification for ${ host . label } ` , LXD_API_VERIFY_TIMEOUT_MS , async ( ) => {
332332 context ?. logger . info ( `verify ${ host . label } LXD API` ) ;
333- await expectHttpsJson (
334- `https://${ host . domains . lxd } /1.0` ,
335- ( body ) => {
336- if ( ! isObject ( body ) ) {
337- throw new Error ( "LXD API root did not return an object" ) ;
338- }
339-
340- const metadata = body . metadata ;
341- if ( ! isObject ( metadata ) ) {
342- throw new Error ( "LXD API root did not include metadata" ) ;
343- }
344-
345- if ( ! Array . isArray ( metadata . api_extensions ) ) {
346- throw new Error ( "LXD API root did not include api_extensions" ) ;
347- }
348-
349- const auth = typeof metadata . auth === "string" ? metadata . auth . toLowerCase ( ) : "" ;
350- if ( ! auth ) {
351- throw new Error ( "LXD API root did not include auth state" ) ;
352- }
353- if ( auth === "trusted" ) {
354- throw new Error ( "LXD API root allowed trusted anonymous access" ) ;
355- }
356- } ,
357- { timeoutMs : LXD_API_POLL_TIMEOUT_MS , resolveIp : host . server . ipv4 }
358- ) ;
333+ const response = await waitForLxdApiRootResponse ( host ) ;
334+ assertSafeLxdApiRootResponse ( response , host . domains . lxd , host . domains . auth ) ;
359335 context ?. logger . info ( `verified ${ host . label } LXD API` ) ;
360336 } ) ;
361337}
362338
339+ async function waitForLxdApiRootResponse ( host : ManagedHost ) : Promise < HttpsResponse > {
340+ const deadline = Date . now ( ) + LXD_API_POLL_TIMEOUT_MS ;
341+ let lastError = "" ;
342+ while ( Date . now ( ) < deadline ) {
343+ try {
344+ return await readHttpsResponse ( `https://${ host . domains . lxd } /1.0` , {
345+ resolveIp : host . server . ipv4 ,
346+ headers : [ "Accept: application/json" ]
347+ } ) ;
348+ } catch ( error ) {
349+ lastError = error instanceof Error ? error . message : String ( error ) ;
350+ await Bun . sleep ( 5000 ) ;
351+ }
352+ }
353+
354+ throw new Error ( `timed out waiting for LXD API root; last error=${ lastError || "none" } ` ) ;
355+ }
356+
357+ export function assertSafeLxdApiRootResponse ( response : HttpsResponse , lxdHost : string , authHost ?: string ) : void {
358+ if ( [ 301 , 302 , 303 , 307 , 308 ] . includes ( response . status ) ) {
359+ const location = response . headers . match ( / ^ l o c a t i o n : \s * ( .+ ) $ / im) ?. [ 1 ] ?. trim ( ) ?? "" ;
360+ if ( isExpectedLxdAuthRedirect ( location , lxdHost , authHost ) ) {
361+ return ;
362+ }
363+ throw new Error ( `LXD API root redirected to unexpected location: ${ location || "<missing>" } ` ) ;
364+ }
365+
366+ if ( [ 401 , 403 ] . includes ( response . status ) ) {
367+ return ;
368+ }
369+
370+ if ( response . status < 200 || response . status >= 300 ) {
371+ throw new Error ( `LXD API root returned unexpected HTTP status ${ response . status } ` ) ;
372+ }
373+
374+ let body : unknown ;
375+ try {
376+ body = JSON . parse ( response . body ) as unknown ;
377+ } catch {
378+ throw new Error ( `LXD API root did not return JSON; body=${ response . body . replace ( / \s + / g, " " ) . trim ( ) . slice ( 0 , 400 ) || "<empty>" } ` ) ;
379+ }
380+
381+ if ( ! isObject ( body ) ) {
382+ throw new Error ( "LXD API root did not return an object" ) ;
383+ }
384+
385+ const metadata = body . metadata ;
386+ if ( ! isObject ( metadata ) ) {
387+ throw new Error ( "LXD API root did not include metadata" ) ;
388+ }
389+
390+ if ( ! Array . isArray ( metadata . api_extensions ) ) {
391+ throw new Error ( "LXD API root did not include api_extensions" ) ;
392+ }
393+
394+ const auth = typeof metadata . auth === "string" ? metadata . auth . toLowerCase ( ) : "" ;
395+ if ( ! auth ) {
396+ throw new Error ( "LXD API root did not include auth state" ) ;
397+ }
398+ if ( auth === "trusted" ) {
399+ throw new Error ( "LXD API root allowed trusted anonymous access" ) ;
400+ }
401+ }
402+
403+ function isExpectedLxdAuthRedirect ( location : string , lxdHost : string , authHost ?: string ) : boolean {
404+ if ( ! location ) {
405+ return false ;
406+ }
407+
408+ let target : URL ;
409+ try {
410+ target = new URL ( location , `https://${ lxdHost } ` ) ;
411+ } catch {
412+ return false ;
413+ }
414+
415+ if ( target . host === lxdHost ) {
416+ return target . pathname . startsWith ( "/oidc/" ) || target . pathname . startsWith ( "/ui/" ) ;
417+ }
418+
419+ return Boolean ( authHost && target . host === authHost ) ;
420+ }
421+
363422/** Verifies a real browser login through LXD's public OIDC flow. */
364423export async function verifyLxdUi (
365424 context : IntegrationContext ,
0 commit comments