From 1dc9a4b3602bae55998545c5252b89b9033cc08b Mon Sep 17 00:00:00 2001 From: maelthi Date: Thu, 20 Aug 2026 16:24:04 +0200 Subject: [PATCH] fix(backup-licenses): fix last bugs ref: #bkp-1161 Signed-off-by: maelthi --- .../translations/order/Messages_de_DE.json | 4 +- .../translations/order/Messages_en_GB.json | 4 +- .../translations/order/Messages_es_ES.json | 4 +- .../translations/order/Messages_fr_CA.json | 4 +- .../translations/order/Messages_fr_FR.json | 4 +- .../translations/order/Messages_it_IT.json | 4 +- .../translations/order/Messages_pl_PL.json | 4 +- .../translations/order/Messages_pt_PT.json | 4 +- .../BackupServerActionsCell.component.tsx | 6 +- .../EditRecapPanel.component.tsx | 9 +- .../backupLicenses.requests.spec.ts | 42 ------- .../backupLicenses/backupLicenses.requests.ts | 16 +-- .../backupServers.requests.spec.ts | 33 ++++- .../backupServers/backupServers.requests.ts | 23 ++++ .../data/api/services/consumption.requests.ts | 6 +- .../usePrepareBackupLicensesCart.spec.tsx | 6 +- .../usePrepareBackupLicensesCart.ts | 5 +- .../src/data/queries/billing.queries.spec.ts | 20 ++-- .../src/data/queries/billing.queries.ts | 2 +- .../useAddServerForm/useAddServerForm.ts | 12 +- .../useCreateBackupLicense.spec.tsx | 113 ++++++++++-------- .../useCreateBackupLicense.ts | 14 ++- .../useLinkedServersColumns.tsx | 6 +- .../src/hooks/useOrderForm/useOrderForm.ts | 12 +- .../backupLicenses/backupLicenses.handler.ts | 15 +-- .../backupLicenses/backupLicenses.mock.ts | 22 +--- .../backupServers/backupServers.handler.ts | 10 ++ .../mocks/consumptions/consumptions.mock.ts | 67 +++++------ .../src/pages/add-server/AddServer.page.tsx | 9 +- .../delete/DeleteBackupServer.page.spec.tsx | 6 +- .../delete/DeleteBackupServer.page.tsx | 13 +- .../edit/EditBackupServer.page.spec.tsx | 4 +- .../edit/EditBackupServer.page.tsx | 4 +- .../src/types/BackupLicense.type.ts | 16 +-- .../src/types/Consumption.type.ts | 16 +++ .../utils/formatIpList/formatIpList.spec.ts | 16 ++- .../src/utils/formatIpList/formatIpList.ts | 12 ++ .../orderComposition/orderComposition.spec.ts | 12 +- .../orderComposition/orderComposition.ts | 8 +- 39 files changed, 309 insertions(+), 278 deletions(-) delete mode 100644 packages/manager/modules/backup-licenses/src/data/api/backupLicenses/backupLicenses.requests.spec.ts diff --git a/packages/manager/modules/backup-licenses/public/translations/order/Messages_de_DE.json b/packages/manager/modules/backup-licenses/public/translations/order/Messages_de_DE.json index de326252a253..e04311f91e36 100644 --- a/packages/manager/modules/backup-licenses/public/translations/order/Messages_de_DE.json +++ b/packages/manager/modules/backup-licenses/public/translations/order/Messages_de_DE.json @@ -96,7 +96,7 @@ "label": "Öffentliche IP-Adresse", "placeholder": "Bsp.: 185.26.17.45", "hint": "Die öffentliche IP-Adresse Ihres VBR-Servers.", - "error": "Diese IP-Adresse scheint nicht gültig zu sein — überprüfen Sie das IPv4- oder IPv6-Format." + "error": "Diese IP-Adresse scheint nicht gültig zu sein — überprüfen Sie das IPv4-Format." }, "veeam_client_ip": { "label": "IP-Adresse des Veeam-Clients", @@ -107,7 +107,7 @@ "label": "Private IP-Adresse (LAN)", "placeholder": "Bsp.: 192.168.1.10", "hint": "Lokale IP-Adresse Ihres VBR-Servers im internen Netzwerk.", - "error": "Diese IP-Adresse scheint nicht gültig zu sein — überprüfen Sie das IPv4- oder IPv6-Format." + "error": "Diese IP-Adresse scheint nicht gültig zu sein — überprüfen Sie das IPv4-Format." }, "vault_name": { "label": "Vault-Name", diff --git a/packages/manager/modules/backup-licenses/public/translations/order/Messages_en_GB.json b/packages/manager/modules/backup-licenses/public/translations/order/Messages_en_GB.json index 4d090c214b74..07bde0fb2404 100644 --- a/packages/manager/modules/backup-licenses/public/translations/order/Messages_en_GB.json +++ b/packages/manager/modules/backup-licenses/public/translations/order/Messages_en_GB.json @@ -96,7 +96,7 @@ "label": "Public IP address", "placeholder": "E.g.: 185.26.17.45", "hint": "The public IP address of your VBR server.", - "error": "This IP address doesn't look valid — check the IPv4 or IPv6 format." + "error": "This IP address doesn't look valid — check the IPv4 format." }, "veeam_client_ip": { "label": "Veeam Client IP address", @@ -107,7 +107,7 @@ "label": "Private IP address (LAN)", "placeholder": "E.g.: 192.168.1.10", "hint": "Local IP address of your VBR server on the internal network.", - "error": "This IP address doesn't look valid — check the IPv4 or IPv6 format." + "error": "This IP address doesn't look valid — check the IPv4 format." }, "vault_name": { "label": "Vault name", diff --git a/packages/manager/modules/backup-licenses/public/translations/order/Messages_es_ES.json b/packages/manager/modules/backup-licenses/public/translations/order/Messages_es_ES.json index de0cf8b4ccc6..03080b448b8b 100644 --- a/packages/manager/modules/backup-licenses/public/translations/order/Messages_es_ES.json +++ b/packages/manager/modules/backup-licenses/public/translations/order/Messages_es_ES.json @@ -96,7 +96,7 @@ "label": "Dirección IP pública", "placeholder": "Ej.: 185.26.17.45", "hint": "La dirección IP pública de tu servidor VBR.", - "error": "Esta dirección IP no parece válida — comprueba el formato IPv4 o IPv6." + "error": "Esta dirección IP no parece válida — comprueba el formato IPv4." }, "veeam_client_ip": { "label": "Dirección IP del cliente Veeam", @@ -107,7 +107,7 @@ "label": "Dirección IP privada (LAN)", "placeholder": "Ej.: 192.168.1.10", "hint": "Dirección IP local de tu servidor VBR en la red interna.", - "error": "Esta dirección IP no parece válida — comprueba el formato IPv4 o IPv6." + "error": "Esta dirección IP no parece válida — comprueba el formato IPv4." }, "vault_name": { "label": "Nombre del Vault", diff --git a/packages/manager/modules/backup-licenses/public/translations/order/Messages_fr_CA.json b/packages/manager/modules/backup-licenses/public/translations/order/Messages_fr_CA.json index 419025052ddb..59404e4b0032 100644 --- a/packages/manager/modules/backup-licenses/public/translations/order/Messages_fr_CA.json +++ b/packages/manager/modules/backup-licenses/public/translations/order/Messages_fr_CA.json @@ -96,7 +96,7 @@ "label": "Adresse IP publique", "placeholder": "Ex : 185.26.17.45", "hint": "L'adresse IP publique de votre serveur VBR.", - "error": "Cette adresse IP ne semble pas valide — vérifiez le format IPv4 ou IPv6." + "error": "Cette adresse IP ne semble pas valide — vérifiez le format IPv4." }, "veeam_client_ip": { "label": "Adresse IP du client Veeam", @@ -107,7 +107,7 @@ "label": "Adresse IP privée (LAN)", "placeholder": "Ex : 192.168.1.10", "hint": "Adresse IP locale de votre serveur VBR sur le réseau interne.", - "error": "Cette adresse IP ne semble pas valide — vérifiez le format IPv4 ou IPv6." + "error": "Cette adresse IP ne semble pas valide — vérifiez le format IPv4." }, "vault_name": { "label": "Nom du Vault", diff --git a/packages/manager/modules/backup-licenses/public/translations/order/Messages_fr_FR.json b/packages/manager/modules/backup-licenses/public/translations/order/Messages_fr_FR.json index bda0a5c80750..12c3d445f9fb 100644 --- a/packages/manager/modules/backup-licenses/public/translations/order/Messages_fr_FR.json +++ b/packages/manager/modules/backup-licenses/public/translations/order/Messages_fr_FR.json @@ -96,7 +96,7 @@ "label": "Adresse IP publique", "placeholder": "Ex : 185.26.17.45", "hint": "L'adresse IP publique de votre serveur VBR.", - "error": "Cette adresse IP ne semble pas valide — vérifiez le format IPv4 ou IPv6." + "error": "Cette adresse IP ne semble pas valide — vérifiez le format IPv4." }, "veeam_client_ip": { "label": "Adresse IP du client Veeam", @@ -107,7 +107,7 @@ "label": "Adresse IP privée (LAN)", "placeholder": "Ex : 192.168.1.10", "hint": "Adresse IP locale de votre serveur VBR sur le réseau interne.", - "error": "Cette adresse IP ne semble pas valide — vérifiez le format IPv4 ou IPv6." + "error": "Cette adresse IP ne semble pas valide — vérifiez le format IPv4." }, "vault_name": { "label": "Nom du Vault", diff --git a/packages/manager/modules/backup-licenses/public/translations/order/Messages_it_IT.json b/packages/manager/modules/backup-licenses/public/translations/order/Messages_it_IT.json index 0dc30f9beda8..60be03923e84 100644 --- a/packages/manager/modules/backup-licenses/public/translations/order/Messages_it_IT.json +++ b/packages/manager/modules/backup-licenses/public/translations/order/Messages_it_IT.json @@ -96,7 +96,7 @@ "label": "Indirizzo IP pubblico", "placeholder": "Es.: 185.26.17.45", "hint": "L'indirizzo IP pubblico del tuo server VBR.", - "error": "Questo indirizzo IP non sembra valido — controlla il formato IPv4 o IPv6." + "error": "Questo indirizzo IP non sembra valido — controlla il formato IPv4." }, "veeam_client_ip": { "label": "Indirizzo IP del client Veeam", @@ -107,7 +107,7 @@ "label": "Indirizzo IP privato (LAN)", "placeholder": "Es.: 192.168.1.10", "hint": "Indirizzo IP locale del tuo server VBR sulla rete interna.", - "error": "Questo indirizzo IP non sembra valido — controlla il formato IPv4 o IPv6." + "error": "Questo indirizzo IP non sembra valido — controlla il formato IPv4." }, "vault_name": { "label": "Nome del Vault", diff --git a/packages/manager/modules/backup-licenses/public/translations/order/Messages_pl_PL.json b/packages/manager/modules/backup-licenses/public/translations/order/Messages_pl_PL.json index 8bf391a1f153..09117808d553 100644 --- a/packages/manager/modules/backup-licenses/public/translations/order/Messages_pl_PL.json +++ b/packages/manager/modules/backup-licenses/public/translations/order/Messages_pl_PL.json @@ -96,7 +96,7 @@ "label": "Publiczny adres IP", "placeholder": "Np.: 185.26.17.45", "hint": "Publiczny adres IP Twojego serwera VBR.", - "error": "Ten adres IP nie wygląda na prawidłowy — sprawdź format IPv4 lub IPv6." + "error": "Ten adres IP nie wygląda na prawidłowy — sprawdź format IPv4." }, "veeam_client_ip": { "label": "Adres IP klienta Veeam", @@ -107,7 +107,7 @@ "label": "Prywatny adres IP (LAN)", "placeholder": "Np.: 192.168.1.10", "hint": "Lokalny adres IP Twojego serwera VBR w sieci wewnętrznej.", - "error": "Ten adres IP nie wygląda na prawidłowy — sprawdź format IPv4 lub IPv6." + "error": "Ten adres IP nie wygląda na prawidłowy — sprawdź format IPv4." }, "vault_name": { "label": "Nazwa Vault", diff --git a/packages/manager/modules/backup-licenses/public/translations/order/Messages_pt_PT.json b/packages/manager/modules/backup-licenses/public/translations/order/Messages_pt_PT.json index b62a89f84bb2..7c3c2965e1aa 100644 --- a/packages/manager/modules/backup-licenses/public/translations/order/Messages_pt_PT.json +++ b/packages/manager/modules/backup-licenses/public/translations/order/Messages_pt_PT.json @@ -96,7 +96,7 @@ "label": "Endereço IP público", "placeholder": "Ex.: 185.26.17.45", "hint": "O endereço IP público do seu servidor VBR.", - "error": "Este endereço IP não parece válido — verifique o formato IPv4 ou IPv6." + "error": "Este endereço IP não parece válido — verifique o formato IPv4." }, "veeam_client_ip": { "label": "Endereço IP do cliente Veeam", @@ -107,7 +107,7 @@ "label": "Endereço IP privado (LAN)", "placeholder": "Ex.: 192.168.1.10", "hint": "Endereço IP local do seu servidor VBR na rede interna.", - "error": "Este endereço IP não parece válido — verifique o formato IPv4 ou IPv6." + "error": "Este endereço IP não parece válido — verifique o formato IPv4." }, "vault_name": { "label": "Nome do Vault", diff --git a/packages/manager/modules/backup-licenses/src/components/linked-servers/BackupServerActionsCell/BackupServerActionsCell.component.tsx b/packages/manager/modules/backup-licenses/src/components/linked-servers/BackupServerActionsCell/BackupServerActionsCell.component.tsx index 65ab614064f6..a3b5f266fb62 100644 --- a/packages/manager/modules/backup-licenses/src/components/linked-servers/BackupServerActionsCell/BackupServerActionsCell.component.tsx +++ b/packages/manager/modules/backup-licenses/src/components/linked-servers/BackupServerActionsCell/BackupServerActionsCell.component.tsx @@ -21,7 +21,7 @@ interface BackupServerActionsCellProps { backupServerId: string; /** Désactive le menu entier : opération en cours sur la ligne. */ isDisabled: boolean; - /** URN du serveur, pour le check IAM des actions « modifier »/« supprimer ». */ + /** URN de la ressource backupLicense, pour le check IAM des actions « modifier »/« supprimer ». */ urn?: string; } @@ -45,8 +45,8 @@ interface BackupServerActionsCellProps { * pleinement cliquable/navigable : désactiver le bouton intérieur ne bloque pas la navigation. * On utilise donc `onClick` + `navigate()` (jamais `href`) pour que `isDisabled` bloque *réellement* * l'action — mêmes patron que `veeam-backup` (`DatagridCell.component.tsx::ActionCell`). - * Fail-closed : tant que `urn` est absent (contrat API non confirmé, cf. API-STATUS.md) ou que - * le check est en cours/négatif, l'entrée reste désactivée — jamais de bypass permissif. + * Fail-closed : tant que `urn` est absent ou que le check est en cours/négatif, l'entrée reste + * désactivée — jamais de bypass permissif. */ export default function BackupServerActionsCell({ backupServerId, diff --git a/packages/manager/modules/backup-licenses/src/components/linked-servers/EditRecapPanel/EditRecapPanel.component.tsx b/packages/manager/modules/backup-licenses/src/components/linked-servers/EditRecapPanel/EditRecapPanel.component.tsx index 0fd6a0d26e97..5296ea05b359 100644 --- a/packages/manager/modules/backup-licenses/src/components/linked-servers/EditRecapPanel/EditRecapPanel.component.tsx +++ b/packages/manager/modules/backup-licenses/src/components/linked-servers/EditRecapPanel/EditRecapPanel.component.tsx @@ -25,7 +25,7 @@ interface EditRecapPanelProps { errorMessage: string | null; isSaving: boolean; onSave: () => void; - /** URN du serveur : accès direct par URL, donc protégé indépendamment du menu ⋮. */ + /** URN de la ressource backupLicense : accès direct par URL, donc protégé indépendamment du menu ⋮. */ urn?: string; } @@ -114,13 +114,6 @@ export default function EditRecapPanel({ )} - {/* Jamais désactivé côté validation : un clic avec formulaire invalide révèle les erreurs et - rouvre l'étape fautive (cf. EditBackupServer.page), feedback actionnable plutôt qu'un - bouton grisé silencieux — même parti pris que le CTA du tunnel de commande. `ManagerButton` - y ajoute un check IAM indépendant du menu ⋮ : protège aussi l'accès direct par URL. - Fail-closed explicite (`!urn`) : par défaut, `ManagerButton` autorise l'action tant que - `urn` est absent (bypass silencieux, cf. son code) — inacceptable ici, donc on force la - désactivation nous-mêmes tant que le contrat API ne garantit pas ce champ. */} { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('poste le corps sur la route backupLicenses du tenant VSPC résolu', async () => { - const body: CreateBackupLicenseBody = { - displayName: 'backup-prod', - licenseType: - 'VEEAM_BACKUP_REPLICATION_ENTERPRISE_PLUS' as CreateBackupLicenseBody['licenseType'], - backupServerExternalIp: '185.26.17.45', - }; - const response = { id: 'server-1' } as BackupServerResource; - mockedPostJSON.mockResolvedValue(response); - - const result = await createBackupLicense({ - backupServicesId: 'service-1', - vspcTenantId: 'vspc-1', - body, - }); - - expect(mockedPostJSON).toHaveBeenCalledWith( - 'v2', - getBackupLicensesRoute('service-1', 'vspc-1'), - body, - ); - expect(result).toBe(response); - }); -}); diff --git a/packages/manager/modules/backup-licenses/src/data/api/backupLicenses/backupLicenses.requests.ts b/packages/manager/modules/backup-licenses/src/data/api/backupLicenses/backupLicenses.requests.ts index 100038df5cde..bc4b82f2c9d0 100644 --- a/packages/manager/modules/backup-licenses/src/data/api/backupLicenses/backupLicenses.requests.ts +++ b/packages/manager/modules/backup-licenses/src/data/api/backupLicenses/backupLicenses.requests.ts @@ -1,8 +1,6 @@ import { fetchIcebergV2 } from '@ovh-ux/manager-core-api'; -import { postJSON } from '@/data/api/Client.api'; -import { BackupLicenseResource, CreateBackupLicenseParams } from '@/types/BackupLicense.type'; -import { BackupServerResource } from '@/types/BackupServer.type'; +import { BackupLicenseResource } from '@/types/BackupLicense.type'; import { getBackupLicensesRoute } from '@/utils/apiRoutes/apiRoutes'; export type GetBackupLicensesParams = { @@ -19,15 +17,3 @@ export const getBackupLicenses = async ({ }); return data; }; - -/** Ajout d'un serveur VBR supplémentaire (BKP-1217) : le vault existe déjà, on ne crée que la licence/serveur. */ -export const createBackupLicense = ({ - backupServicesId, - vspcTenantId, - body, -}: CreateBackupLicenseParams): Promise => - postJSON( - 'v2', - getBackupLicensesRoute(backupServicesId, vspcTenantId), - body, - ); diff --git a/packages/manager/modules/backup-licenses/src/data/api/backupServers/backupServers.requests.spec.ts b/packages/manager/modules/backup-licenses/src/data/api/backupServers/backupServers.requests.spec.ts index d9d772dfbc75..922e1725ab51 100644 --- a/packages/manager/modules/backup-licenses/src/data/api/backupServers/backupServers.requests.spec.ts +++ b/packages/manager/modules/backup-licenses/src/data/api/backupServers/backupServers.requests.spec.ts @@ -6,9 +6,10 @@ import { v2 } from '@ovh-ux/manager-core-api'; import { BackupServerResource } from '@/types/BackupServer.type'; import { getBackupServersRoute } from '@/utils/apiRoutes/apiRoutes'; -import { getBackupServers } from './backupServers.requests'; +import { createBackupServer, getBackupServers } from './backupServers.requests'; type MockableGet = Pick; +type MockablePost = Pick; describe('getBackupServers', () => { const mockGet = vi.fn(); @@ -46,3 +47,33 @@ describe('getBackupServers', () => { await expect(getBackupServers(params)).rejects.toThrow('boom'); }); }); + +describe('createBackupServer', () => { + const mockPost = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + Object.assign(v2 as unknown as MockablePost, { post: mockPost }); + }); + + it('posts the payload to the backupServer route, without the scope ids', async () => { + mockPost.mockResolvedValue({ data: null }); + + await createBackupServer({ + backupServicesId: 'service-1', + vspcTenantId: 'vspc-1', + backupLicensesId: 'license-1', + displayName: 'VBR-CUST-SERV-02', + licenseType: 'VEEAM_BACKUP_REPLICATION_ENTERPRISE_PLUS', + externalIps: ['203.0.113.10'], + privateIps: ['192.168.10.2'], + }); + + expect(mockPost).toHaveBeenCalledWith(getBackupServersRoute('service-1', 'vspc-1', 'license-1'), { + displayName: 'VBR-CUST-SERV-02', + licenseType: 'VEEAM_BACKUP_REPLICATION_ENTERPRISE_PLUS', + externalIps: ['203.0.113.10'], + privateIps: ['192.168.10.2'], + }); + }); +}); diff --git a/packages/manager/modules/backup-licenses/src/data/api/backupServers/backupServers.requests.ts b/packages/manager/modules/backup-licenses/src/data/api/backupServers/backupServers.requests.ts index f5cdaf5fd780..c4e430c5f58f 100644 --- a/packages/manager/modules/backup-licenses/src/data/api/backupServers/backupServers.requests.ts +++ b/packages/manager/modules/backup-licenses/src/data/api/backupServers/backupServers.requests.ts @@ -19,6 +19,13 @@ export type EditBackupServerParams = GetBackupServersParams & { privateIps: string[]; }; +export type CreateBackupServerParams = GetBackupServersParams & { + displayName: string; + licenseType: string; + externalIps: string[]; + privateIps: string[]; +}; + export type DeleteBackupServerParams = GetBackupServersParams & { backupServerId: string; }; @@ -41,6 +48,22 @@ export const getBackupServers = async ({ } }; +/** + * Ajout d'un serveur VBR supplémentaire sur un vault déjà provisionné (BKP-1217) : un simple + * POST sur la ressource `backupServer`, plus de panier Agora pour ce flux. + */ +export const createBackupServer = async ({ + backupServicesId, + vspcTenantId, + backupLicensesId, + ...payload +}: CreateBackupServerParams): Promise => { + await v2.post( + getBackupServersRoute(backupServicesId, vspcTenantId, backupLicensesId), + payload, + ); +}; + /** * Édition d'un serveur VBR (BKP-1218). Nom et IP sont appliqués immédiatement ; un changement * de `licenseType` est différé au 1er du mois suivant côté backend — le front envoie la cible diff --git a/packages/manager/modules/backup-licenses/src/data/api/services/consumption.requests.ts b/packages/manager/modules/backup-licenses/src/data/api/services/consumption.requests.ts index 070e931633d5..e90a032b3a4e 100644 --- a/packages/manager/modules/backup-licenses/src/data/api/services/consumption.requests.ts +++ b/packages/manager/modules/backup-licenses/src/data/api/services/consumption.requests.ts @@ -1,6 +1,6 @@ import { v6 } from '@ovh-ux/manager-core-api'; -import { ServiceConsumption } from '@/types/Consumption.type'; +import { LicenseConsumption, ServiceConsumption } from '@/types/Consumption.type'; import { getLicenseConsumptionRoute, getServiceConsumptionRoute, @@ -13,7 +13,7 @@ export const getServiceConsumption = async (serviceId: string): Promise => { - const { data } = await v6.get(getLicenseConsumptionRoute(serviceId)); +export const getLicenseConsumption = async (serviceId: string): Promise => { + const { data } = await v6.get(getLicenseConsumptionRoute(serviceId)); return data; }; diff --git a/packages/manager/modules/backup-licenses/src/data/hooks/usePrepareBackupLicensesCart/usePrepareBackupLicensesCart.spec.tsx b/packages/manager/modules/backup-licenses/src/data/hooks/usePrepareBackupLicensesCart/usePrepareBackupLicensesCart.spec.tsx index 0232644f95b0..15ecdf455a50 100644 --- a/packages/manager/modules/backup-licenses/src/data/hooks/usePrepareBackupLicensesCart/usePrepareBackupLicensesCart.spec.tsx +++ b/packages/manager/modules/backup-licenses/src/data/hooks/usePrepareBackupLicensesCart/usePrepareBackupLicensesCart.spec.tsx @@ -181,7 +181,7 @@ describe('usePrepareBackupLicensesCart', () => { expect(answered).toHaveLength(12); expect(answered).toEqual( expect.arrayContaining([ - { label: 'backupserver-public-ip', value: '203.0.113.10' }, + { label: 'backupserver-public-ip', value: '203.0.113.10/32' }, { label: 'license-type', value: LicenseApiValue.VDP_PREMIUM }, { label: 'vault-azname', value: 'eu-west-par' }, ]), @@ -226,7 +226,7 @@ describe('usePrepareBackupLicensesCart', () => { const emitted = await resolveApiRequests(requests); expect(configurations(emitted)).toEqual( - expect.arrayContaining([{ label: 'backupserver-private-ip', value: '192.168.1.10' }]), + expect.arrayContaining([{ label: 'backupserver-private-ip', value: '192.168.1.10/32' }]), ); }); @@ -319,10 +319,10 @@ describe('usePrepareBackupLicensesCart, with an existing backup-tenant', () => { expect(sequence).toEqual([ '/order/cart', + `/order/cart/${MOCK_CART_ID}/assign`, `/order/cartServiceOption/backupServices/${serviceName}`, `/order/cart/${MOCK_CART_ID}/backupServices/options`, `/order/cartServiceOption/backupServices/${serviceName}`, - `/order/cart/${MOCK_CART_ID}/assign`, ]); }); diff --git a/packages/manager/modules/backup-licenses/src/data/hooks/usePrepareBackupLicensesCart/usePrepareBackupLicensesCart.ts b/packages/manager/modules/backup-licenses/src/data/hooks/usePrepareBackupLicensesCart/usePrepareBackupLicensesCart.ts index 432781a87212..4656d74f216f 100644 --- a/packages/manager/modules/backup-licenses/src/data/hooks/usePrepareBackupLicensesCart/usePrepareBackupLicensesCart.ts +++ b/packages/manager/modules/backup-licenses/src/data/hooks/usePrepareBackupLicensesCart/usePrepareBackupLicensesCart.ts @@ -87,6 +87,8 @@ export const prepareBackupLicensesCart = async ({ const { cartId } = await createOrderCart(ovhSubsidiary); if (existingTenantServiceName) { + await assignOrderCart(cartId); + for (const node of product.options) { const resolved = await discoverBackupServicesServiceOrderParameters( cartId, @@ -103,10 +105,9 @@ export const prepareBackupLicensesCart = async ({ const { itemId } = await addBackupServicesCartItem(cartId, productParameters); await configureCartItemFromRequirements(cartId, itemId, configurationValues); await addOrderNodeOptions(cartId, itemId, options, configurationValues); + await assignOrderCart(cartId); } - await assignOrderCart(cartId); - const { contracts } = await getOrderCartCheckout(cartId); return { cartId, contractList: contracts }; diff --git a/packages/manager/modules/backup-licenses/src/data/queries/billing.queries.spec.ts b/packages/manager/modules/backup-licenses/src/data/queries/billing.queries.spec.ts index 108bd93e877e..32e3910c6dc4 100644 --- a/packages/manager/modules/backup-licenses/src/data/queries/billing.queries.spec.ts +++ b/packages/manager/modules/backup-licenses/src/data/queries/billing.queries.spec.ts @@ -11,7 +11,7 @@ import { getVaults } from '@/data/api/vaults/vaults.requests'; import { buildBackupLicensesVspcTenant } from '@/mocks/tenants/tenants.mock'; import { BackupLicenseResource } from '@/types/BackupLicense.type'; import { BackupServicesTenant } from '@/types/BackupServicesTenant.type'; -import { ServiceConsumption } from '@/types/Consumption.type'; +import { LicenseConsumption, ServiceConsumption } from '@/types/Consumption.type'; import { Resource } from '@/types/Resource.type'; import { VaultResource } from '@/types/Vault.type'; @@ -65,6 +65,16 @@ const buildConsumption = ( uniqueId: null, }); +const buildLicenseConsumption = (priceText: string): LicenseConsumption => ({ + beginDate: '2026-07-01T00:00:00Z', + endDate: '2026-07-31T23:59:59Z', + id: 1, + orderId: null, + price: { currencyCode: 'EUR', text: priceText, value: 0 }, + priceByPlanFamily: [{ planFamily: 'backup', price: { currencyCode: 'EUR', text: priceText, value: 0 } }], + serviceId: 1, +}); + describe('billingQueries', () => { let queryClient: QueryClient; @@ -90,9 +100,7 @@ describe('billingQueries', () => { vi.mocked(getServiceConsumption).mockResolvedValue([ buildConsumption('backup-vault-backuplicenses-paygo-consumption', 7, '0,05 €'), ]); - vi.mocked(getLicenseConsumption).mockResolvedValue([ - buildConsumption('backup-license-backuplicenses-foundation', 1, '4,90 €'), - ]); + vi.mocked(getLicenseConsumption).mockResolvedValue(buildLicenseConsumption('4,90 €')); const { vaultRows, licenseRows } = await fetchRows(); @@ -149,9 +157,7 @@ describe('billingQueries', () => { vi.mocked(getServiceConsumption).mockResolvedValue([ buildConsumption('backup-vault-backuplicenses-paygo-consumption', 7, '0,05 €'), ]); - vi.mocked(getLicenseConsumption).mockResolvedValue([ - buildConsumption('backup-license-backuplicenses-foundation', 1, '4,90 €'), - ]); + vi.mocked(getLicenseConsumption).mockResolvedValue(buildLicenseConsumption('4,90 €')); const { vaultRows, licenseRows } = await fetchRows(); diff --git a/packages/manager/modules/backup-licenses/src/data/queries/billing.queries.ts b/packages/manager/modules/backup-licenses/src/data/queries/billing.queries.ts index 5a0595591cd6..3c01f8b14e27 100644 --- a/packages/manager/modules/backup-licenses/src/data/queries/billing.queries.ts +++ b/packages/manager/modules/backup-licenses/src/data/queries/billing.queries.ts @@ -48,7 +48,7 @@ const resolveVaultStorageConsumption = async ( const resolveLicensePrice = async (license: BackupLicenseResource) => { const serviceId = await resolveServiceId(license.id); - return (await getLicenseConsumption(serviceId))[0]; + return getLicenseConsumption(serviceId); }; const buildVaultRow = async ( diff --git a/packages/manager/modules/backup-licenses/src/hooks/useAddServerForm/useAddServerForm.ts b/packages/manager/modules/backup-licenses/src/hooks/useAddServerForm/useAddServerForm.ts index 6734a0e772ad..5a6635d64928 100644 --- a/packages/manager/modules/backup-licenses/src/hooks/useAddServerForm/useAddServerForm.ts +++ b/packages/manager/modules/backup-licenses/src/hooks/useAddServerForm/useAddServerForm.ts @@ -3,7 +3,7 @@ import { useCallback, useMemo, useState } from 'react'; import { LICENSE_CARDS, VDP_TIER_CARDS } from '@/data/licenses.data'; import { useStep } from '@/hooks/useStep/useStep'; import { LicenseApiValue, LicenseFamily, VdpTier } from '@/types/Order.type'; -import { isValidIp } from '@/utils/isValidIp/isValidIp'; +import { isValidIpv4 } from '@/utils/isValidIp/isValidIp'; export type AddServerFieldName = | 'displayName' @@ -48,8 +48,8 @@ function isLicenseValidFor(family: LicenseFamily | null, tier: VdpTier | null): function isServerValidFor(form: AddServerFormState): boolean { const nameOk = form.displayName.trim() !== ''; - const externalOk = isValidIp(form.backupServerExternalIp); - const privateOk = !form.isBehindNat || isValidIp(form.backupServerPrivateIp); + const externalOk = isValidIpv4(form.backupServerExternalIp); + const privateOk = !form.isBehindNat || isValidIpv4(form.backupServerPrivateIp); return nameOk && externalOk && privateOk; } @@ -106,7 +106,7 @@ export function useAddServerForm() { show(field) && value.trim() === '' ? key : null; const ipError = (field: AddServerFieldName, value: string, key: string) => { const trimmed = value.trim(); - if (trimmed !== '' && !isValidIp(trimmed)) return key; + if (trimmed !== '' && !isValidIpv4(trimmed)) return key; if (show(field) && trimmed === '') return key; return null; }; @@ -129,8 +129,8 @@ export function useAddServerForm() { // pour que le CTA final puisse amener l'utilisateur droit au champ à corriger. const firstInvalidField = useMemo(() => { if (form.displayName.trim() === '') return 'displayName'; - if (!isValidIp(form.backupServerExternalIp)) return 'backupServerExternalIp'; - if (form.isBehindNat && !isValidIp(form.backupServerPrivateIp)) { + if (!isValidIpv4(form.backupServerExternalIp)) return 'backupServerExternalIp'; + if (form.isBehindNat && !isValidIpv4(form.backupServerPrivateIp)) { return 'backupServerPrivateIp'; } return null; diff --git a/packages/manager/modules/backup-licenses/src/hooks/useCreateBackupLicense/useCreateBackupLicense.spec.tsx b/packages/manager/modules/backup-licenses/src/hooks/useCreateBackupLicense/useCreateBackupLicense.spec.tsx index d661291f2b35..6a9497fbd614 100644 --- a/packages/manager/modules/backup-licenses/src/hooks/useCreateBackupLicense/useCreateBackupLicense.spec.tsx +++ b/packages/manager/modules/backup-licenses/src/hooks/useCreateBackupLicense/useCreateBackupLicense.spec.tsx @@ -1,94 +1,105 @@ import React from 'react'; -import { QueryClientProvider } from '@tanstack/react-query'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createBackupLicense } from '@/data/api/backupLicenses/backupLicenses.requests'; +import { getBackupLicenses } from '@/data/api/backupLicenses/backupLicenses.requests'; +import { createBackupServer } from '@/data/api/backupServers/backupServers.requests'; import { getBackupServicesTenants, getVspcTenants } from '@/data/api/tenants/tenants.requests'; import { queryKeys } from '@/data/queries/queryKeys'; +import { mockBackupLicenses } from '@/mocks/backupLicenses/backupLicenses.mock'; import { buildBackupLicensesVspcTenant } from '@/mocks/tenants/tenants.mock'; -import { createQueryClientTest } from '@/test-utils/renderWithProviders'; -import { BackupServerResource } from '@/types/BackupServer.type'; +import { CreateBackupLicenseBody } from '@/types/BackupLicense.type'; import { BackupServicesTenant } from '@/types/BackupServicesTenant.type'; import { LicenseApiValue } from '@/types/Order.type'; import { Resource } from '@/types/Resource.type'; import { useCreateBackupLicense } from './useCreateBackupLicense'; -vi.mock('@/data/api/backupLicenses/backupLicenses.requests'); +vi.mock('@/data/api/backupServers/backupServers.requests'); vi.mock('@/data/api/tenants/tenants.requests'); +vi.mock('@/data/api/backupLicenses/backupLicenses.requests'); -const mockedCreateBackupLicense = vi.mocked(createBackupLicense); -const mockedGetBackupServicesTenants = vi.mocked(getBackupServicesTenants); -const mockedGetVspcTenants = vi.mocked(getVspcTenants); - -const buildResource = (id: string, currentState: T): Resource => ({ - id, - resourceStatus: 'READY', - currentState, -}); +const mockedCreateBackupServer = vi.mocked(createBackupServer); -const renderUseCreateBackupLicense = () => { - const queryClient = createQueryClientTest(); - const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); +const body: CreateBackupLicenseBody = { + displayName: 'backup-prod', + licenseType: LicenseApiValue.ENTERPRISE_PLUS, + externalIps: ['185.26.17.45'], + privateIps: [], +}; - const hook = renderHook(() => useCreateBackupLicense(), { +const renderCreateHook = ( + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }), +) => ({ + queryClient, + ...renderHook(() => useCreateBackupLicense(), { wrapper: ({ children }: React.PropsWithChildren) => ( {children} ), - }); - - return { ...hook, invalidateSpy }; -}; + }), +}); describe('useCreateBackupLicense', () => { beforeEach(() => { vi.clearAllMocks(); - mockedGetBackupServicesTenants.mockResolvedValue([ - buildResource('service-1', { id: 'service-1', name: 'service' }), + mockedCreateBackupServer.mockResolvedValue(undefined); + vi.mocked(getBackupServicesTenants).mockResolvedValue([ + { + id: 'service-1', + resourceStatus: 'READY', + currentState: { id: 'service-1', name: 'service' }, + } as Resource, ]); - mockedGetVspcTenants.mockResolvedValue([buildBackupLicensesVspcTenant('vspc-1')]); + vi.mocked(getVspcTenants).mockResolvedValue([buildBackupLicensesVspcTenant('vspc-1')]); + vi.mocked(getBackupLicenses).mockResolvedValue(mockBackupLicenses); }); - it('résout la cascade backupServicesId → vspcTenantId puis crée la licence, et invalide la liste des serveurs', async () => { - mockedCreateBackupLicense.mockResolvedValue({} as BackupServerResource); - const { result, invalidateSpy } = renderUseCreateBackupLicense(); + it('creates the server with the ids resolved by the cascade and the submitted body', async () => { + const { result } = renderCreateHook(); - result.current.mutate({ - displayName: 'backup-prod', - licenseType: LicenseApiValue.ENTERPRISE_PLUS, - backupServerExternalIp: '185.26.17.45', - }); + result.current.mutate(body); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(mockedGetVspcTenants).toHaveBeenCalledWith('service-1'); - expect(mockedCreateBackupLicense).toHaveBeenCalledWith({ + expect(mockedCreateBackupServer).toHaveBeenCalledWith({ backupServicesId: 'service-1', vspcTenantId: 'vspc-1', - body: { - displayName: 'backup-prod', - licenseType: LicenseApiValue.ENTERPRISE_PLUS, - backupServerExternalIp: '185.26.17.45', - }, + backupLicensesId: mockBackupLicenses[0]!.id, + ...body, }); + }); + + it('invalidates the backup servers list once the server has been added', async () => { + const { queryClient, result } = renderCreateHook(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + result.current.mutate(body); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.backupServers.all() }); }); - it("remonte l'erreur sans invalider la liste quand la cascade échoue", async () => { - mockedGetBackupServicesTenants.mockResolvedValue([]); - const { result, invalidateSpy } = renderUseCreateBackupLicense(); + it('does not invalidate the list when the creation fails', async () => { + mockedCreateBackupServer.mockRejectedValue(new Error('boom')); + const { queryClient, result } = renderCreateHook(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); - result.current.mutate({ - displayName: 'backup-prod', - licenseType: LicenseApiValue.ENTERPRISE_PLUS, - backupServerExternalIp: '185.26.17.45', - }); + result.current.mutate(body); await waitFor(() => expect(result.current.isError).toBe(true)); - - expect(mockedCreateBackupLicense).not.toHaveBeenCalled(); expect(invalidateSpy).not.toHaveBeenCalled(); }); + + it('fails without calling the API when no service is found', async () => { + vi.mocked(getBackupServicesTenants).mockResolvedValue([]); + const { result } = renderCreateHook(); + + result.current.mutate(body); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(mockedCreateBackupServer).not.toHaveBeenCalled(); + }); }); diff --git a/packages/manager/modules/backup-licenses/src/hooks/useCreateBackupLicense/useCreateBackupLicense.ts b/packages/manager/modules/backup-licenses/src/hooks/useCreateBackupLicense/useCreateBackupLicense.ts index f9ff23a8707c..a71fa423fa9f 100644 --- a/packages/manager/modules/backup-licenses/src/hooks/useCreateBackupLicense/useCreateBackupLicense.ts +++ b/packages/manager/modules/backup-licenses/src/hooks/useCreateBackupLicense/useCreateBackupLicense.ts @@ -1,24 +1,26 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { createBackupLicense } from '@/data/api/backupLicenses/backupLicenses.requests'; +import { createBackupServer } from '@/data/api/backupServers/backupServers.requests'; +import { backupLicenseQueries } from '@/data/queries/backupLicense.queries'; import { queryKeys } from '@/data/queries/queryKeys'; import { tenantsQueries } from '@/data/queries/tenants.queries'; import { CreateBackupLicenseBody } from '@/types/BackupLicense.type'; /** - * Ajout d'un serveur VBR supplémentaire sur un vault déjà provisionné (BKP-1217) : - * résout la cascade backupServicesId → vspcTenantId (déjà en cache la plupart du - * temps, la liste des serveurs l'ayant chargée) puis crée la licence/serveur. + * Ajout d'un serveur VBR supplémentaire (BKP-1217), sur le modèle de `useEditBackupServer` : les + * identifiants de service et de tenant VSPC ne sont pas dans l'URL, ils sont résolus par la + * cascade de queries au moment de la mutation. */ export function useCreateBackupLicense() { const queryClient = useQueryClient(); - const tenants = tenantsQueries.withClient(queryClient); return useMutation({ mutationFn: async (body: CreateBackupLicenseBody) => { + const tenants = tenantsQueries.withClient(queryClient); const backupServicesId = await tenants.backupServicesId(); const vspcTenantId = await tenants.vspcTenantId(); - return createBackupLicense({ backupServicesId, vspcTenantId, body }); + const backupLicensesId = await backupLicenseQueries.withClient(queryClient).id(); + return createBackupServer({ backupServicesId, vspcTenantId, backupLicensesId, ...body }); }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.backupServers.all() }); diff --git a/packages/manager/modules/backup-licenses/src/hooks/useLinkedServersColumns/useLinkedServersColumns.tsx b/packages/manager/modules/backup-licenses/src/hooks/useLinkedServersColumns/useLinkedServersColumns.tsx index e750cfffba19..f0c6c13b9a06 100644 --- a/packages/manager/modules/backup-licenses/src/hooks/useLinkedServersColumns/useLinkedServersColumns.tsx +++ b/packages/manager/modules/backup-licenses/src/hooks/useLinkedServersColumns/useLinkedServersColumns.tsx @@ -9,6 +9,7 @@ import LicenseStatusCell from '@/components/linked-servers/LicenseStatusCell/Lic import LicenseTypeCell from '@/components/linked-servers/LicenseTypeCell/LicenseTypeCell.component'; import OsTypeCell from '@/components/linked-servers/OsTypeCell/OsTypeCell.component'; import ServerIpsCell from '@/components/linked-servers/ServerIpsCell/ServerIpsCell.component'; +import { useBackupLicenseUrn } from '@/hooks/useBackupLicenseUrn/useBackupLicenseUrn'; import { BACKUP_LICENSES_NAMESPACES, EMPTY_VALUE_PLACEHOLDER } from '@/module.constants'; import { BackupServerResource } from '@/types/BackupServer.type'; import { @@ -24,6 +25,7 @@ import { */ export const useLinkedServersColumns = (): DatagridColumn[] => { const { t } = useTranslation(BACKUP_LICENSES_NAMESPACES.LINKED_SERVERS); + const backupLicenseUrn = useBackupLicenseUrn(); return useMemo( () => [ @@ -96,11 +98,11 @@ export const useLinkedServersColumns = (): DatagridColumn[ ), }, ], - [t], + [t, backupLicenseUrn], ); }; diff --git a/packages/manager/modules/backup-licenses/src/hooks/useOrderForm/useOrderForm.ts b/packages/manager/modules/backup-licenses/src/hooks/useOrderForm/useOrderForm.ts index 02f52fe0cbf6..ab8ec7d4e8f4 100644 --- a/packages/manager/modules/backup-licenses/src/hooks/useOrderForm/useOrderForm.ts +++ b/packages/manager/modules/backup-licenses/src/hooks/useOrderForm/useOrderForm.ts @@ -9,7 +9,7 @@ import { ServerVaultFormState, VdpTier, } from '@/types/Order.type'; -import { isValidIp } from '@/utils/isValidIp/isValidIp'; +import { isValidIpv4 } from '@/utils/isValidIp/isValidIp'; import { PersistedOrderState, @@ -52,8 +52,8 @@ function isLicenseValidFor(family: LicenseFamily | null, tier: VdpTier | null): function isServerVaultFieldsValidFor(form: ServerVaultFormState): boolean { const nameOk = form.displayName.trim() !== ''; - const externalOk = isValidIp(form.backupServerExternalIp); - const privateOk = !form.isBehindNat || isValidIp(form.backupServerPrivateIp); + const externalOk = isValidIpv4(form.backupServerExternalIp); + const privateOk = !form.isBehindNat || isValidIpv4(form.backupServerPrivateIp); const vaultOk = form.vaultDisplayName.trim() !== ''; return nameOk && externalOk && privateOk && vaultOk; } @@ -167,7 +167,7 @@ export function useOrderForm({ frozenState = null, isFrozen = false }: UseOrderF show(field) && value.trim() === '' ? key : null; const ipError = (field: OrderFieldName, value: string, key: string) => { const trimmed = value.trim(); - if (trimmed !== '' && !isValidIp(trimmed)) return key; + if (trimmed !== '' && !isValidIpv4(trimmed)) return key; if (show(field) && trimmed === '') return key; return null; }; @@ -196,8 +196,8 @@ export function useOrderForm({ frozenState = null, isFrozen = false }: UseOrderF // l'utilisateur droit au champ à corriger. const firstInvalidField = useMemo(() => { if (form.displayName.trim() === '') return 'displayName'; - if (!isValidIp(form.backupServerExternalIp)) return 'backupServerExternalIp'; - if (form.isBehindNat && !isValidIp(form.backupServerPrivateIp)) { + if (!isValidIpv4(form.backupServerExternalIp)) return 'backupServerExternalIp'; + if (form.isBehindNat && !isValidIpv4(form.backupServerPrivateIp)) { return 'backupServerPrivateIp'; } if (form.vaultDisplayName.trim() === '') return 'vaultDisplayName'; diff --git a/packages/manager/modules/backup-licenses/src/mocks/backupLicenses/backupLicenses.handler.ts b/packages/manager/modules/backup-licenses/src/mocks/backupLicenses/backupLicenses.handler.ts index ba5e2ab82d99..722df30dec33 100644 --- a/packages/manager/modules/backup-licenses/src/mocks/backupLicenses/backupLicenses.handler.ts +++ b/packages/manager/modules/backup-licenses/src/mocks/backupLicenses/backupLicenses.handler.ts @@ -1,10 +1,7 @@ import { Handler } from '@ovh-ux/manager-core-test-utils'; -import { - mockBackupLicenses, - mockCreateBackupLicense, -} from '@/mocks/backupLicenses/backupLicenses.mock'; -import { BackupLicenseResource, CreateBackupLicenseBody } from '@/types/BackupLicense.type'; +import { mockBackupLicenses } from '@/mocks/backupLicenses/backupLicenses.mock'; +import { BackupLicenseResource } from '@/types/BackupLicense.type'; export type TBackupLicenseMockParams = { backupLicenses?: BackupLicenseResource[]; @@ -27,12 +24,4 @@ export const getBackupLicenseMocks = ({ status: isBackupLicensesError ? 500 : 200, delay: 0, }, - { - url: '/backupServices/tenant/:backupServicesId/vspc/:vspcTenantId/backupLicenses', - response: () => mockCreateBackupLicense({} as CreateBackupLicenseBody), - api: 'v2', - method: 'post', - status: 200, - delay: 0, - }, ]; diff --git a/packages/manager/modules/backup-licenses/src/mocks/backupLicenses/backupLicenses.mock.ts b/packages/manager/modules/backup-licenses/src/mocks/backupLicenses/backupLicenses.mock.ts index 26b6f2960a1f..a175e56b4970 100644 --- a/packages/manager/modules/backup-licenses/src/mocks/backupLicenses/backupLicenses.mock.ts +++ b/packages/manager/modules/backup-licenses/src/mocks/backupLicenses/backupLicenses.mock.ts @@ -1,5 +1,4 @@ -import { BackupLicenseResource, CreateBackupLicenseBody } from '@/types/BackupLicense.type'; -import { BackupServerResource, LicenseStatus } from '@/types/BackupServer.type'; +import { BackupLicenseResource } from '@/types/BackupLicense.type'; /** Jeux de données de développement. */ /** `id` sert de `resourceName` que la cascade résout : nom de service côté `/services` et côté Agora. */ @@ -33,22 +32,3 @@ export const mockBackupLicenses: BackupLicenseResource[] = [ currentState: { id: 'license-3' }, }, ]; - -/** Réponse simulée du POST de création : reprend les champs saisis. */ -export const mockCreateBackupLicense = (body: CreateBackupLicenseBody): BackupServerResource => { - const id = `mock-backup-server-${Date.now()}`; - - return { - id, - status: 'CREATING', - currentTasks: [], - currentState: { - id, - displayName: body.displayName, - externalIps: body.backupServerExternalIp.split(';').filter(Boolean), - privateIps: body.backupServerPrivateIp, - licenseTypeRequested: body.licenseType, - licenseStatus: LicenseStatus.CREATING, - }, - }; -}; diff --git a/packages/manager/modules/backup-licenses/src/mocks/backupServers/backupServers.handler.ts b/packages/manager/modules/backup-licenses/src/mocks/backupServers/backupServers.handler.ts index 0d8970283438..033f58d415bc 100644 --- a/packages/manager/modules/backup-licenses/src/mocks/backupServers/backupServers.handler.ts +++ b/packages/manager/modules/backup-licenses/src/mocks/backupServers/backupServers.handler.ts @@ -6,6 +6,7 @@ import { BackupServerResource } from '@/types/BackupServer.type'; export type TBackupServersMockParams = { backupServers?: BackupServerResource[]; isBackupServersError?: boolean; + isCreateBackupServerError?: boolean; isEditBackupServerError?: boolean; isDeleteBackupServerError?: boolean; }; @@ -16,6 +17,7 @@ const BACKUP_SERVERS_URL = export const getBackupServersMocks = ({ backupServers, isBackupServersError, + isCreateBackupServerError, isEditBackupServerError, isDeleteBackupServerError, }: TBackupServersMockParams): Handler[] => [ @@ -27,6 +29,14 @@ export const getBackupServersMocks = ({ status: isBackupServersError ? 500 : 200, delay: 0, }, + { + url: BACKUP_SERVERS_URL, + response: () => null, + api: 'v2', + method: 'post', + status: isCreateBackupServerError ? 500 : 201, + delay: 0, + }, { url: `${BACKUP_SERVERS_URL}/:backupServerId`, response: () => null, diff --git a/packages/manager/modules/backup-licenses/src/mocks/consumptions/consumptions.mock.ts b/packages/manager/modules/backup-licenses/src/mocks/consumptions/consumptions.mock.ts index d30cb7b0b547..17a696270b2c 100644 --- a/packages/manager/modules/backup-licenses/src/mocks/consumptions/consumptions.mock.ts +++ b/packages/manager/modules/backup-licenses/src/mocks/consumptions/consumptions.mock.ts @@ -4,7 +4,7 @@ * `resourceName` du vault/de la licence — les jeux de test n'ont pas de service Agora réel * à résoudre. À supprimer une fois l'endpoint déployé (cf. §15 de la spec). */ -import { ServiceConsumption } from '@/types/Consumption.type'; +import { LicenseConsumption, ServiceConsumption } from '@/types/Consumption.type'; const PERIOD = { beginDate: '2026-07-01T00:00:00Z', endDate: '2026-07-31T23:59:59Z' }; @@ -44,38 +44,35 @@ export const mockStorageConsumptions: Record = { ], }; -export const mockLicenseConsumptions: Record = { - 'license-1': [ - { - ...PERIOD, - pricingMode: 'consumption', - quantity: 1, - planCode: 'backup-license-backuplicenses-foundation', - planFamily: 'backup', - price: { currencyCode: 'EUR', text: '4,90 €', value: 490000000 }, - uniqueId: null, - }, - ], - 'license-2': [ - { - ...PERIOD, - pricingMode: 'consumption', - quantity: 1, - planCode: 'backup-license-backuplicenses-advanced', - planFamily: 'backup', - price: { currencyCode: 'EUR', text: '9,90 €', value: 990000000 }, - uniqueId: null, - }, - ], - 'license-3': [ - { - ...PERIOD, - pricingMode: 'consumption', - quantity: 1, - planCode: 'backup-license-backuplicenses-premium', - planFamily: 'backup', - price: { currencyCode: 'EUR', text: '14,90 €', value: 1490000000 }, - uniqueId: null, - }, - ], +export const mockLicenseConsumptions: Record = { + 'license-1': { + ...PERIOD, + id: 1, + orderId: null, + price: { currencyCode: 'EUR', text: '4,90 €', value: 4.9 }, + priceByPlanFamily: [ + { planFamily: 'backup', price: { currencyCode: 'EUR', text: '4,90 €', value: 4.9 } }, + ], + serviceId: 1, + }, + 'license-2': { + ...PERIOD, + id: 2, + orderId: null, + price: { currencyCode: 'EUR', text: '9,90 €', value: 9.9 }, + priceByPlanFamily: [ + { planFamily: 'backup', price: { currencyCode: 'EUR', text: '9,90 €', value: 9.9 } }, + ], + serviceId: 2, + }, + 'license-3': { + ...PERIOD, + id: 3, + orderId: null, + price: { currencyCode: 'EUR', text: '14,90 €', value: 14.9 }, + priceByPlanFamily: [ + { planFamily: 'backup', price: { currencyCode: 'EUR', text: '14,90 €', value: 14.9 } }, + ], + serviceId: 3, + }, }; diff --git a/packages/manager/modules/backup-licenses/src/pages/add-server/AddServer.page.tsx b/packages/manager/modules/backup-licenses/src/pages/add-server/AddServer.page.tsx index 94866a2a8ec8..4387262410f8 100644 --- a/packages/manager/modules/backup-licenses/src/pages/add-server/AddServer.page.tsx +++ b/packages/manager/modules/backup-licenses/src/pages/add-server/AddServer.page.tsx @@ -34,6 +34,7 @@ import { BACKUP_LICENSES_NAMESPACES, CHANGELOG_LINKS, LABELS } from '@/module.co import { routeUrls } from '@/routes/routes.constants'; import { CreateBackupLicenseBody } from '@/types/BackupLicense.type'; import { LicenseFamily } from '@/types/Order.type'; +import { toIpBlock } from '@/utils/formatIpList/formatIpList'; /** Champ de formulaire → id de l'élément DOM (cf. OrderTextField), pour le scroll-to-error. */ const FIELD_ELEMENT_IDS: Record = { @@ -92,12 +93,10 @@ export default function AddServerPage() { const body: CreateBackupLicenseBody = { displayName: form.form.displayName.trim(), licenseType: resolvedLicenseApiValue, - backupServerExternalIp: [form.form.backupServerExternalIp.trim(), form.form.veeamClientIp.trim()] + externalIps: [form.form.backupServerExternalIp.trim(), form.form.veeamClientIp.trim()] .filter(Boolean) - .join(','), - ...(form.form.isBehindNat - ? { backupServerPrivateIp: [form.form.backupServerPrivateIp.trim()] } - : {}), + .map(toIpBlock), + privateIps: form.form.isBehindNat ? [toIpBlock(form.form.backupServerPrivateIp.trim())] : [], }; createBackupLicense.mutate(body, { diff --git a/packages/manager/modules/backup-licenses/src/pages/linked-servers/delete/DeleteBackupServer.page.spec.tsx b/packages/manager/modules/backup-licenses/src/pages/linked-servers/delete/DeleteBackupServer.page.spec.tsx index 9c157a963429..11010b13dd5f 100644 --- a/packages/manager/modules/backup-licenses/src/pages/linked-servers/delete/DeleteBackupServer.page.spec.tsx +++ b/packages/manager/modules/backup-licenses/src/pages/linked-servers/delete/DeleteBackupServer.page.spec.tsx @@ -189,10 +189,8 @@ describe('DeleteBackupServerPage', () => { expect(mockedDeleteBackupServer).not.toHaveBeenCalled(); }); - it('disables the confirmation and shows the IAM warning when the server has no urn yet (fail-closed)', async () => { - // Contrat API non confirmé : le check ne doit jamais être bypassé au profit de l'autorisation - // quand `urn` est absent, même si `useAuthorizationIam` répondrait « autorisé » par défaut. - mockedGetBackupServers.mockResolvedValue([{ ...server, iam: undefined }]); + it('disables the confirmation and shows the IAM warning when the backup license has no urn yet (fail-closed)', async () => { + vi.mocked(getBackupLicenses).mockResolvedValue([mockBackupLicenses[1]!]); await renderModal(); diff --git a/packages/manager/modules/backup-licenses/src/pages/linked-servers/delete/DeleteBackupServer.page.tsx b/packages/manager/modules/backup-licenses/src/pages/linked-servers/delete/DeleteBackupServer.page.tsx index 4dacadc63a77..3fd8e12bb70a 100644 --- a/packages/manager/modules/backup-licenses/src/pages/linked-servers/delete/DeleteBackupServer.page.tsx +++ b/packages/manager/modules/backup-licenses/src/pages/linked-servers/delete/DeleteBackupServer.page.tsx @@ -13,6 +13,7 @@ import { Modal, useAuthorizationIam, useNotifications } from '@ovh-ux/manager-re import { useDeleteBackupServer } from '@/data/hooks/useDeleteBackupServer/useDeleteBackupServer'; import { backupServersQueries } from '@/data/queries/backupServers.queries'; +import { useBackupLicenseUrn } from '@/hooks/useBackupLicenseUrn/useBackupLicenseUrn'; import { BACKUP_LICENSES_IAM_RULES, BACKUP_LICENSES_NAMESPACES } from '@/module.constants'; /** @@ -27,9 +28,8 @@ import { BACKUP_LICENSES_IAM_RULES, BACKUP_LICENSES_NAMESPACES } from '@/module. * bouton primaire en `ManagerButton` (rendu en interne, hors de portée), donc pas de tooltip au * survol ici — on désactive le bouton et on affiche le même message d'avertissement à la place. * Fail-closed : `canDelete` exige un `urn` présent, un check résolu et positif — tant que l'un des - * trois manque (contrat API pas encore confirmé, requête en cours, ou droit refusé), la - * suppression reste bloquée. Jamais de bypass permissif comme le ferait `ManagerButton` par défaut - * quand `urn` est absent. + * trois manque, la suppression reste bloquée. Jamais de bypass permissif comme le ferait + * `ManagerButton` par défaut quand `urn` est absent. */ export default function DeleteBackupServerPage() { const { t } = useTranslation([ @@ -40,6 +40,7 @@ export default function DeleteBackupServerPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { addSuccess } = useNotifications(); + const backupLicenseUrn = useBackupLicenseUrn(); const { backupServerId } = useParams<{ backupServerId: string }>(); const closeModal = () => navigate('..'); @@ -55,11 +56,9 @@ export default function DeleteBackupServerPage() { const { isAuthorized: isDeleteAuthorized, isLoading: isIamCheckLoading } = useAuthorizationIam( [BACKUP_LICENSES_IAM_RULES['vspc/backupLicenses/delete']], - server?.iam?.urn ?? '', + backupLicenseUrn ?? '', ); - // Fail-closed : pas de `urn` (contrat API pas encore confirmé) ou check pas encore résolu ⇒ - // bloqué, jamais autorisé par défaut. - const canDelete = !!server?.iam?.urn && !isIamCheckLoading && isDeleteAuthorized; + const canDelete = !!backupLicenseUrn && !isIamCheckLoading && isDeleteAuthorized; const isIamBlocked = !!server && !canDelete; const { diff --git a/packages/manager/modules/backup-licenses/src/pages/linked-servers/edit/EditBackupServer.page.spec.tsx b/packages/manager/modules/backup-licenses/src/pages/linked-servers/edit/EditBackupServer.page.spec.tsx index 6c6d7af8c4ed..2c1fff41169f 100644 --- a/packages/manager/modules/backup-licenses/src/pages/linked-servers/edit/EditBackupServer.page.spec.tsx +++ b/packages/manager/modules/backup-licenses/src/pages/linked-servers/edit/EditBackupServer.page.spec.tsx @@ -318,8 +318,8 @@ describe('EditBackupServerPage', () => { expect(screen.queryByTestId('linked-servers')).not.toBeInTheDocument(); }); - it('disables the save button when the server has no urn yet (fail-closed)', async () => { - mockedGetBackupServers.mockResolvedValue([{ ...server, iam: undefined }]); + it('disables the save button when the backup license has no urn yet (fail-closed)', async () => { + vi.mocked(getBackupLicenses).mockResolvedValue([mockBackupLicenses[1]!]); await renderPage(); await waitFor(() => expect(screen.getByTestId('step-1-content')).toBeInTheDocument()); diff --git a/packages/manager/modules/backup-licenses/src/pages/linked-servers/edit/EditBackupServer.page.tsx b/packages/manager/modules/backup-licenses/src/pages/linked-servers/edit/EditBackupServer.page.tsx index b944fdaa8677..3c859aed9692 100644 --- a/packages/manager/modules/backup-licenses/src/pages/linked-servers/edit/EditBackupServer.page.tsx +++ b/packages/manager/modules/backup-licenses/src/pages/linked-servers/edit/EditBackupServer.page.tsx @@ -24,6 +24,7 @@ import EditServerFieldsStep from '@/components/linked-servers/EditServerFieldsSt import { useEditBackupServer } from '@/data/hooks/useEditBackupServer/useEditBackupServer'; import { LICENSE_CARDS, VDP_TIER_CARDS } from '@/data/licenses.data'; import { backupServersQueries } from '@/data/queries/backupServers.queries'; +import { useBackupLicenseUrn } from '@/hooks/useBackupLicenseUrn/useBackupLicenseUrn'; import { useEditBackupServerForm } from '@/hooks/useEditBackupServerForm/useEditBackupServerForm'; import { useMainGuideItem } from '@/hooks/useMainGuideItem'; import { BACKUP_LICENSES_NAMESPACES, CHANGELOG_LINKS, LABELS } from '@/module.constants'; @@ -52,6 +53,7 @@ export default function EditBackupServerPage() { const queryClient = useQueryClient(); const { addSuccess } = useNotifications(); const guideItems = useMainGuideItem(); + const backupLicenseUrn = useBackupLicenseUrn(); const { backupServerId } = useParams<{ backupServerId: string }>(); const { data: servers, isPending: areServersPending } = useQuery( @@ -237,7 +239,7 @@ export default function EditBackupServerPage() { } isSaving={isPending} onSave={handleSave} - urn={server.iam?.urn} + urn={backupLicenseUrn} /> diff --git a/packages/manager/modules/backup-licenses/src/types/BackupLicense.type.ts b/packages/manager/modules/backup-licenses/src/types/BackupLicense.type.ts index 278d935bda9a..f3bcdafcc660 100644 --- a/packages/manager/modules/backup-licenses/src/types/BackupLicense.type.ts +++ b/packages/manager/modules/backup-licenses/src/types/BackupLicense.type.ts @@ -9,20 +9,12 @@ export type BackupLicenseResource = Resource; /** * Corps du POST de création d'un serveur VBR supplémentaire sur un vault déjà - * provisionné (BKP-1217). Contrat non figé (cf. spec) — calqué sur le message - * transmis par le PO le 31/07/2026. + * provisionné (BKP-1217), directement sur `.../backupLicenses/{id}/backupServer` + * (contrat confirmé par le PO le 2026-08-21, plus de panier Agora pour ce flux). */ export interface CreateBackupLicenseBody { displayName: string; licenseType: LicenseApiValue; - /** IP publique VBR, et IP du client Veeam si renseignée, jointes par `,`. */ - backupServerExternalIp: string; - /** Omise si le serveur n'est pas derrière un NAT — pas d'IP privée à envoyer. */ - backupServerPrivateIp?: string[]; -} - -export interface CreateBackupLicenseParams { - backupServicesId: string; - vspcTenantId: string; - body: CreateBackupLicenseBody; + externalIps: string[]; + privateIps: string[]; } diff --git a/packages/manager/modules/backup-licenses/src/types/Consumption.type.ts b/packages/manager/modules/backup-licenses/src/types/Consumption.type.ts index 5661967a5645..0033a2ca0f2e 100644 --- a/packages/manager/modules/backup-licenses/src/types/Consumption.type.ts +++ b/packages/manager/modules/backup-licenses/src/types/Consumption.type.ts @@ -41,3 +41,19 @@ export type ServiceConsumption = { price: Price; uniqueId: string | null; }; + +export type PriceByPlanFamily = { + planFamily: string; + price: Price; +}; + +/** Réponse de `/services/{serviceId}/consumption` (sans `/element`) : un objet agrégé, pas un tableau. */ +export type LicenseConsumption = { + beginDate: string | null; + endDate: string | null; + id: number; + orderId: number | null; + price: Price; + priceByPlanFamily: PriceByPlanFamily[]; + serviceId: number; +}; diff --git a/packages/manager/modules/backup-licenses/src/utils/formatIpList/formatIpList.spec.ts b/packages/manager/modules/backup-licenses/src/utils/formatIpList/formatIpList.spec.ts index 46e3cd5b8194..3a988e2fcf6f 100644 --- a/packages/manager/modules/backup-licenses/src/utils/formatIpList/formatIpList.spec.ts +++ b/packages/manager/modules/backup-licenses/src/utils/formatIpList/formatIpList.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { EMPTY_VALUE_PLACEHOLDER } from '@/module.constants'; -import { firstIpWithoutMask, formatIpList, formatServerIps } from './formatIpList'; +import { firstIpWithoutMask, formatIpList, formatServerIps, toIpBlock } from './formatIpList'; describe('formatIpList', () => { it('returns the placeholder when the list is missing', () => { @@ -68,6 +68,20 @@ describe('firstIpWithoutMask', () => { }); }); +describe('toIpBlock', () => { + it('adds the /32 host mask to a bare IPv4', () => { + expect(toIpBlock('185.26.17.45')).toBe('185.26.17.45/32'); + }); + + it('leaves an IP already carrying a prefix untouched', () => { + expect(toIpBlock('203.0.113.0/24')).toBe('203.0.113.0/24'); + }); + + it('leaves an empty string untouched', () => { + expect(toIpBlock('')).toBe(''); + }); +}); + describe('formatServerIps', () => { it('shows only the public IP when no private IP is configured', () => { expect(formatServerIps(['203.0.113.10/32'])).toBe('203.0.113.10'); diff --git a/packages/manager/modules/backup-licenses/src/utils/formatIpList/formatIpList.ts b/packages/manager/modules/backup-licenses/src/utils/formatIpList/formatIpList.ts index 36cbcecd4af1..13dd04b99936 100644 --- a/packages/manager/modules/backup-licenses/src/utils/formatIpList/formatIpList.ts +++ b/packages/manager/modules/backup-licenses/src/utils/formatIpList/formatIpList.ts @@ -34,6 +34,18 @@ export const firstIpWithoutMask = (ips?: string[]): string => { return first ? stripHostPrefix(first) : ''; }; +/** + * Inverse de `stripHostPrefix` : l'API type ces champs `ipBlock`, une IP hôte seule sans + * masque (`185.26.17.45`) est rejetée (`is not valid for type ipBlock`). Laisser l'utilisateur + * saisir lui-même le masque serait risqué (il pourrait entrer une plage plus large que sa + * propre IP) : le formulaire n'accepte que de l'IPv4 hôte unique, le masque `/32` est donc + * toujours ajouté ici, jamais par l'utilisateur. + */ +export const toIpBlock = (ip: string): string => { + if (!ip || ip.includes('/')) return ip; + return `${ip}${IPV4_HOST_PREFIX}`; +}; + /** * Formate les IP publiques et privées d'un serveur dans une seule colonne * (`public - private`). Sans NAT, aucune IP privée n'est configurée : on diff --git a/packages/manager/modules/backup-licenses/src/utils/orderComposition/orderComposition.spec.ts b/packages/manager/modules/backup-licenses/src/utils/orderComposition/orderComposition.spec.ts index 29521d820261..f420392b1f65 100644 --- a/packages/manager/modules/backup-licenses/src/utils/orderComposition/orderComposition.spec.ts +++ b/packages/manager/modules/backup-licenses/src/utils/orderComposition/orderComposition.spec.ts @@ -73,19 +73,25 @@ describe('buildBackupLicensesOrderComposition', () => { expect(LABELS.backupServerPrivateIp in configurationValues).toBe(false); }); - it('carries the private IP when the NAT toggle is on', () => { + it('carries the private IP when the NAT toggle is on, with its /32 host mask added', () => { const { configurationValues } = build({ isBehindNat: true, backupServerPrivateIp: '192.168.1.10', }); - expect(configurationValues[LABELS.backupServerPrivateIp]).toBe('192.168.1.10'); + expect(configurationValues[LABELS.backupServerPrivateIp]).toBe('192.168.1.10/32'); }); it('trims what the customer typed, so a stray space never reaches the cart', () => { const { configurationValues } = build({ backupServerExternalIp: ' 203.0.113.10 ' }); - expect(configurationValues[LABELS.backupServerPublicIp]).toBe('203.0.113.10'); + expect(configurationValues[LABELS.backupServerPublicIp]).toBe('203.0.113.10/32'); + }); + + it('adds the /32 host mask to the public IP, never left for the customer to type', () => { + const { configurationValues } = build({ backupServerExternalIp: '203.0.113.10' }); + + expect(configurationValues[LABELS.backupServerPublicIp]).toBe('203.0.113.10/32'); }); it('carries the VBR server display name, trimmed', () => { diff --git a/packages/manager/modules/backup-licenses/src/utils/orderComposition/orderComposition.ts b/packages/manager/modules/backup-licenses/src/utils/orderComposition/orderComposition.ts index 0f76c27cd800..90f9ffef84bc 100644 --- a/packages/manager/modules/backup-licenses/src/utils/orderComposition/orderComposition.ts +++ b/packages/manager/modules/backup-licenses/src/utils/orderComposition/orderComposition.ts @@ -1,4 +1,5 @@ import { LicenseApiValue, ServerVaultFormState } from '@/types/Order.type'; +import { toIpBlock } from '@/utils/formatIpList/formatIpList'; /** Relevés sur le catalogue `backupServices` de labeu (catalogId 3190, 2026-08-06). */ export const BACKUP_LICENSES_ORDER_PLAN_CODES = { @@ -58,12 +59,15 @@ export const buildBackupLicensesOrderComposition = ( }, configurationValues: { [BACKUP_LICENSES_CONFIGURATION_LABELS.backupServerDisplayName]: form.displayName.trim(), - [BACKUP_LICENSES_CONFIGURATION_LABELS.backupServerPublicIp]: form.backupServerExternalIp.trim(), + [BACKUP_LICENSES_CONFIGURATION_LABELS.backupServerPublicIp]: toIpBlock( + form.backupServerExternalIp.trim(), + ), // NAT désactivé : l'IP privée est absente de la commande, pas envoyée vide. ...(form.isBehindNat ? { - [BACKUP_LICENSES_CONFIGURATION_LABELS.backupServerPrivateIp]: + [BACKUP_LICENSES_CONFIGURATION_LABELS.backupServerPrivateIp]: toIpBlock( form.backupServerPrivateIp.trim(), + ), } : {}), [BACKUP_LICENSES_CONFIGURATION_LABELS.licenseType]: licenseType,