Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions .github/workflows/deployPR.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,19 +81,19 @@ jobs:
EXPIRY=$(date -u +"%Y-%m-%dT%H:%M:%SZ" -d "5 hours")

# Get preview-base branch ID
PARENT_BRANCH_ID=$(curl -s -H "Authorization: Bearer $NEON_API_KEY" \
"https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/branches" \
| jq -r '.branches[] | select(.name=="preview-base") | .id')
BRANCHES_RESPONSE=$(curl -s -H "Authorization: Bearer $NEON_API_KEY" \
"https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/branches")
echo "Branches API response: $BRANCHES_RESPONSE"
PARENT_BRANCH_ID=$(echo "$BRANCHES_RESPONSE" | jq -r '(.branches // [])[] | select(.name=="preview-base") | .id')

if [ -z "$PARENT_BRANCH_ID" ] || [ "$PARENT_BRANCH_ID" = "null" ]; then
echo "❌ Could not find preview-base branch"
echo "API response was: $BRANCHES_RESPONSE"
exit 1
fi

# Create or reuse PR branch
BRANCH_ID=$(curl -s -H "Authorization: Bearer $NEON_API_KEY" \
"https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/branches" \
| jq -r ".branches[] | select(.name==\"$BRANCH_NAME\") | .id")
BRANCH_ID=$(echo "$BRANCHES_RESPONSE" | jq -r "(.branches // [])[] | select(.name==\"$BRANCH_NAME\") | .id")

if [ -z "$BRANCH_ID" ] || [ "$BRANCH_ID" = "null" ]; then
RESPONSE=$(curl -s -X POST \
Expand All @@ -120,9 +120,9 @@ jobs:
done

# Create or reuse endpoint
ENDPOINT_ID=$(curl -s -H "Authorization: Bearer $NEON_API_KEY" \
"https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/endpoints" \
| jq -r ".endpoints[] | select(.branch_id==\"$BRANCH_ID\") | .id")
ENDPOINTS_RESPONSE=$(curl -s -H "Authorization: Bearer $NEON_API_KEY" \
"https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/endpoints")
ENDPOINT_ID=$(echo "$ENDPOINTS_RESPONSE" | jq -r "(.endpoints // [])[] | select(.branch_id==\"$BRANCH_ID\") | .id")

if [ -z "$ENDPOINT_ID" ] || [ "$ENDPOINT_ID" = "null" ]; then
RESPONSE=$(curl -s -X POST \
Expand Down
177 changes: 177 additions & 0 deletions src/__tests__/unit/api/tasks/[taskId]/ide-token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { describe, test, expect, vi, beforeEach, Mock } from "vitest";
import { NextRequest } from "next/server";
import { POST } from "@/app/api/tasks/[taskId]/ide-token/route";
import { db } from "@/lib/db";
import { validateWorkspaceAccessById } from "@/services/workspace";
import { EncryptionService } from "@/lib/encryption";

// ── Mocks ────────────────────────────────────────────────────────────────────

vi.mock("@/lib/db", () => ({
db: {
task: {
findUnique: vi.fn(),
},
},
}));

vi.mock("@/services/workspace", () => ({
validateWorkspaceAccessById: vi.fn(),
}));

vi.mock("@/lib/pods/queries", () => ({
POD_BASE_DOMAIN: "workspaces.sphinx.chat",
}));

// mockDecryptField is defined inside the factory via vi.hoisted to avoid TDZ
const mockDecryptField = vi.hoisted(() => vi.fn());
vi.mock("@/lib/encryption", () => ({
EncryptionService: {
getInstance: vi.fn(() => ({
decryptField: mockDecryptField,
})),
},
}));

// ── Helpers ───────────────────────────────────────────────────────────────────

const TASK_ID = "task-abc123";
const USER_ID = "user-xyz";
const WORKSPACE_ID = "ws-1";
const POD_ID = "pod-deadbeef";
const PLAIN_PASSWORD = "s3cr3t-password";

function makeRequest(): NextRequest {
const headers = new Headers();
headers.set("x-middleware-auth-status", "authenticated");
headers.set("x-middleware-user-id", USER_ID);
headers.set("x-middleware-user-email", "test@example.com");
headers.set("x-middleware-user-name", "Test User");
return new NextRequest(`http://localhost/api/tasks/${TASK_ID}/ide-token`, {
method: "POST",
headers,
});
}

function makeUnauthenticatedRequest(): NextRequest {
return new NextRequest(`http://localhost/api/tasks/${TASK_ID}/ide-token`, {
method: "POST",
});
}

const mockTask = {
id: TASK_ID,
workspaceId: WORKSPACE_ID,
agentPassword: JSON.stringify({ data: "encrypted", iv: "iv", tag: "tag" }),
podId: POD_ID,
};

const mockDb = db as any;
const mockValidateAccess = validateWorkspaceAccessById as Mock;

// ── Tests ─────────────────────────────────────────────────────────────────────

describe("POST /api/tasks/[taskId]/ide-token", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDecryptField.mockReturnValue(PLAIN_PASSWORD);
mockValidateAccess.mockResolvedValue({ hasAccess: true });
});

test("returns 401 for unauthenticated request", async () => {
const req = makeUnauthenticatedRequest();
const res = await POST(req, { params: Promise.resolve({ taskId: TASK_ID }) });

expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("Unauthorized");
});

test("returns 404 when task does not exist", async () => {
mockDb.task.findUnique.mockResolvedValue(null);

const req = makeRequest();
const res = await POST(req, { params: Promise.resolve({ taskId: TASK_ID }) });

expect(res.status).toBe(404);
const body = await res.json();
expect(body.error).toBe("Not found");
});

test("returns 403 for user without workspace access", async () => {
mockDb.task.findUnique.mockResolvedValue(mockTask);
mockValidateAccess.mockResolvedValue({ hasAccess: false });

const req = makeRequest();
const res = await POST(req, { params: Promise.resolve({ taskId: TASK_ID }) });

expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toBe("Forbidden");
});

test("returns { token: null } when agentPassword is missing", async () => {
mockDb.task.findUnique.mockResolvedValue({ ...mockTask, agentPassword: null });

const req = makeRequest();
const res = await POST(req, { params: Promise.resolve({ taskId: TASK_ID }) });

expect(res.status).toBe(200);
const body = await res.json();
expect(body.token).toBeNull();
});

test("returns { token: null } when podId is missing", async () => {
mockDb.task.findUnique.mockResolvedValue({ ...mockTask, podId: null });

const req = makeRequest();
const res = await POST(req, { params: Promise.resolve({ taskId: TASK_ID }) });

expect(res.status).toBe(200);
const body = await res.json();
expect(body.token).toBeNull();
});

test("returns token, expires, and ideUrl when agentPassword and podId are present", async () => {
mockDb.task.findUnique.mockResolvedValue(mockTask);

const req = makeRequest();
const nowSeconds = Math.floor(Date.now() / 1000);
const res = await POST(req, { params: Promise.resolve({ taskId: TASK_ID }) });

expect(res.status).toBe(200);
const body = await res.json();

expect(typeof body.token).toBe("string");
expect(body.token).toMatch(/^[0-9a-f]{64}$/); // 32-byte HMAC-SHA256 hex
expect(body.expires).toBeGreaterThanOrEqual(nowSeconds + 50);
expect(body.expires).toBeLessThanOrEqual(nowSeconds + 60);
expect(body.ideUrl).toBe(`https://${POD_ID}.workspaces.sphinx.chat`);
});

test("token is a valid HMAC-SHA256 of 'ide-auth:{expires}' signed with decrypted password", async () => {
mockDb.task.findUnique.mockResolvedValue(mockTask);

const req = makeRequest();
const res = await POST(req, { params: Promise.resolve({ taskId: TASK_ID }) });
const body = await res.json();

// Recompute expected HMAC to verify correctness
const crypto = await import("node:crypto");
const expected = crypto
.createHmac("sha256", PLAIN_PASSWORD)
.update(`ide-auth:${body.expires}`)
.digest("hex");

expect(body.token).toBe(expected);
});

test("decryptField is called with 'agentPassword' field name", async () => {
mockDb.task.findUnique.mockResolvedValue(mockTask);

const req = makeRequest();
await POST(req, { params: Promise.resolve({ taskId: TASK_ID }) });

expect(mockDecryptField).toHaveBeenCalledWith("agentPassword", mockTask.agentPassword);
});
});
Loading
Loading