Files
zpan/server/usecases/site/auth-provider.test.ts
T
2026-08-01 00:32:31 -04:00

298 lines
12 KiB
TypeScript

import { FREE_SOCIAL_LOGIN_LIMIT } from '@shared/constants'
import type { BindingState } from '@shared/types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Database } from '../../platform/interface'
import type { LicenseBindingRepo, OAuthGateway, SystemOption, SystemOptionsRepo } from '../ports'
import {
type AuthProviderDeps,
deleteAuthProvider,
listAuthProviderSettings,
listAuthProviders,
listPublicAuthProviders,
type UpsertProviderInput,
upsertAuthProvider,
} from './auth-provider'
import { loadBindingState } from './licensing'
// loadBindingState derives features from a signed certificate — out of scope for
// a usecase unit test. Mock it so each case feeds a chosen edition; the real
// (pure) hasFeature then runs against it. The full cert→features→gate path is
// covered by auth-providers.integration.test.ts.
vi.mock('./licensing', () => ({ loadBindingState: vi.fn() }))
const COMMUNITY: BindingState = { bound: false } // lacks social_login_unlimited
const PRO: BindingState = { bound: true, active: true, edition: 'pro' } // has social_login_unlimited
const AUTH_ORIGIN = 'https://files.example'
const edition = (state: BindingState) => vi.mocked(loadBindingState).mockResolvedValue(state)
const listOptions = { authOrigin: AUTH_ORIGIN }
const upsertOptions = { authOrigin: AUTH_ORIGIN }
const githubInput: UpsertProviderInput = {
type: 'builtin',
clientId: 'client-id-123',
clientSecret: 'super-secret-value',
enabled: true,
}
const oidcInput: UpsertProviderInput = {
type: 'oidc',
clientId: 'oidc-client-id',
clientSecret: 'oidc-secret-value',
enabled: true,
discoveryUrl: 'https://accounts.example.com/.well-known/openid-configuration',
scopes: ['openid', 'email', 'profile'],
}
function row(config: Record<string, unknown>): { key: string; value: string } {
return { key: `oauth_provider_${config.providerId}`, value: JSON.stringify(config) }
}
function makeDeps(systemOptions: Partial<SystemOptionsRepo> = {}) {
const set = vi.fn(async () => {})
const del = vi.fn(async () => {})
const repo: SystemOptionsRepo = {
get: async () => null,
getValue: async () => null,
getMany: async () => [],
listByPrefix: async () => [],
set,
setMany: async () => {},
delete: del,
...systemOptions,
}
const deps: AuthProviderDeps = {
systemOptions: repo,
licenseBinding: {} as LicenseBindingRepo, // unused — loadBindingState is mocked
}
return { deps, set, del }
}
beforeEach(() => vi.clearAllMocks())
describe('auth-provider usecase', () => {
describe('listPublicAuthProviders', () => {
it('returns the minimal enabled provider projection', async () => {
const { deps } = makeDeps({
listByPrefix: async () => [
row({ providerId: 'google', type: 'builtin', clientId: 'b', clientSecret: 'secret', enabled: false }),
row({ providerId: 'github', type: 'builtin', clientId: 'a', clientSecret: 'secret', enabled: true }),
],
})
expect(await listPublicAuthProviders(deps)).toEqual([
{ id: 'github', type: 'builtin', name: 'GitHub', icon: 'github' },
])
})
})
describe('listAuthProviders — admin (isAdmin: true)', () => {
it('returns all configs, enabled or not, with masked secrets', async () => {
const { deps } = makeDeps({
listByPrefix: async () => [
row({
providerId: 'github',
type: 'builtin',
clientId: 'a',
clientSecret: 'super-secret-value',
enabled: true,
}),
row({ providerId: 'google', type: 'builtin', clientId: 'b', clientSecret: 's', enabled: false }),
],
})
const out = await listAuthProviders(deps, listOptions)
expect(out.items).toHaveLength(2)
expect(out.items[0].clientSecret).toMatch(/^\*+alue$/)
expect(out.items[0].clientSecret).not.toBe('super-secret-value')
})
it('masks a short secret entirely with four asterisks', async () => {
const { deps } = makeDeps({
listByPrefix: async () => [
row({ providerId: 'github', type: 'builtin', clientId: 'a', clientSecret: 'abc', enabled: true }),
],
})
const out = await listAuthProviders(deps, listOptions)
expect(out.items[0].clientSecret).toBe('****')
})
it('skips rows whose value fails to parse', async () => {
const { deps } = makeDeps({
listByPrefix: async () => [{ key: 'oauth_provider_bad', value: '{' }],
})
expect(await listAuthProviders(deps, listOptions)).toEqual({ items: [] })
})
})
describe('listAuthProviderSettings', () => {
it('combines provider configuration with dynamically registered applications', async () => {
const { deps } = makeDeps({
listByPrefix: async () => [
row({
providerId: 'github',
type: 'builtin',
clientId: 'a',
clientSecret: 'super-secret-value',
enabled: true,
}),
],
})
const registeredApplications = [
{
clientId: 'dynamic-client',
name: 'Build Agent',
uri: null,
redirectUris: ['http://127.0.0.1/callback'],
grantTypes: ['authorization_code'],
scopes: ['objects:read'],
disabled: false,
createdAt: '2026-07-30T12:00:00.000Z',
},
]
const listRegisteredApplications = vi.fn(async () => registeredApplications)
const oauth = { listRegisteredApplications } as unknown as OAuthGateway
const db = {} as Database
const result = await listAuthProviderSettings({ ...deps, oauth }, db, listOptions)
expect(result.items).toHaveLength(1)
expect(result.registeredApplications).toEqual(registeredApplications)
expect(listRegisteredApplications).toHaveBeenCalledWith(db)
})
})
describe('upsertAuthProvider', () => {
it('creates a new builtin provider under the free limit and stores it', async () => {
edition(COMMUNITY)
const { deps, set } = makeDeps({ get: async () => null, listByPrefix: async () => [] })
const out = await upsertAuthProvider(deps, 'github', githubInput, upsertOptions)
expect(out.ok).toBe(true)
if (out.ok) {
expect(out.config.providerId).toBe('github')
expect(out.config.type).toBe('builtin')
expect(out.config.callbackUri).toBe('https://files.example/api/auth/callback/github')
expect(out.config.clientSecret).toMatch(/^\*+alue$/)
}
expect(set).toHaveBeenCalledWith(
'oauth_provider_github',
JSON.stringify({ providerId: 'github', ...githubInput }),
)
})
it('creates a new OIDC provider with a discoveryUrl', async () => {
edition(COMMUNITY)
const { deps, set } = makeDeps({ get: async () => null, listByPrefix: async () => [] })
const out = await upsertAuthProvider(deps, 'my-oidc', oidcInput, upsertOptions)
expect(out.ok).toBe(true)
if (out.ok) {
expect(out.config.type).toBe('oidc')
expect(out.config.discoveryUrl).toBe(oidcInput.discoveryUrl)
expect(out.config.scopes).toEqual(oidcInput.scopes)
expect(out.config.callbackUri).toBe('https://files.example/api/auth/oauth2/callback/my-oidc')
}
expect(set).toHaveBeenCalledOnce()
})
it('updates an existing provider and does not consult the license', async () => {
const existing: SystemOption = {
key: 'oauth_provider_github',
value: JSON.stringify({ providerId: 'github', ...githubInput }),
}
const { deps, set } = makeDeps({ get: async () => existing })
const out = await upsertAuthProvider(deps, 'github', { ...githubInput, clientId: 'new-client-id' }, upsertOptions)
expect(out.ok).toBe(true)
if (out.ok) expect(out.config.clientId).toBe('new-client-id')
expect(set).toHaveBeenCalledOnce()
expect(loadBindingState).not.toHaveBeenCalled() // free limit never checked on update
})
it('returns a 400 invalid-id error for a malformed provider id and never writes', async () => {
const { deps, set } = makeDeps()
const out = await upsertAuthProvider(deps, 'Not_Valid', githubInput, upsertOptions)
expect(out.ok).toBe(false)
if (out.ok) throw new Error('expected failure')
expect(out.error.httpStatus).toBe(400)
expect(out.error.message).toBe('Provider ID must contain only lowercase letters, numbers, and hyphens')
expect(set).not.toHaveBeenCalled()
})
it('returns a 400 unknown-builtin error for a builtin id not in the registry', async () => {
const { deps, set } = makeDeps()
const out = await upsertAuthProvider(deps, 'not-a-real-provider', githubInput, upsertOptions)
expect(out.ok).toBe(false)
if (out.ok) throw new Error('expected failure')
expect(out.error.httpStatus).toBe(400)
expect(out.error.message).toBe('Unknown builtin provider: not-a-real-provider')
expect(set).not.toHaveBeenCalled()
})
it('returns a 400 missing-discovery error for an OIDC provider with no discoveryUrl', async () => {
const { deps, set } = makeDeps()
const { discoveryUrl: _omit, ...withoutDiscovery } = oidcInput
const out = await upsertAuthProvider(deps, 'my-oidc', withoutDiscovery, upsertOptions)
expect(out.ok).toBe(false)
if (out.ok) throw new Error('expected failure')
expect(out.error.httpStatus).toBe(400)
expect(out.error.message).toBe('discoveryUrl is required for OIDC providers')
expect(set).not.toHaveBeenCalled()
})
it('blocks the second provider on the free plan with a 402 feature-blocked error', async () => {
edition(COMMUNITY)
const { deps, set } = makeDeps({
get: async () => null,
listByPrefix: async () => [row({ providerId: 'github', enabled: true })],
})
const out = await upsertAuthProvider(deps, 'google', { ...githubInput }, upsertOptions)
expect(out.ok).toBe(false)
if (out.ok) throw new Error('expected failure')
expect(out.error.httpStatus).toBe(402)
expect(out.error.meta.reason).toBe('FEATURE_NOT_AVAILABLE')
expect(out.error.meta.metadata).toEqual({
feature: 'social_login_unlimited',
currentCount: '1',
limit: String(FREE_SOCIAL_LOGIN_LIMIT),
upgradeUrl: '/settings/billing',
})
expect(set).not.toHaveBeenCalled()
})
it('allows additional providers with the social_login_unlimited entitlement', async () => {
edition(PRO)
const { deps, set } = makeDeps({
get: async () => null,
listByPrefix: async () => [row({ providerId: 'github', enabled: true })],
})
const out = await upsertAuthProvider(deps, 'google', { ...githubInput }, upsertOptions)
expect(out.ok).toBe(true)
expect(set).toHaveBeenCalledOnce()
})
})
describe('deleteAuthProvider', () => {
it('deletes by option key and reports ok', async () => {
const { deps, del } = makeDeps()
const out = await deleteAuthProvider(deps, 'github')
expect(out).toEqual({ ok: true })
expect(del).toHaveBeenCalledWith('oauth_provider_github')
})
it('returns ok even for a provider that does not exist (idempotent)', async () => {
const { deps, del } = makeDeps()
const out = await deleteAuthProvider(deps, 'never-configured')
expect(out).toEqual({ ok: true })
expect(del).toHaveBeenCalledWith('oauth_provider_never-configured')
})
it('returns a 400 invalid-id error for a malformed provider id and never deletes', async () => {
const { deps, del } = makeDeps()
const out = await deleteAuthProvider(deps, 'Bad_Id')
expect(out.ok).toBe(false)
if (out.ok) throw new Error('expected failure')
expect(out.error.httpStatus).toBe(400)
expect(out.error.message).toBe('Provider ID must contain only lowercase letters, numbers, and hyphens')
expect(del).not.toHaveBeenCalled()
})
})
})