import { beforeAll, afterAll, describe, expect, it } from "vitest"; import Fastify from "fastify"; import Redis from "ioredis"; process.env.REDIS_REST_TOKEN = "test-token"; process.env.INTERNAL_REDIS_URL = process.env.INTERNAL_REDIS_URL || "redis://127.0.0.1:6379"; const redis = new Redis(process.env.INTERNAL_REDIS_URL, { lazyConnect: true, maxRetriesPerRequest: 1, commandTimeout: 2000, }); const BLOCKED_COMMANDS = new Set([ "flushall", "config", "shutdown", "save", "bgsave", "client", "monitor", "debug", "cluster", "replicaof", "slaveof", "replconf", "pfdebug", ]); const ALLOWED_COMMANDS = new Set([ "get", "set", "del", "exists", "expire", "ttl", "incr", "decr", "mget", "mset", "hget", "hset", "hdel", "hgetall", "lpush", "rpush", "lpop", "rpop", "lrange", "sadd", "srem", "smembers", "zadd", "zrem", "zrange", "ping", "scan", "keys", "flushdb", "setex", "psetex", "getdel", "getex", "zcard", "zscore", "zrevrange", "xadd", "xread", "setnx", "getset", "append", "strlen", "incrby", "decrby", "expireat", "pexpire", "pttl", "persist", "rename", "type", "lindex", "llen", "lrem", "ltrim", "hexists", "hincrby", "hkeys", "hvals", "hlen", "hmget", "hmset", "sismember", "scard", "sdiff", "sinter", "sunion", "zrank", "zcount", "zremrangebyrank", "zremrangebyscore", "srandmember", "spop", ]); function formatResult(raw) { if (raw === null || raw === undefined) return null; if (typeof raw === "bigint") return Number(raw); if (Buffer.isBuffer(raw)) return raw.toString("utf-8"); if (Array.isArray(raw)) return raw.map(formatResult); return raw; } async function executeCommand(raw) { if (!Array.isArray(raw) || raw.length === 0) return { error: "Invalid command format" }; const cmd = String(raw[0]).toLowerCase(); const args = raw.slice(1).map(String); if (BLOCKED_COMMANDS.has(cmd)) return { error: `Command '${cmd.toUpperCase()}' is blocked` }; if (!ALLOWED_COMMANDS.has(cmd)) return { error: `Unsupported command: ${cmd.toUpperCase()}` }; try { const result = await redis.call(cmd, ...args); return { result: formatResult(result) }; } catch (err) { return { error: err.message }; } } function isAuthed(request) { const header = request.headers.authorization; if (!header) return false; const m = header.match(/^Bearer\s+(.+)$/i); return m ? m[1] === process.env.REDIS_REST_TOKEN : false; } function buildApp() { const app = Fastify({ bodyLimit: 1024 * 1024 }); app.get("/health", async () => { try { await redis.ping(); return { status: "ok", redis: "connected" }; } catch { return { status: "error", redis: "disconnected" }; } }); app.get("/", async () => ({ service: "hf-redis-rest", status: "running" })); app.get("/:command/*", async (request, reply) => { if (!isAuthed(request)) return reply.code(401).send({ error: "Unauthorized" }); const parts = request.url.slice(1).split("/").filter(Boolean); return executeCommand(parts); }); app.post("/pipeline", async (request, reply) => { if (!isAuthed(request)) return reply.code(401).send({ error: "Unauthorized" }); if (!Array.isArray(request.body)) return reply.code(400).send({ error: "Request body must be an array of commands" }); const results = []; for (const cmd of request.body) results.push(await executeCommand(cmd)); return results; }); app.post("/", async (request, reply) => { if (!isAuthed(request)) return reply.code(401).send({ error: "Unauthorized" }); if (!request.body) return { service: "hf-redis-rest", status: "running" }; if (!Array.isArray(request.body)) return reply.code(400).send({ error: "Request body must be a JSON array" }); return executeCommand(request.body); }); return app; } describe("hf-redis-rest", () => { let app; beforeAll(async () => { await redis.connect(); await redis.flushdb(); app = buildApp(); await app.ready(); }); afterAll(async () => { await app.close(); await redis.quit(); }); it("serves service info at root", async () => { const res = await app.inject({ method: "GET", url: "/" }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ service: "hf-redis-rest", status: "running" }); }); it("serves health without auth", async () => { const res = await app.inject({ method: "GET", url: "/health" }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ status: "ok", redis: "connected" }); }); it("rejects missing token", async () => { const res = await app.inject({ method: "POST", url: "/", headers: { "content-type": "application/json" }, payload: ["PING"] }); expect(res.statusCode).toBe(401); expect(res.json()).toEqual({ error: "Unauthorized" }); }); it("rejects wrong token", async () => { const res = await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer nope", "content-type": "application/json" }, payload: ["PING"], }); expect(res.statusCode).toBe(401); }); it("supports PING, SET, GET, DEL", async () => { expect((await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: ["PING"] })).json()).toEqual({ result: "PONG" }); expect((await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: ["SET", "hello", "world"] })).json()).toEqual({ result: "OK" }); expect((await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: ["GET", "hello"] })).json()).toEqual({ result: "world" }); expect((await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: ["DEL", "hello"] })).json()).toEqual({ result: 1 }); }); it("supports expiration and ttl", async () => { await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: ["SET", "tmp", "v"] }); expect((await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: ["EXPIRE", "tmp", "60"] })).json()).toEqual({ result: 1 }); const ttl = (await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: ["TTL", "tmp"] })).json(); expect(ttl.result).toBeGreaterThanOrEqual(0); await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: ["DEL", "tmp", "counter"] }); }); it("supports hashes, lists, sets, and sorted sets", async () => { expect((await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: ["HSET", "h", "field", "value"] })).json()).toEqual({ result: 1 }); expect((await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: ["HGET", "h", "field"] })).json()).toEqual({ result: "value" }); expect((await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: ["LPUSH", "l", "one", "two"] })).json()).toEqual({ result: 2 }); expect((await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: ["LRANGE", "l", "0", "-1"] })).json()).toEqual({ result: ["two", "one"] }); expect((await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: ["SADD", "s", "a", "b"] })).json()).toEqual({ result: 2 }); expect((await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: ["SMEMBERS", "s"] })).json()).toEqual({ result: ["a", "b"] }); expect((await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: ["ZADD", "z", "1", "x", "2", "y"] })).json()).toEqual({ result: 2 }); expect((await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: ["ZRANGE", "z", "0", "-1"] })).json()).toEqual({ result: ["x", "y"] }); }); it("supports pipeline", async () => { const res = await app.inject({ method: "POST", url: "/pipeline", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: [["SET", "p", "1"], ["GET", "p"]], }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual([{ result: "OK" }, { result: "1" }]); }); it("supports path-style commands", async () => { await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: ["SET", "pathkey", "value"] }); const res = await app.inject({ method: "GET", url: "/get/pathkey", headers: { authorization: "Bearer test-token" } }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ result: "value" }); }); it("blocks dangerous commands", async () => { const res = await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: ["FLUSHALL"] }); expect(res.json()).toEqual({ error: "Command 'FLUSHALL' is blocked" }); }); it("rejects unsupported commands", async () => { const res = await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: ["NOPE"] }); expect(res.json()).toEqual({ error: "Unsupported command: NOPE" }); }); it("rejects invalid body shapes", async () => { const res = await app.inject({ method: "POST", url: "/", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: { a: 1 } }); expect(res.statusCode).toBe(400); }); it("returns error for invalid pipeline body", async () => { const res = await app.inject({ method: "POST", url: "/pipeline", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, payload: { a: 1 } }); expect(res.statusCode).toBe(400); }); });