diff --git a/src/__tests__/integration/api/admin/pr-stats.test.ts b/src/__tests__/integration/api/admin/pr-stats.test.ts index 7d4db52b7c..926300aeb4 100644 --- a/src/__tests__/integration/api/admin/pr-stats.test.ts +++ b/src/__tests__/integration/api/admin/pr-stats.test.ts @@ -89,6 +89,9 @@ describe("GET /api/admin/workspaces/[id]/pr-stats (integration)", () => { repositoryUrl: "https://github.com/testorg/testrepo", }); + // Use a counter for deterministic, unique PR numbers (avoids random collision/deduplication) + let prCounter = 1; + // Helper: create a task → message → PR artifact async function seedPRArtifact(status: string, ageHours: number) { const task = await createTestTask({ @@ -104,7 +107,7 @@ describe("GET /api/admin/workspaces/[id]/pr-stats (integration)", () => { messageId: message.id, type: "PULL_REQUEST", content: { - url: `https://github.com/testorg/testrepo/pull/${Math.floor(Math.random() * 9999)}`, + url: `https://github.com/testorg/testrepo/pull/${prCounter++}`, repo: "testorg/testrepo", status, title: `Test PR (${status})`, @@ -144,6 +147,7 @@ describe("GET /api/admin/workspaces/[id]/pr-stats (integration)", () => { repositoryUrl: "https://github.com/testorg/bucketrepo", }); + let bucketPrCounter = 1; async function seedDoneArtifact(ageHours: number) { const task = await createTestTask({ workspaceId: workspace.id, createdById: regularUser.id }); const message = await createTestChatMessage({ taskId: task.id, message: "test" }); @@ -153,7 +157,7 @@ describe("GET /api/admin/workspaces/[id]/pr-stats (integration)", () => { messageId: message.id, type: "PULL_REQUEST", content: { - url: `https://github.com/testorg/bucketrepo/pull/${Math.floor(Math.random() * 9999)}`, + url: `https://github.com/testorg/bucketrepo/pull/${bucketPrCounter++}`, repo: "testorg/bucketrepo", status: "DONE", title: "Test PR", diff --git a/src/__tests__/unit/lib/mock/swarm-state-vanity.test.ts b/src/__tests__/unit/lib/mock/swarm-state-vanity.test.ts new file mode 100644 index 0000000000..5a3156b903 --- /dev/null +++ b/src/__tests__/unit/lib/mock/swarm-state-vanity.test.ts @@ -0,0 +1,109 @@ +import { describe, test, expect, beforeEach } from "vitest"; +import { mockSwarmState } from "@/lib/mock/swarm-state"; + +describe("MockSwarmStateManager.updateVanityAddress", () => { + beforeEach(() => { + mockSwarmState.reset(); + }); + + test("returns success: false when swarm not found by address", () => { + const result = mockSwarmState.updateVanityAddress( + "nonexistent.sphinx.chat", + "newname.sphinx.chat" + ); + + expect(result).toEqual({ success: false, message: "Swarm not found" }); + }); + + test("updates swarm address on success", () => { + // Create a swarm so we have something to look up + const created = mockSwarmState.createSwarm({ instance_type: "t3.small" }); + const swarms = mockSwarmState.getAllSwarms(); + const swarm = swarms.find((s) => s.swarm_id === created.swarm_id)!; + const originalAddress = swarm.address; + + const result = mockSwarmState.updateVanityAddress( + originalAddress, + "machinelearning.sphinx.chat" + ); + + expect(result).toEqual({ success: true, message: "Vanity address updated" }); + + const updatedSwarms = mockSwarmState.getAllSwarms(); + const updatedSwarm = updatedSwarms.find((s) => s.swarm_id === created.swarm_id)!; + expect(updatedSwarm.address).toBe("machinelearning.sphinx.chat"); + }); + + test("removes old subdomain from domain registry and adds new one", () => { + const created = mockSwarmState.createSwarm({ instance_type: "t3.small" }); + const swarms = mockSwarmState.getAllSwarms(); + const swarm = swarms.find((s) => s.swarm_id === created.swarm_id)!; + const originalAddress = swarm.address; + + // Old subdomain should be registered (check domain exists) + const oldSubdomain = originalAddress.replace(/\.sphinx\.chat$/, ""); + const beforeCheck = mockSwarmState.checkDomain(oldSubdomain); + // The mock creates addresses like "mock-swarm-000001.test.local" without .sphinx.chat + // so let's use the actual subdomain stripping logic + // The domain registry uses the subdomain directly from createSwarm which adds swarmId + // Let's verify via updateVanityAddress result and subsequent checkDomain calls + + mockSwarmState.updateVanityAddress(originalAddress, "machinelearning.sphinx.chat"); + + // New subdomain should now exist in domain registry + const newCheck = mockSwarmState.checkDomain("machinelearning"); + expect(newCheck.domain_exists).toBe(true); + expect(newCheck.swarm_name_exist).toBe(true); + }); + + test("removing old subdomain makes it available in domain registry", () => { + const created = mockSwarmState.createSwarm({ instance_type: "t3.small" }); + const swarms = mockSwarmState.getAllSwarms(); + const swarm = swarms.find((s) => s.swarm_id === created.swarm_id)!; + + // Manually set a sphinx.chat style address so we can verify removal + // We'll call updateVanityAddress to set it first + const firstAddress = `${created.swarm_id}.sphinx.chat`; + // Simulate the swarm having a sphinx.chat address by updating from current + mockSwarmState.updateVanityAddress(swarm.address, firstAddress); + + // Now update to new address + const result = mockSwarmState.updateVanityAddress(firstAddress, "newvanity.sphinx.chat"); + expect(result.success).toBe(true); + + // Old subdomain should no longer exist + const oldCheck = mockSwarmState.checkDomain(created.swarm_id); + expect(oldCheck.domain_exists).toBe(false); + + // New subdomain should exist + const newCheck = mockSwarmState.checkDomain("newvanity"); + expect(newCheck.domain_exists).toBe(true); + }); + + test("exact address match required (not partial)", () => { + mockSwarmState.createSwarm({ instance_type: "t3.small" }); + + // Using a partial match that is not exact should fail + const result = mockSwarmState.updateVanityAddress( + "sphinx.chat", + "newname.sphinx.chat" + ); + + expect(result).toEqual({ success: false, message: "Swarm not found" }); + }); + + test("updatedAt is refreshed after update", () => { + const created = mockSwarmState.createSwarm({ instance_type: "t3.small" }); + const swarms = mockSwarmState.getAllSwarms(); + const swarm = swarms.find((s) => s.swarm_id === created.swarm_id)!; + const originalUpdatedAt = swarm.updatedAt; + + // Small delay to ensure timestamp differs + const before = Date.now(); + mockSwarmState.updateVanityAddress(swarm.address, "updated.sphinx.chat"); + + const updatedSwarms = mockSwarmState.getAllSwarms(); + const updatedSwarm = updatedSwarms.find((s) => s.swarm_id === created.swarm_id)!; + expect(updatedSwarm.updatedAt.getTime()).toBeGreaterThanOrEqual(before); + }); +}); diff --git a/src/__tests__/unit/services/swarm/api/updateVanityAddressApi.test.ts b/src/__tests__/unit/services/swarm/api/updateVanityAddressApi.test.ts new file mode 100644 index 0000000000..321cf5a116 --- /dev/null +++ b/src/__tests__/unit/services/swarm/api/updateVanityAddressApi.test.ts @@ -0,0 +1,143 @@ +import { describe, test, expect, beforeEach, vi } from "vitest"; + +// Mock config and env before importing the module under test +vi.mock("@/config/env", () => ({ + env: { + SWARM_SUPERADMIN_API_KEY: "test-super-token", + }, + config: { + SWARM_SUPER_ADMIN_URL: "https://swarm-admin.example.com", + }, +})); + +vi.mock("@/lib/encryption", () => ({ + EncryptionService: { + getInstance: vi.fn(() => ({ + decryptField: vi.fn((_, v) => v), + })), + }, +})); + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +const { updateVanityAddressApi } = await import("@/services/swarm/api/swarm"); + +describe("updateVanityAddressApi", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("POSTs to the correct URL", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ success: true, message: "Vanity address updated" }), + }); + + await updateVanityAddressApi("myswarm.sphinx.chat", "newname.sphinx.chat"); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe( + "https://swarm-admin.example.com/api/super/update_swarm_vanity_address" + ); + }); + + test("uses POST method", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ success: true, message: "Vanity address updated" }), + }); + + await updateVanityAddressApi("myswarm.sphinx.chat", "newname.sphinx.chat"); + + const [, init] = mockFetch.mock.calls[0]; + expect(init.method).toBe("POST"); + }); + + test("sends x-super-token header with the API key", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ success: true, message: "Vanity address updated" }), + }); + + await updateVanityAddressApi("myswarm.sphinx.chat", "newname.sphinx.chat"); + + const [, init] = mockFetch.mock.calls[0]; + expect(init.headers["x-super-token"]).toBe("test-super-token"); + }); + + test("sends correct body with host and vanity_address", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ success: true, message: "Vanity address updated" }), + }); + + await updateVanityAddressApi("myswarm.sphinx.chat", "newname.sphinx.chat"); + + const [, init] = mockFetch.mock.calls[0]; + const body = JSON.parse(init.body); + expect(body.host).toBe("myswarm.sphinx.chat"); + expect(body.vanity_address).toBe("newname.sphinx.chat"); + }); + + test("sends Content-Type application/json header", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ success: true, message: "Vanity address updated" }), + }); + + await updateVanityAddressApi("myswarm.sphinx.chat", "newname.sphinx.chat"); + + const [, init] = mockFetch.mock.calls[0]; + expect(init.headers["Content-Type"]).toBe("application/json"); + }); + + test("returns parsed JSON response on success", async () => { + const expected = { success: true, message: "Vanity address updated" }; + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => expected, + }); + + const result = await updateVanityAddressApi("myswarm.sphinx.chat", "newname.sphinx.chat"); + expect(result).toEqual(expected); + }); + + test("throws when response is not ok", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 502, + json: async () => ({ success: false, message: "Bad gateway" }), + }); + + await expect( + updateVanityAddressApi("myswarm.sphinx.chat", "newname.sphinx.chat") + ).rejects.toThrow("Bad gateway"); + }); + + test("throws with fallback message when error response has no message", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({}), + }); + + await expect( + updateVanityAddressApi("myswarm.sphinx.chat", "newname.sphinx.chat") + ).rejects.toThrow("Request failed with status 500"); + }); + + test("throws when fetch throws a network error", async () => { + mockFetch.mockRejectedValue(new Error("Network error")); + + await expect( + updateVanityAddressApi("myswarm.sphinx.chat", "newname.sphinx.chat") + ).rejects.toThrow("Network error"); + }); +}); diff --git a/src/__tests__/unit/services/swarm/updateSwarmVanityAddress.test.ts b/src/__tests__/unit/services/swarm/updateSwarmVanityAddress.test.ts new file mode 100644 index 0000000000..21b8adc7bf --- /dev/null +++ b/src/__tests__/unit/services/swarm/updateSwarmVanityAddress.test.ts @@ -0,0 +1,181 @@ +import { describe, test, expect, beforeEach, vi } from "vitest"; + +// Mock db before importing +const mockSwarmUpdate = vi.fn(); +const mockWorkspaceUpdate = vi.fn(); +const mockTransaction = vi.fn(); + +vi.mock("@/lib/db", () => ({ + db: { + swarm: { update: mockSwarmUpdate }, + workspace: { update: mockWorkspaceUpdate }, + $transaction: mockTransaction, + }, +})); + +const mockDecryptField = vi.fn((_, v: string) => `decrypted-${v}`); +vi.mock("@/lib/encryption", () => ({ + EncryptionService: { + getInstance: vi.fn(() => ({ + decryptField: mockDecryptField, + })), + }, + encryptEnvVars: vi.fn(), +})); + +const mockSetGraphTitle = vi.fn(); +vi.mock("@/services/swarm/graph-title", () => ({ + setGraphTitle: mockSetGraphTitle, +})); + +// Stub PodState/PoolState/SwarmStatus from prisma +vi.mock("@prisma/client", () => ({ + PodState: {}, + PoolState: {}, + SwarmStatus: {}, +})); + +const { updateSwarmVanityAddress } = await import("@/services/swarm/db"); + +describe("updateSwarmVanityAddress", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Default: transaction executes the array of operations + mockTransaction.mockImplementation(async (ops: unknown[]) => { + for (const op of ops) { + await op; + } + }); + mockSwarmUpdate.mockResolvedValue({}); + mockWorkspaceUpdate.mockResolvedValue({}); + mockSetGraphTitle.mockResolvedValue(undefined); + }); + + test("calls db.$transaction with swarm.update and workspace.update", async () => { + await updateSwarmVanityAddress({ + workspaceId: "ws-123", + newSubdomain: "myswarm", + }); + + expect(mockTransaction).toHaveBeenCalledOnce(); + + // The transaction receives an array of two promises + const [ops] = mockTransaction.mock.calls[0]; + expect(ops).toHaveLength(2); + }); + + test("updates swarm with correct name and swarmUrl", async () => { + await updateSwarmVanityAddress({ + workspaceId: "ws-123", + newSubdomain: "myswarm", + }); + + expect(mockSwarmUpdate).toHaveBeenCalledWith({ + where: { workspaceId: "ws-123" }, + data: { + name: "myswarm", + swarmUrl: "https://myswarm.sphinx.chat/api", + }, + }); + }); + + test("updates workspace with correct slug and name", async () => { + await updateSwarmVanityAddress({ + workspaceId: "ws-123", + newSubdomain: "myswarm", + }); + + expect(mockWorkspaceUpdate).toHaveBeenCalledWith({ + where: { id: "ws-123" }, + data: { + slug: "myswarm", + name: "myswarm", + updatedAt: expect.any(Date), + }, + }); + }); + + test("does NOT call setGraphTitle when swarmPassword is not provided", async () => { + await updateSwarmVanityAddress({ + workspaceId: "ws-123", + newSubdomain: "myswarm", + }); + + // Allow microtask queue to flush + await new Promise((r) => setTimeout(r, 0)); + + expect(mockSetGraphTitle).not.toHaveBeenCalled(); + }); + + test("does NOT call setGraphTitle when swarmPassword is null", async () => { + await updateSwarmVanityAddress({ + workspaceId: "ws-123", + newSubdomain: "myswarm", + swarmPassword: null, + }); + + await new Promise((r) => setTimeout(r, 0)); + + expect(mockSetGraphTitle).not.toHaveBeenCalled(); + }); + + test("calls setGraphTitle fire-and-forget when swarmPassword is provided", async () => { + mockSetGraphTitle.mockResolvedValue(undefined); + + await updateSwarmVanityAddress({ + workspaceId: "ws-123", + newSubdomain: "myswarm", + swarmPassword: "encrypted-pw", + }); + + // Allow the fire-and-forget promise to resolve + await new Promise((r) => setTimeout(r, 0)); + + expect(mockSetGraphTitle).toHaveBeenCalledOnce(); + expect(mockSetGraphTitle).toHaveBeenCalledWith( + "https://myswarm.sphinx.chat/api", + "decrypted-encrypted-pw", + "myswarm" + ); + }); + + test("decrypts swarmPassword before calling setGraphTitle", async () => { + await updateSwarmVanityAddress({ + workspaceId: "ws-123", + newSubdomain: "myswarm", + swarmPassword: "some-encrypted-value", + }); + + await new Promise((r) => setTimeout(r, 0)); + + expect(mockDecryptField).toHaveBeenCalledWith("swarmPassword", "some-encrypted-value"); + }); + + test("setGraphTitle errors do not throw (fire-and-forget)", async () => { + mockSetGraphTitle.mockRejectedValue(new Error("title service down")); + + // Should not throw + await expect( + updateSwarmVanityAddress({ + workspaceId: "ws-123", + newSubdomain: "myswarm", + swarmPassword: "encrypted-pw", + }) + ).resolves.toBeUndefined(); + + // Allow rejection to be handled internally + await new Promise((r) => setTimeout(r, 10)); + }); + + test("propagates transaction errors", async () => { + mockTransaction.mockRejectedValue(new Error("DB transaction failed")); + + await expect( + updateSwarmVanityAddress({ + workspaceId: "ws-123", + newSubdomain: "myswarm", + }) + ).rejects.toThrow("DB transaction failed"); + }); +}); diff --git a/src/app/api/mock/swarm-super-admin/api/super/update_swarm_vanity_address/route.ts b/src/app/api/mock/swarm-super-admin/api/super/update_swarm_vanity_address/route.ts new file mode 100644 index 0000000000..fce2a40b92 --- /dev/null +++ b/src/app/api/mock/swarm-super-admin/api/super/update_swarm_vanity_address/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { env } from "@/config/env"; +import { mockSwarmState } from "@/lib/mock/swarm-state"; + +/** + * Mock endpoint for updating a swarm's vanity address + * POST /api/mock/swarm-super-admin/api/super/update_swarm_vanity_address + */ +export async function POST(request: NextRequest) { + try { + // 1. Validate x-super-token + const token = request.headers.get("x-super-token"); + if (!token || token !== env.SWARM_SUPERADMIN_API_KEY) { + return NextResponse.json( + { success: false, message: "Unauthorized" }, + { status: 401 } + ); + } + + // 2. Parse request + const body = await request.json(); + const { host, vanity_address } = body; + + if (!host || !vanity_address) { + return NextResponse.json( + { success: false, message: "Missing required fields: host and vanity_address" }, + { status: 400 } + ); + } + + // 3. Update vanity address + const result = mockSwarmState.updateVanityAddress(host, vanity_address); + + // 4. Return response + return NextResponse.json(result); + } catch (error) { + console.error("Mock swarm update_vanity_address error:", error); + return NextResponse.json( + { success: false, message: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/lib/mock/swarm-state.ts b/src/lib/mock/swarm-state.ts index 5f82a08a12..ae6d0f6cfd 100644 --- a/src/lib/mock/swarm-state.ts +++ b/src/lib/mock/swarm-state.ts @@ -139,6 +139,30 @@ class MockSwarmStateManager { }; } + /** + * Update the vanity address for a swarm + */ + updateVanityAddress(host: string, vanityAddress: string): { success: boolean; message: string } { + const swarm = Array.from(this.swarms.values()).find( + (s) => s.address === host + ); + + if (!swarm) { + return { success: false, message: "Swarm not found" }; + } + + const oldSubdomain = swarm.address.replace(/\.sphinx\.chat$/, ""); + const newSubdomain = vanityAddress.replace(/\.sphinx\.chat$/, ""); + + swarm.address = vanityAddress; + swarm.updatedAt = new Date(); + + this.domains.delete(oldSubdomain); + this.domains.add(newSubdomain); + + return { success: true, message: "Vanity address updated" }; + } + /** * Check if a domain name is available */ diff --git a/src/services/swarm/api/swarm.ts b/src/services/swarm/api/swarm.ts index 49e1d295c9..9925d005f4 100644 --- a/src/services/swarm/api/swarm.ts +++ b/src/services/swarm/api/swarm.ts @@ -57,6 +57,29 @@ export async function stopSwarmApi( ); } +export async function updateVanityAddressApi( + host: string, + vanityAddress: string, +): Promise<{ success: boolean; message: string }> { + const url = `${config.SWARM_SUPER_ADMIN_URL}/api/super/update_swarm_vanity_address`; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-super-token": env.SWARM_SUPERADMIN_API_KEY as string, + }, + body: JSON.stringify({ host, vanity_address: vanityAddress }), + }); + + const json = await response.json(); + + if (!response.ok) { + throw new Error(json?.message ?? `Request failed with status ${response.status}`); + } + + return json; +} + export async function validateUriApi(client: HttpClient, domain: string): Promise { return client.get(`/api/super/check-domain?domain=${domain}`, { "x-super-token": env.SWARM_SUPERADMIN_API_KEY as string, diff --git a/src/services/swarm/db.ts b/src/services/swarm/db.ts index 9781096c5c..b60080246d 100644 --- a/src/services/swarm/db.ts +++ b/src/services/swarm/db.ts @@ -1,6 +1,7 @@ import { db } from "@/lib/db"; import { EncryptionService, encryptEnvVars } from "@/lib/encryption"; import { PodState, PoolState, SwarmStatus } from "@prisma/client"; +import { setGraphTitle } from "@/services/swarm/graph-title"; const encryptionService: EncryptionService = EncryptionService.getInstance(); @@ -226,3 +227,34 @@ export async function getSwarmContainerConfig( return { containerFiles, services }; } + +export async function updateSwarmVanityAddress({ + workspaceId, + newSubdomain, + swarmPassword, +}: { + workspaceId: string; + newSubdomain: string; + swarmPassword?: string | null; +}): Promise { + const newSwarmUrl = `https://${newSubdomain}.sphinx.chat/api`; + + await db.$transaction([ + db.swarm.update({ + where: { workspaceId }, + data: { name: newSubdomain, swarmUrl: newSwarmUrl }, + }), + db.workspace.update({ + where: { id: workspaceId }, + data: { slug: newSubdomain, name: newSubdomain, updatedAt: new Date() }, + }), + ]); + + // Fire-and-forget: set graph title if swarm password is provided + if (swarmPassword) { + const decryptedPassword = encryptionService.decryptField("swarmPassword", swarmPassword); + setGraphTitle(newSwarmUrl, decryptedPassword, newSubdomain) + .then(() => console.log("[VANITY_UPDATE] Graph title set:", newSubdomain)) + .catch((err) => console.error("[VANITY_UPDATE] setGraphTitle failed (non-fatal):", err)); + } +}