/** * E2E: App Credentials + Anonymous Session + Conversation History * * Pure API test (no UI) — exercises the full end-user auth flow: * Phase 1 — Security basics (app creation, anon sessions, origin rejection) * Phase 2 — User A conversations + get conversation by ID (Vercel format) * Phase 3 — User B conversations + cross-user isolation * * Bot protection (ALTCHA Sentinel) is intentionally NOT exercised here — * INKEEP_SENTINEL_API_KEY_ID is unset in the Cypress environment, which makes * `isSentinelEnabled()` return false and skips the verify step. Sentinel * proxy/verify behavior is covered by unit/integration tests in agents-api. * * Requires: * - API server running on CYPRESS_API_URL (default http://localhost:3002) * - INKEEP_ANON_JWT_SECRET set in .env * - A working agent (set CYPRESS_E2E_AGENT_ID, default "friendly-agent") * * Run headless: * cd agents-manage-ui * pnpm test:e2e:run --spec cypress/e2e/app-credentials-e2e.cy.ts * * Run interactively: * pnpm test:e2e --spec cypress/e2e/app-credentials-e2e.cy.ts */ const API_URL = Cypress.env('API_URL') || 'http://localhost:3002'; const TENANT_ID = Cypress.env('TENANT_ID') || 'default'; const PROJECT_ID = Cypress.env('E2E_PROJECT_ID') || 'andrew'; const AGENT_ID = Cypress.env('E2E_AGENT_ID') || 'friendly-agent'; const BYPASS_SECRET = Cypress.env('BYPASS_SECRET') || 'test-bypass-secret-for-ci'; const ORIGIN = 'https://test.example.com'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function manageUrl(path: string): string { return `${API_URL}/manage/tenants/${TENANT_ID}/projects/${PROJECT_ID}${path}`; } function manageHeaders(): Record { return { 'Content-Type': 'application/json', Authorization: `Bearer ${BYPASS_SECRET}` }; } function appAuthHeaders(appId: string, token: string): Record { return { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, 'x-inkeep-app-id': appId, Origin: ORIGIN, }; } /** Create an anonymous session and return { token, sub }. */ function createAnonSession(appId: string): Cypress.Chainable<{ token: string; sub: string }> { return cy .request({ method: 'POST', url: `${API_URL}/run/auth/apps/${appId}/anonymous-session`, headers: { Origin: ORIGIN }, failOnStatusCode: true, }) .then((res) => { const token = res.body.token as string; const [, payloadB64] = token.split('.'); const claims = JSON.parse(atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/'))); expect(claims.sub).to.match(/^anon_/); expect(claims.app).to.equal(appId); return { token, sub: claims.sub as string }; }); } /** * Send a chat message via /run/v1/chat/completions (SSE stream). * Uses cy.task('chatSSE') to run in Node.js (avoids browser CORS). */ function chat( appId: string, token: string, messages: { role: string; content: string }[], conversationId?: string ): Cypress.Chainable<{ text: string; conversationId: string }> { const reqBody: Record = { model: AGENT_ID, messages, stream: true, }; if (conversationId) reqBody.conversationId = conversationId; return cy.task<{ text: string; conversationId: string }>( 'chatSSE', { url: `${API_URL}/run/v1/chat/completions`, headers: appAuthHeaders(appId, token), body: JSON.stringify(reqBody), }, { timeout: 120_000 } ); } /** Chat via /run/api/chat (Vercel data stream, non-streaming JSON response). */ function chatDataStream( appId: string, token: string, messages: { role: string; content: string }[] ): Cypress.Chainable<{ text: string; conversationId: string }> { return cy .request({ method: 'POST', url: `${API_URL}/run/api/chat`, headers: appAuthHeaders(appId, token), body: { messages, stream: false }, failOnStatusCode: true, timeout: 120_000, }) .then((res) => { const text = (res.body.choices?.[0]?.message?.content ?? '') as string; const convId = (res.body.conversationId || res.body.id || '') as string; expect(text, 'chatDataStream should return text').to.not.be.empty; return { text, conversationId: convId }; }); } // --------------------------------------------------------------------------- // Tests — sequential, no retries (shared state across tests) // --------------------------------------------------------------------------- describe('App Credentials + Anonymous Session E2E', { testIsolation: false, retries: 0 }, () => { // Shared state across tests in this sequential suite let appId: string; let userA: { token: string; sub: string }; let userB: { token: string; sub: string }; let a1ConvId: string; let userAConvIds: string[]; let userATotal: number; after(() => { if (!appId) return; cy.request({ method: 'DELETE', url: manageUrl(`/apps/${appId}`), headers: { Authorization: `Bearer ${BYPASS_SECRET}` }, failOnStatusCode: false, }); }); // ========================================================================= // Phase 1: Security basics // ========================================================================= it('should create a web_client app', () => { cy.request({ method: 'POST', url: manageUrl('/apps'), headers: manageHeaders(), body: { name: `Cypress E2E App ${Date.now()}`, type: 'web_client', defaultAgentId: AGENT_ID, config: { type: 'web_client', webClient: { allowedDomains: ['*.example.com'], allowAnonymous: true }, }, }, }).then((res) => { expect(res.status).to.equal(201); appId = res.body.data.app.id; expect(appId).to.match(/^app_/); }); }); it('should create an anonymous session (User A)', () => { createAnonSession(appId).then((session) => { userA = session; expect(userA.sub).to.match(/^anon_/); }); }); it('should reject disallowed origin', () => { cy.request({ method: 'POST', url: `${API_URL}/run/auth/apps/${appId}/anonymous-session`, headers: { Origin: 'https://evil.attacker.com' }, failOnStatusCode: false, }).then((res) => { expect(res.status).to.equal(403); }); }); // ========================================================================= // Phase 2: User A conversations // ========================================================================= it('User A: chat message 1 (new conversation via SSE)', () => { chat(appId, userA.token, [ { role: 'user', content: 'Say "Alpha one" in exactly those words and nothing else.' }, ]).then((result) => { expect(result.text.trim()).to.not.be.empty; expect(result.conversationId).to.not.be.empty; a1ConvId = result.conversationId; }); }); it('User A: chat message 2 (follow-up in same conversation)', () => { chat( appId, userA.token, [ { role: 'user', content: 'Say "Alpha one" in exactly those words and nothing else.' }, { role: 'assistant', content: 'Alpha one' }, { role: 'user', content: 'Now say "Alpha two" in exactly those words and nothing else.', }, ], a1ConvId ).then((result) => { expect(result.text.trim()).to.not.be.empty; // The SSE endpoint may assign a new chatcmpl- ID even for follow-ups; // the important thing is that the conversation context is maintained. expect(result.conversationId).to.not.be.empty; }); }); it('User A: chat message 3 (new conversation via SSE)', () => { chat(appId, userA.token, [ { role: 'user', content: 'Say "Alpha three" in exactly those words and nothing else.' }, ]).then((result) => { expect(result.text.trim()).to.not.be.empty; expect(result.conversationId).to.not.equal(a1ConvId); }); }); it('User A: chat via data stream endpoint (new conversation)', () => { chatDataStream(appId, userA.token, [ { role: 'user', content: 'Say "Alpha four" in exactly those words and nothing else.' }, ]).then((result) => { expect(result.text.trim()).to.not.be.empty; }); }); it('User A: list conversations (expect at least 3)', () => { cy.request({ url: `${API_URL}/run/v1/conversations?limit=50`, headers: appAuthHeaders(appId, userA.token), failOnStatusCode: true, }).then((res) => { expect(res.body.pagination.total).to.be.at.least(3); userAConvIds = res.body.data.map((c: { id: string }) => c.id); userATotal = res.body.pagination.total; }); }); it('User A: get conversation by ID (Vercel format)', () => { cy.request({ url: `${API_URL}/run/v1/conversations/${a1ConvId}?format=vercel`, headers: appAuthHeaders(appId, userA.token), failOnStatusCode: true, }).then((res) => { const { data } = res.body; expect(data.id).to.equal(a1ConvId); expect(data.title).to.be.a('string').and.not.be.empty; expect(data.messages.length).to.be.at.least(2); const firstMsg = data.messages[0]; expect(firstMsg.content).to.be.a('string'); expect(firstMsg.parts).to.be.an('array'); expect(firstMsg.parts[0].type).to.equal('text'); }); }); it('User A: get conversation by ID (OpenAI format — expect 400)', () => { cy.request({ url: `${API_URL}/run/v1/conversations/${a1ConvId}?format=openai`, headers: appAuthHeaders(appId, userA.token), failOnStatusCode: false, }).then((res) => { expect(res.status).to.equal(400); expect(res.body.error.message).to.include('not available yet'); }); }); // ========================================================================= // Phase 3: User B + cross-isolation // ========================================================================= it('should create an anonymous session (User B)', () => { createAnonSession(appId).then((session) => { userB = session; expect(userB.sub).to.not.equal(userA.sub); }); }); it('User B: chat via SSE (new conversation)', () => { chat(appId, userB.token, [ { role: 'user', content: 'Say "Bravo one" in exactly those words and nothing else.' }, ]).then((result) => { expect(result.text.trim()).to.not.be.empty; }); }); it('User B: chat via data stream endpoint', () => { chatDataStream(appId, userB.token, [ { role: 'user', content: 'Say "Bravo two" in exactly those words and nothing else.' }, ]).then((result) => { expect(result.text.trim()).to.not.be.empty; }); }); it('User B: list conversations (expect 2, only their own)', () => { cy.request({ url: `${API_URL}/run/v1/conversations?limit=50`, headers: appAuthHeaders(appId, userB.token), failOnStatusCode: true, }).then((res) => { expect(res.body.pagination.total).to.be.at.least(2); const userBIds = res.body.data.map((c: { id: string }) => c.id); const leaked = userBIds.filter((id: string) => userAConvIds.includes(id)); expect(leaked, 'User B should not see User A conversations').to.have.length(0); }); }); it('User A: list conversations (still unchanged)', () => { cy.request({ url: `${API_URL}/run/v1/conversations?limit=50`, headers: appAuthHeaders(appId, userA.token), failOnStatusCode: true, }).then((res) => { expect(res.body.pagination.total).to.equal(userATotal); }); }); it('Cross-isolation: User B cannot get User A conversation by ID', () => { cy.request({ url: `${API_URL}/run/v1/conversations/${a1ConvId}`, headers: appAuthHeaders(appId, userB.token), failOnStatusCode: false, }).then((res) => { expect(res.status).to.equal(404); }); }); });