@@ -67,6 +67,23 @@ export class CoreApi {
6767 return response ;
6868 }
6969
70+ // Core serves the web UI for any unrouted GET, so an endpoint the deployed core does not
71+ // have arrives as 200 text/html instead of a 404.
72+ private async requestJson < T > (
73+ method : string ,
74+ apiPath : string ,
75+ body ?: unknown ,
76+ ) : Promise < T > {
77+ const response = await this . request ( method , apiPath , body ) ;
78+ const contentType = response . headers . get ( "content-type" ) ?? "" ;
79+ if ( ! contentType . includes ( "application/json" ) ) {
80+ throw new Error (
81+ `Core API ${ method } ${ apiPath } returned ${ contentType || "no content type" } instead of JSON - the endpoint is missing from this core version` ,
82+ ) ;
83+ }
84+ return ( await response . json ( ) ) as T ;
85+ }
86+
7087 async login ( ) : Promise < void > {
7188 const response = await this . request ( "POST" , "/api/v1/auth" , {
7289 username : process . env . CORE_ADMIN_USER ?? "admin" ,
@@ -100,82 +117,125 @@ export class CoreApi {
100117 await this . request ( "DELETE" , `/api/v1/user/${ username } ` ) ;
101118 }
102119
103- async listNetworks ( ) : Promise <
104- Array < { id : number ; location_mfa_mode : LocationMfaMode } >
105- > {
106- const response = await this . request ( "GET" , "/api/v1/network" ) ;
107- return ( await response . json ( ) ) as Array < {
108- id : number ;
109- location_mfa_mode : LocationMfaMode ;
110- } > ;
120+ async listNetworks ( ) : Promise < Array < { id : number ; name : string } > > {
121+ return this . requestJson < Array < { id : number ; name : string } > > (
122+ "GET" ,
123+ "/api/v1/network" ,
124+ ) ;
111125 }
112126
113127 async addUserDevice ( name : string , pubkey : string ) : Promise < AddedUserDevice > {
114128 const username = process . env . CORE_ADMIN_USER ?? "admin" ;
115- const response = await this . request ( "POST" , `/api/v1/device/${ username } ` , {
129+ const data = await this . requestJson < {
130+ configs : DeviceConfig [ ] ;
131+ device : { id : number } ;
132+ } > ( "POST" , `/api/v1/device/${ username } ` , {
116133 name,
117134 wireguard_pubkey : pubkey ,
118135 } ) ;
119- const data = ( await response . json ( ) ) as {
120- configs : DeviceConfig [ ] ;
121- device : { id : number } ;
122- } ;
123136 return { deviceId : data . device . id , configs : data . configs } ;
124137 }
125138
126139 async deleteDevice ( deviceId : number ) : Promise < void > {
127140 await this . request ( "DELETE" , `/api/v1/device/${ deviceId } ` ) ;
128141 }
129142
143+ private async getNetworkDetails (
144+ networkId : number ,
145+ ) : Promise < Record < string , unknown > > {
146+ return this . requestJson < Record < string , unknown > > (
147+ "GET" ,
148+ `/api/v1/network/${ networkId } ` ,
149+ ) ;
150+ }
151+
130152 async setLocationMfaMode (
131153 networkId : number ,
132154 mode : LocationMfaMode ,
133155 ) : Promise < LocationMfaMode > {
134- const current = ( await (
135- await this . request ( "GET" , `/api/v1/network/${ networkId } ` )
136- ) . json ( ) ) as Record < string , unknown > ;
156+ const current = await this . getNetworkDetails ( networkId ) ;
137157 const previous = current . location_mfa_mode as LocationMfaMode ;
138158 if ( previous === mode ) {
139159 return previous ;
140160 }
141161 const joinList = ( value : unknown ) : string =>
142- Array . isArray ( value ) ? value . join ( "," ) : ( ( value as string | null ) ?? "" ) ;
162+ Array . isArray ( value )
163+ ? value . join ( "," )
164+ : typeof value === "string"
165+ ? value
166+ : "" ;
167+ const peerDisconnectThreshold = Number (
168+ current . peer_disconnect_threshold ?? 0 ,
169+ ) ;
143170 await this . request ( "PUT" , `/api/v1/network/${ networkId } ` , {
144171 name : current . name ,
145172 address : joinList ( current . address ) ,
146173 endpoint : current . endpoint ,
147174 port : current . port ,
148175 allowed_ips : joinList ( current . allowed_ips ) || null ,
149- dns : ( current . dns as string | null ) ?? null ,
176+ dns : typeof current . dns === " string" ? current . dns : null ,
150177 mtu : current . mtu ,
151178 fwmark : current . fwmark ,
152- allow_all_groups : current . allow_all_groups ,
153- allowed_groups : current . allowed_groups ?? [ ] ,
179+ allow_all_groups : current . allow_all_groups === true ,
180+ allowed_groups : Array . isArray ( current . allowed_groups )
181+ ? current . allowed_groups
182+ : [ ] ,
154183 keepalive_interval : current . keepalive_interval ,
155- peer_disconnect_threshold : Math . max (
156- Number ( current . peer_disconnect_threshold ?? 0 ) ,
157- MIN_PEER_DISCONNECT_THRESHOLD_WITH_MFA ,
158- ) ,
159- acl_enabled : current . acl_enabled ,
160- acl_default_allow : current . acl_default_allow ,
184+ peer_disconnect_threshold :
185+ mode === "disabled"
186+ ? peerDisconnectThreshold
187+ : Math . max (
188+ peerDisconnectThreshold ,
189+ MIN_PEER_DISCONNECT_THRESHOLD_WITH_MFA ,
190+ ) ,
191+ acl_enabled : current . acl_enabled === true ,
192+ acl_default_allow : current . acl_default_allow === true ,
161193 location_mfa_mode : mode ,
162- service_location_mode : current . service_location_mode ?? "disabled" ,
194+ service_location_mode :
195+ typeof current . service_location_mode === "string"
196+ ? current . service_location_mode
197+ : "disabled" ,
163198 } ) ;
164199 return previous ;
165200 }
166201
202+ // Core reports `mfa_required` during enrollment when any location on the instance enforces
203+ // internal MFA, so a test that expects no MFA has to clear every location, not just its own.
204+ async disableAllLocationMfa ( ) : Promise < Map < number , LocationMfaMode > > {
205+ const previous = new Map < number , LocationMfaMode > ( ) ;
206+ try {
207+ for ( const network of await this . listNetworks ( ) ) {
208+ previous . set (
209+ network . id ,
210+ await this . setLocationMfaMode ( network . id , "disabled" ) ,
211+ ) ;
212+ }
213+ } catch ( error ) {
214+ await this . restoreLocationMfaModes ( previous ) . catch ( ( ) => undefined ) ;
215+ throw error ;
216+ }
217+ return previous ;
218+ }
219+
220+ async restoreLocationMfaModes (
221+ modes : Map < number , LocationMfaMode > ,
222+ ) : Promise < void > {
223+ for ( const [ networkId , mode ] of modes ) {
224+ await this . setLocationMfaMode ( networkId , mode ) ;
225+ }
226+ }
227+
167228 private async startEnrollment (
168229 username : string ,
169230 ephemeral : boolean ,
170231 ) : Promise < EnrollmentFixture > {
171- const response = await this . request (
232+ const data = await this . requestJson < { enrollment_token : string } > (
172233 "POST" ,
173234 `/api/v1/user/${ username } /start_enrollment` ,
174235 {
175236 send_enrollment_notification : false ,
176237 } ,
177238 ) ;
178- const data = ( await response . json ( ) ) as { enrollment_token : string } ;
179239 return {
180240 username,
181241 enrollmentToken : data . enrollment_token ,
0 commit comments