Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d92f817e73 | |||
| ad8390e297 |
+30
-3
@@ -286,17 +286,44 @@ jobs:
|
||||
CRANK_SESSION_SECRET=ci-session-secret
|
||||
CRANK_PASSWORD_PEPPER=ci-password-pepper
|
||||
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.test
|
||||
CRANK_BOOTSTRAP_ADMIN_PASSWORD=ci-admin-password
|
||||
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=CI Owner
|
||||
CRANK_BASE_URL=http://127.0.0.1
|
||||
CRANK_ENVIRONMENT=ci
|
||||
CRANK_OUTBOUND_ALLOWED_HOSTS=admin-api
|
||||
CRANK_PUBLISH_BIND=127.0.0.1
|
||||
CRANK_ADMIN_PUBLISH_PORT=0
|
||||
CRANK_MCP_PUBLISH_PORT=0
|
||||
CRANK_UI_PUBLISH_PORT=0
|
||||
CRANK_DEMO_SEED=true
|
||||
EOF
|
||||
docker compose -f deploy/community/docker-compose.images.yml \
|
||||
--env-file .tmp/community-smoke.env --profile local-db up -d --wait
|
||||
compose=(docker compose -f deploy/community/docker-compose.images.yml \
|
||||
--env-file .tmp/community-smoke.env --profile local-db)
|
||||
"${compose[@]}" up -d --wait postgres
|
||||
"${compose[@]}" run --rm migrate
|
||||
bootstrap_json="$("${compose[@]}" run --rm --no-deps \
|
||||
--entrypoint crank-migrate migrate admin-auth bootstrap-create \
|
||||
--email owner@crank.test --display-name 'CI Owner')"
|
||||
bootstrap_token="$(python3 -c \
|
||||
'import json,sys; print(json.loads(sys.stdin.read())["bootstrap_token"])' \
|
||||
<<<"$bootstrap_json")"
|
||||
install -d -m 700 .tmp/community-bootstrap
|
||||
printf '%s' "$bootstrap_token" > .tmp/community-bootstrap/token
|
||||
printf '%s' 'ci-admin-password' > .tmp/community-bootstrap/password
|
||||
printf '%s' 'ci-password-pepper' > .tmp/community-bootstrap/password-pepper
|
||||
chmod 600 .tmp/community-bootstrap/token \
|
||||
.tmp/community-bootstrap/password \
|
||||
.tmp/community-bootstrap/password-pepper
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
-v "$PWD/.tmp/community-bootstrap:/run/bootstrap:ro" \
|
||||
--entrypoint crank-migrate migrate admin-auth bootstrap-complete \
|
||||
--token-file /run/bootstrap/token \
|
||||
--password-file /run/bootstrap/password \
|
||||
--password-pepper-file /run/bootstrap/password-pepper
|
||||
rm -f .tmp/community-bootstrap/token \
|
||||
.tmp/community-bootstrap/password \
|
||||
.tmp/community-bootstrap/password-pepper
|
||||
rmdir .tmp/community-bootstrap
|
||||
"${compose[@]}" up -d --wait
|
||||
|
||||
- name: Run authenticated Community image smoke
|
||||
env:
|
||||
|
||||
@@ -217,8 +217,8 @@ jobs:
|
||||
CRANK_SESSION_SECRET=release-smoke-session
|
||||
CRANK_PASSWORD_PEPPER=release-smoke-pepper
|
||||
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.test
|
||||
CRANK_BOOTSTRAP_ADMIN_PASSWORD=release-smoke-password
|
||||
CRANK_BASE_URL=http://127.0.0.1
|
||||
CRANK_ENVIRONMENT=release-smoke
|
||||
CRANK_PUBLISH_BIND=127.0.0.1
|
||||
CRANK_ADMIN_PUBLISH_PORT=0
|
||||
CRANK_MCP_PUBLISH_PORT=0
|
||||
|
||||
@@ -18,21 +18,15 @@ FROM debian:bookworm-slim
|
||||
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
RUN test -s /etc/ssl/certs/ca-certificates.crt \
|
||||
&& sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources \
|
||||
&& grep -q '^URIs: https://deb.debian.org/' /etc/apt/sources.list.d/debian.sources \
|
||||
&& ! grep -Eq '^[[:space:]]*URIs:[[:space:]]+http://' /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get -o Acquire::Retries=3 update \
|
||||
&& apt-get -o Acquire::Retries=3 install -y --no-install-recommends ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /tmp/admin-api /usr/local/bin/admin-api
|
||||
COPY --from=builder /tmp/crank-migrate /usr/local/bin/crank-migrate
|
||||
COPY apps/admin-api/docker-entrypoint.sh /usr/local/bin/crank-admin-entrypoint
|
||||
COPY scripts/docker-http-healthcheck.sh /usr/local/bin/crank-http-healthcheck
|
||||
|
||||
RUN chmod 0755 /usr/local/bin/crank-admin-entrypoint
|
||||
RUN test -s /etc/ssl/certs/ca-certificates.crt \
|
||||
&& chmod 0755 /usr/local/bin/crank-admin-entrypoint /usr/local/bin/crank-http-healthcheck
|
||||
|
||||
ENV CRANK_ADMIN_BIND=0.0.0.0:3001
|
||||
ENV CRANK_STORAGE_ROOT=/var/lib/crank/storage
|
||||
|
||||
@@ -55,17 +55,13 @@ FROM debian:bookworm-slim
|
||||
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
RUN test -s /etc/ssl/certs/ca-certificates.crt \
|
||||
&& sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources \
|
||||
&& grep -q '^URIs: https://deb.debian.org/' /etc/apt/sources.list.d/debian.sources \
|
||||
&& ! grep -Eq '^[[:space:]]*URIs:[[:space:]]+http://' /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get -o Acquire::Retries=3 update \
|
||||
&& apt-get -o Acquire::Retries=3 install -y --no-install-recommends ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /tmp/mcp-server /usr/local/bin/mcp-server
|
||||
COPY scripts/docker-http-healthcheck.sh /usr/local/bin/crank-http-healthcheck
|
||||
|
||||
RUN test -s /etc/ssl/certs/ca-certificates.crt \
|
||||
&& chmod 0755 /usr/local/bin/crank-http-healthcheck
|
||||
|
||||
ENV CRANK_MCP_BIND=0.0.0.0:3002
|
||||
|
||||
|
||||
@@ -72,6 +72,17 @@
|
||||
body:has(.drawer.open) .onboarding-trigger,
|
||||
body:has(.drawer.open) .onboarding-panel { z-index: 149; }
|
||||
|
||||
/* The wizard owns a fixed bottom action bar. Keep this optional helper above
|
||||
it so the primary Continue action remains both visible and clickable. */
|
||||
body.wizard-page .onboarding-trigger {
|
||||
bottom: calc(96px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
body.wizard-page .onboarding-panel {
|
||||
bottom: calc(150px + env(safe-area-inset-bottom, 0px));
|
||||
max-height: min(700px, calc(100vh - 178px));
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.onboarding-trigger { right: 14px; bottom: 14px; }
|
||||
.onboarding-panel { right: 14px; bottom: 68px; }
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { getCurrentWorkspace, login, localized } = require('./helpers');
|
||||
|
||||
async function expectOnboardingOutsideWizardNavigation(page) {
|
||||
await expect(page.getByTestId('onboarding-trigger')).toBeVisible();
|
||||
|
||||
const overlapsContinue = await page.locator('#btn-continue').evaluate((continueButton) => {
|
||||
const trigger = document.querySelector('[data-testid="onboarding-trigger"]');
|
||||
if (!trigger) return false;
|
||||
const continueRect = continueButton.getBoundingClientRect();
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
return !(
|
||||
triggerRect.right <= continueRect.left
|
||||
|| triggerRect.left >= continueRect.right
|
||||
|| triggerRect.bottom <= continueRect.top
|
||||
|| triggerRect.top >= continueRect.bottom
|
||||
);
|
||||
});
|
||||
|
||||
expect(overlapsContinue).toBe(false);
|
||||
}
|
||||
|
||||
test('mobile wizard progress connector crosses the indicator centers', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 720, height: 900 });
|
||||
await login(page);
|
||||
@@ -24,6 +43,19 @@ test('mobile wizard progress connector crosses the indicator centers', async ({
|
||||
expect(geometry.topDelta).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test('onboarding helper does not cover wizard navigation', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/wizard/');
|
||||
await expectOnboardingOutsideWizardNavigation(page);
|
||||
});
|
||||
|
||||
test('onboarding helper does not cover wizard navigation on mobile', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await login(page);
|
||||
await page.goto('/wizard/');
|
||||
await expectOnboardingOutsideWizardNavigation(page);
|
||||
});
|
||||
|
||||
test('wizard loads and protocol selection updates flow', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/wizard/');
|
||||
|
||||
@@ -138,7 +138,7 @@ services:
|
||||
ports:
|
||||
- "${CRANK_PUBLISH_BIND:-127.0.0.1}:${CRANK_ADMIN_PUBLISH_PORT:-3001}:3001"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://127.0.0.1:3001/ready"]
|
||||
test: ["CMD", "/usr/local/bin/crank-http-healthcheck", "3001", "/ready"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -199,7 +199,7 @@ services:
|
||||
ports:
|
||||
- "${CRANK_PUBLISH_BIND:-127.0.0.1}:${CRANK_MCP_PUBLISH_PORT:-3002}:3002"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://127.0.0.1:3002/ready"]
|
||||
test: ["CMD", "/usr/local/bin/crank-http-healthcheck", "3002", "/ready"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -141,7 +141,7 @@ services:
|
||||
ports:
|
||||
- "${CRANK_PUBLISH_BIND:-127.0.0.1}:${CRANK_ADMIN_PUBLISH_PORT:-3001}:3001"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://127.0.0.1:3001/ready"]
|
||||
test: ["CMD", "/usr/local/bin/crank-http-healthcheck", "3001", "/ready"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -202,7 +202,7 @@ services:
|
||||
ports:
|
||||
- "${CRANK_PUBLISH_BIND:-127.0.0.1}:${CRANK_MCP_PUBLISH_PORT:-3002}:3002"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://127.0.0.1:3002/ready"]
|
||||
test: ["CMD", "/usr/local/bin/crank-http-healthcheck", "3002", "/ready"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
+2
-2
@@ -127,7 +127,7 @@ services:
|
||||
ports:
|
||||
- "${CRANK_PUBLISH_BIND:-127.0.0.1}:3001:3001"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://127.0.0.1:3001/ready"]
|
||||
test: ["CMD", "/usr/local/bin/crank-http-healthcheck", "3001", "/ready"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -190,7 +190,7 @@ services:
|
||||
ports:
|
||||
- "${CRANK_PUBLISH_BIND:-127.0.0.1}:3002:3002"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://127.0.0.1:3002/health"]
|
||||
test: ["CMD", "/usr/local/bin/crank-http-healthcheck", "3002", "/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -134,6 +134,7 @@ class Client:
|
||||
def __init__(self, base_url: str, timeout_seconds: int) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.csrf_token: str | None = None
|
||||
cookie_jar = http.cookiejar.CookieJar()
|
||||
self.opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPCookieProcessor(cookie_jar)
|
||||
@@ -156,6 +157,21 @@ class Client:
|
||||
request_headers = {"Accept": "application/json"}
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
if (
|
||||
self.csrf_token
|
||||
and method not in ("GET", "HEAD", "OPTIONS")
|
||||
and (
|
||||
path_or_url.startswith("/api/admin/")
|
||||
or path_or_url.startswith("/api/auth/")
|
||||
)
|
||||
and path_or_url
|
||||
not in (
|
||||
"/api/auth/login",
|
||||
"/api/auth/bootstrap/complete",
|
||||
"/api/auth/session/csrf",
|
||||
)
|
||||
):
|
||||
request_headers.setdefault("x-csrf-token", self.csrf_token)
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
request_headers["Content-Type"] = "application/json"
|
||||
@@ -203,12 +219,21 @@ def admin_path(workspace_id: str, suffix: str) -> str:
|
||||
|
||||
|
||||
def login(client: Client, email: str, password: str) -> None:
|
||||
client.request_json(
|
||||
response = client.request_json(
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
{"email": email, "password": password},
|
||||
expected=(200,),
|
||||
)
|
||||
session = require_object(response.body, "login")
|
||||
csrf_token = session.get("csrf_token")
|
||||
if (
|
||||
not isinstance(csrf_token, str)
|
||||
or not 32 <= len(csrf_token) <= 256
|
||||
or not all(character.isalnum() or character in "-_." for character in csrf_token)
|
||||
):
|
||||
raise safe_error("login", "invalid_csrf_token")
|
||||
client.csrf_token = csrf_token
|
||||
|
||||
|
||||
def resolve_workspace(
|
||||
@@ -326,6 +351,17 @@ def create_agent(client: Client, workspace_id: str, agent_slug: str) -> tuple[st
|
||||
raise safe_error("agent_create", "invalid_response") from error
|
||||
|
||||
|
||||
def agent_etag(client: Client, workspace_id: str, agent_id: str) -> str:
|
||||
response = client.request_json(
|
||||
"GET",
|
||||
admin_path(workspace_id, f"/agents/{agent_id}"),
|
||||
)
|
||||
etag = response.headers.get("ETag") if response.headers is not None else None
|
||||
if not isinstance(etag, str) or len(etag) > 128 or not etag.startswith('"') or not etag.endswith('"'):
|
||||
raise safe_error("agent_precondition", "invalid_response")
|
||||
return etag
|
||||
|
||||
|
||||
def edit_and_archive_operation(
|
||||
client: Client,
|
||||
workspace_id: str,
|
||||
@@ -377,11 +413,13 @@ def bind_and_publish_agent(
|
||||
"enabled": True,
|
||||
}
|
||||
],
|
||||
headers={"If-Match": agent_etag(client, workspace_id, agent_id)},
|
||||
)
|
||||
published = client.request_json(
|
||||
"POST",
|
||||
admin_path(workspace_id, f"/agents/{agent_id}/publish"),
|
||||
{"version": agent_version},
|
||||
headers={"If-Match": agent_etag(client, workspace_id, agent_id)},
|
||||
).body
|
||||
try:
|
||||
published_version = int(published["published_version"])
|
||||
@@ -436,8 +474,15 @@ def cleanup_smoke_assets(
|
||||
if agent_id:
|
||||
try:
|
||||
client.request_json(
|
||||
"DELETE",
|
||||
admin_path(workspace_id, f"/agents/{agent_id}"),
|
||||
"POST",
|
||||
admin_path(workspace_id, f"/agents/{agent_id}/unpublish"),
|
||||
headers={"If-Match": agent_etag(client, workspace_id, agent_id)},
|
||||
expected=(200, 404),
|
||||
)
|
||||
client.request_json(
|
||||
"POST",
|
||||
admin_path(workspace_id, f"/agents/{agent_id}/archive"),
|
||||
headers={"If-Match": agent_etag(client, workspace_id, agent_id)},
|
||||
expected=(200, 404),
|
||||
)
|
||||
except SmokeError as error:
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
port="${1:?port is required}"
|
||||
path="${2:?path is required}"
|
||||
|
||||
if [[ ! "$port" =~ ^[0-9]{1,5}$ ]] || (( port < 1 || port > 65535 )); then
|
||||
exit 64
|
||||
fi
|
||||
|
||||
if [[ "$path" != /* || "$path" == *$'\r'* || "$path" == *$'\n'* ]]; then
|
||||
exit 64
|
||||
fi
|
||||
|
||||
# Bash provides /dev/tcp without adding a network client package to the runtime
|
||||
# image. Compose still applies its own five-second timeout to the whole probe.
|
||||
exec 3<>"/dev/tcp/127.0.0.1/${port}"
|
||||
printf 'GET %s HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n' "$path" >&3
|
||||
|
||||
IFS=$'\r' read -r -t 2 status <&3
|
||||
case "$status" in
|
||||
'HTTP/1.0 200 '*|'HTTP/1.1 200 '*) ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
@@ -17,6 +17,48 @@ def load_smoke_module():
|
||||
|
||||
|
||||
class AuthenticatedProductSmokeTests(unittest.TestCase):
|
||||
def test_client_attaches_csrf_only_to_browser_api_mutations(self) -> None:
|
||||
smoke = load_smoke_module()
|
||||
|
||||
class Response:
|
||||
status = 200
|
||||
headers = {}
|
||||
|
||||
def read(self, _limit):
|
||||
return b"{}"
|
||||
|
||||
class Opener:
|
||||
def __init__(self):
|
||||
self.requests = []
|
||||
|
||||
def open(self, request, timeout):
|
||||
self.requests.append((request, timeout))
|
||||
return Response()
|
||||
|
||||
client = smoke.Client("http://crank.test", 5)
|
||||
opener = Opener()
|
||||
client.opener = opener
|
||||
client.csrf_token = "a" * 32
|
||||
|
||||
client.request_json("POST", "/api/admin/workspaces/ws/operations", {})
|
||||
client.request_json("POST", "http://mcp.test/v1/ws/agent", {})
|
||||
|
||||
self.assertEqual(opener.requests[0][0].get_header("X-csrf-token"), "a" * 32)
|
||||
self.assertIsNone(opener.requests[1][0].get_header("X-csrf-token"))
|
||||
|
||||
def test_login_keeps_server_issued_csrf_token(self) -> None:
|
||||
smoke = load_smoke_module()
|
||||
|
||||
class FakeClient:
|
||||
csrf_token = None
|
||||
|
||||
def request_json(self, *args, **kwargs):
|
||||
return smoke.JsonResponse(200, {}, {"csrf_token": "b" * 32})
|
||||
|
||||
client = FakeClient()
|
||||
smoke.login(client, "owner@crank.test", "safe-password")
|
||||
self.assertEqual(client.csrf_token, "b" * 32)
|
||||
|
||||
def test_operation_payload_uses_internal_upstream(self) -> None:
|
||||
smoke = load_smoke_module()
|
||||
|
||||
@@ -136,6 +178,8 @@ class AuthenticatedProductSmokeTests(unittest.TestCase):
|
||||
return smoke.JsonResponse(200, {}, {"operation_id": "op_safe", "version": 7})
|
||||
if path.endswith("/operations/op_safe"):
|
||||
return smoke.JsonResponse(200, {"ETag": '"safe-etag"'}, {"id": "op_safe"})
|
||||
if path.endswith("/agents/agent_safe"):
|
||||
return smoke.JsonResponse(200, {"ETag": '"safe-agent-etag"'}, {"id": "agent_safe"})
|
||||
if path.endswith("/publish") and "/operations/" in path:
|
||||
return smoke.JsonResponse(200, {}, {"published_version": 7})
|
||||
if path.endswith("/agents"):
|
||||
@@ -158,6 +202,8 @@ class AuthenticatedProductSmokeTests(unittest.TestCase):
|
||||
self.assertEqual((agent_id, agent_version, published_agent_version), ("agent_safe", 3, 3))
|
||||
binding = next(payload for _, path, payload, _ in client.requests if path.endswith("/bindings"))[0]
|
||||
self.assertEqual(binding["operation_version"], 7)
|
||||
binding_request = next(request for request in client.requests if request[1].endswith("/bindings"))
|
||||
self.assertEqual(binding_request[3]["headers"], {"If-Match": '"safe-agent-etag"'})
|
||||
publish = next(request for request in client.requests if request[1].endswith("/operations/op_safe/publish"))
|
||||
self.assertEqual(publish[3]["headers"], {"If-Match": '"safe-etag"'})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user