Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9279f9292b | |||
| a832bd805c |
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user