Skip to content
Closed
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
4 changes: 3 additions & 1 deletion docs/SYNC.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ python -m scripts.sync \

The dashboard's **Sync now** action invokes the same customer protocol. The public package does
not run a local auto-sync loop or ship a cron/Task Scheduler wrapper. Hosted automation belongs
to the private service.
to the private service. If the relay denies every workspace because the session is expired,
revoked, or no longer entitled, the dashboard returns to the hosted Pro/Team recovery CTA
instead of reporting a successful empty sync.

### Local folder transport

Expand Down
17 changes: 12 additions & 5 deletions engraphis/routes/v2_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1917,11 +1917,18 @@ async def sync_run():
import asyncio
summary = await asyncio.to_thread(_sync_all, svc)
_SYNC_STATE["last"] = summary
# If cloud authorization failed for every workspace (nothing exported, a 402 seen),
# surface it as the button's upgrade/renew prompt rather than a silent partial success.
if summary["exported"] == 0 and any(e.get("status") == 402 for e in summary["errors"]):
first = next(e for e in summary["errors"] if e.get("status") == 402)
raise HTTPException(status_code=402, detail={
# If the relay denied every workspace for authentication, authorization, or an
# inactive entitlement, surface that result to the dashboard. Returning a 200
# here made a revoked/expired session look like a successful zero-item sync and
# left the user with only a retry prompt instead of the Cloud recovery CTA.
authorization_statuses = {401, 402, 403}
if summary["exported"] == 0 and any(
e.get("status") in authorization_statuses for e in summary["errors"]
):
first = next(
e for e in summary["errors"] if e.get("status") in authorization_statuses
)
raise HTTPException(status_code=first["status"], detail={
"error": first["error"], "upgrade_url": licensing.upgrade_url()})
return {"ok": True, "summary": summary}

Expand Down
4 changes: 2 additions & 2 deletions engraphis/static/dashboard.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 38 additions & 9 deletions tests/e2e/commercial.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const hostedLicense = {
trial: { used: false, trial_days: 3 },
};

async function mockLocalClient(page, cloudStatus = 402) {
async function mockLocalClient(page, cloudStatus = 402, syncRunStatus = null) {
const calls = [];

await page.route('**/api/**', async route => {
Expand Down Expand Up @@ -53,7 +53,17 @@ async function mockLocalClient(page, cloudStatus = 402) {
cloud_url: 'https://cloud.engraphis.test/team',
};
} else if (path === '/sync/status') {
body = { available: false };
body = { available: syncRunStatus !== null };
} else if (path === '/sync/run' && syncRunStatus !== null) {
status = syncRunStatus;
body = {
detail: {
error: syncRunStatus === 402
? 'Cloud Sync entitlement is inactive (upgrade or renew required)'
: 'cloud relay synchronization failed',
upgrade_url: 'https://cloud.engraphis.test/pro',
},
};
} else if (path === '/llm/status') {
body = {
configured: false,
Expand Down Expand Up @@ -88,14 +98,33 @@ async function mockLocalClient(page, cloudStatus = 402) {
return calls;
}

test('Cloud Sync denial returns an unlicensed installation to the hosted upgrade CTA', async ({ page }) => {
const errors = recordBrowserErrors(page);
const calls = await mockLocalClient(page, 402, 402);
await page.goto('/');
await openView(page, 'settings');

await expect(page.getByRole('button', { name: 'Sync now' })).toBeVisible();
await page.getByRole('button', { name: 'Sync now' }).click();

const sync = page.locator('#sync-body');
await expect(sync).toContainText('Cloud Sync runs in Engraphis Pro Cloud');
await expect(sync.getByRole('link', { name: 'Start hosted Pro trial' }))
.toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro&trial=pro');
await expect(calls.some(call => call.path === '/sync/run' && call.method === 'POST')).toBe(true);
expect(errors).toEqual([]);
});

function recordBrowserErrors(page) {
const errors = [];
page.on('console', message => {
if (message.type() === 'error') {
const location = message.location();
const expectedCloudDenial = /\/api\/(analytics|automation)/.test(location.url || '')
&& /status of (401|402|501)/.test(message.text());
if (expectedCloudDenial) return;
const expectedSyncDenial = /\/api\/sync\/run/.test(location.url || '')
&& /status of (401|402|403)/.test(message.text());
if (expectedCloudDenial || expectedSyncDenial) return;
errors.push(message.text() + (location.url
? ` @ ${location.url}:${location.lineNumber}`
: ''));
Expand Down Expand Up @@ -134,9 +163,9 @@ test('local dashboard exposes hosted Pro and Team CTAs without local commercial
const team = page.locator('#team-body');
await expect(team.getByText('Engraphis Team Cloud', { exact: false })).toBeVisible();
await expect(team.getByRole('link', { name: 'Start hosted Team trial' }))
.toHaveAttribute('href', 'https://cloud.engraphis.test/team?trial=team');
.toHaveAttribute('href', 'https://cloud.engraphis.test/team?plan=team&trial=team');
await expect(team.getByRole('link', { name: 'Open Team Cloud' }))
.toHaveAttribute('href', 'https://cloud.engraphis.test/team');
.toHaveAttribute('href', 'https://cloud.engraphis.test/team?plan=team');
await expect(team).toContainText('exactly 3 active days');
await expect(team).toContainText(
'A separate local-only write grace is capped at 24 hours and never extends Team or other cloud access.',
Expand Down Expand Up @@ -187,9 +216,9 @@ for (const cloudStatus of [401, 402, 501]) {
'Local-only write grace is separate, capped at 24 hours, and never extends cloud access.',
);
await expect(analytics.getByRole('link', { name: 'Start hosted Pro trial' }))
.toHaveAttribute('href', 'https://cloud.engraphis.test/pro?trial=pro');
.toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro&trial=pro');
await expect(analytics.getByRole('link', { name: 'View Pro plans' }))
.toHaveAttribute('href', 'https://cloud.engraphis.test/pro');
.toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro');
await expect(page.locator('#an-lock')).toHaveText('PRO');

const automationBefore = calls.filter(call => call.path === '/automation').length;
Expand All @@ -206,9 +235,9 @@ for (const cloudStatus of [401, 402, 501]) {
'Local-only write grace is separate, capped at 24 hours, and never extends cloud access.',
);
await expect(automation.getByRole('link', { name: 'Start hosted Pro trial' }))
.toHaveAttribute('href', 'https://cloud.engraphis.test/pro?trial=pro');
.toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro&trial=pro');
await expect(automation.getByRole('link', { name: 'View Pro plans' }))
.toHaveAttribute('href', 'https://cloud.engraphis.test/pro');
.toHaveAttribute('href', 'https://cloud.engraphis.test/pro?plan=pro');
await expect(page.locator('#au-lock')).toHaveText('PRO');

expect(calls.some(call => call.path === '/analytics' && call.method === 'GET')).toBe(true);
Expand Down
18 changes: 18 additions & 0 deletions tests/test_sync_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,21 @@ def fail_transport(*args, **kwargs):
"status": 502,
}]
assert secret not in repr(errors)


@pytest.mark.parametrize("status", (401, 402, 403))
def test_sync_run_surfaces_full_authorization_denial(monkeypatch, tmp_path, status):
"""A denied session must not look like a successful zero-item dashboard sync."""
from engraphis.backends.sync_relay import RelayError

def fail_transport(*args, **kwargs):
raise RelayError("relay authorization denied", status=status)

monkeypatch.setattr("engraphis.backends.sync_folder.get_transport", fail_transport)
with _client(monkeypatch, tmp_path, cloud=True) as c:
response = c.post("/api/sync/run", json={})

assert response.status_code == status
detail = response.json()["detail"]
assert detail["error"] == "cloud relay synchronization failed"
assert detail["upgrade_url"].startswith("https://api.engraphis.com/account")
Loading