diff --git a/packages/server/src/controllers/predictions/index.ts b/packages/server/src/controllers/predictions/index.ts index d467f3157e4..7865f40e19c 100644 --- a/packages/server/src/controllers/predictions/index.ts +++ b/packages/server/src/controllers/predictions/index.ts @@ -25,9 +25,9 @@ const createPrediction = async (req: Request, res: Response, next: NextFunction) `Error: predictionsController.createPrediction - body not provided!` ) } - const workspaceId = req.user?.activeWorkspaceId - - const chatflow = await chatflowsService.getChatflowById(req.params.id, workspaceId) + // Not scoped by req.user.activeWorkspaceId (upstream #5228): this route is public, so the filter + // guards nothing, and it 404s chatflows in the caller's other workspaces. Same fix as 3cddc905c. + const chatflow = await chatflowsService.getChatflowById(req.params.id) if (!chatflow) { throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${req.params.id} not found`) } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index f6152dd9208..9a9295446e4 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -39,7 +39,7 @@ import { ExpressAdapter } from '@bull-board/express' import { createRedisStore } from './AppConfig' import { aaiPostAuthMiddleware } from './middlewares/authentication/aaiPostAuthMiddleware' -import { verifyAAIToken } from './middlewares/authentication/verifyAAIToken' +import { verifyAAIToken, isBearerJWT } from './middlewares/authentication/verifyAAIToken' import { populateWorkspaceData } from './middlewares/authentication/populateWorkspaceData' import { User } from './database/entities/User' import { initAAI } from './aai/repository' @@ -259,13 +259,6 @@ export class App { await initializeJwtCookieMiddleware(this.app, this.identityManager) this.app.use(async (req, res, next) => { - // Helper to detect JWT tokens (three dot-separated base64 segments) - const looksLikeJWT = (): boolean => { - const authHeader = req.headers.authorization as string | undefined - const token = authHeader?.startsWith('Bearer ') ? authHeader.split(' ')[1] : null - return token ? token.split('.').length === 3 : false - } - // Helper for API key authentication (used as primary or fallback) const handleApiKeyAuth = async () => { // Only check license validity for non-open-source platforms @@ -359,7 +352,7 @@ export class App { const isWhitelisted = whitelistURLs.some((url) => req.path.startsWith(url)) if (isWhitelisted) { next() - } else if (req.headers['x-request-from'] === 'aai' || looksLikeJWT()) { + } else if (req.headers['x-request-from'] === 'aai' || isBearerJWT(req)) { // AAI requests OR auto-detected JWT tokens: Auth0 RS256 JWT (primary) with enterprise HS256 fallback // If JWT fails, fall through to API key validation verifyAAIToken(this.AppDataSource)(req, res, (err?: any) => { diff --git a/packages/server/src/middlewares/authentication/verifyAAIToken.ts b/packages/server/src/middlewares/authentication/verifyAAIToken.ts index 3f276170310..d4f322c5647 100644 --- a/packages/server/src/middlewares/authentication/verifyAAIToken.ts +++ b/packages/server/src/middlewares/authentication/verifyAAIToken.ts @@ -38,8 +38,18 @@ const jwtCheck = auth({ tokenSigningAlg: process.env.AUTH0_TOKEN_SIGN_ALG ?? 'RS256' }) -export const verifyAAIToken = (AppDataSource: DataSource) => { +export const isBearerJWT = (req: Request): boolean => { + const authHeader = req.headers.authorization as string | undefined + const token = authHeader?.startsWith('Bearer ') ? authHeader.split(' ')[1] : null + return token ? token.split('.').length === 3 : false +} + +// optional: for public routes (/prediction). Identifies the caller when a valid Auth0 JWT is sent, +// otherwise continues anonymous; never sends a response. +export const verifyAAIToken = (AppDataSource: DataSource, opts: { optional?: boolean } = {}) => { return async (req: Request, res: Response, next: NextFunction) => { + if (opts.optional && (req.user || !isBearerJWT(req))) return next() + // STEP 1: Try Auth0 RS256 verification first jwtCheck(req, res, async (auth0Err?: any) => { if (!auth0Err) { @@ -52,6 +62,10 @@ export const verifyAAIToken = (AppDataSource: DataSource) => { const isValidOrg = userOrgId && process.env.AUTH0_ORGANIZATION_ID?.split(',')?.includes(userOrgId) if (!isValidOrg) { + if (opts.optional) { + console.warn(`[verifyAAIToken] optional: org '${userOrgId}' not allowed, continuing anonymous`) + return next() + } console.warn(`[verifyAAIToken] User ${authPayload.email} from org '${userOrgId}' - not in allowed orgs`) return res.status(401).json({ message: "Unauthorized: Organization doesn't match" }) } @@ -164,6 +178,11 @@ export const verifyAAIToken = (AppDataSource: DataSource) => { } } + if (opts.optional) { + console.warn('[verifyAAIToken] optional: continuing anonymous', auth0Err?.message) + return next() + } + // STEP 2: Auth0 failed - try enterprise passport (HS256) // Import verifyToken lazily to avoid circular dependencies try { diff --git a/packages/server/src/routes/predictions/index.ts b/packages/server/src/routes/predictions/index.ts index 077d10be45a..f7212de2d67 100644 --- a/packages/server/src/routes/predictions/index.ts +++ b/packages/server/src/routes/predictions/index.ts @@ -1,9 +1,16 @@ -import express from 'express' +import express, { NextFunction, Request, Response } from 'express' import predictionsController from '../../controllers/predictions' import { getMulterStorage } from '../../utils' +import { getRunningExpressApp } from '../../utils/getRunningExpressApp' +import { verifyAAIToken } from '../../middlewares/authentication/verifyAAIToken' const router = express.Router() +// /prediction is whitelisted, so the global auth never reads the token. Identify Auth0 callers +// (browser extension) here; anonymous callers pass through. The data source exists only at request time. +const optionalAAIAuth = (req: Request, res: Response, next: NextFunction) => + verifyAAIToken(getRunningExpressApp().AppDataSource, { optional: true })(req, res, next) + // NOTE: extractChatflowId function in XSS.ts extracts the chatflow ID from the prediction URL. // It assumes the URL format is /prediction/{chatflowId}. Make sure to update the function if the URL format changes. // CREATE @@ -11,6 +18,7 @@ router.post( ['/', '/:id'], getMulterStorage().array('files'), predictionsController.getRateLimiterMiddleware, + optionalAAIAuth, predictionsController.createPrediction ) diff --git a/packages/server/test/auth/verifyAAIToken/index.test.ts b/packages/server/test/auth/verifyAAIToken/index.test.ts new file mode 100644 index 00000000000..db5e8acbb3f --- /dev/null +++ b/packages/server/test/auth/verifyAAIToken/index.test.ts @@ -0,0 +1,193 @@ +/** + * verifyAAIToken unit tests — optional mode (public /prediction) and strict mode (unchanged). + */ + +const mockJwtMiddleware = jest.fn() +jest.mock('express-oauth2-jwt-bearer', () => ({ + auth: () => mockJwtMiddleware +})) + +const mockVerifyToken = jest.fn() +jest.mock('../../../src/enterprise/middleware/passport', () => ({ + verifyToken: (...args: any[]) => mockVerifyToken(...args) +})) +jest.mock('../../../src/utils/quotaUsage', () => ({ + ENTERPRISE_FEATURE_FLAGS: [] +})) +jest.mock('../../../src/middlewares/authentication/findOrCreateOrganization', () => ({ + findOrCreateOrganization: jest.fn() +})) +jest.mock('../../../src/middlewares/authentication/findOrCreateUser', () => ({ + findOrCreateUser: jest.fn(), + updateUserOrganization: jest.fn() +})) +jest.mock('../../../src/middlewares/authentication/findOrCreateWorkspacesForUser', () => ({ + findOrCreateWorkspacesForUser: jest.fn().mockResolvedValue(undefined) +})) +jest.mock('../../../src/middlewares/authentication/populateWorkspaceData', () => ({ + populateWorkspaceData: jest.fn().mockResolvedValue({ activeWorkspaceId: 'ws-1' }) +})) + +import { Request, Response } from 'express' +import { DataSource } from 'typeorm' +import { verifyAAIToken, isBearerJWT } from '../../../src/middlewares/authentication/verifyAAIToken' +import { findOrCreateUser } from '../../../src/middlewares/authentication/findOrCreateUser' + +const AUTH0_ORG = 'org_valid123' +process.env.AUTH0_ORGANIZATION_ID = AUTH0_ORG + +const dataSource = { + getRepository: jest.fn(() => ({ findOneBy: jest.fn().mockResolvedValue({ id: 'org-db-1', auth0Id: AUTH0_ORG }) })) +} as unknown as DataSource + +const makeReq = (authorization?: string, user?: any) => ({ headers: authorization ? { authorization } : {}, user } as unknown as Request) + +const makeRes = () => { + const res: any = {} + res.status = jest.fn().mockReturnValue(res) + res.json = jest.fn().mockReturnValue(res) + return res as Response & { status: jest.Mock; json: jest.Mock } +} + +// Resolves when the middleware either calls next() or sends a response. +const run = (mw: ReturnType, req: Request, res: ReturnType) => + new Promise((resolve) => { + const next: jest.Mock = jest.fn(() => resolve(next)) + res.json.mockImplementation(() => { + resolve(next) + return res + }) + mw(req, res, next) + }) + +const jwtSucceeds = (payload: Record) => + mockJwtMiddleware.mockImplementation((req: any, _res: any, next: any) => { + req.auth = { payload } + next() + }) + +const validPayload = { sub: 'auth0|u1', email: 'a@b.c', org_id: AUTH0_ORG } + +beforeEach(() => { + jest.clearAllMocks() + ;(findOrCreateUser as jest.Mock).mockResolvedValue({ id: 'user-1', email: 'a@b.c', name: 'A', organizationId: 'org-db-1' }) +}) + +describe('isBearerJWT', () => { + it('is true only for Bearer with three dot-separated segments', () => { + expect(isBearerJWT(makeReq('Bearer a.b.c'))).toBe(true) + expect(isBearerJWT(makeReq('Bearer abc123'))).toBe(false) + expect(isBearerJWT(makeReq('Basic a.b.c'))).toBe(false) + expect(isBearerJWT(makeReq())).toBe(false) + }) +}) + +describe('verifyAAIToken optional mode', () => { + const mw = verifyAAIToken(dataSource, { optional: true }) + + it('optional: no header skips jwtCheck', async () => { + const req = makeReq() + const res = makeRes() + const next = await run(mw, req, res) + expect(mockJwtMiddleware).not.toHaveBeenCalled() + expect(next).toHaveBeenCalledTimes(1) + expect(req.user).toBeUndefined() + expect(res.status).not.toHaveBeenCalled() + }) + + it('optional: api key is not treated as JWT', async () => { + const req = makeReq('Bearer abc123') + const res = makeRes() + const next = await run(mw, req, res) + expect(mockJwtMiddleware).not.toHaveBeenCalled() + expect(next).toHaveBeenCalledTimes(1) + expect(req.user).toBeUndefined() + }) + + it('optional: valid token sets req.user', async () => { + jwtSucceeds(validPayload) + const req = makeReq('Bearer a.b.c') + const res = makeRes() + const next = await run(mw, req, res) + expect(next).toHaveBeenCalledTimes(1) + expect((req.user as any).id).toBe('user-1') + expect((req.user as any).activeWorkspaceId).toBe('ws-1') + expect(res.status).not.toHaveBeenCalled() + }) + + it('optional: disallowed org continues anonymous', async () => { + jwtSucceeds({ ...validPayload, org_id: 'org_other' }) + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const req = makeReq('Bearer a.b.c') + const res = makeRes() + const next = await run(mw, req, res) + expect(next).toHaveBeenCalledTimes(1) + expect(req.user).toBeUndefined() + expect(res.status).not.toHaveBeenCalled() + expect(findOrCreateUser).not.toHaveBeenCalled() + expect(JSON.stringify(warn.mock.calls)).not.toContain('a@b.c') + warn.mockRestore() + }) + + it('optional: invalid token continues anonymous', async () => { + mockJwtMiddleware.mockImplementation((_req: any, _res: any, next: any) => next(new Error('invalid_token'))) + jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const req = makeReq('Bearer a.b.c') + const res = makeRes() + const next = await run(mw, req, res) + expect(next).toHaveBeenCalledTimes(1) + expect(next).toHaveBeenCalledWith() + expect(req.user).toBeUndefined() + expect(res.status).not.toHaveBeenCalled() + expect(mockVerifyToken).not.toHaveBeenCalled() + }) + + it('optional: user sync error continues anonymous', async () => { + jwtSucceeds(validPayload) + ;(findOrCreateUser as jest.Mock).mockRejectedValue(new Error('db down')) + jest.spyOn(console, 'error').mockImplementation(() => undefined) + jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const req = makeReq('Bearer a.b.c') + const res = makeRes() + const next = await run(mw, req, res) + expect(next).toHaveBeenCalledTimes(1) + expect(req.user).toBeUndefined() + expect(res.status).not.toHaveBeenCalled() + expect(mockVerifyToken).not.toHaveBeenCalled() + }) + + it('optional: existing req.user is kept', async () => { + const existing = { id: 'session-user' } + const req = makeReq('Bearer a.b.c', existing) + const res = makeRes() + const next = await run(mw, req, res) + expect(mockJwtMiddleware).not.toHaveBeenCalled() + expect(next).toHaveBeenCalledTimes(1) + expect(req.user).toBe(existing) + }) +}) + +describe('verifyAAIToken strict mode', () => { + const mw = verifyAAIToken(dataSource) + + it('strict: invalid token still returns 401', async () => { + mockJwtMiddleware.mockImplementation((_req: any, _res: any, next: any) => next(new Error('invalid_token'))) + mockVerifyToken.mockImplementation((_req: any, _res: any, cb: any) => cb(new Error('no passport user'))) + jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const req = makeReq('Bearer a.b.c') + const res = makeRes() + const next = await run(mw, req, res) + expect(res.status).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) + + it('strict: disallowed org still returns 401', async () => { + jwtSucceeds({ ...validPayload, org_id: 'org_other' }) + jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const req = makeReq('Bearer a.b.c') + const res = makeRes() + const next = await run(mw, req, res) + expect(res.status).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) +}) diff --git a/packages/server/test/integration/prediction/index.test.ts b/packages/server/test/integration/prediction/index.test.ts new file mode 100644 index 00000000000..8600c11eb73 --- /dev/null +++ b/packages/server/test/integration/prediction/index.test.ts @@ -0,0 +1,229 @@ +/** + * Integration test for optional auth on /api/v1/prediction (local only). + * + * Real: RS256 token verification (express-oauth2-jwt-bearer against a local issuer), Postgres with the + * app's own migrations, user/org/workspace provisioning, chatflow lookup. Mocked: getRunningExpressApp + * (no full server), buildChatflow (records req.user instead of running a flow) and the streaming check. + * + * Run: docker run --rm -d --name pred-it-pg -e POSTGRES_PASSWORD=test -p 127.0.0.1::5432 postgres:16 + * docker port pred-it-pg # -> 127.0.0.1: + * NODE_OPTIONS=--experimental-vm-modules TEST_PG_URL=postgres://postgres:test@127.0.0.1:/postgres \ + * pnpm exec jest test/integration/prediction + * NODE_OPTIONS is needed because langfuse-core (loaded through flowise-components) runs a dynamic import() at load. + */ +import crypto from 'node:crypto' +import http from 'node:http' +import { AddressInfo } from 'node:net' +import express from 'express' +import supertest from 'supertest' +import { DataSource } from 'typeorm' + +const mockApp: { AppDataSource?: DataSource } = {} +jest.mock('../../../src/utils/getRunningExpressApp', () => ({ + getRunningExpressApp: () => mockApp +})) +const mockBuildChatflow = jest.fn() +jest.mock('../../../src/services/predictions', () => ({ + __esModule: true, + default: { buildChatflow: (...args: any[]) => mockBuildChatflow(...args) } +})) + +const PG = process.env.TEST_PG_URL +const d = PG ? describe : describe.skip + +const AUDIENCE = 'https://api.test.local' +const ORG = 'org_ias' +const b64 = (o: object) => Buffer.from(JSON.stringify(o)).toString('base64url') + +d('POST /api/v1/prediction (integration)', () => { + jest.setTimeout(180000) + + const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }) + const otherKeys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }) + let issuer = '' + let issuerServer: http.Server + let ds: DataSource + let app: express.Express + let entities: { User: any; Organization: any; Workspace: any; WorkspaceUser: any; ChatFlow: any } + let userId = '' + let chatflowId = '' + + const sign = (claims: Record, key = keys.privateKey) => { + const now = Math.floor(Date.now() / 1000) + const head = b64({ alg: 'RS256', typ: 'JWT', kid: 'k1' }) + const body = b64({ iss: issuer, aud: AUDIENCE, iat: now, exp: now + 600, ...claims }) + const sig = crypto.sign('sha256', Buffer.from(`${head}.${body}`), key).toString('base64url') + return `${head}.${body}.${sig}` + } + const alice = (extra: Record = {}) => + sign({ sub: 'auth0|alice', email: 'alice@test.local', name: 'Alice', org_id: ORG, ...extra }) + + const post = (id: string, authorization?: string) => { + const req = supertest(app).post(`/api/v1/prediction/${id}`).send({ question: 'hi' }) + return authorization ? req.set('Authorization', authorization) : req + } + // Shows the error body when the status is not 200. + const ok = (res: supertest.Response) => expect({ status: res.status, body: res.body }).toEqual({ status: 200, body: { text: 'ok' } }) + + let seenUser: any + const counts = async () => ({ + users: await ds.getRepository(entities.User).count(), + orgs: await ds.getRepository(entities.Organization).count(), + workspaces: await ds.getRepository(entities.Workspace).count() + }) + + beforeAll(async () => { + const jwk = { ...keys.publicKey.export({ format: 'jwk' }), kid: 'k1', alg: 'RS256', use: 'sig' } + issuerServer = http.createServer((req, res) => { + res.setHeader('Content-Type', 'application/json') + if (req.url === '/.well-known/openid-configuration') return res.end(JSON.stringify({ issuer, jwks_uri: `${issuer}jwks` })) + if (req.url === '/jwks') return res.end(JSON.stringify({ keys: [jwk] })) + res.statusCode = 404 + res.end('{}') + }) + await new Promise((resolve) => issuerServer.listen(0, '127.0.0.1', resolve)) + issuer = `http://127.0.0.1:${(issuerServer.address() as AddressInfo).port}/` + + const url = new URL(PG!) + Object.assign(process.env, { + AUTH0_ISSUER_BASE_URL: issuer, + AUTH0_AUDIENCE: AUDIENCE, + AUTH0_ORGANIZATION_ID: ORG, + DATABASE_TYPE: 'postgres', + DATABASE_HOST: url.hostname, + DATABASE_PORT: url.port, + DATABASE_USER: decodeURIComponent(url.username), + DATABASE_PASSWORD: decodeURIComponent(url.password), + DATABASE_NAME: url.pathname.slice(1) + }) + + // Imported after env is set: jwtCheck reads AUTH0_* at module load. + const dsModule = await import('../../../src/DataSource') + await dsModule.init() + ds = dsModule.getDataSource() + await ds.initialize() + await ds.query('DROP SCHEMA public CASCADE; CREATE SCHEMA public; CREATE EXTENSION IF NOT EXISTS "uuid-ossp"') + await ds.runMigrations({ transaction: 'each' }) + mockApp.AppDataSource = ds + + entities = { + User: (await import('../../../src/database/entities/User')).User, + Organization: (await import('../../../src/database/entities/Organization')).Organization, + Workspace: (await import('../../../src/enterprise/database/entities/workspace.entity')).Workspace, + WorkspaceUser: (await import('../../../src/enterprise/database/entities/workspace-user.entity')).WorkspaceUser, + ChatFlow: (await import('../../../src/database/entities/ChatFlow')).ChatFlow + } + + // The streaming check needs real flow nodes; it is not what this test is about. + const chatflowsService = (await import('../../../src/services/chatflows')).default + jest.spyOn(chatflowsService, 'checkIfChatflowIsValidForStreaming').mockResolvedValue({ isStreaming: false } as any) + const router = (await import('../../../src/routes/predictions')).default + app = express() + app.use(express.json()) + app.use('/api/v1/prediction', router) + app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(err.statusCode || err.status || 500).json({ message: err.message }) + }) + + // First signed-in request provisions alice like her first Studio login (user, org, Default + Personal). + mockBuildChatflow.mockResolvedValue({ text: 'ok' }) + await post(crypto.randomUUID(), `Bearer ${alice()}`) + + const user = await ds.getRepository(entities.User).findOneByOrFail({ auth0Id: 'auth0|alice' }) + userId = user.id + const memberships = await ds.getRepository(entities.WorkspaceUser).find({ where: { userId }, relations: ['workspace'] }) + const personal = memberships.find((m: any) => m.workspace.name === 'Personal Workspace') + const shared = memberships.find((m: any) => m.workspace.name === 'Default Workspace') + if (!personal || !shared) + throw new Error(`expected Personal + Default memberships, got ${memberships.map((m: any) => m.workspace.name)}`) + // Personal is the active workspace; the chatflow lives in the shared one. + await ds + .getRepository(entities.WorkspaceUser) + .update({ workspaceId: personal.workspaceId, userId }, { lastLogin: new Date(Date.now() + 86400000).toISOString() }) + const chatflow = await ds.getRepository(entities.ChatFlow).save({ + name: 'Chief Sidekick', + flowData: JSON.stringify({ nodes: [], edges: [] }), + workspaceId: shared.workspaceId, + userId + }) + chatflowId = chatflow.id + }) + + afterAll(async () => { + await ds?.destroy() + await new Promise((resolve) => issuerServer?.close(resolve)) + }) + + beforeEach(() => { + seenUser = 'not-called' + mockBuildChatflow.mockReset().mockImplementation(async (req: any) => { + seenUser = req.user + return { text: 'ok' } + }) + }) + + it('valid token identifies the seeded user, once', async () => { + await post(chatflowId, `Bearer ${alice()}`).then(ok) + expect(seenUser.id).toBe(userId) + await post(chatflowId, `Bearer ${alice()}`).then(ok) + expect(await ds.getRepository(entities.User).countBy({ auth0Id: 'auth0|alice' })).toBe(1) + }) + + it('chatflow in a non-active workspace answers', async () => { + const res = await post(chatflowId, `Bearer ${alice()}`) + expect(res.status).toBe(200) + expect(seenUser.activeWorkspace).toBe('Personal Workspace') + }) + + it('no header stays anonymous', async () => { + const before = await counts() + const warn = jest.spyOn(console, 'warn') + await post(chatflowId).then(ok) + expect(seenUser).toBeUndefined() + expect(await counts()).toEqual(before) + expect(warn.mock.calls.flat().join(' ')).not.toContain('[verifyAAIToken]') + warn.mockRestore() + }) + + it('expired token stays anonymous', async () => { + const past = Math.floor(Date.now() / 1000) - 120 + await post(chatflowId, `Bearer ${alice({ iat: past - 600, exp: past })}`).then(ok) + expect(seenUser).toBeUndefined() + }) + + it('forged token stays anonymous', async () => { + const forged = sign({ sub: 'auth0|alice', email: 'alice@test.local', org_id: ORG }, otherKeys.privateKey) + await post(chatflowId, `Bearer ${forged}`).then(ok) + expect(seenUser).toBeUndefined() + }) + + it('wrong audience stays anonymous', async () => { + await post(chatflowId, `Bearer ${alice({ aud: 'https://someone.else' })}`).then(ok) + expect(seenUser).toBeUndefined() + }) + + it('disallowed org stays anonymous', async () => { + const orgsBefore = await ds.getRepository(entities.Organization).count() + await post(chatflowId, `Bearer ${sign({ sub: 'auth0|mallory', email: 'm@other.local', org_id: 'org_other' })}`).then(ok) + expect(seenUser).toBeUndefined() + expect(await ds.getRepository(entities.Organization).count()).toBe(orgsBefore) + expect(await ds.getRepository(entities.User).countBy({ auth0Id: 'auth0|mallory' })).toBe(0) + }) + + it('api key is not treated as JWT', async () => { + const warn = jest.spyOn(console, 'warn') + await post(chatflowId, `Bearer ${crypto.randomBytes(32).toString('base64url')}`).then(ok) + expect(seenUser).toBeUndefined() + expect(warn.mock.calls.flat().join(' ')).not.toContain('[verifyAAIToken]') + warn.mockRestore() + }) + + it('unknown chatflow: same status with and without token', async () => { + const id = crypto.randomUUID() + const anon = await post(id) + const signedIn = await post(id, `Bearer ${alice()}`) + expect(anon.status).not.toBe(200) + expect(signedIn.status).toBe(anon.status) + expect(mockBuildChatflow).not.toHaveBeenCalled() + }) +}) diff --git a/packages/server/test/routes/predictions/index.test.ts b/packages/server/test/routes/predictions/index.test.ts new file mode 100644 index 00000000000..7a8f86d4053 --- /dev/null +++ b/packages/server/test/routes/predictions/index.test.ts @@ -0,0 +1,91 @@ +/** + * /api/v1/prediction router tests — real verifyAAIToken in optional mode, real controller. + * Only the JWT library, DB helpers and downstream services are mocked. + */ + +const mockJwtMiddleware = jest.fn() +jest.mock('express-oauth2-jwt-bearer', () => ({ + auth: () => mockJwtMiddleware +})) +jest.mock('../../../src/enterprise/middleware/passport', () => ({ + verifyToken: jest.fn() +})) +jest.mock('../../../src/utils/quotaUsage', () => ({ + ENTERPRISE_FEATURE_FLAGS: [] +})) +jest.mock('../../../src/middlewares/authentication/findOrCreateOrganization', () => ({ + findOrCreateOrganization: jest.fn() +})) +jest.mock('../../../src/middlewares/authentication/findOrCreateUser', () => ({ + findOrCreateUser: jest.fn().mockResolvedValue({ id: 'user-1', email: 'a@b.c', name: 'A', organizationId: 'org-db-1' }), + updateUserOrganization: jest.fn() +})) +jest.mock('../../../src/middlewares/authentication/findOrCreateWorkspacesForUser', () => ({ + findOrCreateWorkspacesForUser: jest.fn().mockResolvedValue(undefined) +})) +jest.mock('../../../src/middlewares/authentication/populateWorkspaceData', () => ({ + populateWorkspaceData: jest.fn().mockResolvedValue({ activeWorkspaceId: 'ws-personal' }) +})) +jest.mock('../../../src/utils', () => ({ + getMulterStorage: () => ({ array: () => (_req: any, _res: any, next: any) => next() }) +})) +jest.mock('../../../src/utils/rateLimit', () => ({ + RateLimiterManager: { getInstance: () => ({ getRateLimiter: () => (_req: any, _res: any, next: any) => next() }) } +})) +jest.mock('../../../src/utils/getRunningExpressApp', () => ({ + getRunningExpressApp: () => ({ + AppDataSource: { getRepository: () => ({ findOneBy: jest.fn().mockResolvedValue({ id: 'org-db-1', auth0Id: 'org_valid123' }) }) } + }) +})) +jest.mock('../../../src/services/chatflows', () => ({ + __esModule: true, + default: { + getChatflowById: jest.fn().mockResolvedValue({ id: 'cf-1', chatbotConfig: null }), + checkIfChatflowIsValidForStreaming: jest.fn().mockResolvedValue({ isStreaming: false }) + } +})) +jest.mock('../../../src/services/predictions', () => ({ + __esModule: true, + default: { buildChatflow: jest.fn() } +})) + +import express from 'express' +import supertest from 'supertest' +import router from '../../../src/routes/predictions' +import chatflowsService from '../../../src/services/chatflows' +import predictionsServices from '../../../src/services/predictions' + +process.env.AUTH0_ORGANIZATION_ID = 'org_valid123' + +const app = express() +app.use(express.json()) +app.use('/api/v1/prediction', router) + +let seenUser: any +beforeEach(() => { + jest.clearAllMocks() + seenUser = 'not-called' + ;(predictionsServices.buildChatflow as jest.Mock).mockImplementation(async (req: any) => { + seenUser = req.user + return { text: 'ok' } + }) +}) + +describe('POST /api/v1/prediction/:id', () => { + it('createPrediction does not scope by activeWorkspaceId', async () => { + mockJwtMiddleware.mockImplementation((req: any, _res: any, next: any) => { + req.auth = { payload: { sub: 'auth0|u1', email: 'a@b.c', org_id: 'org_valid123' } } + next() + }) + await supertest(app).post('/api/v1/prediction/cf-1').set('Authorization', 'Bearer a.b.c').send({ question: 'hi' }).expect(200) + expect(seenUser.id).toBe('user-1') + expect(chatflowsService.getChatflowById).toHaveBeenCalledWith('cf-1') + expect((chatflowsService.getChatflowById as jest.Mock).mock.calls[0]).toHaveLength(1) + }) + + it('no header stays anonymous', async () => { + await supertest(app).post('/api/v1/prediction/cf-1').send({ question: 'hi' }).expect(200) + expect(seenUser).toBeUndefined() + expect(mockJwtMiddleware).not.toHaveBeenCalled() + }) +})