feat(vault): add vault and LLM provider services
Adds the services the credential flows need, following the existing call/fake pattern: a base, an HTTP implementation and a fake for the vault, the LLM provider catalog and the per-provider credential listing, registered in all three environments. The two fakes share an in-memory vault store that reproduces the server-side rules of the credential listing (owner, active, provider, soft delete), so a credential created from one panel shows up in the other exactly as it does against the real backend. Also teaches extractHttpErrorMessage the RFC 7807 `detail` field, which is what the execution endpoints answer with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8b34f49056
commit
7e9b483882
|
|
@ -57,9 +57,34 @@ export type AssistantPhaseModels = {
|
|||
export type AssistantLlmSelection = {
|
||||
provider: string;
|
||||
model: string;
|
||||
credentialId?: string;
|
||||
phaseModels?: AssistantPhaseModels;
|
||||
};
|
||||
|
||||
export type VaultSecret = {
|
||||
id: string;
|
||||
label: string;
|
||||
provider: string;
|
||||
description?: string;
|
||||
active: boolean;
|
||||
lastUsedAt?: string;
|
||||
maskedPreview?: string;
|
||||
};
|
||||
|
||||
export type VaultSecretCreateRequest = {
|
||||
label: string;
|
||||
provider: string;
|
||||
description?: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type VaultSecretUpdateRequest = {
|
||||
label?: string;
|
||||
description?: string;
|
||||
active?: boolean;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export type AssistantSessionRequest = {
|
||||
llmSelection?: AssistantLlmSelection;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
export type LlmProviderCapability = {
|
||||
name: string;
|
||||
requiresCredential: boolean;
|
||||
};
|
||||
|
||||
export type ExecutionVaultCredential = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
provider: string;
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { ExecutionVaultCredential } from '@models/llm-provider';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export abstract class ExecutionVaultCredentialsCallServiceBase {
|
||||
abstract listForProvider(provider: string): Observable<ExecutionVaultCredential[]>;
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { ExecutionVaultCredential } from '@models/llm-provider';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { vaultFakeStore } from '@services/vault/vault-fake-store';
|
||||
import { ExecutionVaultCredentialsCallServiceBase } from './execution-vault-credentials-call.base';
|
||||
|
||||
export class ExecutionVaultCredentialsCallServiceFake extends ExecutionVaultCredentialsCallServiceBase {
|
||||
override listForProvider(provider: string): Observable<ExecutionVaultCredential[]> {
|
||||
return of(vaultFakeStore.listActiveByProvider(provider).map((secret) => ({
|
||||
id: secret.id,
|
||||
label: secret.label,
|
||||
description: secret.description,
|
||||
provider: secret.provider
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { environment } from '@environment';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { ExecutionVaultCredentialsCallService } from './execution-vault-credentials-call';
|
||||
|
||||
describe('ExecutionVaultCredentialsCallService', () => {
|
||||
let service: ExecutionVaultCredentialsCallService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [ExecutionVaultCredentialsCallService, provideHttpClient(), provideHttpClientTesting()]
|
||||
});
|
||||
service = TestBed.inject(ExecutionVaultCredentialsCallService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('requests the credentials of a single provider and maps the vault secret id', async () => {
|
||||
const result = firstValueFrom(service.listForProvider('OpenAI'));
|
||||
const request = httpMock.expectOne(
|
||||
(candidate) => candidate.url === `${environment.apiUrl}/secure-retriever/UserSecrets/forProvider/items`
|
||||
);
|
||||
|
||||
expect(request.request.params.get('provider')).toBe('OpenAI');
|
||||
request.flush({
|
||||
items: [
|
||||
{
|
||||
data: 'vault-secret-1',
|
||||
descriptor: { label: 'OpenAI key', description: 'shared', meta: { provider: 'OpenAI' } }
|
||||
},
|
||||
{
|
||||
data: '',
|
||||
descriptor: { label: 'Broken entry', meta: { provider: 'OpenAI' } }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await expect(result).resolves.toEqual([{
|
||||
id: 'vault-secret-1',
|
||||
label: 'OpenAI key',
|
||||
description: 'shared',
|
||||
provider: 'OpenAI'
|
||||
}]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { environment } from '@environment';
|
||||
import { ExecutionVaultCredential } from '@models/llm-provider';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { ExecutionVaultCredentialsCallServiceBase } from './execution-vault-credentials-call.base';
|
||||
|
||||
export class ExecutionVaultCredentialsCallService extends ExecutionVaultCredentialsCallServiceBase {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
override listForProvider(provider: string): Observable<ExecutionVaultCredential[]> {
|
||||
const params = new HttpParams().set('provider', provider);
|
||||
return this.http.get<unknown>(
|
||||
`${environment.apiUrl}/secure-retriever/UserSecrets/forProvider/items`,
|
||||
{ params }
|
||||
).pipe(map((raw) => normalizeCredentials(raw)));
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCredentials(raw: unknown): ExecutionVaultCredential[] {
|
||||
const items = Array.isArray(raw)
|
||||
? raw
|
||||
: raw && typeof raw === 'object'
|
||||
? ((raw as Record<string, unknown>)['items'] ?? (raw as Record<string, unknown>)['values'] ?? [])
|
||||
: [];
|
||||
if (!Array.isArray(items)) return [];
|
||||
|
||||
return items.map((item) => {
|
||||
const value = item && typeof item === 'object' ? item as Record<string, unknown> : {};
|
||||
const descriptor = value['descriptor'] && typeof value['descriptor'] === 'object'
|
||||
? value['descriptor'] as Record<string, unknown>
|
||||
: {};
|
||||
const meta = descriptor['meta'] && typeof descriptor['meta'] === 'object'
|
||||
? descriptor['meta'] as Record<string, unknown>
|
||||
: {};
|
||||
return {
|
||||
id: String(value['data'] ?? ''),
|
||||
label: String(descriptor['label'] ?? 'Credential'),
|
||||
description: typeof descriptor['description'] === 'string' ? descriptor['description'] : undefined,
|
||||
provider: String(meta['provider'] ?? '')
|
||||
};
|
||||
}).filter((item) => item.id.length > 0);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { environment } from '@environment';
|
||||
import { ExecutionVaultCredential } from '@models/llm-provider';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ExecutionVaultCredentialsCallServiceBase } from './execution-vault-credentials-call.base';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ExecutionVaultCredentialsService {
|
||||
executionVaultCredentialsCallService: ExecutionVaultCredentialsCallServiceBase =
|
||||
new environment.executionVaultCredentialsCallService();
|
||||
|
||||
listForProvider(provider: string): Observable<ExecutionVaultCredential[]> {
|
||||
return this.executionVaultCredentialsCallService.listForProvider(provider);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { LlmProviderCapability } from '@models/llm-provider';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export abstract class LlmProviderCallServiceBase {
|
||||
abstract listCapabilities(): Observable<LlmProviderCapability[]>;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { LlmProviderCapability } from '@models/llm-provider';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { LlmProviderCallServiceBase } from './llm-provider-call.base';
|
||||
|
||||
export class LlmProviderCallServiceFake extends LlmProviderCallServiceBase {
|
||||
override listCapabilities(): Observable<LlmProviderCapability[]> {
|
||||
return of([
|
||||
{ name: 'InternalOllama', requiresCredential: false },
|
||||
{ name: 'testProvider', requiresCredential: true },
|
||||
{ name: 'Gemini', requiresCredential: true }
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { environment } from '@environment';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { LlmProviderCallService } from './llm-provider-call';
|
||||
|
||||
describe('LlmProviderCallService', () => {
|
||||
let service: LlmProviderCallService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [LlmProviderCallService, provideHttpClient(), provideHttpClientTesting()]
|
||||
});
|
||||
service = TestBed.inject(LlmProviderCallService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('maps provider capabilities from the backend', async () => {
|
||||
const result = firstValueFrom(service.listCapabilities());
|
||||
httpMock.expectOne(`${environment.apiUrl}/llm/providers`).flush([
|
||||
{ name: 'InternalOllama', requiresCredential: false },
|
||||
{ name: 'OpenAI', requiresCredential: true }
|
||||
]);
|
||||
await expect(result).resolves.toEqual([
|
||||
{ name: 'InternalOllama', requiresCredential: false },
|
||||
{ name: 'OpenAI', requiresCredential: true }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { HttpClient } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { environment } from '@environment';
|
||||
import { LlmProviderCapability } from '@models/llm-provider';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { LlmProviderCallServiceBase } from './llm-provider-call.base';
|
||||
|
||||
export class LlmProviderCallService extends LlmProviderCallServiceBase {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
override listCapabilities(): Observable<LlmProviderCapability[]> {
|
||||
return this.http.get<unknown>(`${environment.apiUrl}/llm/providers`).pipe(
|
||||
map((raw) => {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
|
||||
.map((item) => ({
|
||||
name: String(item['name'] ?? '').trim(),
|
||||
requiresCredential: item['requiresCredential'] === true
|
||||
}))
|
||||
.filter((item) => item.name.length > 0);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { environment } from '@environment';
|
||||
import { LlmProviderCapability } from '@models/llm-provider';
|
||||
import { Observable } from 'rxjs';
|
||||
import { LlmProviderCallServiceBase } from './llm-provider-call.base';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LlmProviderService {
|
||||
llmProviderCallService: LlmProviderCallServiceBase = new environment.llmProviderCallService();
|
||||
|
||||
listCapabilities(): Observable<LlmProviderCapability[]> {
|
||||
return this.llmProviderCallService.listCapabilities();
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ describe('extractHttpErrorMessage', () => {
|
|||
expect(extractHttpErrorMessage(new HttpErrorResponse({ error: { message: 'msg' }, status: 400 }))).toBe('msg');
|
||||
expect(extractHttpErrorMessage(new HttpErrorResponse({ error: { error: 'err' }, status: 400 }))).toBe('err');
|
||||
expect(extractHttpErrorMessage(new HttpErrorResponse({ error: { details: 'det' }, status: 400 }))).toBe('det');
|
||||
expect(extractHttpErrorMessage(new HttpErrorResponse({ error: { detail: 'problem detail' }, status: 400 }))).toBe('problem detail');
|
||||
});
|
||||
|
||||
it('returns null when nothing usable is present', () => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Observable, throwError } from 'rxjs';
|
|||
|
||||
/**
|
||||
* Reads a human-readable message out of a backend error body, trying the
|
||||
* conventional `message`/`error`/`details` string fields in that order.
|
||||
* conventional `message`/`error`/`details`/`detail` string fields in that order.
|
||||
*/
|
||||
export function extractHttpErrorMessage(error: HttpErrorResponse): string | null {
|
||||
const payload = error.error;
|
||||
|
|
@ -24,6 +24,11 @@ export function extractHttpErrorMessage(error: HttpErrorResponse): string | null
|
|||
if (typeof details === 'string' && details.trim().length > 0) {
|
||||
return details.trim();
|
||||
}
|
||||
// RFC 7807 ProblemDetail, which the execution endpoints answer with.
|
||||
const detail = record['detail'];
|
||||
if (typeof detail === 'string' && detail.trim().length > 0) {
|
||||
return detail.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* Copy shared by every caller that talks to a credential endpoint, so the vault
|
||||
* and the assistant report the same statuses the same way.
|
||||
*/
|
||||
export const CREDENTIAL_ERROR_MESSAGES: Record<number, string> = {
|
||||
400: 'The credential is invalid, inactive, or not compatible with the selected provider.',
|
||||
401: 'You must sign in to use or manage credentials.',
|
||||
409: 'This provider does not support user credentials.'
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { VaultSecret, VaultSecretCreateRequest, VaultSecretUpdateRequest } from '@models/assistant';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export abstract class VaultCallServiceBase {
|
||||
abstract listSecrets(): Observable<VaultSecret[]>;
|
||||
abstract createSecret(request: VaultSecretCreateRequest): Observable<VaultSecret>;
|
||||
abstract updateSecret(id: string, request: VaultSecretUpdateRequest): Observable<VaultSecret>;
|
||||
abstract deleteSecret(id: string): Observable<void>;
|
||||
}
|
||||
|
||||
export function mapVaultSecrets(raw: unknown): VaultSecret[] {
|
||||
const items = Array.isArray(raw)
|
||||
? raw
|
||||
: raw && typeof raw === 'object'
|
||||
? ((raw as Record<string, unknown>)['items'] ?? (raw as Record<string, unknown>)['values'] ?? [])
|
||||
: [];
|
||||
return Array.isArray(items) ? items.map(mapVaultSecret).filter((item) => !!item.id) : [];
|
||||
}
|
||||
|
||||
export function mapVaultSecret(raw: unknown): VaultSecret {
|
||||
const value = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
|
||||
return {
|
||||
id: String(value['id'] ?? value['secretId'] ?? ''),
|
||||
label: String(value['label'] ?? ''),
|
||||
provider: String(value['provider'] ?? ''),
|
||||
description: typeof value['description'] === 'string' ? value['description'] : undefined,
|
||||
active: value['active'] !== false && value['enabled'] !== false,
|
||||
lastUsedAt: typeof value['lastUsedAt'] === 'string' ? value['lastUsedAt'] : undefined,
|
||||
maskedPreview: typeof value['maskedPreview'] === 'string' ? value['maskedPreview'] : undefined
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { VaultSecret, VaultSecretCreateRequest, VaultSecretUpdateRequest } from '@models/assistant';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
import { VaultCallServiceBase } from './vault-call.base';
|
||||
import { vaultFakeStore } from './vault-fake-store';
|
||||
|
||||
export class VaultCallServiceFake extends VaultCallServiceBase {
|
||||
override listSecrets(): Observable<VaultSecret[]> {
|
||||
return of(vaultFakeStore.list());
|
||||
}
|
||||
|
||||
override createSecret(request: VaultSecretCreateRequest): Observable<VaultSecret> {
|
||||
if (!request.value.trim()) return throwError(() => new Error('The credential value is required.'));
|
||||
return of(vaultFakeStore.create(request));
|
||||
}
|
||||
|
||||
override updateSecret(id: string, request: VaultSecretUpdateRequest): Observable<VaultSecret> {
|
||||
try {
|
||||
return of(vaultFakeStore.update(id, request));
|
||||
} catch (error) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
|
||||
override deleteSecret(id: string): Observable<void> {
|
||||
vaultFakeStore.remove(id);
|
||||
return of(void 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { environment } from '@environment';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { CREDENTIAL_ERROR_MESSAGES } from './credential-error-messages';
|
||||
import { VaultCallService } from './vault-call';
|
||||
|
||||
describe('VaultCallService', () => {
|
||||
let service: VaultCallService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [VaultCallService, provideHttpClient(), provideHttpClientTesting()]
|
||||
});
|
||||
service = TestBed.inject(VaultCallService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('maps secret metadata without exposing a value', async () => {
|
||||
const result = firstValueFrom(service.listSecrets());
|
||||
httpMock.expectOne(`${environment.apiUrl}/vault/secrets`).flush([{
|
||||
id: 'credential-1',
|
||||
label: 'OpenAI key',
|
||||
provider: 'OpenAI',
|
||||
active: true,
|
||||
maskedPreview: '***',
|
||||
value: 'must-not-reach-the-ui'
|
||||
}]);
|
||||
|
||||
await expect(result).resolves.toEqual([{
|
||||
id: 'credential-1',
|
||||
label: 'OpenAI key',
|
||||
provider: 'OpenAI',
|
||||
active: true,
|
||||
maskedPreview: '***'
|
||||
}]);
|
||||
});
|
||||
|
||||
it('sends a value only on explicit create or rotation requests', async () => {
|
||||
const create = firstValueFrom(service.createSecret({
|
||||
label: 'OpenAI key', provider: 'OpenAI', value: 'secret-value'
|
||||
}));
|
||||
const createRequest = httpMock.expectOne(`${environment.apiUrl}/vault/secrets`);
|
||||
expect(createRequest.request.body).toEqual({ label: 'OpenAI key', provider: 'OpenAI', value: 'secret-value' });
|
||||
createRequest.flush({ id: 'credential-1', label: 'OpenAI key', provider: 'OpenAI', active: true });
|
||||
await create;
|
||||
|
||||
const update = firstValueFrom(service.updateSecret('credential-1', { value: 'rotated-value' }));
|
||||
const updateRequest = httpMock.expectOne(`${environment.apiUrl}/vault/secrets/credential-1`);
|
||||
expect(updateRequest.request.body).toEqual({ value: 'rotated-value' });
|
||||
updateRequest.flush({ id: 'credential-1', label: 'OpenAI key', provider: 'OpenAI', active: true });
|
||||
await update;
|
||||
});
|
||||
|
||||
it('falls back to the shared credential message when the backend sends no body', async () => {
|
||||
const result = firstValueFrom(service.deleteSecret('credential-1'));
|
||||
httpMock.expectOne(`${environment.apiUrl}/vault/secrets/credential-1`)
|
||||
.flush(null, { status: 409, statusText: 'Conflict' });
|
||||
|
||||
await expect(result).rejects.toThrow(CREDENTIAL_ERROR_MESSAGES[409]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { HttpClient } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { environment } from '@environment';
|
||||
import { VaultSecret, VaultSecretCreateRequest, VaultSecretUpdateRequest } from '@models/assistant';
|
||||
import { extractHttpErrorMessage } from '@services/shared/http-error.util';
|
||||
import { catchError, map, Observable, throwError } from 'rxjs';
|
||||
import { CREDENTIAL_ERROR_MESSAGES } from './credential-error-messages';
|
||||
import { mapVaultSecret, mapVaultSecrets, VaultCallServiceBase } from './vault-call.base';
|
||||
|
||||
export class VaultCallService extends VaultCallServiceBase {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
override listSecrets(): Observable<VaultSecret[]> {
|
||||
return this.http.get<unknown>(`${environment.apiUrl}/vault/secrets`).pipe(
|
||||
map((raw) => mapVaultSecrets(raw)),
|
||||
catchError((error: unknown) => this.vaultError(error))
|
||||
);
|
||||
}
|
||||
|
||||
override createSecret(request: VaultSecretCreateRequest): Observable<VaultSecret> {
|
||||
return this.http.post<unknown>(`${environment.apiUrl}/vault/secrets`, request).pipe(
|
||||
map((raw) => mapVaultSecret(raw)),
|
||||
catchError((error: unknown) => this.vaultError(error))
|
||||
);
|
||||
}
|
||||
|
||||
override updateSecret(id: string, request: VaultSecretUpdateRequest): Observable<VaultSecret> {
|
||||
return this.http.put<unknown>(`${environment.apiUrl}/vault/secrets/${encodeURIComponent(id)}`, request).pipe(
|
||||
map((raw) => mapVaultSecret(raw)),
|
||||
catchError((error: unknown) => this.vaultError(error))
|
||||
);
|
||||
}
|
||||
|
||||
override deleteSecret(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${environment.apiUrl}/vault/secrets/${encodeURIComponent(id)}`).pipe(
|
||||
catchError((error: unknown) => this.vaultError(error))
|
||||
);
|
||||
}
|
||||
|
||||
private vaultError(error: unknown): Observable<never> {
|
||||
const response = error as { status?: unknown; error?: unknown };
|
||||
const message = extractHttpErrorMessage(response as any)
|
||||
?? CREDENTIAL_ERROR_MESSAGES[Number(response?.status)]
|
||||
?? 'Unable to update provider credentials.';
|
||||
return throwError(() => new Error(message));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { ExecutionVaultCredentialsCallServiceFake } from '@services/llm-provider/execution-vault-credentials-call.fake';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { VaultCallServiceFake } from './vault-call.fake';
|
||||
import { vaultFakeStore } from './vault-fake-store';
|
||||
|
||||
describe('vault fake store', () => {
|
||||
const vault = new VaultCallServiceFake();
|
||||
const credentials = new ExecutionVaultCredentialsCallServiceFake();
|
||||
|
||||
it('offers only active credentials of the requested provider to an execution', async () => {
|
||||
const listed = await firstValueFrom(credentials.listForProvider('testProvider'));
|
||||
|
||||
expect(listed.map((item) => item.id)).toEqual([
|
||||
'vault-secret-testprovider-1',
|
||||
'vault-secret-testprovider-2'
|
||||
]);
|
||||
expect(vaultFakeStore.list().some((secret) => !secret.active)).toBe(true);
|
||||
});
|
||||
|
||||
it('shows a credential created through the vault in the execution picker', async () => {
|
||||
const created = await firstValueFrom(vault.createSecret({
|
||||
label: 'testProvider - fresh key', provider: 'testProvider', value: 'sk-brand-new'
|
||||
}));
|
||||
const listed = await firstValueFrom(credentials.listForProvider('testProvider'));
|
||||
|
||||
expect(listed.map((item) => item.id)).toContain(created.id);
|
||||
|
||||
await firstValueFrom(vault.deleteSecret(created.id));
|
||||
const afterDelete = await firstValueFrom(credentials.listForProvider('testProvider'));
|
||||
expect(afterDelete.map((item) => item.id)).not.toContain(created.id);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { VaultSecret, VaultSecretCreateRequest, VaultSecretUpdateRequest } from '@models/assistant';
|
||||
|
||||
/**
|
||||
* In-memory vault shared by the fake call services, so a credential created from
|
||||
* the assistant panel shows up in the execution credential picker, exactly as it
|
||||
* does against the real backend.
|
||||
*/
|
||||
class VaultFakeStore {
|
||||
private sequence = 0;
|
||||
private readonly secrets: VaultSecret[] = [
|
||||
{
|
||||
id: 'vault-secret-testprovider-1',
|
||||
label: 'testProvider - team key',
|
||||
provider: 'testProvider',
|
||||
description: 'Shared key for the demo workspace',
|
||||
active: true,
|
||||
maskedPreview: 'sk-...4f2a'
|
||||
},
|
||||
{
|
||||
id: 'vault-secret-testprovider-2',
|
||||
label: 'testProvider - personal key',
|
||||
provider: 'testProvider',
|
||||
active: true,
|
||||
maskedPreview: 'sk-...91cd'
|
||||
},
|
||||
{
|
||||
id: 'vault-secret-testprovider-3',
|
||||
label: 'testProvider - revoked key',
|
||||
provider: 'testProvider',
|
||||
description: 'Disabled, never offered to an execution',
|
||||
active: false,
|
||||
maskedPreview: 'sk-...0007'
|
||||
},
|
||||
{
|
||||
id: 'vault-secret-gemini-1',
|
||||
label: 'Gemini - default key',
|
||||
provider: 'Gemini',
|
||||
active: true,
|
||||
maskedPreview: 'AI...b31'
|
||||
}
|
||||
];
|
||||
|
||||
list(): VaultSecret[] {
|
||||
return this.secrets.map((secret) => ({ ...secret }));
|
||||
}
|
||||
|
||||
/** Mirrors the server-side filter of the credential listing: owner, active, provider. */
|
||||
listActiveByProvider(provider: string): VaultSecret[] {
|
||||
const wanted = provider.trim().toLowerCase();
|
||||
return this.secrets
|
||||
.filter((secret) => secret.active && secret.provider.trim().toLowerCase() === wanted)
|
||||
.map((secret) => ({ ...secret }));
|
||||
}
|
||||
|
||||
create(request: VaultSecretCreateRequest): VaultSecret {
|
||||
const created: VaultSecret = {
|
||||
id: `vault-secret-created-${++this.sequence}`,
|
||||
label: request.label,
|
||||
provider: request.provider,
|
||||
description: request.description,
|
||||
active: true,
|
||||
maskedPreview: `${request.value.slice(0, 2)}...${request.value.slice(-3)}`
|
||||
};
|
||||
this.secrets.push(created);
|
||||
return { ...created };
|
||||
}
|
||||
|
||||
update(id: string, request: VaultSecretUpdateRequest): VaultSecret {
|
||||
const secret = this.secrets.find((item) => item.id === id);
|
||||
if (!secret) throw new Error('The credential no longer exists.');
|
||||
if (request.label !== undefined) secret.label = request.label;
|
||||
if (request.description !== undefined) secret.description = request.description;
|
||||
if (request.active !== undefined) secret.active = request.active;
|
||||
if (request.value) secret.maskedPreview = `${request.value.slice(0, 2)}...${request.value.slice(-3)}`;
|
||||
return { ...secret };
|
||||
}
|
||||
|
||||
/** The backend soft-deletes, so the secret stays but stops being offered. */
|
||||
remove(id: string): void {
|
||||
const secret = this.secrets.find((item) => item.id === id);
|
||||
if (secret) secret.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const vaultFakeStore = new VaultFakeStore();
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { environment } from '@environment';
|
||||
import { VaultSecret, VaultSecretCreateRequest, VaultSecretUpdateRequest } from '@models/assistant';
|
||||
import { Observable } from 'rxjs';
|
||||
import { VaultCallServiceBase } from './vault-call.base';
|
||||
|
||||
export { CREDENTIAL_ERROR_MESSAGES } from './credential-error-messages';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VaultService {
|
||||
vaultCallService: VaultCallServiceBase = new environment.vaultCallService();
|
||||
|
||||
listSecrets(): Observable<VaultSecret[]> {
|
||||
return this.vaultCallService.listSecrets();
|
||||
}
|
||||
|
||||
createSecret(request: VaultSecretCreateRequest): Observable<VaultSecret> {
|
||||
return this.vaultCallService.createSecret(request);
|
||||
}
|
||||
|
||||
updateSecret(id: string, request: VaultSecretUpdateRequest): Observable<VaultSecret> {
|
||||
return this.vaultCallService.updateSecret(id, request);
|
||||
}
|
||||
|
||||
deleteSecret(id: string): Observable<void> {
|
||||
return this.vaultCallService.deleteSecret(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,9 @@ import { AssistantCallServiceFake } from "@services/assistant/assistant-call.fak
|
|||
import { AuthorizationCallFakeService } from "@services/authorization/authorization-call.fake";
|
||||
import { BlocksCallServiceFake } from "@services/blocks/blocks-call.fake";
|
||||
import { ContainersCallServiceFake } from "@services/containers/containers-call.fake";
|
||||
import { ExecutionVaultCredentialsCallServiceFake } from "@services/llm-provider/execution-vault-credentials-call.fake";
|
||||
import { LlmProviderCallServiceFake } from "@services/llm-provider/llm-provider-call.fake";
|
||||
import { VaultCallServiceFake } from "@services/vault/vault-call.fake";
|
||||
import { FlowsCallServiceFake } from "@services/flows/flows-call.fake";
|
||||
import { FieldRetrieverCallServiceFake } from "@services/retriever/field-retriever-call.fake";
|
||||
import { TaskExecutionsCallServiceFake } from "@services/task-executions/task-executions-call.fake";
|
||||
|
|
@ -20,5 +23,8 @@ export const environment = {
|
|||
blocksCallService: BlocksCallServiceFake,
|
||||
containersCallService: ContainersCallServiceFake,
|
||||
fieldRetrieverCallService: FieldRetrieverCallServiceFake,
|
||||
taskExecutionsCallService: TaskExecutionsCallServiceFake
|
||||
taskExecutionsCallService: TaskExecutionsCallServiceFake,
|
||||
llmProviderCallService: LlmProviderCallServiceFake,
|
||||
executionVaultCredentialsCallService: ExecutionVaultCredentialsCallServiceFake,
|
||||
vaultCallService: VaultCallServiceFake
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import { AssistantCallService } from "@services/assistant/assistant-call";
|
|||
import { AuthorizationCallService } from "@services/authorization/authorization-call";
|
||||
import { BlocksCallService } from "@services/blocks/blocks-call";
|
||||
import { ContainersCallService } from "@services/containers/containers-call";
|
||||
import { ExecutionVaultCredentialsCallService } from "@services/llm-provider/execution-vault-credentials-call";
|
||||
import { LlmProviderCallService } from "@services/llm-provider/llm-provider-call";
|
||||
import { VaultCallService } from "@services/vault/vault-call";
|
||||
import { FlowsCallService } from "@services/flows/flows-call";
|
||||
import { FieldRetrieverCallService } from "@services/retriever/field-retriever-call";
|
||||
import { TaskExecutionsCallService } from "@services/task-executions/task-executions-call";
|
||||
|
|
@ -20,5 +23,8 @@ export const environment = {
|
|||
blocksCallService: BlocksCallService,
|
||||
containersCallService: ContainersCallService,
|
||||
fieldRetrieverCallService: FieldRetrieverCallService,
|
||||
taskExecutionsCallService: TaskExecutionsCallService
|
||||
taskExecutionsCallService: TaskExecutionsCallService,
|
||||
llmProviderCallService: LlmProviderCallService,
|
||||
executionVaultCredentialsCallService: ExecutionVaultCredentialsCallService,
|
||||
vaultCallService: VaultCallService
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import { AssistantCallService } from "@services/assistant/assistant-call";
|
|||
import { AuthorizationCallService } from "@services/authorization/authorization-call";
|
||||
import { BlocksCallService } from "@services/blocks/blocks-call";
|
||||
import { ContainersCallService } from "@services/containers/containers-call";
|
||||
import { ExecutionVaultCredentialsCallService } from "@services/llm-provider/execution-vault-credentials-call";
|
||||
import { LlmProviderCallService } from "@services/llm-provider/llm-provider-call";
|
||||
import { VaultCallService } from "@services/vault/vault-call";
|
||||
import { FlowsCallService } from "@services/flows/flows-call";
|
||||
import { FieldRetrieverCallService } from "@services/retriever/field-retriever-call";
|
||||
import { TaskExecutionsCallService } from "@services/task-executions/task-executions-call";
|
||||
|
|
@ -20,5 +23,8 @@ export const environment = {
|
|||
blocksCallService: BlocksCallService,
|
||||
containersCallService: ContainersCallService,
|
||||
fieldRetrieverCallService: FieldRetrieverCallService,
|
||||
taskExecutionsCallService: TaskExecutionsCallService
|
||||
taskExecutionsCallService: TaskExecutionsCallService,
|
||||
llmProviderCallService: LlmProviderCallService,
|
||||
executionVaultCredentialsCallService: ExecutionVaultCredentialsCallService,
|
||||
vaultCallService: VaultCallService
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue