dify/cli/src/commands/auth/login/login.test.ts

204 lines
6.5 KiB
TypeScript

import type { DifyMock } from '@test/fixtures/dify-mock/server'
import type { Clock } from './device-flow.js'
import type { Key, Store } from '@/store/store'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { startMock } from '@test/fixtures/dify-mock/server'
import { testHttpClient } from '@test/fixtures/http-client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { DeviceFlowApi } from '@/api/oauth-device'
import { createHttpClient } from '@/http/client'
import { ENV_CONFIG_DIR } from '@/store/dir'
import { tokenKey } from '@/store/manager'
import { bufferStreams } from '@/sys/io/streams'
import { openAPIBase } from '@/util/host'
import { runLogin } from './login.js'
const noopClock: Clock = {
sleepMs: async () => { /* immediate */ },
isCancelled: () => false,
}
const noopBrowser = async (): Promise<void> => { /* skip OS open */ }
class MemStore implements Store {
readonly entries = new Map<string, unknown>()
get<T>(key: Key<T>): T {
return (this.entries.get(key.key) as T | undefined) ?? key.default
}
set<T>(key: Key<T>, value: T): void {
this.entries.set(key.key, value)
}
unset<T>(key: Key<T>): void {
this.entries.delete(key.key)
}
}
describe('runLogin', () => {
let mock: DifyMock
let configDir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
mock = await startMock({ scenario: 'happy' })
configDir = await mkdtemp(join(tmpdir(), 'difyctl-login-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = configDir
})
afterEach(async () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await mock.stop()
await rm(configDir, { recursive: true, force: true })
})
it('happy: stores bearer + writes hosts.yml + greets account user', async () => {
const io = bufferStreams()
const store = new MemStore()
const reg = await runLogin({
io,
host: mock.url,
noBrowser: true,
insecure: true,
deviceLabel: 'difyctl on test',
api: new DeviceFlowApi(testHttpClient(mock.url)),
store: { store, mode: 'file' },
clock: noopClock,
browserOpener: noopBrowser,
})
const active = reg.resolveActive()
expect(active?.ctx.account.email).toBe('tester@dify.ai')
expect(active?.ctx.workspace?.id).toBe('ws-1')
expect(active?.ctx.available_workspaces).toHaveLength(2)
expect(store.get(tokenKey(active!.host, 'tester@dify.ai'))).toBe('dfoa_test')
const hostsRaw = await readFile(join(configDir, 'hosts.yml'), 'utf8')
expect(hostsRaw).toContain('current_host:')
expect(hostsRaw).toContain('tester@dify.ai')
expect(hostsRaw).not.toContain('dfoa_test')
expect(hostsRaw).not.toContain('bearer')
expect(io.outBuf()).toContain('Logged in to')
expect(io.outBuf()).toContain('tester@dify.ai')
expect(io.outBuf()).toContain('Default')
expect(io.errBuf()).toContain('ABCD-1234')
})
it('sso: stores dfoe_ token + greets external SSO subject (no account)', async () => {
mock.setScenario('sso')
const io = bufferStreams()
const store = new MemStore()
const reg = await runLogin({
io,
host: mock.url,
noBrowser: true,
insecure: true,
deviceLabel: 'difyctl on test',
api: new DeviceFlowApi(testHttpClient(mock.url)),
store: { store, mode: 'file' },
clock: noopClock,
browserOpener: noopBrowser,
})
const active = reg.resolveActive()
expect(active?.ctx.external_subject?.email).toBe('sso@dify.ai')
expect(active?.ctx.external_subject?.issuer).toBe('https://issuer.example')
expect(active?.ctx.account.email).toBe('')
expect(store.get(tokenKey(active!.host, 'sso@dify.ai'))).toBe('dfoe_test')
expect(io.outBuf()).toContain('external SSO')
expect(io.outBuf()).toContain('sso@dify.ai')
})
it('denied: throws DeviceFlowError + leaves config dir empty', async () => {
mock.setScenario('denied')
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
io,
host: mock.url,
noBrowser: true,
insecure: true,
deviceLabel: 'difyctl on test',
api: new DeviceFlowApi(testHttpClient(mock.url)),
store: { store, mode: 'file' },
clock: noopClock,
browserOpener: noopBrowser,
})).rejects.toThrow(/denied/)
expect(store.entries.size).toBe(0)
await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
})
it('expired: throws DeviceFlowError', async () => {
mock.setScenario('expired')
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
io,
host: mock.url,
noBrowser: true,
insecure: true,
deviceLabel: 'difyctl on test',
api: new DeviceFlowApi(testHttpClient(mock.url)),
store: { store, mode: 'file' },
clock: noopClock,
browserOpener: noopBrowser,
})).rejects.toThrow(/expired/)
})
it('rejects login when the account has no email', async () => {
mock.setScenario('no-email')
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
io,
host: mock.url,
noBrowser: true,
insecure: true,
deviceLabel: 'difyctl on test',
api: new DeviceFlowApi(createHttpClient({ baseURL: openAPIBase(mock.url) })),
store: { store, mode: 'file' },
clock: noopClock,
browserOpener: noopBrowser,
})).rejects.toThrow(/no email/i)
expect(store.entries.size).toBe(0)
})
it('rejects http:// host without --insecure', async () => {
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
io,
host: mock.url,
noBrowser: true,
insecure: false,
deviceLabel: 'difyctl on test',
api: new DeviceFlowApi(testHttpClient(mock.url)),
store: { store, mode: 'file' },
clock: noopClock,
browserOpener: noopBrowser,
})).rejects.toThrow(/https:\/\//)
})
it('emits skip-reason to stderr when --no-browser', async () => {
const io = bufferStreams()
const store = new MemStore()
await runLogin({
io,
host: mock.url,
noBrowser: true,
insecure: true,
deviceLabel: 'difyctl on test',
api: new DeviceFlowApi(testHttpClient(mock.url)),
store: { store, mode: 'file' },
clock: noopClock,
browserOpener: noopBrowser,
})
expect(io.errBuf()).toContain('--no-browser requested')
})
})