Переглянути джерело

支持9119双模式认证并补齐连接设置

samien 1 місяць тому
батько
коміт
8fcc38b4d4

+ 14 - 0
src/client/api/admin.ts

@@ -38,6 +38,20 @@ export async function setUpstreamCredentials(username: string, password: string)
   })
 }
 
+export interface UpstreamConnectionStatus {
+  endpoint: string
+  authMode: 'unknown' | 'loopback-token' | 'loopback-direct' | 'password'
+  networkScope: 'local' | 'network'
+  webNetworkScope: 'local' | 'network'
+  ready: boolean
+  error?: string
+  lastVerifiedAt?: number
+}
+
+export function getUpstreamConnectionStatus(): Promise<UpstreamConnectionStatus> {
+  return apiRequest('/api/app/admin/upstream-connection')
+}
+
 export interface AllowedHostsSettings {
   source: 'none' | 'file' | 'environment'
   hosts: string[]

+ 42 - 5
src/client/components/app/SystemManagementPanel.vue

@@ -5,12 +5,14 @@ import {
   createUser,
   deleteUser,
   getAllowedHostsSettings,
+  getUpstreamConnectionStatus,
   listUsers,
   saveAllowedHostsSettings,
   setUpstreamCredentials,
   updateUser,
   type AllowedHostsSettings,
   type ManagedUser,
+  type UpstreamConnectionStatus,
 } from '@/api/admin'
 import {
   getPushSystemStatus,
@@ -41,6 +43,7 @@ const password = ref('')
 const upstreamUsername = ref('')
 const upstreamPassword = ref('')
 const connectionBaseline = ref({ username: '', password: '' })
+const connectionStatus = ref<UpstreamConnectionStatus>()
 const allowedHostsSettings = ref<AllowedHostsSettings>()
 const allowedHostsText = ref('')
 const allowedHostsBaseline = ref('')
@@ -66,6 +69,19 @@ const fcmBusy = ref(false)
 const fcmError = ref('')
 const fcmNotice = ref('')
 const currentAccessHost = window.location.hostname || 'localhost'
+const effectiveUpstreamReady = computed(() => connectionStatus.value?.ready ?? props.upstreamReady)
+const effectiveUpstreamError = computed(() => connectionStatus.value?.error || props.upstreamError)
+const passwordAuthentication = computed(() => !['loopback-token', 'loopback-direct'].includes(connectionStatus.value?.authMode ?? 'unknown'))
+const authModeLabel = computed(() => {
+  if (connectionStatus.value?.authMode === 'loopback-token') return '本机 Session Token(自动)'
+  if (connectionStatus.value?.authMode === 'loopback-direct') return '本机直连(兼容模式)'
+  if (connectionStatus.value?.authMode === 'password') return '用户名和密码'
+  return '等待检测'
+})
+const verifiedAtLabel = computed(() => {
+  const value = connectionStatus.value?.lastVerifiedAt
+  return value === undefined ? '尚未成功验证' : new Date(value).toLocaleString()
+})
 
 interface APNsFormSnapshot {
   keyFile: string
@@ -174,8 +190,12 @@ async function refresh() {
     return
   }
   if (props.section === 'connection') {
-    const settings = await getAllowedHostsSettings()
+    const [settings, status] = await Promise.all([
+      getAllowedHostsSettings(),
+      getUpstreamConnectionStatus(),
+    ])
     allowedHostsSettings.value = settings
+    connectionStatus.value = status
     allowedHostsText.value = settings.editableHosts.join('\n')
     allowedHostsBaseline.value = allowedHostsText.value
     return
@@ -245,7 +265,7 @@ async function saveUpstream() {
     upstreamUsername.value = submittedUsername
     upstreamPassword.value = ''
     connectionBaseline.value = { username: submittedUsername, password: '' }
-  }, false)
+  })
 }
 
 function parsedAllowedHosts(): string[] {
@@ -394,9 +414,18 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
     </div>
 
     <div v-else-if="section === 'connection'" class="block">
-      <h3>9119 服务账号</h3>
-      <p :class="{ ok: upstreamReady }">{{ upstreamReady ? '上游连接正常' : upstreamError || '尚未验证上游连接' }}</p>
-      <form @submit.prevent="saveUpstream">
+      <h3>Hermes 连接</h3>
+      <p :class="{ ok: effectiveUpstreamReady }">{{ effectiveUpstreamReady ? '9119 连接正常' : effectiveUpstreamError || '尚未验证上游连接' }}</p>
+      <div class="connection-summary" aria-label="Hermes 连接状态">
+        <span><small>9119 地址</small><b>{{ connectionStatus?.endpoint || '等待检测' }}</b></span>
+        <span><small>认证模式</small><b>{{ authModeLabel }}</b></span>
+        <span><small>8800 网络范围</small><b>{{ connectionStatus?.webNetworkScope === 'local' ? '仅本机' : '局域网可访问' }}</b></span>
+        <span><small>9119 网络范围</small><b>{{ connectionStatus?.networkScope === 'network' ? '网络可访问' : '仅本机' }}</b></span>
+        <span class="wide"><small>最近验证</small><b>{{ verifiedAtLabel }}</b></span>
+      </div>
+      <p v-if="connectionStatus?.authMode === 'loopback-token'" class="connection-mode-note">9119 使用本机临时 Token,8800 会自动读取并在 9119 重启后刷新;Token 不会在设置中显示。</p>
+      <p v-else-if="connectionStatus?.authMode === 'loopback-direct'" class="connection-mode-note">当前 9119 允许本机直接访问,不需要账号或 Token;该兼容模式只允许回环地址使用。</p>
+      <form v-if="passwordAuthentication" @submit.prevent="saveUpstream">
         <label><span>9119 用户名</span><input v-model="upstreamUsername" name="upstream-username" autocomplete="off" :disabled="busy" /></label>
         <label><span>9119 密码</span><input v-model="upstreamPassword" name="upstream-password" type="password" autocomplete="new-password" :disabled="busy" /></label>
         <button class="solid-button" :disabled="busy || !upstreamUsername.trim() || !upstreamPassword">验证并保存</button>
@@ -493,6 +522,12 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
 .block h3 { margin: 0 0 14px; font-size: 16px; }
 .block p { color: var(--danger); font-size: 13px; }
 .block p.ok { color: var(--success, #21845b); }
+.connection-summary { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 14px; }
+.connection-summary span { display: flex; min-width: 0; flex-direction: column; gap: 5px; padding: 12px; border-radius: 9px; background: var(--surface-soft); }
+.connection-summary span.wide { grid-column: 1 / -1; }
+.connection-summary small { color: var(--text-muted); font-size: 11px; }
+.connection-summary b { overflow-wrap: anywhere; color: var(--text-primary); font-size: 13px; }
+.connection-mode-note { margin: 12px 0 0; padding: 9px 10px; border-radius: 9px; background: var(--surface-soft); color: var(--text-muted) !important; line-height: 1.55; }
 .network-description { margin: 0; color: var(--text-muted) !important; line-height: 1.6; }
 .network-access-block { margin-top: 26px; padding-top: 24px; border-top: 1px solid var(--line); }
 .network-description code { color: var(--text-secondary); }
@@ -551,5 +586,7 @@ input:not([type="checkbox"]):focus { border-color: var(--line-strong); box-shado
   .push-status { grid-template-columns: 1fr; }
   .push-config-form { grid-template-columns: 1fr; }
   .push-config-form .wide { grid-column: 1; }
+  .connection-summary { grid-template-columns: 1fr; }
+  .connection-summary span.wide { grid-column: 1; }
 }
 </style>

+ 57 - 4
src/server/localAuth.ts

@@ -453,6 +453,8 @@ export class UpstreamServiceSession {
   #validatedUntil = 0
   #credentialFingerprint: string | undefined
   #generation = 0
+  #authMode: 'unknown' | 'loopback-token' | 'loopback-direct' | 'password' = 'unknown'
+  #lastVerifiedAt: number | undefined
   readonly #now: () => number
   readonly #validationTTL: number
 
@@ -466,12 +468,26 @@ export class UpstreamServiceSession {
     client.setReauthenticationHandler(this.#jar, async () => {
       this.#syncCredentials()
       this.#validatedUntil = 0
-      await this.#login(this.#generation)
+      await this.ensure()
     })
   }
 
   get jar(): CookieJar { return this.#jar }
 
+  connectionInfo(): {
+    endpoint: string
+    authMode: 'unknown' | 'loopback-token' | 'loopback-direct' | 'password'
+    networkScope: 'local' | 'network'
+    lastVerifiedAt?: number
+  } {
+    return {
+      endpoint: this.client.baseURL.origin,
+      authMode: this.#authMode,
+      networkScope: this.#isLoopbackUpstream() ? 'local' : 'network',
+      lastVerifiedAt: this.#lastVerifiedAt,
+    }
+  }
+
   async ensure(): Promise<void> {
     this.#syncCredentials()
     if (this.#validatedUntil > this.#now()) return
@@ -479,7 +495,8 @@ export class UpstreamServiceSession {
     const generation = this.#generation
     const task = this.#validate(generation).then(() => {
       this.#checkGeneration(generation)
-      this.#validatedUntil = this.#now() + this.#validationTTL
+      this.#lastVerifiedAt = this.#now()
+      this.#validatedUntil = this.#lastVerifiedAt + this.#validationTTL
     }).finally(() => { if (this.#ensureTask === task) this.#ensureTask = undefined })
     this.#ensureTask = task
     return task
@@ -493,6 +510,7 @@ export class UpstreamServiceSession {
       this.#generation++
       this.#validatedUntil = 0; this.#ensureTask = undefined; this.#loginTask = undefined
       this.#jar.replace(new CookieJar())
+      this.#authMode = 'unknown'
       this.client.invalidateReads()
     }
     this.#credentialFingerprint = fingerprint
@@ -511,10 +529,24 @@ export class UpstreamServiceSession {
     if (status.status < 200 || status.status >= 300) throw new HttpError(502, '9119 状态不可用', 'upstream_unavailable')
     const parsed = JSON.parse(status.body.toString('utf8')) as Record<string, unknown>
     this.#checkGeneration(generation)
-    if (parsed.auth_required !== true && parsed.authRequired !== true) { this.#jar.replace(probe); return }
+    if (parsed.auth_required !== true && parsed.authRequired !== true) {
+      if (!this.#isLoopbackUpstream()) {
+        throw new HttpError(502, '9119 未启用认证,且不是本机回环地址', 'upstream_auth_unsafe')
+      }
+      this.#authMode = await this.#loadLoopbackToken(probe)
+        ? 'loopback-token'
+        : 'loopback-direct'
+      this.#checkGeneration(generation)
+      this.#jar.replace(probe)
+      return
+    }
+    this.#authMode = 'password'
     const me = await this.client.request('/api/auth/me', probe)
     this.#checkGeneration(generation)
-    if (me.status >= 200 && me.status < 300) { this.#jar.replace(probe); return }
+    if (me.status >= 200 && me.status < 300) {
+      this.#jar.replace(probe)
+      return
+    }
     if (me.status !== 401 && me.status !== 403) throw new HttpError(502, '无法验证 9119 服务会话', 'upstream_auth_unavailable')
     await this.#login(generation)
   }
@@ -536,6 +568,7 @@ export class UpstreamServiceSession {
     const task = this.#authenticate({ ...credentials }, jar).then(() => {
       this.#checkGeneration(generation)
       this.#jar.replace(jar)
+      this.#authMode = 'password'
       this.client.invalidateReads()
       this.#validatedUntil = this.#now() + this.#validationTTL
     }).finally(() => { if (this.#loginTask === task) this.#loginTask = undefined })
@@ -554,4 +587,24 @@ export class UpstreamServiceSession {
       throw new HttpError(503, '9119 服务账号验证失败', 'upstream_credentials_invalid')
     }
   }
+
+  #isLoopbackUpstream(): boolean {
+    const hostname = this.client.baseURL.hostname.toLowerCase().replace(/^\[|\]$/g, '')
+    return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
+  }
+
+  async #loadLoopbackToken(jar: CookieJar): Promise<boolean> {
+    const response = await this.client.request('/', jar, {
+      headers: { accept: 'text/html' },
+      maxResponseBytes: 2 * 1_024 * 1_024,
+    })
+    if (response.status < 200 || response.status >= 300) return false
+    const html = response.body.toString('utf8')
+    const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(html)
+    let token: unknown
+    try { token = match ? JSON.parse(match[1]!) : undefined } catch { token = undefined }
+    if (typeof token !== 'string' || token.length < 16 || token.length > 4_096) return false
+    jar.setSessionToken(token)
+    return true
+  }
 }

+ 17 - 0
src/server/routes.ts

@@ -1731,6 +1731,23 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     dependencies.auth.setUpstreamCredentials(admin, { username, password })
     json(ctx, 200, { ok: true })
   })
+  router.get('/api/app/admin/upstream-connection', async (ctx) => {
+    dependencies.auth.requireAdmin(ctx)
+    let ready = false
+    let error: string | undefined
+    try {
+      await dependencies.upstreamSession.ensure()
+      ready = true
+    } catch (cause) {
+      error = cause instanceof Error ? cause.message : '9119 不可用'
+    }
+    json(ctx, 200, {
+      ...dependencies.upstreamSession.connectionInfo(),
+      ready,
+      error,
+      webNetworkScope: isLoopbackHost(dependencies.config.host) ? 'local' : 'network',
+    })
+  })
   router.get('/api/app/admin/model-services', async (ctx) => {
     await proxyAdminFeature(ctx, dependencies, '/api/providers/custom-endpoints', {
       search: searchFrom(ctx, ['profile']),

+ 9 - 0
src/server/upstream.ts

@@ -48,6 +48,7 @@ function browserCookie(value: string, publicSecure: boolean): string | null {
 export class CookieJar {
   readonly #values = new Map<string, string>()
   readonly #browserCookies = new Map<string, string>()
+  #sessionToken: string | undefined
 
   constructor(cookieHeader?: string) {
     for (const [name, value] of Object.entries(parse(cookieHeader ?? ''))) {
@@ -72,9 +73,16 @@ export class CookieJar {
     return [...this.#values.entries()].map(([name, value]) => `${name}=${value}`).join('; ')
   }
 
+  get sessionToken(): string | undefined { return this.#sessionToken }
+
+  setSessionToken(value: string | undefined): void {
+    this.#sessionToken = value
+  }
+
   replace(other: CookieJar): void {
     this.#values.clear(); this.#browserCookies.clear()
     for (const [name, value] of other.#values) this.#values.set(name, value)
+    this.#sessionToken = other.#sessionToken
   }
 
   get browserCookies(): string[] {
@@ -213,6 +221,7 @@ export class UpstreamClient {
       if (/^[0-9a-f:.]+$/i.test(clientAddress)) headers.set('x-forwarded-for', clientAddress)
     }
     if (jar.header) headers.set('cookie', jar.header)
+    if (jar.sessionToken) headers.set('x-hermes-session-token', jar.sessionToken)
     let body: BodyInit | undefined
     if (options.rawBody !== undefined) {
       body = options.rawBody

+ 2 - 1
tests/client/allowedHostsPanel.test.ts

@@ -43,9 +43,10 @@ describe('allowed hosts settings panel', () => {
 
     expect(calls.map(call => call.path)).toEqual([
       '/api/app/system/allowed-hosts',
+      '/api/app/admin/upstream-connection',
       '/api/app/system/allowed-hosts',
     ])
-    expect(calls[1]!.init?.body).toBe(JSON.stringify({
+    expect(calls[2]!.init?.body).toBe(JSON.stringify({
       hosts: ['yaoyao.example.com', '203.0.113.10'],
     }))
     expect(wrapper.text()).toContain('允许的访问地址已保存并立即生效')

+ 33 - 1
tests/client/systemManagementDialog.test.ts

@@ -2,7 +2,7 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { nextTick } from 'vue'
 import SystemManagementDialog from '@/components/app/SystemManagementDialog.vue'
-import { listUsers } from '@/api/admin'
+import { getUpstreamConnectionStatus, listUsers } from '@/api/admin'
 import { getPushSystemStatus, saveFCMPushSystemConfig, savePushSystemConfig, type PushSystemStatus } from '@/api/push'
 
 vi.mock('@/api/admin', () => ({
@@ -11,6 +11,10 @@ vi.mock('@/api/admin', () => ({
   getAllowedHostsSettings: vi.fn(async () => ({
     source: 'none', hosts: [], editableHosts: [], environmentHosts: [],
   })),
+  getUpstreamConnectionStatus: vi.fn(async () => ({
+    endpoint: 'http://127.0.0.1:9119', authMode: 'loopback-token', networkScope: 'local',
+    webNetworkScope: 'network', ready: true, lastVerifiedAt: 1_788_273_600_000,
+  })),
   listUsers: vi.fn(async () => []),
   saveAllowedHostsSettings: vi.fn(async (hosts: string[]) => ({
     source: 'file', hosts, editableHosts: hosts, environmentHosts: [],
@@ -80,6 +84,34 @@ afterEach(() => {
 })
 
 describe('System management APNs configuration', () => {
+  it('shows automatic loopback authentication and separate 8800/9119 network boundaries', async () => {
+    const wrapper = await mountOpen()
+
+    expect(document.body.textContent).toContain('本机 Session Token(自动)')
+    expect(document.body.textContent).toContain('http://127.0.0.1:9119')
+    expect(document.body.textContent).toContain('局域网可访问')
+    expect(document.body.textContent).toContain('9119 网络范围')
+    expect(document.body.textContent).toContain('仅本机')
+    expect(input('upstream-username')).toBeNull()
+    expect(document.body.textContent).toContain('Token 不会在设置中显示')
+    wrapper.unmount()
+  })
+
+  it('shows credential controls when 9119 requires password authentication', async () => {
+    vi.mocked(getUpstreamConnectionStatus).mockResolvedValueOnce({
+      endpoint: 'https://hermes.example.test', authMode: 'password', networkScope: 'network',
+      webNetworkScope: 'network', ready: false, error: '9119 服务账号验证失败',
+    })
+    const wrapper = await mountOpen()
+
+    expect(document.body.textContent).toContain('用户名和密码')
+    expect(document.body.textContent).toContain('9119 服务账号验证失败')
+    expect(input('upstream-username')).not.toBeNull()
+    expect(input('upstream-password')).not.toBeNull()
+    expect(button('验证并保存')).not.toBeUndefined()
+    wrapper.unmount()
+  })
+
   it('includes the target username in every row action accessible name', async () => {
     vi.mocked(listUsers).mockResolvedValueOnce([{
       id: 'user-1',

+ 10 - 0
tests/server/localAuthRoutes.test.ts

@@ -69,6 +69,16 @@ describe('8800 local authentication routes', () => {
     const ready = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
     expect(ready.body).toMatchObject({ upstreamReady: true, serverKind: 'yaoyao-web' })
     expect(ready.body.profiles).toHaveLength(1)
+    const connection = await agent.get('/api/app/admin/upstream-connection')
+      .set('Host', '127.0.0.1:8800').expect(200)
+    expect(connection.body).toMatchObject({
+      endpoint: 'http://127.0.0.1:9119',
+      authMode: 'password',
+      networkScope: 'local',
+      webNetworkScope: 'local',
+      ready: true,
+    })
+    expect(connection.body.lastVerifiedAt).toEqual(expect.any(Number))
 
     await agent.post('/api/app/admin/users')
       .set('Host', '127.0.0.1:8800').set('Origin', 'http://127.0.0.1:8800')

+ 83 - 1
tests/server/upstreamServiceSession.test.ts

@@ -10,7 +10,11 @@ describe('upstream service validation lease', () => {
     const fetch = vi.fn<typeof globalThis.fetch>(async (input, init) => {
       const path = new URL(String(input)).pathname; paths.push(path)
       if (path === '/api/status') return Response.json({ auth_required: true })
-      if (path === '/api/auth/me') return Response.json({ user_id: 'service' })
+      if (path === '/api/auth/me') {
+        return expire && new Headers(init?.headers).get('cookie') === 'session=old'
+          ? Response.json({ detail: 'expired' }, { status: 401 })
+          : Response.json({ user_id: 'service' })
+      }
       if (path === '/api/auth/providers') return Response.json({ providers: [] })
       if (path === '/auth/password-login') return Response.json({ ok: true }, { headers: { 'set-cookie': 'session=new; Path=/' } })
       if (expire && new Headers(init?.headers).get('cookie') === 'session=old') return Response.json({ error: 'expired' }, { status: 401 })
@@ -96,4 +100,82 @@ describe('upstream service validation lease', () => {
     expect(await old).toMatchObject({ code: 'upstream_credentials_changed' })
     expect(session.jar.header).toBe('session=new')
   })
+
+  it('uses the injected loopback session token without password login', async () => {
+    const paths: string[] = []
+    const fetch = vi.fn<typeof globalThis.fetch>(async (input, init) => {
+      const path = new URL(String(input)).pathname
+      paths.push(path)
+      if (path === '/api/status') return Response.json({ auth_required: false })
+      if (path === '/') {
+        return new Response('<script>window.__HERMES_SESSION_TOKEN__="loopback-token-123456";</script>', {
+          headers: { 'content-type': 'text/html' },
+        })
+      }
+      const token = new Headers(init?.headers).get('x-hermes-session-token')
+      return token === 'loopback-token-123456'
+        ? Response.json({ ok: true })
+        : Response.json({ detail: 'Unauthorized' }, { status: 401 })
+    })
+    const client = new UpstreamClient(new URL('http://127.0.0.1:9119'), fetch)
+    const session = new UpstreamServiceSession(client, () => ({ username: 'unused', password: 'unused' }))
+
+    expect((await session.request('/api/sessions')).status).toBe(200)
+    expect(paths).toEqual(['/api/status', '/', '/api/sessions'])
+    expect(session.connectionInfo()).toMatchObject({
+      endpoint: 'http://127.0.0.1:9119',
+      authMode: 'loopback-token',
+      networkScope: 'local',
+    })
+  })
+
+  it('refreshes a rotated loopback token once and replays parallel reads', async () => {
+    let activeToken = 'loopback-token-first'
+    let pageLoads = 0
+    const fetch = vi.fn<typeof globalThis.fetch>(async (input, init) => {
+      const path = new URL(String(input)).pathname
+      if (path === '/api/status') return Response.json({ auth_required: false })
+      if (path === '/') {
+        pageLoads += 1
+        return new Response(`<script>window.__HERMES_SESSION_TOKEN__=${JSON.stringify(activeToken)};</script>`)
+      }
+      return new Headers(init?.headers).get('x-hermes-session-token') === activeToken
+        ? Response.json({ ok: true })
+        : Response.json({ detail: 'Unauthorized' }, { status: 401 })
+    })
+    const client = new UpstreamClient(new URL('http://localhost:9119'), fetch)
+    const session = new UpstreamServiceSession(client, () => undefined)
+    await session.ensure()
+    activeToken = 'loopback-token-second'
+
+    const responses = await Promise.all([
+      client.request('/api/sessions/a', session.jar),
+      client.request('/api/sessions/b', session.jar),
+    ])
+    expect(responses.map(response => response.status)).toEqual([200, 200])
+    expect(pageLoads).toBe(2)
+  })
+
+  it('refuses token discovery from a non-loopback upstream', async () => {
+    const fetch = vi.fn<typeof globalThis.fetch>(async () => Response.json({ auth_required: false }))
+    const client = new UpstreamClient(new URL('http://192.168.1.20:9119'), fetch)
+    const session = new UpstreamServiceSession(client, () => undefined)
+
+    await expect(session.ensure()).rejects.toMatchObject({ code: 'upstream_auth_unsafe' })
+    expect(fetch).toHaveBeenCalledTimes(1)
+  })
+
+  it('keeps older unauthenticated loopback dashboards compatible when no token is injected', async () => {
+    const fetch = vi.fn<typeof globalThis.fetch>(async (input) => {
+      const path = new URL(String(input)).pathname
+      if (path === '/api/status') return Response.json({ auth_required: false })
+      if (path === '/') return new Response('<html>legacy dashboard</html>')
+      return Response.json({ ok: true })
+    })
+    const client = new UpstreamClient(new URL('http://127.0.0.1:9119'), fetch)
+    const session = new UpstreamServiceSession(client, () => undefined)
+
+    expect((await session.request('/api/sessions')).status).toBe(200)
+    expect(session.connectionInfo().authMode).toBe('loopback-direct')
+  })
 })