Skip to content

Commit af808e6

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(netsuite): complete integration validation
1 parent 9ae96d8 commit af808e6

34 files changed

Lines changed: 1687 additions & 356 deletions

File tree

apps/docs/content/docs/en/integrations/netsuite-service-account.mdx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,20 +65,21 @@ Selecting the credential enables these account-backed fields:
6565
| Field | Lists | Additional scope |
6666
| --- | --- | --- |
6767
| Record Type | Up to 1,000 record types visible in the metadata catalog | credential |
68-
| SuiteAnalytics Dataset | The first 1,000 datasets visible to the role | credential |
6968
| Async Task | Up to 100 tasks belonging to a known batch job | job ID |
7069

71-
Picker results reflect the selected role's permissions. Switch any picker to Advanced mode to type an identifier or reference an upstream output. Record IDs, job IDs, transform targets, actions, fields, forms, subresources, and relationship IDs remain manual because NetSuite does not expose a bounded universal listing that would make those choices complete and reliable.
70+
Picker results reflect the selected role's permissions. Switch any picker to Advanced mode to type an identifier or reference an upstream output. Enter SuiteAnalytics dataset IDs manually after finding them with **List SuiteAnalytics Datasets**. Record IDs, job IDs, transform targets, actions, fields, forms, subresources, and relationship IDs also remain manual because NetSuite does not expose bounded universal listings that would make those choices complete and reliable.
71+
72+
**Create Record** without `replace` returns HTTP 204 with no response body; with `replace`, it returns HTTP 201 and the created record object. Both responses expose NetSuite's validated `location`. The `replace` option applies to create and update, not upsert.
7273

7374
## Rotate or Revoke
7475

75-
To rotate a certificate, create and upload the replacement certificate, create the new NetSuite mapping, then reconnect the existing Sim credential with the new Certificate ID and private key. Reconnecting changes the encrypted credential fingerprint, so later executions mint against the new material.
76+
To rotate a certificate, create and upload the replacement certificate and create its new NetSuite mapping. Then reconnect the existing Sim credential by re-entering all four required fields: SuiteTalk URL, Client ID, the new Certificate ID, and the replacement private key. Reconnecting changes the encrypted credential fingerprint, so later executions mint against the new material.
7677

77-
After confirming workflows succeed, remove the old certificate mapping in NetSuite. Deleting a Sim credential removes its workflow bindings but does not revoke the corresponding NetSuite certificate mapping.
78+
After confirming workflows succeed, remove the old certificate mapping in NetSuite so the previous certificate can no longer mint tokens. Deleting a Sim credential removes its workflow bindings but does not revoke the corresponding NetSuite certificate mapping.
7879

7980
<FAQ items={[
8081
{ question: "Why can’t I paste the key into each block?", answer: "The signing key is long-lived account material. Keeping it in one encrypted credential avoids duplicating it in workflow state and lets every block reuse the same verified account connection." },
81-
{ question: "Why is my picker empty?", answer: "The pickers use the selected integration role. Confirm that role can access the metadata catalog, datasets, or async job, then use Advanced mode when you already know an identifier." },
82+
{ question: "Why is my picker empty?", answer: "The pickers use the selected integration role. Confirm that role can access the metadata catalog or async job, then use Advanced mode when you already know an identifier." },
8283
{ question: "Can one credential access production and sandbox?", answer: "No. Each environment has its own SuiteTalk URL and client-certificate mapping. Create one Sim credential per environment." },
8384
{ question: "Why did the credential stop working after a sandbox refresh?", answer: "NetSuite clears OAuth 2.0 client-credential mappings during a sandbox refresh. Recreate the mapping and reconnect the Sim credential with its new Certificate ID." },
8485
{ question: "Does deleting the credential revoke it in NetSuite?", answer: "No. Remove the certificate mapping in NetSuite as well when decommissioning or responding to a compromise." },

apps/docs/content/docs/en/integrations/netsuite.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
2626
## Usage notes
2727

2828
- Record fields and supported actions vary by account, enabled features, custom records, forms, role, and permissions. Use **List Record Types** and **Get Record Metadata** before constructing create, update, upsert, action, or transform bodies. Sim intentionally accepts JSON for these dynamic record shapes instead of guessing a fixed schema.
29-
- Select the stored NetSuite account once per block. Record Type, SuiteAnalytics Dataset, and known-job Async Task fields use account-backed pickers; switch a field to Advanced mode to type or reference an identifier that is not present in the bounded picker result.
29+
- Select the stored NetSuite account once per block. Record Type and known-job Async Task fields use account-backed pickers; switch a field to Advanced mode to type or reference an identifier that is not present in the bounded picker result. Enter SuiteAnalytics dataset IDs manually after finding them with **List SuiteAnalytics Datasets**.
30+
- **Create Record** without `replace` returns NetSuite's HTTP 204 response with no body; with `replace`, it returns HTTP 201 and the created record object. Both cases expose the validated `location` returned by NetSuite. The `replace` option applies to create and update, not upsert.
3031
- Paged operations return one page only. The default limit is 100, the maximum is 1,000, and the offset must be a non-negative multiple of the limit. Sim never fetches later pages automatically. Requests must stay within NetSuite's first 100,000 results and first 1,000 pages.
3132
- Homogeneous batch operations accept 1–100 records of one record type and always run asynchronously. NetSuite processes records in parallel, and individual tasks can fail independently; submission is not an all-or-none transaction. Preserve the returned `location` or `jobId`, use **Get Async Status** with **Job Status** until the job completes, choose **List Tasks** to collect task IDs, check each ID with **Task Status**, then use completed IDs with **Get Async Operation Result**. Canceling or timing out the local Sim request does not cancel a batch that NetSuite has already accepted.
33+
- Sim gives the OAuth token exchange and each SuiteTalk request up to 30 seconds. A timed-out request fails locally, but a mutation that NetSuite already accepted may still finish remotely.
3234
- Sim limits each materialized request body and successful SuiteTalk response to 16 MiB. Request JSON is also limited to 100 levels of nesting and 100,000 JSON values. Split work into smaller pages or batches when a request or response would exceed these ceilings, even if NetSuite would otherwise accept the payload.
3335
- When attaching a contact with a role, provide either the role's internal ID or external ID, not both. File attachments do not use a contact role.
3436
- **Attach/Detach**, homogeneous batch operations, **Get Record Form**, and **Get Select Options** require a NetSuite 2026.1-compatible account. Oracle introduced these SuiteTalk REST capabilities in [NetSuite 2026.1](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_N3950559.html).
@@ -150,7 +152,6 @@ Create or update a NetSuite record by external ID with PUT.
150152
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
151153
| `externalId` | string | Yes | External ID without the eid: prefix |
152154
| `body` | json | Yes | Record fields matching the account-specific NetSuite metadata schema |
153-
| `replace` | string | No | Comma-separated sublists whose existing lines should be replaced |
154155

155156
#### Output
156157

apps/sim/app/api/credentials/route.test.ts

Lines changed: 78 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ describe('POST /api/credentials', () => {
146146
})
147147
})
148148

149-
describe('client-credential service accounts', () => {
149+
describe('service-account credentials', () => {
150150
it('forwards clientId, clientSecret, and orgId to the secret builder on create', async () => {
151151
mockVerifyAndBuildServiceAccountSecret.mockResolvedValueOnce({
152152
providerId: 'zoom-service-account',
@@ -252,42 +252,88 @@ describe('POST /api/credentials', () => {
252252
)
253253
})
254254

255-
it('conflicts for an existing non-NetSuite service account instead of dropping new secrets', async () => {
256-
mockVerifyAndBuildServiceAccountSecret.mockResolvedValueOnce({
255+
it.each([
256+
{
257+
family: 'client-credential',
257258
providerId: 'zoom-service-account',
258-
encryptedServiceAccountKey: 'new-secret-ciphertext',
259-
displayName: 'Production Zoom',
260-
auditMetadata: {},
261-
principal: { kind: 'tenant', id: 'zoom-account' },
262-
})
263-
queueTableRows(credential, [
264-
{
265-
id: 'existing-credential',
266-
workspaceId: WORKSPACE_ID,
267-
type: 'service_account',
268-
displayName: 'Production Zoom',
269-
providerId: 'zoom-service-account',
270-
},
271-
])
272-
273-
const response = await POST(
274-
createMockRequest('POST', {
275-
workspaceId: WORKSPACE_ID,
276-
type: 'service_account',
277-
providerId: 'zoom-service-account',
278-
displayName: 'Production Zoom',
259+
fields: {
279260
orgId: 'zoom-account',
280261
clientId: 'new-client-id',
281262
clientSecret: 'new-client-secret',
263+
},
264+
},
265+
{
266+
family: 'token-paste',
267+
providerId: 'snowflake-service-account',
268+
fields: {
269+
domain: 'acme.snowflakecomputing.com',
270+
apiToken: 'new-programmatic-access-token',
271+
},
272+
},
273+
{
274+
family: 'Google JSON-key',
275+
providerId: 'google-service-account',
276+
fields: {
277+
serviceAccountJson: JSON.stringify({
278+
type: 'service_account',
279+
client_email: 'automation@example.iam.gserviceaccount.com',
280+
private_key: 'new-private-key',
281+
project_id: 'example-project',
282+
}),
283+
},
284+
},
285+
{
286+
family: 'Atlassian',
287+
providerId: 'atlassian-service-account',
288+
fields: { domain: 'example.atlassian.net', apiToken: 'new-atlassian-token' },
289+
},
290+
{
291+
family: 'Slack different-ID',
292+
providerId: 'slack-custom-bot',
293+
fields: {
294+
id: '33333333-4444-4555-8666-777777777777',
295+
signingSecret: 'new-slack-signing-secret',
296+
botToken: 'xoxb-new-slack-bot-token',
297+
},
298+
},
299+
])(
300+
'conflicts for an existing $family service account instead of dropping new secrets',
301+
async ({ providerId, fields }) => {
302+
const displayName = 'Production automation'
303+
mockVerifyAndBuildServiceAccountSecret.mockResolvedValueOnce({
304+
providerId,
305+
encryptedServiceAccountKey: 'new-secret-ciphertext',
306+
displayName,
307+
auditMetadata: {},
308+
principal: null,
282309
})
283-
)
284-
const body = await response.json()
285-
286-
expect(response.status).toBe(409)
287-
expect(body.code).toBe('duplicate_display_name')
288-
expect(dbChainMockFns.update).not.toHaveBeenCalled()
289-
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
290-
})
310+
queueTableRows(credential, [
311+
{
312+
id: 'existing-credential',
313+
workspaceId: WORKSPACE_ID,
314+
type: 'service_account',
315+
displayName,
316+
providerId,
317+
},
318+
])
319+
320+
const response = await POST(
321+
createMockRequest('POST', {
322+
workspaceId: WORKSPACE_ID,
323+
type: 'service_account',
324+
providerId,
325+
displayName,
326+
...fields,
327+
})
328+
)
329+
const body = await response.json()
330+
331+
expect(response.status).toBe(409)
332+
expect(body.code).toBe('duplicate_display_name')
333+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
334+
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
335+
}
336+
)
291337

292338
it('preserves an exact-ID Slack custom-bot create replay', async () => {
293339
const credentialId = '22222222-3333-4444-8555-666666666666'

0 commit comments

Comments
 (0)