|
| 1 | +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | +// SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +import { Code, ConnectError, createRouterTransport, type ServiceImpl, type Transport } from '@connectrpc/connect'; |
| 5 | +import { describe, expect, it } from 'vitest'; |
| 6 | +import { OpenShell } from './gen/openshell_pb.js'; |
| 7 | +import { ProviderClient } from './provider.js'; |
| 8 | + |
| 9 | +function client(impl: Partial<ServiceImpl<typeof OpenShell>>): ProviderClient { |
| 10 | + const transport: Transport = createRouterTransport((router) => router.service(OpenShell, impl)); |
| 11 | + return new ProviderClient(transport); |
| 12 | +} |
| 13 | + |
| 14 | +function record(name = 'user-token', resourceVersion = 7n) { |
| 15 | + return { |
| 16 | + provider: { |
| 17 | + metadata: { |
| 18 | + id: `id-${name}`, |
| 19 | + name, |
| 20 | + labels: { owner: 'app' }, |
| 21 | + annotations: { purpose: 'per-sandbox' }, |
| 22 | + workspace: 'tenant-a', |
| 23 | + resourceVersion, |
| 24 | + createdAtMs: 123n, |
| 25 | + }, |
| 26 | + type: 'backend-api', |
| 27 | + config: { endpoint: 'https://api.example.com' }, |
| 28 | + credentialExpiresAtMs: { USER_JWT: 456n }, |
| 29 | + profileWorkspace: 'tenant-a', |
| 30 | + }, |
| 31 | + }; |
| 32 | +} |
| 33 | + |
| 34 | +describe('ProviderClient', () => { |
| 35 | + it('creates a provider without returning credential plaintext', async () => { |
| 36 | + let request: Parameters<NonNullable<Partial<ServiceImpl<typeof OpenShell>>['createProvider']>>[0] | undefined; |
| 37 | + const providers = client({ |
| 38 | + createProvider: (req) => { |
| 39 | + request = req; |
| 40 | + return record(); |
| 41 | + }, |
| 42 | + }); |
| 43 | + |
| 44 | + const created = await providers.create('tenant-a', { |
| 45 | + name: 'user-token', |
| 46 | + type: 'backend-api', |
| 47 | + credentials: { USER_JWT: 'secret-value' }, |
| 48 | + credentialExpiresAtMs: { USER_JWT: '456' }, |
| 49 | + }); |
| 50 | + |
| 51 | + expect(request?.workspace).toBe('tenant-a'); |
| 52 | + expect(request?.provider?.credentials).toEqual({ USER_JWT: 'secret-value' }); |
| 53 | + expect(request?.provider?.credentialExpiresAtMs.USER_JWT).toBe(456n); |
| 54 | + expect(created).not.toHaveProperty('credentials'); |
| 55 | + expect(created.resourceVersion).toBe('7'); |
| 56 | + expect(created.credentialExpiresAtMs).toEqual({ USER_JWT: '456' }); |
| 57 | + }); |
| 58 | + |
| 59 | + it('lists providers and validates pagination before the RPC', async () => { |
| 60 | + let request: { workspace?: string; limit?: number; offset?: number; allWorkspaces?: boolean } | undefined; |
| 61 | + const providers = client({ |
| 62 | + listProviders: (req) => { |
| 63 | + request = req; |
| 64 | + return { providers: [record('one').provider, record('two').provider] }; |
| 65 | + }, |
| 66 | + }); |
| 67 | + |
| 68 | + const listed = await providers.list('tenant-a', { limit: 10, offset: 2 }); |
| 69 | + expect(request).toMatchObject({ workspace: 'tenant-a', limit: 10, offset: 2, allWorkspaces: false }); |
| 70 | + expect(listed.map((provider) => provider.name)).toEqual(['one', 'two']); |
| 71 | + await expect(providers.list('tenant-a', { limit: -1 })).rejects.toMatchObject({ code: 'invalid_config' }); |
| 72 | + await expect(providers.list('tenant-a', { allWorkspaces: true })).rejects.toMatchObject({ |
| 73 | + code: 'invalid_config', |
| 74 | + }); |
| 75 | + }); |
| 76 | + |
| 77 | + it('updates credentials with a resource-version pin for safe rotation', async () => { |
| 78 | + let request: Parameters<NonNullable<Partial<ServiceImpl<typeof OpenShell>>['updateProvider']>>[0] | undefined; |
| 79 | + const providers = client({ |
| 80 | + updateProvider: (req) => { |
| 81 | + request = req; |
| 82 | + return record('user-token', 9n); |
| 83 | + }, |
| 84 | + }); |
| 85 | + |
| 86 | + const updated = await providers.update('tenant-a', { |
| 87 | + name: 'user-token', |
| 88 | + type: 'backend-api', |
| 89 | + credentials: { USER_JWT: 'rotated-value' }, |
| 90 | + resourceVersion: '7', |
| 91 | + }); |
| 92 | + |
| 93 | + expect(request?.provider?.metadata?.resourceVersion).toBe(7n); |
| 94 | + expect(request?.provider?.credentials).toEqual({ USER_JWT: 'rotated-value' }); |
| 95 | + expect(updated.resourceVersion).toBe('9'); |
| 96 | + }); |
| 97 | + |
| 98 | + it('ensure creates when absent and updates with the current resource version when present', async () => { |
| 99 | + let exists = false; |
| 100 | + let createCount = 0; |
| 101 | + let updateVersion = 0n; |
| 102 | + const providers = client({ |
| 103 | + getProvider: () => { |
| 104 | + if (!exists) throw new ConnectError('missing', Code.NotFound); |
| 105 | + return record('user-token', 42n); |
| 106 | + }, |
| 107 | + createProvider: () => { |
| 108 | + createCount += 1; |
| 109 | + exists = true; |
| 110 | + return record(); |
| 111 | + }, |
| 112 | + updateProvider: (req) => { |
| 113 | + updateVersion = req.provider?.metadata?.resourceVersion ?? 0n; |
| 114 | + return record('user-token', 43n); |
| 115 | + }, |
| 116 | + }); |
| 117 | + |
| 118 | + const desired = { name: 'user-token', type: 'backend-api', credentials: { USER_JWT: 'value' } }; |
| 119 | + await providers.ensure('tenant-a', desired); |
| 120 | + expect(createCount).toBe(1); |
| 121 | + await providers.ensure('tenant-a', desired); |
| 122 | + expect(updateVersion).toBe(42n); |
| 123 | + }); |
| 124 | + |
| 125 | + it('does not turn an update race into a create', async () => { |
| 126 | + let createCount = 0; |
| 127 | + const providers = client({ |
| 128 | + getProvider: () => record('user-token', 42n), |
| 129 | + updateProvider: () => { |
| 130 | + throw new ConnectError('deleted concurrently', Code.NotFound); |
| 131 | + }, |
| 132 | + createProvider: () => { |
| 133 | + createCount += 1; |
| 134 | + return record(); |
| 135 | + }, |
| 136 | + }); |
| 137 | + |
| 138 | + await expect(providers.ensure('tenant-a', { name: 'user-token', type: 'backend-api' })).rejects.toMatchObject({ |
| 139 | + code: 'not_found', |
| 140 | + }); |
| 141 | + expect(createCount).toBe(0); |
| 142 | + }); |
| 143 | + |
| 144 | + it('maps delete and malformed gateway responses through the SDK error taxonomy', async () => { |
| 145 | + const providers = client({ |
| 146 | + deleteProvider: () => ({ deleted: true }), |
| 147 | + getProvider: () => ({ provider: { type: 'backend-api' } }), |
| 148 | + }); |
| 149 | + await expect(providers.delete('tenant-a', 'user-token')).resolves.toBe(true); |
| 150 | + await expect(providers.get('tenant-a', 'user-token')).rejects.toMatchObject({ code: 'invalid_config' }); |
| 151 | + }); |
| 152 | +}); |
0 commit comments