OmniRoute/tests/unit/api-auth.test.mjs
Diego Rodrigues de Sa e Souza 46acd16999
Some checks are pending
CI / Lint (push) Waiting to run
CI / Security Audit (push) Waiting to run
CI / Build (push) Waiting to run
CI / Build-1 (push) Waiting to run
CI / Unit Tests (push) Blocked by required conditions
CI / Unit Tests-1 (push) Blocked by required conditions
CI / Coverage (push) Blocked by required conditions
CI / E2E Tests (push) Blocked by required conditions
CI / Integration Tests (push) Blocked by required conditions
CI / Security Tests (push) Blocked by required conditions
Publish to Docker Hub / Build and Push Docker (multi-arch) (push) Waiting to run
chore(release): v3.2.8 — Docker Auto-Update & Analytics Fixes (#755)
* chore(release): v3.2.8 — Docker auto-update UI and cache analytics fixes

* fix(sse): remove race condition in cache metrics tracking (#758)

- Remove in-memory metrics tracking (currentMetrics, trackCacheMetrics, updateCacheMetrics)
- Cache metrics now computed on-the-fly from usage_history table (single source of truth)
- Fixes CRITICAL issue from code review: concurrent requests overwriting metrics
- Fixes WARNING: duplicate metric tracking logic in streaming/non-streaming paths

Ref: PR #752 (merged before this fix was included)

* fix: handle allRateLimited credentials & forward extra body keys in embeddings/images routes (#757)

* fix: handle allRateLimited credentials in embeddings and images routes

When getProviderCredentials() returns an allRateLimited object (truthy,
but without apiKey/accessToken), the embeddings and images routes
incorrectly passed it to handlers as valid credentials. The handlers
then sent upstream requests without Authorization headers, causing
401 errors from providers (e.g. NVIDIA NIM).

This only manifested under concurrent requests: a chat/completions
call could trigger rate limiting on a provider account, and a
simultaneous embeddings request would receive the allRateLimited
sentinel — but treat it as valid credentials.

The chat pipeline already handled this case correctly. This commit
adds the same allRateLimited guard to all affected routes:
- POST /v1/embeddings
- POST /v1/providers/{provider}/embeddings
- POST /v1/images/generations
- POST /v1/providers/{provider}/images/generations

Also adds a defense-in-depth guard in the embeddings handler itself:
if no auth token is available for a non-local provider, return 401
immediately instead of sending an unauthenticated request upstream.

Made-with: Cursor

* fix(embeddings): forward extra body keys to upstream providers

The embeddings handler only forwarded model, input, dimensions, and
encoding_format to upstream providers, silently dropping any additional
fields. This broke asymmetric embedding APIs (e.g. NVIDIA NIM
nv-embedqa-e5-v5) that require input_type, and other providers
expecting user or truncate parameters.

Add a KNOWN_FIELDS exclusion set and forward all unrecognized body
keys to the upstream request, matching the passthrough pattern used
by the chat pipeline's DefaultExecutor.transformRequest().

Made-with: Cursor

* fix(auth): redirect and unconditional 401 on disabled requireLogin + fix test cases

* fix(build): remove legacy proxy.ts causing Next.js build collision

* fix(build): revert middleware.ts rename to proxy.ts because of Next.js Edge constraints

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: tombii <tombii@users.noreply.github.com>
Co-authored-by: Gorchakov-Pressure <117600961+Gorchakov-Pressure@users.noreply.github.com>
2026-03-29 13:09:38 -03:00

166 lines
4.9 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { SignJWT } from "jose";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-api-auth-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-api-key-secret";
const core = await import("../../src/lib/db/core.ts");
const localDb = await import("../../src/lib/localDb.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const apiAuth = await import("../../src/shared/utils/apiAuth.ts");
const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
async function resetStorage() {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
delete process.env.JWT_SECRET;
delete process.env.INITIAL_PASSWORD;
}
function makeCookieRequest(token) {
return {
cookies: {
get(name) {
return name === "auth_token" && token ? { value: token } : undefined;
},
},
headers: new Headers(),
};
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(() => {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_JWT_SECRET === undefined) {
delete process.env.JWT_SECRET;
} else {
process.env.JWT_SECRET = ORIGINAL_JWT_SECRET;
}
if (ORIGINAL_INITIAL_PASSWORD === undefined) {
delete process.env.INITIAL_PASSWORD;
} else {
process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
}
});
test("isPublicRoute recognizes allowed API prefixes", () => {
assert.equal(apiAuth.isPublicRoute("/api/auth/login"), true);
assert.equal(apiAuth.isPublicRoute("/api/v1/chat/completions"), true);
assert.equal(apiAuth.isPublicRoute("/api/settings"), false);
});
test("verifyAuth accepts a valid JWT session cookie", async () => {
process.env.JWT_SECRET = "jwt-secret-for-tests";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const token = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(secret);
const result = await apiAuth.verifyAuth(makeCookieRequest(token));
assert.equal(result, null);
});
test("verifyAuth falls back to bearer API key validation after a bad JWT", async () => {
process.env.JWT_SECRET = "jwt-secret-for-tests";
const key = await apiKeysDb.createApiKey("integration", "machine1234567890");
const request = {
cookies: {
get() {
return { value: "definitely-not-a-valid-jwt" };
},
},
headers: new Headers({ authorization: `Bearer ${key.key}` }),
};
const result = await apiAuth.verifyAuth(request);
assert.equal(result, null);
});
test("verifyAuth rejects requests without valid credentials", async () => {
const result = await apiAuth.verifyAuth({
cookies: {
get() {
return undefined;
},
},
headers: new Headers({ authorization: "Bearer sk-invalid" }),
});
assert.equal(result, "Authentication required");
});
test("isAuthenticated accepts bearer API keys", async () => {
const key = await apiKeysDb.createApiKey("integration", "machine1234567890");
const request = new Request("https://example.com/api/providers", {
headers: { authorization: `Bearer ${key.key}` },
});
const result = await apiAuth.isAuthenticated(request);
assert.equal(result, true);
});
test("isAuthenticated returns false when auth is required without valid credentials", async () => {
// Force requireLogin to be active
process.env.INITIAL_PASSWORD = "bootstrap-password";
await localDb.updateSettings({ requireLogin: true, password: "" });
const request = new Request("https://example.com/api/providers");
const result = await apiAuth.isAuthenticated(request);
assert.equal(result, false);
});
test("isAuthRequired is disabled when requireLogin is false", async () => {
await localDb.updateSettings({ requireLogin: false });
const result = await apiAuth.isAuthRequired();
assert.equal(result, false);
});
test("isAuthRequired is disabled while no password exists", async () => {
await localDb.updateSettings({ requireLogin: true, password: "" });
const result = await apiAuth.isAuthRequired();
assert.equal(result, false);
});
test("isAuthRequired stays enabled when a password exists", async () => {
await localDb.updateSettings({ requireLogin: true, password: "hashed-password" });
const result = await apiAuth.isAuthRequired();
assert.equal(result, true);
});
test("isAuthRequired stays enabled when INITIAL_PASSWORD is present", async () => {
process.env.INITIAL_PASSWORD = "bootstrap-password";
await localDb.updateSettings({ requireLogin: true, password: "" });
const result = await apiAuth.isAuthRequired();
assert.equal(result, true);
});