@startsimpli/integrations 0.2.19 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/integrations",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The member connector client (bd startsim-768w.22.25.6).
3
+ *
4
+ * WHAT THIS SUITE IS FOR, and it is one thing: the URLs. Every method on
5
+ * `connectionsMe` must land under `/connections/me/`, and NOTHING it sends may
6
+ * name a foundry, a slug, or a user. The member router's guarantee — "there is
7
+ * no id here to substitute, no slug to swap" — is a property of the URL space,
8
+ * and a client is exactly where that guarantee gets quietly given back.
9
+ *
10
+ * So each assertion below reads the URL that actually reached `fetch`, not the
11
+ * arguments the caller passed.
12
+ */
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
14
+
15
+ import { connectionsMe } from '../api/connections';
16
+ import { setApiBase, setAuthTokenProvider } from '../api/_fetch';
17
+
18
+ interface Call { url: string; method: string; body: unknown }
19
+
20
+ let calls: Call[] = [];
21
+
22
+ function jsonOnce(payload: unknown, status = 200) {
23
+ return new Response(JSON.stringify(payload), {
24
+ status, headers: { 'Content-Type': 'application/json' },
25
+ });
26
+ }
27
+
28
+ beforeEach(() => {
29
+ calls = [];
30
+ setApiBase('/api/v1');
31
+ setAuthTokenProvider(null);
32
+ vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit = {}) => {
33
+ calls.push({
34
+ url: String(url),
35
+ method: init.method ?? 'GET',
36
+ body: init.body ? JSON.parse(String(init.body)) : undefined,
37
+ });
38
+ return jsonOnce({ results: [], permissions: {} });
39
+ }));
40
+ });
41
+
42
+ afterEach(() => { vi.unstubAllGlobals(); });
43
+
44
+ describe('connectionsMe — every route is /connections/me/', () => {
45
+ it('reads the ALLOWED catalog, not the global one', async () => {
46
+ await connectionsMe.connectorCatalog();
47
+ expect(calls[0].url).toBe('/api/v1/connections/me/catalog/');
48
+ expect(calls[0].method).toBe('GET');
49
+ });
50
+
51
+ it('lists my connections with no query string at all', async () => {
52
+ await connectionsMe.connections();
53
+ // No `?owner=`, no `?foundry=`. A parameter that scopes the list is a
54
+ // parameter someone can change.
55
+ expect(calls[0].url).toBe('/api/v1/connections/me/');
56
+ });
57
+
58
+ it('connects by POSTing the input as the body, unwrapped', async () => {
59
+ await connectionsMe.connect({
60
+ connector_key: 'granola', name: 'Mine',
61
+ credentials: { api_key: 'grn_x' }, capabilities_enabled: ['poll'],
62
+ });
63
+ expect(calls[0].url).toBe('/api/v1/connections/me/');
64
+ expect(calls[0].method).toBe('POST');
65
+ expect(calls[0].body).toEqual({
66
+ connector_key: 'granola', name: 'Mine',
67
+ credentials: { api_key: 'grn_x' }, capabilities_enabled: ['poll'],
68
+ });
69
+ });
70
+
71
+ it('tests against the STORED credential, sending no key', async () => {
72
+ await connectionsMe.checkConnection('abc');
73
+ expect(calls[0].url).toBe('/api/v1/connections/me/abc/check/');
74
+ expect(calls[0].method).toBe('POST');
75
+ // Deliberately empty: a check that accepts a pasted key is a second connect
76
+ // path, and the router does not read this body.
77
+ expect(calls[0].body).toEqual({});
78
+ });
79
+
80
+ it('rotates through the credential route, wrapping the map the way the API expects', async () => {
81
+ await connectionsMe.rotateCredential('abc', { api_key: 'grn_new' });
82
+ expect(calls[0].url).toBe('/api/v1/connections/me/abc/credential/');
83
+ expect(calls[0].body).toEqual({ credentials: { api_key: 'grn_new' } });
84
+ });
85
+
86
+ it('disconnects with DELETE on the detail route', async () => {
87
+ await connectionsMe.disconnect('abc');
88
+ expect(calls[0].url).toBe('/api/v1/connections/me/abc/');
89
+ expect(calls[0].method).toBe('DELETE');
90
+ });
91
+
92
+ it('reads runs off the connection it belongs to', async () => {
93
+ await connectionsMe.connectionRuns('abc');
94
+ expect(calls[0].url).toBe('/api/v1/connections/me/abc/runs/');
95
+ });
96
+
97
+ it('carries no slug, tenant or user anywhere in any request it makes', async () => {
98
+ await connectionsMe.connectorCatalog();
99
+ await connectionsMe.connections();
100
+ await connectionsMe.connect({ connector_key: 'granola' });
101
+ await connectionsMe.checkConnection('abc');
102
+ await connectionsMe.rotateCredential('abc', { api_key: 'x' });
103
+ await connectionsMe.disconnect('abc');
104
+ await connectionsMe.connectionRuns('abc');
105
+
106
+ for (const call of calls) {
107
+ expect(call.url.startsWith('/api/v1/connections/me/')).toBe(true);
108
+ expect(call.url).not.toMatch(/[?&]/);
109
+ const serialised = JSON.stringify(call.body ?? {});
110
+ expect(serialised).not.toMatch(/slug|foundry|tenant|company|owner|user_id/i);
111
+ }
112
+ });
113
+
114
+ it('follows setApiBase, which is how a tenant app proxies same-origin', async () => {
115
+ // bd startsim-768w.22.25.7: the tenant serves `/central-api` from its own
116
+ // origin. If this client ignored the base, the browser would talk to central
117
+ // cross-origin with a bearer token attached.
118
+ setApiBase('/central-api/v1');
119
+ await connectionsMe.connections();
120
+ expect(calls[0].url).toBe('/central-api/v1/connections/me/');
121
+ });
122
+ });
@@ -20,7 +20,7 @@ import userEvent from '@testing-library/user-event';
20
20
 
21
21
  import { CredentialSchemaForm, schemaFields } from '../components/CredentialSchemaForm';
22
22
  import { ConnectionsPage } from '../components/pages/ConnectionsPage';
23
- import type { ConnectionsApi } from '../components/pages/types';
23
+ import type { ConnectionsApi, MemberConnectionsApi } from '../components/pages/types';
24
24
 
25
25
  // --------------------------------------------------------------------------
26
26
  // two schemas, one component
@@ -792,3 +792,208 @@ describe('ConnectionCard — a connected account has settings', () => {
792
792
  expect(await within(section).findByText(/keeping verbatim meeting speech forever/i)).toBeInTheDocument();
793
793
  });
794
794
  });
795
+
796
+ // --------------------------------------------------------------------------
797
+ // personal mode (bd startsim-768w.22.25.6)
798
+ // --------------------------------------------------------------------------
799
+
800
+ /**
801
+ * THE SAME COMPOSER, POINTED AT THE MEMBER SURFACE.
802
+ *
803
+ * A member of a foundry sets THEIR OWN key on a connector, from inside their
804
+ * tenant app, and the page they use is this one — `mode="personal"`. There is no
805
+ * second component, because a second component is a second place for the
806
+ * capability picker, the rotation form and the disconnect confirmation to drift.
807
+ *
808
+ * What differs is not cosmetic, and every difference below is forced by
809
+ * `backend/apps/connectors/member_urls.py`:
810
+ *
811
+ * * NO SLUG anywhere in the member client's signatures. The member endpoints
812
+ * are `connections/me/...` and the foundry is named by the token's audience
813
+ * — "there is no id here to substitute, no slug to swap". A client that
814
+ * takes a slug re-introduces, in the types, the exact affordance that URL
815
+ * space was designed not to have.
816
+ * * NO PATCH. `MyConnectionDetailView` serves GET and DELETE only, so the
817
+ * capability picker is informational and there is no Save beside it, and no
818
+ * settings form.
819
+ * * NO targets, choices, actions or tokens. Those are foundry-level and stay
820
+ * on the control-plane router, so the Workflows control is not rendered.
821
+ * * THE CATALOG IS THE SERVER'S ANSWER. `me/catalog/` returns only what the
822
+ * foundry has allowed members to connect. Filtering client-side would ship
823
+ * the disallowed vendors in the bundle.
824
+ */
825
+ const MEMBER_CATALOG = {
826
+ // Only Granola. The foundry allowed one connector to its members; n8n is in
827
+ // the foundry-wide catalog and must not appear on this surface at all.
828
+ results: [
829
+ {
830
+ ...CATALOG.results[1],
831
+ availability: { available: true, reason: '' },
832
+ },
833
+ ],
834
+ };
835
+
836
+ const MY_CONNECTIONS = {
837
+ results: [
838
+ {
839
+ id: 'm1', public_id: 'mpub1', name: 'My Granola', connector_key: 'granola',
840
+ connector_name: 'Granola', external_account_id: '', account_label: 'me@example.com',
841
+ capabilities_enabled: ['poll'], config: {}, status: 'connected',
842
+ health: 'ok', auth_mode: 'api_key',
843
+ },
844
+ ],
845
+ permissions: { view: true, manage: true, issue_tokens: false },
846
+ };
847
+
848
+ function stubMemberApi(overrides: Partial<MemberConnectionsApi> = {}): MemberConnectionsApi {
849
+ return {
850
+ connectorCatalog: vi.fn().mockResolvedValue(MEMBER_CATALOG),
851
+ connections: vi.fn().mockResolvedValue(MY_CONNECTIONS),
852
+ connect: vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]),
853
+ checkConnection: vi.fn().mockResolvedValue({ ok: true, account_label: '', detail: 'ok' }),
854
+ rotateCredential: vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]),
855
+ disconnect: vi.fn().mockResolvedValue(undefined),
856
+ connectionRuns: vi.fn().mockResolvedValue({ results: [] }),
857
+ ...overrides,
858
+ } as MemberConnectionsApi;
859
+ }
860
+
861
+ async function myCard(api: MemberConnectionsApi = stubMemberApi()) {
862
+ render(<ConnectionsPage mode="personal" api={api} />);
863
+ const card = await screen.findByText('My Granola');
864
+ return (card.closest('div[data-connection]') ?? card.parentElement!) as HTMLElement;
865
+ }
866
+
867
+ describe('ConnectionsPage — personal mode speaks to the member surface', () => {
868
+ it('calls the member endpoints with NO slug argument', async () => {
869
+ const api = stubMemberApi();
870
+ render(<ConnectionsPage mode="personal" api={api} />);
871
+ expect(await screen.findByText('My Granola')).toBeInTheDocument();
872
+ // Not `toHaveBeenCalledWith(undefined)` — called with nothing. A slug
873
+ // parameter that happens to be undefined is still a slug parameter.
874
+ expect((api.connectorCatalog as ReturnType<typeof vi.fn>).mock.calls[0]).toEqual([]);
875
+ expect((api.connections as ReturnType<typeof vi.fn>).mock.calls[0]).toEqual([]);
876
+ });
877
+
878
+ it('renders EXACTLY the catalog the server returned — no client-side filter', async () => {
879
+ // The foundry-wide catalog holds n8n. The member catalog does not. If this
880
+ // page ever merges the two, or filters a full list in the browser, the
881
+ // vendor the foundry deliberately withheld ships in the bundle anyway.
882
+ render(<ConnectionsPage mode="personal" api={stubMemberApi()} />);
883
+ expect(await screen.findByText('Granola')).toBeInTheDocument();
884
+ expect(screen.queryByText('n8n')).toBeNull();
885
+ });
886
+
887
+ it('offers the controls a member actually has: Test, Replace key, Disconnect', async () => {
888
+ const section = await myCard();
889
+ expect(within(section).getByRole('button', { name: /^Test$/i })).toBeInTheDocument();
890
+ expect(within(section).getByRole('button', { name: /Replace key/i })).toBeInTheDocument();
891
+ expect(within(section).getByRole('button', { name: /Disconnect/i })).toBeInTheDocument();
892
+ });
893
+
894
+ it('does NOT offer Workflows — choices, targets and actions are foundry-level', async () => {
895
+ const section = await myCard();
896
+ expect(within(section).queryByRole('button', { name: /Workflows/i })).toBeNull();
897
+ });
898
+
899
+ it('shows the capabilities but offers no Save, because there is no PATCH', async () => {
900
+ const section = await myCard();
901
+ // Informational, not hidden: a member chose these at connect time and is
902
+ // entitled to see what their account is doing.
903
+ expect(within(section).getByText(/Fetch new records on a schedule/i)).toBeInTheDocument();
904
+ expect(within(section).queryByRole('button', { name: /^Save$/i })).toBeNull();
905
+ expect(within(section).queryByRole('button', { name: /Settings/i })).toBeNull();
906
+ });
907
+
908
+ it('connects with no slug, sending credentials and capabilities', async () => {
909
+ const connect = vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]);
910
+ const api = stubMemberApi({ connect, connections: vi.fn().mockResolvedValue({
911
+ ...MY_CONNECTIONS, results: [],
912
+ }) });
913
+ render(<ConnectionsPage mode="personal" api={api} />);
914
+ await userEvent.click(await screen.findByRole('button', { name: /^Connect$/i }));
915
+ await userEvent.type(screen.getByLabelText(/API key/i), 'grn_mine');
916
+ await userEvent.click(screen.getByRole('button', { name: /Test and save/i }));
917
+
918
+ await waitFor(() => expect(connect).toHaveBeenCalled());
919
+ const [payload, ...rest] = connect.mock.calls[0];
920
+ expect(rest).toEqual([]);
921
+ expect(payload.connector_key).toBe('granola');
922
+ expect(payload.credentials).toEqual({ api_key: 'grn_mine' });
923
+ expect(payload.capabilities_enabled).toEqual(['poll']);
924
+ });
925
+
926
+ it('rotates and disconnects through the slugless member client', async () => {
927
+ const rotateCredential = vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]);
928
+ const disconnect = vi.fn().mockResolvedValue(undefined);
929
+ const section = await myCard(stubMemberApi({ rotateCredential, disconnect }));
930
+
931
+ await userEvent.click(within(section).getByRole('button', { name: /Replace key/i }));
932
+ await userEvent.type(within(section).getByLabelText(/API key/i), 'grn_rotated');
933
+ await userEvent.click(within(section).getByRole('button', { name: /Test and replace/i }));
934
+ await waitFor(() => expect(rotateCredential).toHaveBeenCalled());
935
+ expect(rotateCredential.mock.calls[0]).toEqual(['m1', { api_key: 'grn_rotated' }]);
936
+
937
+ await userEvent.click(within(section).getByRole('button', { name: /^Disconnect$/i }));
938
+ await userEvent.click(within(section).getByRole('button', { name: /Yes, disconnect/i }));
939
+ await waitFor(() => expect(disconnect).toHaveBeenCalled());
940
+ expect(disconnect.mock.calls[0]).toEqual(['m1']);
941
+ });
942
+
943
+ it('says the account is YOURS, never the foundry’s', async () => {
944
+ render(<ConnectionsPage mode="personal" api={stubMemberApi()} />);
945
+ expect(await screen.findByText(/Your connected accounts/i)).toBeInTheDocument();
946
+ });
947
+
948
+ it('reads a 403 on load as “you have no seat here”, not “ask for permission”', async () => {
949
+ // The ONLY 403 either load GET can produce is `IsSeatedMember` denying — the
950
+ // catalog answers a foundry that has allowed nothing with an empty 200, and
951
+ // the list runs no per-connector policy. So this copy must name the seat.
952
+ // Blaming the allowlist would send someone whose membership has ended to ask
953
+ // an admin to allow a connector, which cannot fix them.
954
+ const api = stubMemberApi({
955
+ connections: vi.fn().mockRejectedValue(Object.assign(new Error('nope'), { status: 403 })),
956
+ });
957
+ render(<ConnectionsPage mode="personal" api={api} />);
958
+ const alert = await screen.findByRole('alert');
959
+ expect(alert.textContent).toMatch(/not a member of this foundry/i);
960
+ expect(alert.textContent).not.toMatch(/connections permission/i);
961
+ });
962
+
963
+ it('renders the SERVER’s allowlist refusal when connecting is what was refused', async () => {
964
+ // `_NOT_ALLOWED` is raised by the POST, not the load, and it lands in the
965
+ // catalog card's own catch. That is the one place the allowlist wording
966
+ // belongs, and it comes from the server verbatim rather than being guessed
967
+ // at here — the server knows which connector was refused.
968
+ const refusal = 'your foundry has not allowed members to connect their own account on this connector.';
969
+ const api = stubMemberApi({
970
+ connect: vi.fn().mockRejectedValue(Object.assign(new Error(refusal), { status: 403 })),
971
+ connections: vi.fn().mockResolvedValue({ ...MY_CONNECTIONS, results: [] }),
972
+ });
973
+ render(<ConnectionsPage mode="personal" api={api} />);
974
+
975
+ await userEvent.click(await screen.findByRole('button', { name: /^Connect$/i }));
976
+ await userEvent.type(screen.getByLabelText(/API key/i), 'grn_live');
977
+ await userEvent.click(screen.getByRole('button', { name: /Test and save/i }));
978
+
979
+ await waitFor(() => expect(screen.getByText(refusal)).toBeInTheDocument());
980
+ });
981
+ });
982
+
983
+ describe('ConnectionsPage — foundry mode is untouched by personal mode', () => {
984
+ it('still defaults to foundry mode and still passes the slug', async () => {
985
+ const api = stubApi();
986
+ render(<ConnectionsPage api={api} foundrySlug="acme" />);
987
+ expect(await screen.findByText('Ops n8n')).toBeInTheDocument();
988
+ expect(api.connectorCatalog).toHaveBeenCalledWith('acme');
989
+ expect(screen.getByText('Connected accounts')).toBeInTheDocument();
990
+ });
991
+
992
+ it('still offers Workflows and the capability Save in foundry mode', async () => {
993
+ render(<ConnectionsPage api={stubApi()} foundrySlug="acme" />);
994
+ const card = await screen.findByText('Ops n8n');
995
+ const section = (card.closest('div[data-connection]') ?? card.parentElement!) as HTMLElement;
996
+ expect(within(section).getByRole('button', { name: /Workflows/i })).toBeInTheDocument();
997
+ expect(within(section).getByRole('button', { name: /^Save$/i })).toBeInTheDocument();
998
+ });
999
+ });