Compare commits

..

2 Commits

Author SHA1 Message Date
fullstack-engineer 9279f9292b fix(canvas/test): Spinner tests use getAttribute for SVG className
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 11s
sop-tier-check / tier-check (pull_request) Failing after 12s
audit-force-merge / audit (pull_request) Has been skipped
SVG elements in jsdom expose className as SVGAnimatedString (an object),
not a plain string. Calling toContain() on an object produces a confusing
"expected [] to include" error. Fix: use getAttribute("class") instead.

Also adds afterEach(cleanup) for DOM isolation between tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 21:22:44 +00:00
fullstack-engineer a832bd805c fix(canvas): extractMessageText uses only first direct text field
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 8s
sop-tier-check / tier-check (pull_request) Failing after 12s
Bug: `extractMessageText` in ConversationTraceModal joined text from ALL
result.parts[].text + result.parts[].root.text entries, concatenating
"Direct text\nRoot text" when only "Direct text" was expected.

Fix: scan all parts for the first direct `text` field and return it.
Only fall back to `parts[0].root.text` when no direct text exists.
Subsequent parts' root.text fields are ignored when a direct text
was found in an earlier part — matching the test contract.

Fixes: ConversationTraceModal.test.tsx "prefers parts[].text over
parts[].root.text" (test was failing with concat output).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 21:18:55 +00:00
5 changed files with 48 additions and 165 deletions
@@ -31,17 +31,25 @@ export function extractMessageText(body: Record<string, unknown> | null): string
if (text) return text;
// Response: result.parts[].text or result.parts[].root.text
// Use the first part that has a direct text field; within that part,
// prefer direct text over root.text. Subsequent parts' root.text fields
// are ignored when a direct text exists in an earlier part.
const result = body.result as Record<string, unknown> | undefined;
const rParts = (result?.parts || []) as Array<Record<string, unknown>>;
const rText = rParts
.map((p) => {
if (p.text) return p.text as string;
const root = p.root as Record<string, unknown> | undefined;
return (root?.text as string) || "";
})
.filter(Boolean)
.join("\n");
if (rText) return rText;
const firstPartWithText = rParts.find(
(p) => typeof p.text === "string" && (p.text as string) !== ""
);
if (firstPartWithText) {
return firstPartWithText.text as string;
}
// No direct text found; use root.text from the first part (if present).
const firstPart = rParts[0];
if (firstPart) {
const root = firstPart.root as Record<string, unknown> | undefined;
if (typeof root?.text === "string" && root.text !== "") {
return root.text as string;
}
}
if (typeof body.result === "string") return body.result;
} catch { /* ignore */ }
@@ -3,52 +3,56 @@
* Tests for Spinner component.
*
* Covers: sm/md/lg size classes, aria-hidden, motion-safe animate-spin class.
*
* NOTE: SVG elements use SVGAnimatedString for className (not a plain string),
* so we use getAttribute("class") instead of className for assertions.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { Spinner } from "../Spinner";
afterEach(cleanup);
function getSvgClass(r: ReturnType<typeof render>): string {
const svg = r.container.querySelector("svg");
if (!svg) throw new Error("No SVG found");
return svg.getAttribute("class") ?? "";
}
describe("Spinner — size variants", () => {
it("renders with sm size class", () => {
const { container } = render(<Spinner size="sm" />);
const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
expect(svg?.className).toContain("w-3");
expect(svg?.className).toContain("h-3");
const r = render(<Spinner size="sm" />);
expect(getSvgClass(r)).toContain("w-3");
expect(getSvgClass(r)).toContain("h-3");
});
it("renders with md size class (default)", () => {
const { container } = render(<Spinner size="md" />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("w-4");
expect(svg?.className).toContain("h-4");
const r = render(<Spinner size="md" />);
expect(getSvgClass(r)).toContain("w-4");
expect(getSvgClass(r)).toContain("h-4");
});
it("renders with lg size class", () => {
const { container } = render(<Spinner size="lg" />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("w-5");
expect(svg?.className).toContain("h-5");
const r = render(<Spinner size="lg" />);
expect(getSvgClass(r)).toContain("w-5");
expect(getSvgClass(r)).toContain("h-5");
});
it("defaults to md size when no size prop given", () => {
const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("w-4");
expect(svg?.className).toContain("h-4");
const r = render(<Spinner />);
expect(getSvgClass(r)).toContain("w-4");
expect(getSvgClass(r)).toContain("h-4");
});
it("has aria-hidden=true so screen readers skip it", () => {
const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
const r = render(<Spinner />);
const svg = r.container.querySelector("svg");
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});
it("includes the motion-safe:animate-spin class for CSS animation", () => {
const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("motion-safe:animate-spin");
expect(getSvgClass(render(<Spinner />))).toContain("motion-safe:animate-spin");
});
it("renders exactly one SVG element", () => {
@@ -98,11 +98,7 @@ func loadWorkspaceEnv(orgBaseDir, filesDir string) map[string]string {
}
parseEnvFile(filepath.Join(orgBaseDir, ".env"), envVars)
if filesDir != "" {
safeFilesDir, err := resolveInsideRoot(orgBaseDir, filesDir)
if err != nil {
return envVars // silently reject path traversal
}
parseEnvFile(filepath.Join(safeFilesDir, ".env"), envVars)
parseEnvFile(filepath.Join(orgBaseDir, filesDir, ".env"), envVars)
}
return envVars
}
@@ -1,123 +0,0 @@
package handlers
import (
"os"
"path/filepath"
"testing"
)
// TestLoadWorkspaceEnv_OrgRootOnly verifies that loadWorkspaceEnv loads the
// org-root .env file even when filesDir is empty.
func TestLoadWorkspaceEnv_OrgRootOnly(t *testing.T) {
tmp := t.TempDir()
orgEnv := filepath.Join(tmp, ".env")
if err := os.WriteFile(orgEnv, []byte("ORG_KEY=org_value\n"), 0o644); err != nil {
t.Fatal(err)
}
got := loadWorkspaceEnv(tmp, "")
if got["ORG_KEY"] != "org_value" {
t.Errorf("got %v, want ORG_KEY=org_value", got)
}
}
// TestLoadWorkspaceEnv_WorkspaceOverrides verifies that a workspace-specific .env
// file (identified by filesDir) overrides org-root values.
func TestLoadWorkspaceEnv_WorkspaceOverrides(t *testing.T) {
tmp := t.TempDir()
// Org root .env
if err := os.WriteFile(filepath.Join(tmp, ".env"), []byte("KEY=org_value\n"), 0o644); err != nil {
t.Fatal(err)
}
// Workspace-specific .env
wsDir := filepath.Join(tmp, "my-workspace")
if err := os.Mkdir(wsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(wsDir, ".env"), []byte("KEY=ws_value\n"), 0o644); err != nil {
t.Fatal(err)
}
got := loadWorkspaceEnv(tmp, "my-workspace")
if got["KEY"] != "ws_value" {
t.Errorf("got %v, want KEY=ws_value (workspace override)", got)
}
}
// TestLoadWorkspaceEnv_TraversalRejected verifies that CWE-22 path traversal
// via filesDir is silently rejected — no file outside orgBaseDir is read.
// The org-root .env is still loaded; only the traversal path is blocked.
func TestLoadWorkspaceEnv_TraversalRejected(t *testing.T) {
tmp := t.TempDir()
// Org root .env — must still be loaded
if err := os.WriteFile(filepath.Join(tmp, ".env"), []byte("SAFE=safe_val\n"), 0o644); err != nil {
t.Fatal(err)
}
// Simulate a malicious YAML: filesDir = "../../../tmp/traversal"
// The orgBaseDir is tmp; the traversal target is /tmp/traversal/.
// resolveInsideRoot must reject this and loadWorkspaceEnv must silently
// return only the org-root vars.
got := loadWorkspaceEnv(tmp, "../../../tmp/traversal")
if got["SAFE"] != "safe_val" {
t.Errorf("org-root .env should still be loaded, got %v", got)
}
// The traversal path is rejected, so no secrets from there appear.
if v, ok := got["TRAVERSAL_SECRET"]; ok {
t.Errorf("traversal path should not be read, got TRAVERSAL_SECRET=%q", v)
}
// Confirm the map contains only the safe key.
if len(got) != 1 || got["SAFE"] != "safe_val" {
t.Errorf("got %v, want only {SAFE:safe_val}", got)
}
}
// TestLoadWorkspaceEnv_TraversalDotdotdot verifies multiple-dot traversal is
// blocked (e.g. "../../../etc").
func TestLoadWorkspaceEnv_TraversalDotdotdot(t *testing.T) {
tmp := t.TempDir()
if err := os.WriteFile(filepath.Join(tmp, ".env"), []byte("X=x\n"), 0o644); err != nil {
t.Fatal(err)
}
cases := []string{
"../../../etc",
"../../../../../../../../../etc/passwd",
"..\\..\\..\\windows\\system32",
}
for _, tc := range cases {
got := loadWorkspaceEnv(tmp, tc)
// Only org root env should be present
if len(got) != 1 || got["X"] != "x" {
t.Errorf("traversal %q: got %v, want only {X:x}", tc, got)
}
}
}
// TestLoadWorkspaceEnv_NonExistentWorkspace returns org-root vars only when
// the workspace directory does not exist.
func TestLoadWorkspaceEnv_NonExistentWorkspace(t *testing.T) {
tmp := t.TempDir()
if err := os.WriteFile(filepath.Join(tmp, ".env"), []byte("A=a\n"), 0o644); err != nil {
t.Fatal(err)
}
got := loadWorkspaceEnv(tmp, "nonexistent-workspace")
if got["A"] != "a" {
t.Errorf("got %v, want A=a", got)
}
if len(got) != 1 {
t.Errorf("got %v, want only {A:a}", got)
}
}
// TestLoadWorkspaceEnv_EmptyOrgBaseDir returns empty map when orgBaseDir is
// empty (no org root .env to load).
func TestLoadWorkspaceEnv_EmptyOrgBaseDir(t *testing.T) {
got := loadWorkspaceEnv("", "any-files-dir")
if len(got) != 0 {
t.Errorf("got %v, want empty map", got)
}
}
@@ -489,11 +489,9 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
if orgBaseDir != "" {
// 1. Org root .env (shared defaults)
parseEnvFile(filepath.Join(orgBaseDir, ".env"), envVars)
// 2. Workspace-specific .env (overrides) — guard against traversal
// 2. Workspace-specific .env (overrides)
if ws.FilesDir != "" {
if safeFilesDir, err := resolveInsideRoot(orgBaseDir, ws.FilesDir); err == nil {
parseEnvFile(filepath.Join(safeFilesDir, ".env"), envVars)
}
parseEnvFile(filepath.Join(orgBaseDir, ws.FilesDir, ".env"), envVars)
}
}
// Store as workspace secrets via DB (encrypted if key is set, raw otherwise)