Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 432d9d4ce1 | |||
| b171f745db | |||
| f5e70a78d8 | |||
| c5a2fc9105 | |||
| c9b0677fed |
@@ -0,0 +1,46 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.4.2
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Type-check
|
||||||
|
run: pnpm type-check
|
||||||
|
|
||||||
|
- name: API tests
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
|
- name: Frontend tests
|
||||||
|
run: pnpm --filter @floci/frontend test
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
+6
-1
@@ -23,7 +23,9 @@ RUN bun build --compile --minify src/index.ts --outfile server
|
|||||||
|
|
||||||
# Stage 3 — minimal runtime (no bun, no node_modules)
|
# Stage 3 — minimal runtime (no bun, no node_modules)
|
||||||
FROM alpine:3
|
FROM alpine:3
|
||||||
RUN apk add --no-cache ca-certificates libstdc++
|
RUN apk add --no-cache ca-certificates libstdc++ \
|
||||||
|
&& addgroup -S -g 10001 thecloud \
|
||||||
|
&& adduser -S -D -H -u 10001 -G thecloud thecloud
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=api-build /app/server ./server
|
COPY --from=api-build /app/server ./server
|
||||||
COPY --from=frontend-build /app/packages/frontend/dist ./public
|
COPY --from=frontend-build /app/packages/frontend/dist ./public
|
||||||
@@ -33,4 +35,7 @@ COPY svgAsset/authentik.svg ./public/authentik.svg
|
|||||||
COPY aws-icons-svg ./public/aws-icons
|
COPY aws-icons-svg ./public/aws-icons
|
||||||
ENV PORT=4500
|
ENV PORT=4500
|
||||||
EXPOSE 4500
|
EXPOSE 4500
|
||||||
|
USER thecloud
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-4500}/api/health" || exit 1
|
||||||
CMD ["./server"]
|
CMD ["./server"]
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ describe('AwsLightsailAdapter', () => {
|
|||||||
test('merges diagnostic host-port mappings into UI metadata', async () => {
|
test('merges diagnostic host-port mappings into UI metadata', async () => {
|
||||||
process.env.FLOCI_ENDPOINT = 'http://core.test:4566'
|
process.env.FLOCI_ENDPOINT = 'http://core.test:4566'
|
||||||
const fetchMock = globalThis.fetch
|
const fetchMock = globalThis.fetch
|
||||||
globalThis.fetch = async () => new Response(JSON.stringify({mappings: [
|
globalThis.fetch = (async () => new Response(JSON.stringify({mappings: [
|
||||||
{instancePort: 80, host: 'localhost', hostPort: 18080, protocol: 'tcp'},
|
{instancePort: 80, host: 'localhost', hostPort: 18080, protocol: 'tcp'},
|
||||||
]}))
|
]}))) as unknown as typeof fetch
|
||||||
try {
|
try {
|
||||||
const resource = await new AwsLightsailAdapter(stubLightsail().client).get('web')
|
const resource = await new AwsLightsailAdapter(stubLightsail().client).get('web')
|
||||||
expect(resource?.metadata.hostPortMappings).toEqual([
|
expect(resource?.metadata.hostPortMappings).toEqual([
|
||||||
|
|||||||
@@ -106,6 +106,27 @@ describe('login throttling', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('post-login redirect', () => {
|
||||||
|
async function redirectAfterLogin(returnTo: string) {
|
||||||
|
const response = await request(app(localConfig()), '/api/auth/login', '198.51.100.40', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {cookie: 'floci_ui_csrf=csrf-token', 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json'},
|
||||||
|
body: new URLSearchParams({username: 'admin', password: 'correct-horse-battery', csrf: 'csrf-token', returnTo}),
|
||||||
|
})
|
||||||
|
return (await response.json() as {redirectTo: string}).redirectTo
|
||||||
|
}
|
||||||
|
|
||||||
|
test('keeps same-origin paths with their query and fragment', async () => {
|
||||||
|
expect(await redirectAfterLogin('/cloud-explorer/aws/storage?search=logs#top')).toBe('/cloud-explorer/aws/storage?search=logs#top')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses destinations that a browser would resolve to another origin', async () => {
|
||||||
|
for (const returnTo of ['//evil.example', '/\\evil.example', '/\\/evil.example', '/\t/evil.example', 'https://evil.example', '/%0d%0a/evil.example'.replace('%0d%0a', '\r\n')]) {
|
||||||
|
expect(await redirectAfterLogin(returnTo)).toBe('/console/aws')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('OIDC authorization', () => {
|
describe('OIDC authorization', () => {
|
||||||
let privateKey: CryptoKey
|
let privateKey: CryptoKey
|
||||||
let jwk: JWK
|
let jwk: JWK
|
||||||
|
|||||||
@@ -290,7 +290,19 @@ function loginErrorMessage(code: string | undefined): string | null {
|
|||||||
|
|
||||||
function isPublic(path: string): boolean { return path === '/login' || path === '/login.js' || path === '/TheCloud.png' || path === '/authentik.svg' || path.startsWith('/aws-icons/') || path === '/api/auth/login' || path === '/api/auth/oidc/login' || path === '/api/auth/oidc/callback' || path === '/api/auth/session' || path === '/api/health' }
|
function isPublic(path: string): boolean { return path === '/login' || path === '/login.js' || path === '/TheCloud.png' || path === '/authentik.svg' || path.startsWith('/aws-icons/') || path === '/api/auth/login' || path === '/api/auth/oidc/login' || path === '/api/auth/oidc/callback' || path === '/api/auth/session' || path === '/api/health' }
|
||||||
function unauthenticated(c: Context): Response { return c.req.path.startsWith('/api/') ? c.json({error: 'Authentication required'}, 401) : c.redirect(`/login?returnTo=${encodeURIComponent(safeReturnTo(c.req.path + new URL(c.req.url).search))}`, 302) }
|
function unauthenticated(c: Context): Response { return c.req.path.startsWith('/api/') ? c.json({error: 'Authentication required'}, 401) : c.redirect(`/login?returnTo=${encodeURIComponent(safeReturnTo(c.req.path + new URL(c.req.url).search))}`, 302) }
|
||||||
function safeReturnTo(value?: string): string { return value && value.startsWith('/') && !value.startsWith('//') ? value : '/console/aws' }
|
// Browsers treat `\` like `/` and drop tabs/newlines in URLs, so `/\evil.example` would
|
||||||
|
// otherwise pass a plain prefix check and land on another origin.
|
||||||
|
function safeReturnTo(value?: string): string {
|
||||||
|
const fallback = '/console/aws'
|
||||||
|
if (!value || !value.startsWith('/') || /[\\\u0000-\u001f\u007f]/.test(value)) return fallback
|
||||||
|
const base = 'http://return-to.invalid'
|
||||||
|
try {
|
||||||
|
const url = new URL(value, base)
|
||||||
|
return url.origin === base ? `${url.pathname}${url.search}${url.hash}` : fallback
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
function escapeHtml(value: string): string { return value.replace(/[&<>'"]/g, (character) => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[character] ?? character)) }
|
function escapeHtml(value: string): string { return value.replace(/[&<>'"]/g, (character) => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[character] ?? character)) }
|
||||||
function acceptsHtml(c: Context): boolean { return c.req.header('accept')?.includes('text/html') ?? false }
|
function acceptsHtml(c: Context): boolean { return c.req.header('accept')?.includes('text/html') ?? false }
|
||||||
function stateChanging(method: string): boolean { return !['GET', 'HEAD', 'OPTIONS'].includes(method) }
|
function stateChanging(method: string): boolean { return !['GET', 'HEAD', 'OPTIONS'].includes(method) }
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {describe, expect, test} from 'bun:test'
|
||||||
|
import server from './index'
|
||||||
|
|
||||||
|
describe('API surface hardening', () => {
|
||||||
|
test('serves an unauthenticated JSON health check', async () => {
|
||||||
|
const res = await server.fetch(new Request('http://localhost/api/health'))
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual({status: 'ok'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('answers unknown API paths with JSON instead of the SPA shell', async () => {
|
||||||
|
const res = await server.fetch(new Request('http://localhost/api/does-not-exist'))
|
||||||
|
|
||||||
|
expect(res.headers.get('content-type')).toContain('application/json')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects an oversized sign-in body before parsing it', async () => {
|
||||||
|
const res = await server.fetch(new Request('http://localhost/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'content-type': 'application/x-www-form-urlencoded', accept: 'application/json'},
|
||||||
|
body: `username=admin&password=${'x'.repeat(20 * 1024)}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(res.status).toBe(413)
|
||||||
|
expect(await res.json()).toEqual({error: 'Request body is too large'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps the upload ceiling for object uploads', async () => {
|
||||||
|
const res = await server.fetch(new Request('http://localhost/api/clouds/aws/services/storage/resources/bucket-1/object?key=big.bin', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'content-type': 'application/octet-stream'},
|
||||||
|
body: new Uint8Array(11 * 1024 * 1024),
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('applies the default limit to other API routes', async () => {
|
||||||
|
const res = await server.fetch(new Request('http://localhost/api/clouds/aws/services/messaging/resources/queue-1/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
body: new Uint8Array(11 * 1024 * 1024),
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(res.status).toBe(413)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
|
import { bodyLimit } from "hono/body-limit";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { logger } from "hono/logger";
|
import { logger } from "hono/logger";
|
||||||
import {authConfig, authMiddleware, authRoutes} from './auth';
|
import {authConfig, authMiddleware, authRoutes} from './auth';
|
||||||
@@ -44,9 +45,19 @@ app.use(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.use("*", logger());
|
app.use("*", logger());
|
||||||
|
// Sign-in endpoints read the body before a session exists, so they get a small
|
||||||
|
// limit; uploads (S3 objects, Lambda ZIPs sent as base64, Lightsail bundles) keep
|
||||||
|
// Bun's default 128 MB ceiling.
|
||||||
|
app.use("/api/*", (c, next) =>
|
||||||
|
bodyLimit({
|
||||||
|
maxSize: requestBodyLimit(c.req.path),
|
||||||
|
onError: (c) => c.json({ error: "Request body is too large" }, 413),
|
||||||
|
})(c, next),
|
||||||
|
);
|
||||||
app.use("*", authMiddleware(authentication));
|
app.use("*", authMiddleware(authentication));
|
||||||
app.use("*", authRoutes(authentication));
|
app.use("*", authRoutes(authentication));
|
||||||
|
|
||||||
|
app.get("/api/health", (c) => c.json({ status: "ok" }));
|
||||||
app.route("/api/eks", eks);
|
app.route("/api/eks", eks);
|
||||||
app.route("/api/ec2", ec2);
|
app.route("/api/ec2", ec2);
|
||||||
app.route("/api/secretsmanager", secretsmanager);
|
app.route("/api/secretsmanager", secretsmanager);
|
||||||
@@ -55,6 +66,7 @@ app.route("/api/msk", msk);
|
|||||||
app.route("/api/msk-connect", mskConnect);
|
app.route("/api/msk-connect", mskConnect);
|
||||||
app.route("/api/ses", ses);
|
app.route("/api/ses", ses);
|
||||||
app.route("/api/elasticache", elasticache);
|
app.route("/api/elasticache", elasticache);
|
||||||
|
app.all("/api/*", (c) => c.json({ error: "Not found" }, 404));
|
||||||
|
|
||||||
// Serve static frontend files when public/ directory is present (production)
|
// Serve static frontend files when public/ directory is present (production)
|
||||||
app.use("*", serveStatic({ root: "./public" }));
|
app.use("*", serveStatic({ root: "./public" }));
|
||||||
@@ -63,6 +75,16 @@ app.get("*", serveStatic({ path: "./public/index.html" }));
|
|||||||
const port = Number(process.env.PORT ?? 4501);
|
const port = Number(process.env.PORT ?? 4501);
|
||||||
export default { port, fetch: app.fetch };
|
export default { port, fetch: app.fetch };
|
||||||
|
|
||||||
|
function requestBodyLimit(path: string): number {
|
||||||
|
if (path.startsWith("/api/auth/")) return 16 * 1024;
|
||||||
|
if (
|
||||||
|
/^\/api\/clouds\/[^/]+\/services\/[^/]+\/resources(?:\/[^/]+(?:\/object|\/static-files|\/node-application)?)?$/.test(path)
|
||||||
|
) {
|
||||||
|
return 128 * 1024 * 1024;
|
||||||
|
}
|
||||||
|
return 10 * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
function isSensitiveCredentialRoute(path: string): boolean {
|
function isSensitiveCredentialRoute(path: string): boolean {
|
||||||
return (
|
return (
|
||||||
path.startsWith("/api/secretsmanager") ||
|
path.startsWith("/api/secretsmanager") ||
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ describe('CloudFormationCreatePanel', () => {
|
|||||||
fireEvent.change(screen.getByPlaceholderText('orders-stack'), {target: {value: 'orders-stack'}})
|
fireEvent.change(screen.getByPlaceholderText('orders-stack'), {target: {value: 'orders-stack'}})
|
||||||
fireEvent.change(screen.getByLabelText('Template S3 URL'), {target: {value: 'http://templates.localhost:4566/orders.yaml'}})
|
fireEvent.change(screen.getByLabelText('Template S3 URL'), {target: {value: 'http://templates.localhost:4566/orders.yaml'}})
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'Create stack'}))
|
fireEvent.click(screen.getByRole('button', {name: 'Create stack'}))
|
||||||
expect(onSubmit).toHaveBeenCalledWith({stackName: 'orders-stack', templateBody: '', templateUrl: 'http://templates.localhost:4566/orders.yaml'})
|
expect(onSubmit).toHaveBeenCalledWith({stackName: 'orders-stack', templateBody: '', templateUrl: 'http://templates.localhost:4566/orders.yaml', parameters: '{}', capabilities: ''})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ describe('CodeDeployCreatePanel', () => {
|
|||||||
const onSubmit = vi.fn()
|
const onSubmit = vi.fn()
|
||||||
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
|
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
|
||||||
|
|
||||||
await user.type(screen.getByLabelText('Application name'), 'orders')
|
await user.type(screen.getByLabelText(/^Application name/), 'orders')
|
||||||
await user.type(screen.getByLabelText('Deployment group name'), 'production')
|
await user.type(screen.getByLabelText(/^Deployment group name/), 'production')
|
||||||
await user.type(screen.getByLabelText('CodeDeploy service role ARN'), 'role-arn')
|
await user.type(screen.getByLabelText(/^Service role ARN/), 'role-arn')
|
||||||
await user.click(screen.getByRole('button', {name: 'Create deployment group'}))
|
await user.click(screen.getByRole('button', {name: 'Create deployment group'}))
|
||||||
|
|
||||||
expect(screen.queryByLabelText('ECS cluster name')).not.toBeInTheDocument()
|
expect(screen.queryByLabelText(/^Cluster name/)).not.toBeInTheDocument()
|
||||||
expect(onSubmit).toHaveBeenCalledWith({applicationName: 'orders', deploymentGroupName: 'production', computePlatform: 'Lambda', serviceRoleArn: 'role-arn', deploymentConfig: 'AllAtOnce', rollbackOnFailure: true})
|
expect(onSubmit).toHaveBeenCalledWith({applicationName: 'orders', deploymentGroupName: 'production', computePlatform: 'Lambda', serviceRoleArn: 'role-arn', deploymentConfig: 'AllAtOnce', rollbackOnFailure: true})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -24,16 +24,16 @@ describe('CodeDeployCreatePanel', () => {
|
|||||||
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
|
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
|
||||||
|
|
||||||
await user.selectOptions(screen.getByLabelText('Compute platform'), 'ECS')
|
await user.selectOptions(screen.getByLabelText('Compute platform'), 'ECS')
|
||||||
await user.type(screen.getByLabelText('Application name'), 'orders')
|
await user.type(screen.getByLabelText(/^Application name/), 'orders')
|
||||||
await user.type(screen.getByLabelText('Deployment group name'), 'production')
|
await user.type(screen.getByLabelText(/^Deployment group name/), 'production')
|
||||||
await user.type(screen.getByLabelText('CodeDeploy service role ARN'), 'role-arn')
|
await user.type(screen.getByLabelText(/^Service role ARN/), 'role-arn')
|
||||||
await user.type(screen.getByLabelText('ECS cluster name'), 'orders-cluster')
|
await user.type(screen.getByLabelText(/^Cluster name/), 'orders-cluster')
|
||||||
await user.type(screen.getByLabelText('ECS service name'), 'orders-service')
|
await user.type(screen.getByLabelText(/^Service name/), 'orders-service')
|
||||||
await user.type(screen.getByLabelText('Blue target group name'), 'orders-blue')
|
await user.type(screen.getByLabelText(/Current production target group/), 'orders-blue')
|
||||||
await user.type(screen.getByLabelText('Green target group name'), 'orders-green')
|
await user.type(screen.getByLabelText(/Replacement target group/), 'orders-green')
|
||||||
await user.type(screen.getByLabelText('Production listener ARN'), 'listener-arn')
|
await user.type(screen.getByLabelText(/^Production listener ARN/), 'listener-arn')
|
||||||
await user.selectOptions(screen.getByLabelText('Traffic configuration'), 'Linear10PercentEvery3Minutes')
|
await user.selectOptions(screen.getByLabelText('Traffic configuration'), 'Linear10PercentEvery3Minutes')
|
||||||
await user.click(screen.getByLabelText('Roll back automatically on deployment failure'))
|
await user.click(screen.getByLabelText(/Automatic rollback/))
|
||||||
await user.click(screen.getByRole('button', {name: 'Create deployment group'}))
|
await user.click(screen.getByRole('button', {name: 'Create deployment group'}))
|
||||||
|
|
||||||
expect(onSubmit).toHaveBeenCalledWith({applicationName: 'orders', deploymentGroupName: 'production', computePlatform: 'ECS', serviceRoleArn: 'role-arn', clusterName: 'orders-cluster', serviceName: 'orders-service', blueTargetGroupName: 'orders-blue', greenTargetGroupName: 'orders-green', productionListenerArn: 'listener-arn', deploymentConfig: 'Linear10PercentEvery3Minutes', rollbackOnFailure: false})
|
expect(onSubmit).toHaveBeenCalledWith({applicationName: 'orders', deploymentGroupName: 'production', computePlatform: 'ECS', serviceRoleArn: 'role-arn', clusterName: 'orders-cluster', serviceName: 'orders-service', blueTargetGroupName: 'orders-blue', greenTargetGroupName: 'orders-green', productionListenerArn: 'listener-arn', deploymentConfig: 'Linear10PercentEvery3Minutes', rollbackOnFailure: false})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {render, screen} from '@testing-library/react'
|
import {render, screen, waitFor} from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
import {expect, test, vi} from 'vitest'
|
import {afterEach, expect, test, vi} from 'vitest'
|
||||||
import {LoadBalancerListenerPanel} from './LoadBalancerListenerPanel'
|
import {LoadBalancerListenerPanel} from './LoadBalancerListenerPanel'
|
||||||
|
|
||||||
const api = vi.hoisted(() => ({
|
const api = vi.hoisted(() => ({
|
||||||
@@ -14,8 +14,13 @@ const api = vi.hoisted(() => ({
|
|||||||
vi.mock('@/api/cloudProxyClient', () => api)
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
test('creates and deletes selected ALB HTTP listeners through generic cloud proxy items', async () => {
|
test('creates and deletes selected ALB HTTP listeners through generic cloud proxy items', async () => {
|
||||||
const user = userEvent.setup()
|
vi.useFakeTimers({shouldAdvanceTime: true})
|
||||||
|
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime})
|
||||||
api.listCloudResources.mockResolvedValue([{id: 'target-group-arn', name: 'web-targets'}])
|
api.listCloudResources.mockResolvedValue([{id: 'target-group-arn', name: 'web-targets'}])
|
||||||
api.listFlatItems.mockResolvedValue({items: [{id: 'listener-arn', body: {protocol: 'HTTP', port: 80, targetGroupArn: 'target-group-arn'}}], nextCursor: null})
|
api.listFlatItems.mockResolvedValue({items: [{id: 'listener-arn', body: {protocol: 'HTTP', port: 80, targetGroupArn: 'target-group-arn'}}], nextCursor: null})
|
||||||
api.putFlatItem.mockResolvedValue({})
|
api.putFlatItem.mockResolvedValue({})
|
||||||
@@ -32,6 +37,10 @@ test('creates and deletes selected ALB HTTP listeners through generic cloud prox
|
|||||||
await user.click(screen.getByRole('button', {name: /create listener/i}))
|
await user.click(screen.getByRole('button', {name: /create listener/i}))
|
||||||
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', {port: 8080, targetGroupArn: 'target-group-arn'})
|
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', {port: 8080, targetGroupArn: 'target-group-arn'})
|
||||||
|
|
||||||
|
// Creation holds its pending state for a minimum of five seconds, which disables the row actions.
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000)
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', {name: /delete listener 80/i})).toBeEnabled())
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', {name: /delete listener 80/i}))
|
await user.click(screen.getByRole('button', {name: /delete listener 80/i}))
|
||||||
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', 'listener-arn')
|
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', 'listener-arn')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function TargetGroupPanel({resource, runtimeReachable}: Props) {
|
|||||||
queryFn: ({signal}) => listFlatItems('aws', 'targetgroups', resource?.id ?? '', undefined, signal),
|
queryFn: ({signal}) => listFlatItems('aws', 'targetgroups', resource?.id ?? '', undefined, signal),
|
||||||
enabled: Boolean(resource) && runtimeReachable,
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
})
|
})
|
||||||
|
const refetchHealth = healthQuery.refetch
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInstanceId('')
|
setInstanceId('')
|
||||||
@@ -50,10 +51,10 @@ export function TargetGroupPanel({resource, runtimeReachable}: Props) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const interval = window.setInterval(() => {
|
const interval = window.setInterval(() => {
|
||||||
void healthQuery.refetch()
|
void refetchHealth()
|
||||||
}, 1_000)
|
}, 1_000)
|
||||||
return () => window.clearInterval(interval)
|
return () => window.clearInterval(interval)
|
||||||
}, [pendingTargets.length, healthQuery.refetch])
|
}, [pendingTargets.length, refetchHealth])
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
void Promise.all([dependenciesQuery.refetch(), healthQuery.refetch()])
|
void Promise.all([dependenciesQuery.refetch(), healthQuery.refetch()])
|
||||||
|
|||||||
Reference in New Issue
Block a user