This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-7384-fceef70e32260c9dc29762a2f70d87f8fef03f3b
in repository https://gitbox.apache.org/repos/asf/texera.git

commit e328232b436b3c57936f1ef8618a14b5e87ba559
Author: Xinyuan Lin <[email protected]>
AuthorDate: Sun Aug 9 18:48:03 2026 -0700

    test(agent-service): cover the auth, workflow, and backend API clients 
(#7384)
    
    ### What changes were proposed in this PR?
    
    Three of the four API clients under `agent-service/src/api` had no spec.
    `auth-api.ts` is the one that decides whether a request is authenticated
    at all, and none of its decisions were pinned.
    
    Adds 30 tests across three spec files, following the `fetch`-spy pattern
    already established by `compile-api.spec.ts`.
    
    **auth-api** — several of these are policy choices that read like
    oversights, so the tests state the intent rather than just the
    behaviour:
    
    | Input | Result |
    |---|---|
    | token with no `exp` | valid — tokens minted without an expiry never
    expire |
    | malformed token | invalid — the decode error is swallowed and reported
    as expired, not thrown |
    | payload with no `role` | `REGULAR`, so absent means least privilege |
    | `bearer` / `BEARER` | accepted; the scheme is matched
    case-insensitively |
    | two-segment token whose payload parses | rejected |
    
    **workflow-api** — the workflow `content` round-trips as a nested JSON
    **string**: the request sends `JSON.stringify(content)` and the response
    is re-parsed when it comes back as a string. Sending the object directly
    is the obvious-looking mistake and the backend rejects it, so both
    directions are pinned, along with the empty-description default and the
    error text on a refused save or a missing workflow.
    
    **backend-api** — the endpoint set, the defensive copy of the
    module-level config, and the two failure paths of the metadata fetch.
    
    **Verified by mutation**, all reverted (production diff empty):
    
    | Mutation | Result |
    |---|---|
    | default a missing role to `ADMIN` | red |
    | remove the three-segment check | red |
    | treat a token with no `exp` as expired | red |
    | compare `exp` as milliseconds instead of seconds | red |
    | make the Bearer scheme case-sensitive | red |
    | report a malformed token as valid | red |
    | send `content` as a nested object | red |
    | drop the empty-description default | red |
    | stop re-parsing a stringified response `content` | red |
    | drop the wid from the retrieve URL | red |
    | return the shared config by reference | red |
    
    The three-segment mutation initially **survived**: the test used
    `"only.two"`, whose payload fails `JSON.parse` regardless, so the
    segment check was never actually exercised. Replaced with a two-segment
    token carrying a valid payload — an unsigned token — which is the case
    the check exists for.
    
    No production file is touched.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7381
    
    ### How was this PR tested?
    
    ```
    bun test
    ```
    
    ```
     232 pass
     0 fail
    Ran 232 tests across 18 files.
    ```
    
    `bun run typecheck` and `bun run format:check` both pass.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
---
 agent-service/src/api/auth-api.spec.ts     | 137 ++++++++++++++++++++++++++
 agent-service/src/api/backend-api.spec.ts  |  78 +++++++++++++++
 agent-service/src/api/workflow-api.spec.ts | 150 +++++++++++++++++++++++++++++
 3 files changed, 365 insertions(+)

diff --git a/agent-service/src/api/auth-api.spec.ts 
b/agent-service/src/api/auth-api.spec.ts
new file mode 100644
index 0000000000..0037790ffc
--- /dev/null
+++ b/agent-service/src/api/auth-api.spec.ts
@@ -0,0 +1,137 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { describe, expect, test } from "bun:test";
+import { createAuthHeaders, extractBearerToken, extractUserFromToken, 
validateToken } from "./auth-api";
+
+/**
+ * Builds a well-formed three-segment token carrying the given payload. All 
three segments are
+ * required — `decodeJWT` rejects anything that is not exactly three — but 
only the payload one is
+ * decoded, so the header and signature just have to be present.
+ */
+function tokenWith(payload: Record<string, unknown>): string {
+  const encoded = Buffer.from(JSON.stringify(payload), 
"utf-8").toString("base64");
+  return `header.${encoded}.signature`;
+}
+
+/** `exp` is a UNIX second count, so expiries are built in seconds throughout. 
*/
+function nowInSeconds(): number {
+  return Math.floor(Date.now() / 1000);
+}
+
+describe("extractUserFromToken", () => {
+  test("maps the JWT payload onto the user record", () => {
+    const token = tokenWith({ userId: 42, sub: "ada", email: 
"[email protected]", role: "ADMIN" });
+
+    expect(extractUserFromToken(token)).toEqual({
+      uid: 42,
+      name: "ada",
+      email: "[email protected]",
+      role: "ADMIN",
+    });
+  });
+
+  test("defaults a missing email to empty and a missing role to REGULAR", () 
=> {
+    // Role defaulting is a privilege decision: absent must mean least 
privilege, never admin.
+    const user = extractUserFromToken(tokenWith({ userId: 7, sub: "grace" }));
+
+    expect(user.email).toBe("");
+    expect(user.role).toBe("REGULAR");
+  });
+
+  test("rejects a token missing its signature segment even when the payload 
decodes", () => {
+    // The payload here is perfectly good JSON, so only the segment-count 
check stands between an
+    // unsigned token and a populated user record.
+    const payload = Buffer.from(JSON.stringify({ userId: 1, sub: "ada" }), 
"utf-8").toString("base64");
+
+    expect(() => extractUserFromToken(`header.${payload}`)).toThrow(/Failed to 
decode JWT/);
+  });
+
+  test("rejects a token with too many segments", () => {
+    const payload = Buffer.from(JSON.stringify({ userId: 1, sub: "ada" }), 
"utf-8").toString("base64");
+
+    expect(() => 
extractUserFromToken(`header.${payload}.signature.extra`)).toThrow(/Failed to 
decode JWT/);
+  });
+
+  test("rejects a token whose payload is not JSON", () => {
+    const notJson = Buffer.from("<html>", "utf-8").toString("base64");
+    expect(() => 
extractUserFromToken(`header.${notJson}.signature`)).toThrow(/Failed to decode 
JWT/);
+  });
+});
+
+describe("validateToken", () => {
+  test("accepts a token whose expiry is still in the future", () => {
+    expect(validateToken(tokenWith({ userId: 1, exp: nowInSeconds() + 60 
}))).toBe(true);
+  });
+
+  test("rejects a token whose expiry has passed", () => {
+    expect(validateToken(tokenWith({ userId: 1, exp: nowInSeconds() - 60 
}))).toBe(false);
+  });
+
+  test("treats a token with no expiry as valid forever", () => {
+    // Deliberate: tokens minted without `exp` never expire. Pinned because it 
is a policy choice
+    // that reads like an oversight, and flipping it would lock out every 
non-expiring token.
+    expect(validateToken(tokenWith({ userId: 1 }))).toBe(true);
+  });
+
+  test("rejects a malformed token rather than letting the decode failure 
escape", () => {
+    // isTokenExpired swallows the decode error and reports "expired", so 
callers get false here
+    // instead of an exception. A caller that only catches would otherwise let 
a junk token through.
+    expect(validateToken("not-a-token")).toBe(false);
+  });
+
+  test("expiry is compared in seconds, not milliseconds", () => {
+    // exp is a UNIX second count; reading it as milliseconds would place 
every real token in 1970
+    // and reject it. A far-future second count must still validate.
+    expect(validateToken(tokenWith({ exp: nowInSeconds() + 3600 
}))).toBe(true);
+  });
+});
+
+describe("extractBearerToken", () => {
+  test("returns the token from a Bearer header", () => {
+    expect(extractBearerToken("Bearer abc123")).toBe("abc123");
+  });
+
+  test("accepts the scheme in any case", () => {
+    expect(extractBearerToken("bearer abc123")).toBe("abc123");
+    expect(extractBearerToken("BEARER abc123")).toBe("abc123");
+  });
+
+  test("ignores a non-Bearer scheme", () => {
+    expect(extractBearerToken("Basic abc123")).toBeUndefined();
+  });
+
+  test("returns undefined for a missing header", () => {
+    expect(extractBearerToken(undefined)).toBeUndefined();
+  });
+
+  test("returns undefined when the scheme carries no token", () => {
+    expect(extractBearerToken("Bearer")).toBeUndefined();
+    expect(extractBearerToken("Bearer ")).toBeUndefined();
+  });
+});
+
+describe("createAuthHeaders", () => {
+  test("sends the token as a Bearer credential alongside a JSON content type", 
() => {
+    expect(createAuthHeaders("abc123")).toEqual({
+      Authorization: "Bearer abc123",
+      "Content-Type": "application/json",
+    });
+  });
+});
diff --git a/agent-service/src/api/backend-api.spec.ts 
b/agent-service/src/api/backend-api.spec.ts
new file mode 100644
index 0000000000..1ce1cf0557
--- /dev/null
+++ b/agent-service/src/api/backend-api.spec.ts
@@ -0,0 +1,78 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
+import { fetchOperatorMetadata, getBackendConfig, type OperatorMetadata } from 
"./backend-api";
+
+const metadata: OperatorMetadata = { operators: [], groups: [] };
+
+describe("getBackendConfig", () => {
+  test("exposes the four service endpoints", () => {
+    const config = getBackendConfig();
+
+    expect(Object.keys(config).sort()).toEqual([
+      "apiEndpoint",
+      "compileEndpoint",
+      "executionEndpoint",
+      "modelsEndpoint",
+    ]);
+  });
+
+  test("hands out a copy, so a caller cannot repoint the service endpoints", 
() => {
+    // The config is module-level state shared by every API client; returning 
the live object would
+    // let one caller's edit silently redirect everyone else's requests.
+    const first = getBackendConfig();
+    first.apiEndpoint = "http://evil.example.com";;
+
+    expect(getBackendConfig().apiEndpoint).not.toBe("http://evil.example.com";);
+  });
+});
+
+describe("fetchOperatorMetadata", () => {
+  afterEach(() => {
+    mock.restore();
+  });
+
+  test("reads the metadata from the dashboard service", async () => {
+    const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
+      new Response(JSON.stringify(metadata), { status: 200 })
+    );
+
+    const result = await fetchOperatorMetadata();
+
+    expect(result).toEqual(metadata);
+    const [url] = fetchSpy.mock.calls[0] as [string];
+    
expect(url).toBe(`${getBackendConfig().apiEndpoint}/api/resources/operator-metadata`);
+  });
+
+  test("throws with the status when the metadata cannot be fetched", async () 
=> {
+    spyOn(globalThis, "fetch").mockResolvedValue(
+      new Response("down", { status: 503, statusText: "Service Unavailable" })
+    );
+
+    await expect(fetchOperatorMetadata()).rejects.toThrow(/Failed to fetch 
operator metadata: 503 Service Unavailable/);
+  });
+
+  test("lets a network failure surface rather than returning empty metadata", 
async () => {
+    // A silent empty result here would leave the agent believing the cluster 
has zero operators.
+    spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down"));
+
+    await expect(fetchOperatorMetadata()).rejects.toThrow(/network down/);
+  });
+});
diff --git a/agent-service/src/api/workflow-api.spec.ts 
b/agent-service/src/api/workflow-api.spec.ts
new file mode 100644
index 0000000000..6ddaea515b
--- /dev/null
+++ b/agent-service/src/api/workflow-api.spec.ts
@@ -0,0 +1,150 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
+import { persistWorkflow, retrieveWorkflow, type Workflow } from 
"./workflow-api";
+import type { WorkflowContent } from "../types/workflow";
+
+const TOKEN = "tok-123";
+
+const content = {
+  operators: [{ operatorID: "opX", operatorType: "CSVFileScan" }],
+  links: [],
+} as unknown as WorkflowContent;
+
+/** The backend stores `content` as a JSON string, so responses echo it back 
in that form. */
+function storedWorkflow(overrides: Partial<Workflow> = {}): Record<string, 
unknown> {
+  return { wid: 5, name: "flow", content: JSON.stringify(content), 
...overrides };
+}
+
+describe("persistWorkflow", () => {
+  afterEach(() => {
+    mock.restore();
+  });
+
+  test("POSTs the workflow with the caller's bearer token", async () => {
+    const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
+      new Response(JSON.stringify(storedWorkflow()), { status: 200 })
+    );
+
+    await persistWorkflow(TOKEN, 5, "flow", content, "a description");
+
+    const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
+    expect(url).toMatch(/\/api\/workflow\/persist$/);
+    expect(init.method).toBe("POST");
+    expect(init.headers).toEqual({
+      Authorization: `Bearer ${TOKEN}`,
+      "Content-Type": "application/json",
+    });
+  });
+
+  test("serializes the content as a nested JSON string, not a nested object", 
async () => {
+    // The persist endpoint takes `content` as a string field. Sending the 
object directly is the
+    // obvious-looking mistake and the backend rejects it, so the double 
encoding is pinned here.
+    const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
+      new Response(JSON.stringify(storedWorkflow()), { status: 200 })
+    );
+
+    await persistWorkflow(TOKEN, 5, "flow", content, "a description");
+
+    const body = JSON.parse((fetchSpy.mock.calls[0] as [string, 
RequestInit])[1].body as string);
+    expect(typeof body.content).toBe("string");
+    expect(JSON.parse(body.content)).toEqual(content);
+    expect(body).toMatchObject({ wid: 5, name: "flow", description: "a 
description", isPublic: false });
+  });
+
+  test("sends an empty description when none is given", async () => {
+    const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
+      new Response(JSON.stringify(storedWorkflow()), { status: 200 })
+    );
+
+    await persistWorkflow(TOKEN, 5, "flow", content);
+
+    const body = JSON.parse((fetchSpy.mock.calls[0] as [string, 
RequestInit])[1].body as string);
+    expect(body.description).toBe("");
+  });
+
+  test("parses the stringified content on the way back out", async () => {
+    spyOn(globalThis, "fetch").mockResolvedValue(new 
Response(JSON.stringify(storedWorkflow()), { status: 200 }));
+
+    const saved = await persistWorkflow(TOKEN, 5, "flow", content);
+
+    expect(saved.content).toEqual(content);
+  });
+
+  test("leaves an already-parsed content object alone", async () => {
+    spyOn(globalThis, "fetch").mockResolvedValue(
+      new Response(JSON.stringify({ wid: 5, name: "flow", content }), { 
status: 200 })
+    );
+
+    const saved = await persistWorkflow(TOKEN, 5, "flow", content);
+
+    expect(saved.content).toEqual(content);
+  });
+
+  test("reports the status and the server's message when the save is refused", 
async () => {
+    spyOn(globalThis, "fetch").mockResolvedValue(
+      new Response("workflow is read-only", { status: 403, statusText: 
"Forbidden" })
+    );
+
+    await expect(persistWorkflow(TOKEN, 5, "flow", content)).rejects.toThrow(
+      /Failed to persist workflow: 403 Forbidden - workflow is read-only/
+    );
+  });
+});
+
+describe("retrieveWorkflow", () => {
+  afterEach(() => {
+    mock.restore();
+  });
+
+  test("GETs the workflow by id with the caller's bearer token", async () => {
+    const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
+      new Response(JSON.stringify(storedWorkflow()), { status: 200 })
+    );
+
+    await retrieveWorkflow(TOKEN, 5);
+
+    const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
+    expect(url).toMatch(/\/api\/workflow\/5$/);
+    expect(init.method).toBe("GET");
+    expect(init.headers).toEqual({
+      Authorization: `Bearer ${TOKEN}`,
+      "Content-Type": "application/json",
+    });
+  });
+
+  test("parses the stringified content on the way back out", async () => {
+    spyOn(globalThis, "fetch").mockResolvedValue(new 
Response(JSON.stringify(storedWorkflow()), { status: 200 }));
+
+    const loaded = await retrieveWorkflow(TOKEN, 5);
+
+    expect(loaded.content).toEqual(content);
+  });
+
+  test("reports the status and the server's message when the workflow is 
missing", async () => {
+    spyOn(globalThis, "fetch").mockResolvedValue(
+      new Response("no such workflow", { status: 404, statusText: "Not Found" 
})
+    );
+
+    await expect(retrieveWorkflow(TOKEN, 5)).rejects.toThrow(
+      /Failed to retrieve workflow: 404 Not Found - no such workflow/
+    );
+  });
+});

Reply via email to