mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-12 17:05:37 +02:00
Reads, lists, and slices with profiles from a user's Orca Cloud account (OrcaSlicer 2.4.0-alpha's Supabase-backed sync) alongside the existing Bambu Cloud integration. Four sign-in providers (Google / Apple / GitHub / email+password); password defaults. Paste-flow PKCE because Orca's Supabase project only allowlists localhost redirect_to — open feature request at OrcaSlicer/OrcaSlicer#14028. Surfaces: - Profiles tab: new "Orca Cloud" tab next to "Bambu Cloud" with the same rich layout (search + 5 filter dropdowns + 3-column grouped grid + read-only detail modal) - SliceModal: 4-tier preset picker (orca_cloud > local > bambu cloud > standard); separate status banner per cloud; metadata-aware pre-pick scores Orca filaments above local (Orca's sync_pull returns full content inline so filament_type / filament_colour come for free, no per-setting fetch rate-limit dance) - ConfigureAmsSlotModal: orca_cloud as a new preset source (prefixed orca_<UUID> to match local_/builtin_); generic Bambu filament-ID derivation from parsed material (printer firmware can't grok Orca UUIDs); slot mapping persists preset_source='orca_cloud' - SpoolForm / SpoolBuddyWriteTagPage: Orca filaments merge into the cloud preset list via Promise.allSettled (OrcaProfileMeta is structurally identical to SlicerSetting) Backend: - services/orca_cloud.py: OrcaCloudService with PKCE / token exchange / single-use refresh rotation / get_user_info / list_profiles via the bare /sync/pull bootstrap path - routes/orca_cloud.py: 7 endpoints (auth/start, auth/finish, auth/password, status, logout, profiles, profiles/{id}); router-level _cloud_api_key_gate + per-route cloud_caller() so API-keyed callers (SpoolBuddy kiosk) properly resolve their owner User; just-in-time refresh with atomic persist-before-API-call - routes/slicer_presets.py: _fetch_orca_cloud_presets mirrors the Bambu Cloud fetcher (status vocabulary, 5min cache, permission shortcut); _dedupe_by_name extended to 4 tiers; UnifiedPresetsResponse gains orca_cloud + orca_cloud_status - services/preset_resolver.py: PresetRef.source extended with "orca_cloud"; _resolve_orca_cloud walks list + filters - 8 columns on users table for tokens (5 persistent) + transient PKCE handshake state with 10-min TTL (3); dialect-branched DATETIME / TIMESTAMP; auth-disabled mode falls back to Settings table - orca_cloud:auth permission folded into can_access_cloud API-key scope (same trust dimension)
212 lines
8.5 KiB
TypeScript
212 lines
8.5 KiB
TypeScript
/**
|
|
* Tests for OrcaCloudView component — covers the four UI phases of the
|
|
* paste-based PKCE handshake: disconnected, awaiting-paste, connected,
|
|
* and disconnect.
|
|
*/
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { screen, waitFor, fireEvent } from '@testing-library/react';
|
|
import { http, HttpResponse } from 'msw';
|
|
|
|
import { server } from '../mocks/server';
|
|
import { render } from '../utils';
|
|
import { OrcaCloudView } from '../../components/OrcaCloudView';
|
|
|
|
// JSDOM doesn't implement window.open; the connect flow opens the auth URL
|
|
// in a new tab so we stub it to capture the call.
|
|
beforeEach(() => {
|
|
vi.stubGlobal('open', vi.fn());
|
|
});
|
|
|
|
const noProfilesResponse = { profiles: [] };
|
|
|
|
describe('OrcaCloudView', () => {
|
|
it('shows all four sign-in options when not connected', async () => {
|
|
server.use(
|
|
http.get('/api/v1/orca-cloud/status', () =>
|
|
HttpResponse.json({ connected: false, email: null, user_id: null }),
|
|
),
|
|
);
|
|
render(<OrcaCloudView />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Connect to Orca Cloud/i)).toBeInTheDocument();
|
|
});
|
|
expect(screen.getByRole('button', { name: /Sign in with Google/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /Sign in with Apple/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /Sign in with GitHub/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /Sign in with email and password/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('passes the selected OAuth provider to auth/start', async () => {
|
|
let receivedProvider: string | undefined;
|
|
server.use(
|
|
http.get('/api/v1/orca-cloud/status', () =>
|
|
HttpResponse.json({ connected: false, email: null, user_id: null }),
|
|
),
|
|
http.post('/api/v1/orca-cloud/auth/start', async ({ request }) => {
|
|
const body = (await request.json()) as { provider?: string };
|
|
receivedProvider = body.provider;
|
|
return HttpResponse.json({ auth_url: 'https://auth.orcaslicer.com/auth/v1/authorize?test=1' });
|
|
}),
|
|
);
|
|
render(<OrcaCloudView />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /Sign in with Apple/i })).toBeInTheDocument();
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: /Sign in with Apple/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(receivedProvider).toBe('apple');
|
|
});
|
|
expect(window.open).toHaveBeenCalledWith(
|
|
'https://auth.orcaslicer.com/auth/v1/authorize?test=1',
|
|
'_blank',
|
|
'noopener,noreferrer',
|
|
);
|
|
});
|
|
|
|
it('connects via email and password without the paste flow', async () => {
|
|
let connected = false;
|
|
let receivedCreds: { email?: string; password?: string } = {};
|
|
server.use(
|
|
http.get('/api/v1/orca-cloud/status', () =>
|
|
HttpResponse.json(
|
|
connected
|
|
? { connected: true, email: 'martin@example.com', user_id: 'u1' }
|
|
: { connected: false, email: null, user_id: null },
|
|
),
|
|
),
|
|
http.post('/api/v1/orca-cloud/auth/password', async ({ request }) => {
|
|
receivedCreds = (await request.json()) as { email?: string; password?: string };
|
|
connected = true;
|
|
return HttpResponse.json({ connected: true, email: 'martin@example.com', user_id: 'u1' });
|
|
}),
|
|
http.get('/api/v1/orca-cloud/profiles', () => HttpResponse.json(noProfilesResponse)),
|
|
);
|
|
render(<OrcaCloudView />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /Sign in with email and password/i })).toBeInTheDocument();
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: /Sign in with email and password/i }));
|
|
|
|
// The password form replaces the provider picker.
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText(/^Email$/i)).toBeInTheDocument();
|
|
});
|
|
fireEvent.change(screen.getByLabelText(/^Email$/i), { target: { value: 'martin@example.com' } });
|
|
fireEvent.change(screen.getByLabelText(/^Password$/i), { target: { value: 'hunter2' } });
|
|
// Click the submit button inside the form (not the picker's email button).
|
|
const submitButtons = screen.getAllByRole('button', { name: /^Sign in$/i });
|
|
fireEvent.click(submitButtons[submitButtons.length - 1]);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('martin@example.com')).toBeInTheDocument();
|
|
});
|
|
expect(receivedCreds).toEqual({ email: 'martin@example.com', password: 'hunter2' });
|
|
});
|
|
|
|
it('rejects a URL without a code parameter with a client-side error', async () => {
|
|
server.use(
|
|
http.get('/api/v1/orca-cloud/status', () =>
|
|
HttpResponse.json({ connected: false, email: null, user_id: null }),
|
|
),
|
|
http.post('/api/v1/orca-cloud/auth/start', () =>
|
|
HttpResponse.json({ auth_url: 'https://auth.orcaslicer.com/auth/v1/authorize?test=1' }),
|
|
),
|
|
);
|
|
render(<OrcaCloudView />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /Sign in with Google/i })).toBeInTheDocument();
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: /Sign in with Google/i }));
|
|
await waitFor(() => {
|
|
expect(screen.getByPlaceholderText(/http:\/\/localhost:41172/i)).toBeInTheDocument();
|
|
});
|
|
|
|
const textarea = screen.getByPlaceholderText(/http:\/\/localhost:41172/i);
|
|
// Paste something with no ?code= — the client-side guard should fire
|
|
// before we hit the server.
|
|
fireEvent.change(textarea, { target: { value: 'http://localhost:41172/callback?error=denied' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /Finish connecting/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/does not look like an Orca Cloud callback/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows the connected state with the email after a successful paste', async () => {
|
|
// Start disconnected; after a successful finish the status query refetches
|
|
// and returns connected. MSW lets us swap handlers mid-test.
|
|
let connected = false;
|
|
server.use(
|
|
http.get('/api/v1/orca-cloud/status', () => {
|
|
return HttpResponse.json(
|
|
connected
|
|
? { connected: true, email: 'martin@example.com', user_id: 'u1' }
|
|
: { connected: false, email: null, user_id: null },
|
|
);
|
|
}),
|
|
http.post('/api/v1/orca-cloud/auth/start', () =>
|
|
HttpResponse.json({ auth_url: 'https://auth.orcaslicer.com/auth/v1/authorize?test=1' }),
|
|
),
|
|
http.post('/api/v1/orca-cloud/auth/finish', () => {
|
|
connected = true;
|
|
return HttpResponse.json({ connected: true, email: 'martin@example.com', user_id: 'u1' });
|
|
}),
|
|
http.get('/api/v1/orca-cloud/profiles', () => HttpResponse.json(noProfilesResponse)),
|
|
);
|
|
render(<OrcaCloudView />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /Sign in with Google/i })).toBeInTheDocument();
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: /Sign in with Google/i }));
|
|
await waitFor(() => {
|
|
expect(screen.getByPlaceholderText(/http:\/\/localhost:41172/i)).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.change(screen.getByPlaceholderText(/http:\/\/localhost:41172/i), {
|
|
target: { value: 'http://localhost:41172/callback?code=ABC&state=XYZ' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: /Finish connecting/i }));
|
|
|
|
// After the finish call resolves, the status query is invalidated and
|
|
// refetches connected=true → the connection banner appears with the email.
|
|
await waitFor(() => {
|
|
expect(screen.getByText('martin@example.com')).toBeInTheDocument();
|
|
});
|
|
expect(screen.getByRole('button', { name: /Disconnect/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('clears the connection on Disconnect', async () => {
|
|
let connected = true;
|
|
server.use(
|
|
http.get('/api/v1/orca-cloud/status', () =>
|
|
HttpResponse.json(
|
|
connected
|
|
? { connected: true, email: 'martin@example.com', user_id: 'u1' }
|
|
: { connected: false, email: null, user_id: null },
|
|
),
|
|
),
|
|
http.get('/api/v1/orca-cloud/profiles', () => HttpResponse.json(noProfilesResponse)),
|
|
http.post('/api/v1/orca-cloud/logout', () => {
|
|
connected = false;
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
render(<OrcaCloudView />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('martin@example.com')).toBeInTheDocument();
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: /Disconnect/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Connect to Orca Cloud/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|