Replaces the Qoder placeholder with a real free-tier provider: - Device-flow OAuth: PKCE + nonce generated locally, user authorizes at qoder.com/device/selectAccounts, poll openapi.qoder.sh until token - COSY signing (RSA-1024 + AES-128-CBC + MD5) for chat / model-list - WAF-bypass body encoding (custom-alphabet base64 + thirds rearrange) - Live model_config catalog from /algo/api/v2/model/list, cached 1h - 11 models registered (auto/ultimate/performance/efficient/lite + 6 frontier *model ids) - Usage fetcher for openapi.qoder.sh/api/v2/quota/usage - Dashboard live-models resolver, provider test, OAuth modal hookup - 24 unit tests covering encoder, PKCE, COSY headers, sigPath stripping
239 lines
9.1 KiB
JavaScript
239 lines
9.1 KiB
JavaScript
/**
|
|
* Unit tests for Qoder encoding + COSY signing primitives.
|
|
*
|
|
* These cover the parts that would silently produce wrong-but-plausible
|
|
* output if logic regressed:
|
|
* - body encoder boundary cases (empty input, lengths not divisible by 3)
|
|
* - COSY header production (signature deterministic given fixed inputs,
|
|
* all required headers present, sigPath correctly stripped)
|
|
* - device flow URL construction
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import crypto from "crypto";
|
|
|
|
import { qoderEncodeBody } from "../../src/lib/qoder/encoding.js";
|
|
import { buildCosyHeaders } from "../../src/lib/qoder/cosy.js";
|
|
import { initiateDeviceFlow, generatePkcePair } from "../../src/lib/qoder/auth.js";
|
|
import { QODER_CHAT_URL_ENCODED, QODER_MODEL_LIST_URL } from "../../src/lib/qoder/constants.js";
|
|
|
|
describe("qoderEncodeBody", () => {
|
|
it("preserves base64 length (input length divisible by 3)", () => {
|
|
const input = Buffer.from("abcdef", "utf8"); // 6 bytes → 8 base64 chars
|
|
const encoded = qoderEncodeBody(input);
|
|
expect(encoded.length).toBe(8);
|
|
});
|
|
|
|
it("preserves base64 length (input length not divisible by 3)", () => {
|
|
const input = Buffer.from("hello", "utf8"); // 5 bytes → 8 base64 chars (with padding)
|
|
const encoded = qoderEncodeBody(input);
|
|
expect(encoded.length).toBe(8);
|
|
});
|
|
|
|
it("handles empty input without throwing", () => {
|
|
const encoded = qoderEncodeBody(Buffer.alloc(0));
|
|
expect(encoded).toBe("");
|
|
});
|
|
|
|
it("accepts string and Buffer inputs equivalently", () => {
|
|
const a = qoderEncodeBody("hello");
|
|
const b = qoderEncodeBody(Buffer.from("hello", "utf8"));
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
it("only emits characters from the custom alphabet", () => {
|
|
// The custom alphabet is "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!"
|
|
// plus "$" for the padding char. If the substitution step regresses,
|
|
// characters outside that set would leak into the output.
|
|
const allowed = new Set(
|
|
"_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!$",
|
|
);
|
|
const encoded = qoderEncodeBody(
|
|
"hello world this is a longer string for testing 0123456789",
|
|
);
|
|
for (const ch of encoded) {
|
|
expect(allowed.has(ch), `unexpected char in output: ${JSON.stringify(ch)}`).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("is deterministic for identical input", () => {
|
|
const a = qoderEncodeBody("abc");
|
|
const b = qoderEncodeBody("abc");
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
it("produces different output for different input", () => {
|
|
const a = qoderEncodeBody("abc");
|
|
const b = qoderEncodeBody("xyz");
|
|
expect(a).not.toBe(b);
|
|
});
|
|
});
|
|
|
|
describe("generatePkcePair", () => {
|
|
it("produces base64url-safe verifier and challenge of the right length", () => {
|
|
const { verifier, challenge } = generatePkcePair();
|
|
// 32 bytes → 43 base64url chars (no padding)
|
|
expect(verifier.length).toBe(43);
|
|
expect(challenge.length).toBe(43);
|
|
expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
});
|
|
|
|
it("verifier and challenge are different (challenge is sha256 of verifier)", () => {
|
|
const { verifier, challenge } = generatePkcePair();
|
|
expect(verifier).not.toBe(challenge);
|
|
// S256: challenge should be base64url(sha256(verifier))
|
|
const expected = crypto
|
|
.createHash("sha256")
|
|
.update(verifier)
|
|
.digest("base64")
|
|
.replace(/=/g, "")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_");
|
|
expect(challenge).toBe(expected);
|
|
});
|
|
|
|
it("returns codeVerifier (not verifier) on the higher-level helper", () => {
|
|
// Regression: the providers.js qoder entry once read flow.verifier (undefined)
|
|
// because initiateDeviceFlow returns the field as `codeVerifier`.
|
|
const flow = initiateDeviceFlow();
|
|
expect(typeof flow.codeVerifier).toBe("string");
|
|
expect(flow.codeVerifier.length).toBe(43);
|
|
expect(flow.verifier).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("initiateDeviceFlow", () => {
|
|
it("produces a verification URL pointing at qoder.com/device/selectAccounts", () => {
|
|
const flow = initiateDeviceFlow();
|
|
expect(flow.verificationUriComplete).toMatch(
|
|
/^https:\/\/qoder\.com\/device\/selectAccounts\?/,
|
|
);
|
|
expect(flow.verificationUriComplete).toContain("challenge_method=S256");
|
|
expect(flow.verificationUriComplete).toContain(`nonce=${flow.nonce}`);
|
|
expect(flow.verificationUriComplete).toContain(`machine_id=${flow.machineId}`);
|
|
});
|
|
|
|
it("returns nonce and machineId as UUIDs", () => {
|
|
const flow = initiateDeviceFlow();
|
|
const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
expect(flow.nonce).toMatch(uuidRe);
|
|
expect(flow.machineId).toMatch(uuidRe);
|
|
});
|
|
});
|
|
|
|
describe("buildCosyHeaders", () => {
|
|
const creds = {
|
|
userId: "test-user-id",
|
|
authToken: "dt-test-token",
|
|
name: "Test",
|
|
email: "test@example.com",
|
|
machineId: "fixed-machine-id",
|
|
};
|
|
|
|
it("produces all required Cosy-* headers", () => {
|
|
const headers = buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, creds);
|
|
const required = [
|
|
"Authorization",
|
|
"Cosy-Key",
|
|
"Cosy-User",
|
|
"Cosy-Date",
|
|
"Cosy-Version",
|
|
"Cosy-Machineid",
|
|
"Cosy-Machinetoken",
|
|
"Cosy-Machinetype",
|
|
"Cosy-Machineos",
|
|
"Cosy-Clienttype",
|
|
"Cosy-Clientip",
|
|
"Cosy-Bodyhash",
|
|
"Cosy-Bodylength",
|
|
"Cosy-Sigpath",
|
|
"Cosy-Data-Policy",
|
|
"Login-Version",
|
|
"X-Request-Id",
|
|
];
|
|
for (const key of required) {
|
|
expect(headers[key], `missing header ${key}`).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it("Authorization is a Bearer COSY token with payload+sig", () => {
|
|
const headers = buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, creds);
|
|
expect(headers.Authorization).toMatch(/^Bearer COSY\.[A-Za-z0-9+/=]+\.[a-f0-9]{32}$/);
|
|
});
|
|
|
|
it("Cosy-Sigpath strips the leading /algo prefix", () => {
|
|
const headers = buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, creds);
|
|
expect(headers["Cosy-Sigpath"]).toBe("/api/v2/model/list");
|
|
});
|
|
|
|
it("Cosy-Sigpath also handles the encoded chat URL", () => {
|
|
const headers = buildCosyHeaders(Buffer.from("body", "utf8"), QODER_CHAT_URL_ENCODED, creds);
|
|
expect(headers["Cosy-Sigpath"]).toBe(
|
|
"/api/v2/service/pro/sse/agent_chat_generation",
|
|
);
|
|
});
|
|
|
|
it("Cosy-Bodyhash is the MD5 of the request body, Cosy-Bodylength is the length", () => {
|
|
const body = Buffer.from("hello qoder", "utf8");
|
|
const headers = buildCosyHeaders(body, QODER_MODEL_LIST_URL, creds);
|
|
const expectedHash = crypto.createHash("md5").update(body).digest("hex");
|
|
expect(headers["Cosy-Bodyhash"]).toBe(expectedHash);
|
|
expect(headers["Cosy-Bodylength"]).toBe(String(body.length));
|
|
});
|
|
|
|
it("empty body produces the canonical empty-MD5 hash", () => {
|
|
const headers = buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, creds);
|
|
expect(headers["Cosy-Bodyhash"]).toBe("d41d8cd98f00b204e9800998ecf8427e");
|
|
expect(headers["Cosy-Bodylength"]).toBe("0");
|
|
});
|
|
|
|
it("Cosy-Machineid + Cosy-Machinetoken match the supplied machineId", () => {
|
|
const headers = buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, creds);
|
|
expect(headers["Cosy-Machineid"]).toBe("fixed-machine-id");
|
|
expect(headers["Cosy-Machinetoken"]).toBe("fixed-machine-id");
|
|
});
|
|
|
|
it("auto-generates a machineId when none is supplied", () => {
|
|
const headers = buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, {
|
|
...creds,
|
|
machineId: "",
|
|
});
|
|
expect(headers["Cosy-Machineid"]).toMatch(
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
|
);
|
|
});
|
|
|
|
it("throws when userId is missing", () => {
|
|
expect(() =>
|
|
buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, { ...creds, userId: "" }),
|
|
).toThrow(/user id is empty/);
|
|
});
|
|
|
|
it("throws when authToken is missing", () => {
|
|
expect(() =>
|
|
buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, { ...creds, authToken: "" }),
|
|
).toThrow(/auth token is empty/);
|
|
});
|
|
|
|
it("Cosy-User reflects the supplied userId verbatim", () => {
|
|
const headers = buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, creds);
|
|
expect(headers["Cosy-User"]).toBe("test-user-id");
|
|
});
|
|
|
|
it("two calls with identical inputs differ only in fields that include fresh randomness", () => {
|
|
// The signature fingerprints a fresh AES key + UUID per call, so the
|
|
// signature, Cosy-Key, X-Request-Id, and Cosy-Date (1s resolution)
|
|
// can differ — but Cosy-User, Cosy-Bodyhash, Cosy-Bodylength,
|
|
// Cosy-Sigpath, and the machineId-derived headers must be stable.
|
|
const a = buildCosyHeaders(Buffer.from("payload", "utf8"), QODER_CHAT_URL_ENCODED, creds);
|
|
const b = buildCosyHeaders(Buffer.from("payload", "utf8"), QODER_CHAT_URL_ENCODED, creds);
|
|
expect(a["Cosy-User"]).toBe(b["Cosy-User"]);
|
|
expect(a["Cosy-Bodyhash"]).toBe(b["Cosy-Bodyhash"]);
|
|
expect(a["Cosy-Bodylength"]).toBe(b["Cosy-Bodylength"]);
|
|
expect(a["Cosy-Sigpath"]).toBe(b["Cosy-Sigpath"]);
|
|
expect(a["Cosy-Machineid"]).toBe(b["Cosy-Machineid"]);
|
|
expect(a["X-Request-Id"]).not.toBe(b["X-Request-Id"]);
|
|
});
|
|
});
|