Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion QA-CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
- [-] POST with `tweaks` → parameters override flow configuration
- [-] POST with custom `session_id`
- [-] POST with `input_type: "chat"` and `output_type: "chat"`
- [-] POST with invalid API key → returns 401/403
- [x] POST with invalid API key → returns 401/403 → `api-invalid-key.spec.ts`
- [-] POST to non-existent flow → returns 404

#### 1.4 Components via API
Expand Down
83 changes: 83 additions & 0 deletions docs/api/flows/api-invalid-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# API Invalid Key Handling

**Last validated:** Langflow 1.10.x

---

## What this test validates *(required)*
Validates that Langflow rejects unauthenticated and badly-authenticated REST API requests on the routes that change or read flow state. The endpoints under test must return `401`, `403`, or — for malformed bodies on flow creation — `422`. The test also confirms that a rejected `PATCH` does **not** mutate the underlying flow, which is the behavior callers depend on when integrating against Langflow.

If any of these tests fail, the auth boundary on the public REST API has regressed and Langflow leaks write/read access to anyone with network reach to the backend.

---

## Tags *(required)*
`@stable` `@release` `@api` `@workspace` `@regression`

---

## Step by step *(required)*

The spec runs **6 independent tests** via Playwright's `request` fixture. Tests that need a real flow create one with a valid Bearer token (obtained via `getAuthToken`) and clean it up in a `finally` block.

---

**Test 1 — `POST /api/v1/flows/` with invalid Bearer token**
1. POST a minimal flow with `Authorization: Bearer invalid-token-xyz`
2. Assert response status is in `[401, 403, 422]`

**Test 2 — `GET /api/v1/flows/` without `Authorization` header**
1. GET with empty headers
2. Assert response status is in `[401, 403]`

**Test 3 — `GET /api/v1/flows/{id}` with invalid Bearer token**
1. GET a synthetic UUID with `Authorization: Bearer totally-invalid-token`
2. Assert response status is in `[401, 403]`

**Test 4 — `POST /api/v1/run/{id}` with invalid `x-api-key`**
1. Create a real flow with a valid Bearer token (`expect(createRes.status()).toBe(201)` — guarantees `flowId` exists for cleanup)
2. POST `/api/v1/run/{flowId}` with `x-api-key: invalid-api-key-0000`
3. Assert response status is in `[401, 403]`
4. `finally`: delete the created flow with the valid Bearer token

**Test 5 — `DELETE /api/v1/flows/{id}` without `Authorization` header**
1. DELETE a synthetic UUID with empty headers
2. Assert response status is in `[401, 403]`

**Test 6 — `PATCH /api/v1/flows/{id}` with wrong token does not mutate the flow**
1. Create a real flow with a valid Bearer token (same guarantee as Test 4)
2. PATCH the flow with `Authorization: Bearer wrong-token-here` and a new name
3. Assert PATCH response status is in `[401, 403]`
4. GET the flow with the valid Bearer token
5. Assert GET status is `200` and `body.name` equals the original name (no mutation)
6. `finally`: delete the created flow

---

## Validation criterion *(required)*
- Every "rejected status" assertion across the 6 tests returns one of the documented codes (`401`, `403`, or `422` for malformed-create).
- The `PATCH` rejection in Test 6 leaves the flow's `name` field unchanged when read back.
- All created flows are cleaned up; no leaked test fixtures remain after the run.

---

## What this test does not cover *(optional)*
- Expired or revoked tokens (separate concern from "invalid format")
- Cross-tenant access (a valid token from user A trying to read user B's flows)
- Rate limiting or repeated-failure lockout
- WebSocket / streaming endpoint authentication
- The `/api/v1/run/{id}` happy path with a valid `x-api-key` (covered by `api-run-flow.spec.ts`)

---

## Preconditions *(optional)*
- Langflow running and reachable at `PLAYWRIGHT_BASE_URL`
- Default superuser credentials available (`LANGFLOW_SUPERUSER` / `LANGFLOW_SUPERUSER_PASSWORD`) — used by `getAuthToken` to mint a valid Bearer for setup/cleanup
- No third-party API keys required

---

## External dependencies *(required)*
- `tests/helpers/auth/get-auth-token.ts` — issues a valid `Bearer` via `/api/v1/auto_login`; if its contract changes, Tests 4 and 6 break
- `src/backend/base/langflow/api/v1/flows.py` (or wherever the flows router is mounted) — the spec is bound to the documented status-code semantics; loosening rejection to `200` would silently leak access
- `src/backend/base/langflow/api/v1/endpoints.py` — `/api/v1/run/{id}` auth path; if it switches from `x-api-key` back to Bearer, Test 4 needs adjustment
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const FLOW_BASE = {
test.describe("API Invalid Key Handling", () => {
test(
"POST /api/v1/flows/ with invalid Bearer token returns 401, 403, or 422",
{ tag: ["@release", "@workspace", "@regression"] },
{ tag: ["@stable", "@release", "@api", "@workspace", "@regression"] },
async ({ request }) => {
const res = await request.post("/api/v1/flows/", {
headers: { Authorization: "Bearer invalid-token-xyz" },
Expand All @@ -27,7 +27,7 @@ test.describe("API Invalid Key Handling", () => {

test(
"GET /api/v1/flows/ without Authorization header returns 401 or 403",
{ tag: ["@release", "@workspace", "@regression"] },
{ tag: ["@stable", "@release", "@api", "@workspace", "@regression"] },
async ({ request }) => {
const res = await request.get("/api/v1/flows/", {
headers: {},
Expand All @@ -39,7 +39,7 @@ test.describe("API Invalid Key Handling", () => {

test(
"GET /api/v1/flows/{id} with invalid Bearer token returns 401 or 403",
{ tag: ["@release", "@workspace", "@regression"] },
{ tag: ["@stable", "@release", "@api", "@workspace", "@regression"] },
async ({ request }) => {
const fakeId = "00000000-0000-0000-0000-000000000001";

Expand All @@ -53,43 +53,38 @@ test.describe("API Invalid Key Handling", () => {

test(
"POST /api/v1/run/{id} with invalid x-api-key returns 401 or 403",
{ tag: ["@release", "@workspace", "@regression"] },
{ tag: ["@stable", "@release", "@api", "@workspace", "@regression"] },
async ({ request }) => {
const authToken = await getAuthToken(request);
const flowName = `Invalid Key Run Test - ${Date.now()}`;
let flowId: string | null = null;

try {
// Create a real flow with valid credentials
const createRes = await request.post("/api/v1/flows/", {
headers: { Authorization: authToken },
data: { ...FLOW_BASE, name: flowName },
});
expect(createRes.status()).toBe(201);

const body = await createRes.json();
flowId = body.id;
// Created outside try so finally runs only when flowId is guaranteed defined.
const createRes = await request.post("/api/v1/flows/", {
headers: { Authorization: authToken },
data: { ...FLOW_BASE, name: flowName },
});
expect(createRes.status()).toBe(201);
const { id: flowId } = await createRes.json();
expect(flowId).toBeTruthy();

// Attempt to run the flow with an invalid API key
try {
const runRes = await request.post(`/api/v1/run/${flowId}`, {
headers: { "x-api-key": "invalid-api-key-0000" },
data: { input_value: "test", input_type: "chat", output_type: "chat" },
});

expect([401, 403]).toContain(runRes.status());
} finally {
if (flowId) {
await request.delete(`/api/v1/flows/${flowId}`, {
headers: { Authorization: authToken },
});
}
await request.delete(`/api/v1/flows/${flowId}`, {
headers: { Authorization: authToken },
});
}
},
);

test(
"DELETE /api/v1/flows/{id} without Authorization header returns 401 or 403",
{ tag: ["@release", "@workspace", "@regression"] },
{ tag: ["@stable", "@release", "@api", "@workspace", "@regression"] },
async ({ request }) => {
const fakeId = "00000000-0000-0000-0000-000000000002";

Expand All @@ -103,44 +98,38 @@ test.describe("API Invalid Key Handling", () => {

test(
"PATCH /api/v1/flows/{id} with wrong token does not update the flow",
{ tag: ["@release", "@workspace", "@regression"] },
{ tag: ["@stable", "@release", "@api", "@workspace", "@regression"] },
async ({ request }) => {
const authToken = await getAuthToken(request);
const flowName = `Invalid Patch Test - ${Date.now()}`;
let flowId: string | null = null;

try {
// Create flow with valid credentials
const createRes = await request.post("/api/v1/flows/", {
headers: { Authorization: authToken },
data: { ...FLOW_BASE, name: flowName },
});
expect(createRes.status()).toBe(201);

const body = await createRes.json();
flowId = body.id;
// Created outside try so finally runs only when flowId is guaranteed defined.
const createRes = await request.post("/api/v1/flows/", {
headers: { Authorization: authToken },
data: { ...FLOW_BASE, name: flowName },
});
expect(createRes.status()).toBe(201);
const { id: flowId } = await createRes.json();
expect(flowId).toBeTruthy();

// Try to patch with an invalid token
try {
const patchRes = await request.patch(`/api/v1/flows/${flowId}`, {
headers: { Authorization: "Bearer wrong-token-here" },
data: { name: "Should Not Update" },
});

expect([401, 403]).toContain(patchRes.status());

// Verify the name was NOT changed
const getRes = await request.get(`/api/v1/flows/${flowId}`, {
headers: { Authorization: authToken },
});
expect(getRes.status()).toBe(200);
const flow = await getRes.json();
expect(flow.name).toBe(flowName);
} finally {
if (flowId) {
await request.delete(`/api/v1/flows/${flowId}`, {
headers: { Authorization: authToken },
});
}
await request.delete(`/api/v1/flows/${flowId}`, {
headers: { Authorization: authToken },
});
}
},
);
Expand Down
Loading