diff --git a/package.json b/package.json index 97f553a9..ed8eba92 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,12 @@ "sort-exports": "perl -i -pe 's/^export/import/' src/**/index.ts ; npm run prettier ; perl -i -pe 's/^import/export/' src/**/index.ts", "start": "NODE_ENV=development webpack serve --config config/start.config.js", "test": "true", + "test:check-ts": "node -e \"process.versions.node.localeCompare('22.10.0',undefined,{numeric:true})>=0||(console.error('Testing uses feature flags requiring Node >= 22.10.0, but is using '+process.versions.node),process.exit(1))\"", + "test:check-api-status": "curl -sf \"${PULP_BASE_URL:-http://localhost:8080/pulp/api/v3/}status/\" -o /dev/null || (echo 'Integration tests require the Pulp API, is it running?' >&2; exit 1)", + "test:check": "npm run test:check-ts && npm run test:check-api-status", + "test:unit": "npm run test:check && node --test --experimental-strip-types --test-name-pattern '^Unit: '", + "test:integration": "npm run test:check && node --test --experimental-strip-types --test-name-pattern '^Integration: '", + "test:coverage": "npm run test:check && node --test --experimental-strip-types --experimental-test-coverage --test-coverage-exclude '**/*.test.ts' --test-coverage-exclude 'src/api/test-utils/**'", "upgrade": "npx npm-check-updates -u -t minor" }, "engines": { diff --git a/src/api/ansible-distribution.ts b/src/api/ansible-distribution.ts index 8a06fe3d..c5def02a 100644 --- a/src/api/ansible-distribution.ts +++ b/src/api/ansible-distribution.ts @@ -1,5 +1,19 @@ +import type { GenericDistribution } from './common'; import { PulpAPI } from './pulp'; +/** + * Ansible Distribution Type. + * + * @see https://github.com/pulp/pulp_ansible/blob/0043923641fc7fd3893f8489fd29ff04addc9d71/pulp_ansible/app/serializers.py#L356 + */ +interface AnsibleDistributionType extends Omit< + GenericDistribution, + 'base_url' +> { + readonly client_url: string; + repository_version?: string | null; +} + const base = new PulpAPI(); export const AnsibleDistributionAPI = { @@ -11,3 +25,5 @@ export const AnsibleDistributionAPI = { url: (distro_data) => distro_data.client_url, }; + +export type { AnsibleDistributionType }; diff --git a/src/api/ansible-remote.ts b/src/api/ansible-remote.ts index 98c1ecdc..61f7594a 100644 --- a/src/api/ansible-remote.ts +++ b/src/api/ansible-remote.ts @@ -1,36 +1,22 @@ +import type { AnsibleLastSyncType, GenericRemote } from './common'; import { PulpAPI } from './pulp'; -export class AnsibleRemoteType { - auth_url: string; - ca_cert: string; - client_cert: string; - download_concurrency: number; - name: string; - proxy_url: string; - pulp_href?: string; - rate_limit: number; - requirements_file: string; - tls_validation: boolean; - url: string; - signed_only: boolean; +/** + * Ansible Remote Type. + * + * @see https://github.com/pulp/pulp_ansible/blob/0043923641fc7fd3893f8489fd29ff04addc9d71/pulp_ansible/app/serializers.py#L213 + */ +interface AnsibleRemoteType extends GenericRemote { + requirements_file?: string | null; + auth_url?: string | null; + token?: string | null; sync_dependencies?: boolean; - - // connect_timeout - // headers - // max_retries - // policy - // pulp_created - // pulp_labels - // pulp_last_updated - // sock_connect_timeout - // sock_read_timeout - // total_timeout - - hidden_fields: { - is_set: boolean; - name: string; - }[]; - + signed_only?: boolean; + readonly last_sync_task?: AnsibleLastSyncType | null; + /** + * NOTE: Not part of the Ansible serializer, populated separately. + * This should be broken out into its own type and extend the interface. + */ my_permissions?: string[]; } @@ -90,3 +76,5 @@ export const AnsibleRemoteAPI = { smartUpdate(newValue, oldValue), ), }; + +export type { AnsibleRemoteType }; diff --git a/src/api/ansible-repository.ts b/src/api/ansible-repository.ts index 7a0758a1..5c73681c 100644 --- a/src/api/ansible-repository.ts +++ b/src/api/ansible-repository.ts @@ -1,22 +1,20 @@ +import type { AnsibleLastSyncType, GenericRepository } from './common'; import { PulpAPI } from './pulp'; -import { type LastSyncType } from './response-types/remote'; -export class AnsibleRepositoryType { - description: string; - last_sync_task?: LastSyncType; - latest_version_href?: string; - name: string; +/** + * Ansible Repository Type. + * + * @see https://github.com/pulp/pulp_ansible/blob/0043923641fc7fd3893f8489fd29ff04addc9d71/pulp_ansible/app/serializers.py#L148 + */ +interface AnsibleRepositoryType extends GenericRepository { + last_synced_metadata_time?: string | null; + gpgkey?: string | null; + readonly last_sync_task?: AnsibleLastSyncType | null; private?: boolean; - pulp_created?: string; - pulp_href?: string; - pulp_labels?: Record; - remote?: string; - retain_repo_versions: number; - - // gpgkey - // last_synced_metadata_time - // versions_href - + /** + * NOTE: Not part of the Ansible serializer, populated separately. + * This should be broken out into its own type and extend the interface. + */ my_permissions?: string[]; } @@ -91,3 +89,5 @@ export const AnsibleRepositoryAPI = { update: (id: string, data) => base.http.put(`repositories/ansible/ansible/${id}/`, data), }; + +export type { AnsibleRepositoryType }; diff --git a/src/api/common.ts b/src/api/common.ts new file mode 100644 index 00000000..8c4064ab --- /dev/null +++ b/src/api/common.ts @@ -0,0 +1,223 @@ +import type { PulpStatus } from './response-types/pulp'; + +/** + * Generic PulpCore Exceptions based on dictionary representation. + * @see https://github.com/pulp/pulpcore/blob/934c752dae916857b2005e1fe0ef75496accc082/pulpcore/exceptions/base.py#L37 + */ +interface TaskErrorType { + description: string; + traceback: string | null; + error_code?: string; +} + +/** + * Generic Resource shared across every Pulp resource. + * + * @see https://github.com/pulp/pulpcore/blob/934c752dae916857b2005e1fe0ef75496accc082/pulpcore/app/serializers/base.py#L448 + */ +interface GenericResource { + readonly pulp_href?: string; + readonly prn?: string; + readonly pulp_created?: string; + readonly pulp_last_updated?: string; +} + +/** + * Generic Repository shared across Pulp Repository plugins. + * + * @see https://github.com/pulp/pulpcore/blob/934c752dae916857b2005e1fe0ef75496accc082/pulpcore/app/serializers/repository.py#L26 + */ +interface GenericRepository extends GenericResource { + pulp_labels?: Record; + readonly versions_href?: string; + readonly latest_version_href?: string; + name: string; + description?: string | null; + retain_repo_versions?: number | null; + retain_checkpoints?: number | null; + remote?: string | null; +} + +/** + * Generic Distribution shared across Pulp Distribution plugins. + * + * @see https://github.com/pulp/pulpcore/blob/934c752dae916857b2005e1fe0ef75496accc082/pulpcore/app/serializers/publication.py + */ +interface GenericDistribution extends GenericResource { + base_path: string; + readonly base_url?: string; + content_guard?: string | null; + readonly content_guard_prn?: string | null; + readonly no_content_change_since?: string | null; + hidden?: boolean; + pulp_labels?: Record; + name: string; + repository?: string; + repository_version?: string; +} + +/** + * Generic Publication shared across Pulp Publication plugins. + * + * @see https://github.com/pulp/pulpcore/blob/934c752dae916857b2005e1fe0ef75496accc082/pulpcore/app/serializers/publication.py#L23 + */ +interface GenericPublication extends GenericResource { + repository_version?: string; + repository?: string; +} + +/** + * Generic Remote shared across Pulp Remote plugins. + * + * @see https://github.com/pulp/pulpcore/blob/934c752dae916857b2005e1fe0ef75496accc082/pulpcore/app/serializers/repository.py#L85 + * @see https://github.com/pulp/pulpcore/blob/934c752dae916857b2005e1fe0ef75496accc082/pulpcore/app/serializers/base.py#L612 + * @see https://github.com/pulp/pulpcore/blob/934c752dae916857b2005e1fe0ef75496accc082/pulpcore/app/serializers/base.py#L367 + */ +interface GenericRemote extends GenericResource { + pulp_labels?: Record; + name: string; + url: string; + policy?: string; + readonly hidden_fields?: { name: string; is_set: boolean }[]; + ca_cert?: string | null; + client_cert?: string | null; + client_key?: string | null; + tls_validation?: boolean; + proxy_url?: string | null; + proxy_username?: string | null; + proxy_password?: string | null; + username?: string | null; + password?: string | null; + max_retries?: number | null; + total_timeout?: number | null; + connect_timeout?: number | null; + sock_connect_timeout?: number | null; + sock_read_timeout?: number | null; + headers?: unknown[]; + download_concurrency?: number | null; + rate_limit?: number | null; +} + +/** + * Generic Filters shared across PulpCore & Plugin Endpoints. + * + * @see https://github.com/pulp/pulpcore/blob/934c752dae916857b2005e1fe0ef75496accc082/pulpcore/filters.py#L290 + */ +interface GenericFilterParams { + pulp_id__in?: string; + pulp_href__in?: string; + prn__in?: string; + q?: string; + exclude_fields?: string; + fields?: string; + limit?: number; + minimal?: boolean; + offset?: number; + page_size?: number; + ordering?: string; + format?: string; +} + +type LookupFilterParams< + Field extends string, + Lookup extends string, + Value, +> = Partial>; +type NameFilterOptions = + | 'iexact' + | 'in' + | 'contains' + | 'icontains' + | 'startswith' + | 'istartswith' + | 'regex' + | 'iregex'; +type NullableNumericFilterOptions = + | 'ne' + | 'lt' + | 'lte' + | 'gt' + | 'gte' + | 'range' + | 'isnull'; +type NameFilterParams = LookupFilterParams<'name', NameFilterOptions, string>; +type RetainRepoVersionsFilterParams = LookupFilterParams< + 'retain_repo_versions', + NullableNumericFilterOptions, + number | string +>; +type RetainCheckpointsFilterParams = LookupFilterParams< + 'retain_checkpoints', + NullableNumericFilterOptions, + number | string +>; + +/** + * Generic Repository Filters shared across PulpCore & Plugin Endpoints. + * + * @see https://github.com/pulp/pulpcore/blob/934c752dae916857b2005e1fe0ef75496accc082/pulpcore/app/viewsets/repository.py#L88 + */ +interface GenericRepositoryFilterParams + extends + GenericFilterParams, + NameFilterParams, + RetainRepoVersionsFilterParams, + RetainCheckpointsFilterParams { + pulp_label_select?: string; + remote?: string | null; + with_content?: string; + latest_with_content?: string; +} + +/** + * Paginated Response shared across PulpCore & Plugin Endpoints. + * + * @see https://github.com/pulp/pulpcore/blob/934c752dae916857b2005e1fe0ef75496accc082/pulpcore/app/settings.py#L186 + * @see https://github.com/encode/django-rest-framework/blob/6f0b74def3fcc81e126b87b08e59abdb6c2ad056/rest_framework/pagination.py#L364 + */ +interface PaginatedResponse { + count: number; + next: string | null; + previous: string | null; + results: TResult[]; +} + +/** + * Async Task Dispatch Response shared across PulpCore & Plugin Endpoints. + * + * @see https://github.com/pulp/pulpcore/blob/dea04fa79a6ca590f2943a0a7754c219061be10c/pulpcore/app/response.py#L6 + */ +interface DispatchedTaskResponse { + task: string; +} + +/** + * -------------------- + * These are shared Plugin Types outside the PulpCore Generics. + * -------------------- + */ + +/** + * Last Sync Task Type. + * + * @see https://github.com/pulp/pulp_ansible/blob/0043923641fc7fd3893f8489fd29ff04addc9d71/pulp_ansible/app/utils.py#L47 + */ +interface AnsibleLastSyncType { + pk: string; + state: PulpStatus; + pulp_created: string; + finished_at: string | null; + error: TaskErrorType | null; +} + +export type { + TaskErrorType, + GenericRepository, + GenericDistribution, + GenericPublication, + GenericRemote, + GenericRepositoryFilterParams, + PaginatedResponse, + DispatchedTaskResponse, + AnsibleLastSyncType, +}; diff --git a/src/api/container-distribution.ts b/src/api/container-distribution.ts index 7bd1da77..b1213a15 100644 --- a/src/api/container-distribution.ts +++ b/src/api/container-distribution.ts @@ -1,5 +1,43 @@ +import type { GenericDistribution } from './common'; import { PulpAPI } from './pulp'; +/** + * Container Distribution Type. + * + * @see https://github.com/pulp/pulp_container/blob/b109af03cb7aae3431f7a73204e6a73a8457694c/pulp_container/app/serializers.py#L424 + */ +interface ContainerDistributionType extends Omit< + GenericDistribution, + 'base_url' +> { + readonly registry_path: string; + content_guard?: string; + readonly namespace?: string; + description?: string | null; + repository_version?: string | null; + readonly remote?: string; + private?: boolean; + readonly pulp_domain?: string; +} + +/** + * Container Pull Through Type. + * + * @see https://github.com/pulp/pulp_container/blob/b109af03cb7aae3431f7a73204e6a73a8457694c/pulp_container/app/serializers.py#L520 + */ +interface ContainerPullThroughDistributionType extends Omit< + GenericDistribution, + 'base_url' +> { + remote: string; + readonly namespace?: string; + content_guard?: string; + distributions?: string[]; + description?: string | null; + private?: boolean; + readonly pulp_domain?: string; +} + const base = new PulpAPI(); export const ContainerDistributionAPI = { @@ -17,3 +55,5 @@ export const ContainerPullThroughDistributionAPI = { // We should probably put this into a field on the serializer url: (distro_data) => `${window.location.host}/${distro_data.base_path}/`, }; + +export type { ContainerDistributionType, ContainerPullThroughDistributionType }; diff --git a/src/api/file-distribution.ts b/src/api/file-distribution.ts index ac05996b..179f1505 100644 --- a/src/api/file-distribution.ts +++ b/src/api/file-distribution.ts @@ -1,5 +1,16 @@ +import type { GenericDistribution } from './common'; import { PulpAPI } from './pulp'; +/** + * File Distribution Type. + * + * @see https://github.com/pulp/pulpcore/blob/934c752dae916857b2005e1fe0ef75496accc082/pulp_file/app/serializers.py#L225 + */ +interface FileDistributionType extends GenericDistribution { + publication?: string | null; + checkpoint?: boolean; +} + const base = new PulpAPI(); export const FileDistributionAPI = { @@ -9,3 +20,5 @@ export const FileDistributionAPI = { list: (params?) => base.list(`distributions/file/file/`, params), }; + +export type { FileDistributionType }; diff --git a/src/api/file-publication.ts b/src/api/file-publication.ts index b0bcc6b7..30f7a071 100644 --- a/src/api/file-publication.ts +++ b/src/api/file-publication.ts @@ -1,5 +1,17 @@ +import type { GenericPublication } from './common'; import { PulpAPI } from './pulp'; +/** + * File Publication Type. + * + * @see https://github.com/pulp/pulpcore/blob/934c752dae916857b2005e1fe0ef75496accc082/pulp_file/app/serializers.py#L200 + */ +interface FilePublicationType extends GenericPublication { + readonly distributions: string[]; + manifest?: string | null; + checkpoint?: boolean; +} + const base = new PulpAPI(); export const FilePublicationAPI = { @@ -9,3 +21,5 @@ export const FilePublicationAPI = { list: (params?) => base.list(`publications/file/file/`, params), }; + +export type { FilePublicationType }; diff --git a/src/api/file-remote.ts b/src/api/file-remote.ts index fc8c486e..f5a1d7d2 100644 --- a/src/api/file-remote.ts +++ b/src/api/file-remote.ts @@ -1,34 +1,16 @@ +import type { GenericRemote } from './common'; import { PulpAPI } from './pulp'; -export class FileRemoteType { - ca_cert: string; - client_cert: string; - download_concurrency: number; - name: string; - proxy_url: string; - pulp_href?: string; - rate_limit: number; - tls_validation: boolean; - url: string; - sync_dependencies?: boolean; - - // connect_timeout - // headers - // max_retries - // policy - // prn - // pulp_created - // pulp_labels - // pulp_last_updated - // sock_connect_timeout - // sock_read_timeout - // total_timeout - - hidden_fields: { - is_set: boolean; - name: string; - }[]; - +/** + * File Remote Type. + * + * @see https://github.com/pulp/pulpcore/blob/934c752dae916857b2005e1fe0ef75496accc082/pulp_file/app/serializers.py#L165 + */ +interface FileRemoteType extends GenericRemote { + /** + * NOTE: Not part of the File serializer, populated separately. + * This should be broken out into its own type and extend the interface. + */ my_permissions?: string[]; } @@ -62,3 +44,5 @@ export const FileRemoteAPI = { smartUpdate: (id, newValue: FileRemoteType, oldValue: FileRemoteType) => base.http.put(`remotes/file/file/${id}/`, smartUpdate(newValue, oldValue)), }; + +export type { FileRemoteType }; diff --git a/src/api/file-repository.ts b/src/api/file-repository.ts index 7481c2f3..44fffbeb 100644 --- a/src/api/file-repository.ts +++ b/src/api/file-repository.ts @@ -1,19 +1,28 @@ +import type { GenericRepository } from './common'; import { PulpAPI } from './pulp'; -export class FileRepositoryType { +/** + * Type for syncing metadata set on FileRepository after each sync. + * @see https://github.com/pulp/pulpcore/blob/934c752dae916857b2005e1fe0ef75496accc082/pulp_file/app/tasks/synchronizing.py#L97 + */ +interface FileLastSyncDetailsType { + remote_pk: string; + url: string; + download_policy: string; + mirror: boolean; + most_recent_version: number; + manifest_checksum: string; +} + +/** + * File Repository Type. + * + * @see https://github.com/pulp/pulpcore/blob/934c752dae916857b2005e1fe0ef75496accc082/pulp_file/app/serializers.py#L122 + */ +interface FileRepositoryType extends GenericRepository { autopublish?: boolean; - description: string | null; - latest_version_href?: string; - manifest?: string; - name: string; - prn?: string; - pulp_created?: string; - pulp_href?: string; - pulp_labels: Record; - pulp_last_updated?: string; - remote: string | null; - retain_repo_versions: number; - versions_href?: string; + manifest?: string | null; + readonly last_sync_details?: FileLastSyncDetailsType | null; } const base = new PulpAPI(); @@ -39,3 +48,5 @@ export const FileRepositoryAPI = { update: (id: string, data) => base.http.put(`repositories/file/file/${id}/`, data), }; + +export type { FileRepositoryType }; diff --git a/src/api/plugins/rpm/client.ts b/src/api/plugins/rpm/client.ts new file mode 100644 index 00000000..71184973 --- /dev/null +++ b/src/api/plugins/rpm/client.ts @@ -0,0 +1,8 @@ +import { PulpAPI } from 'src/api/pulp'; +import { createRepositoryAPI } from './repository'; + +class RpmClient extends PulpAPI { + repository = createRepositoryAPI(this); +} + +export { RpmClient }; diff --git a/src/api/plugins/rpm/repository.test.ts b/src/api/plugins/rpm/repository.test.ts new file mode 100644 index 00000000..0cc131ff --- /dev/null +++ b/src/api/plugins/rpm/repository.test.ts @@ -0,0 +1,402 @@ +import { isAxiosError } from 'axios'; +import assert from 'node:assert/strict'; +import { after, afterEach, before, beforeEach, describe, it } from 'node:test'; +import { + testAxiosClient, + testPulpAPI, + waitForTaskCompletion, +} from '../../test-utils/integration-client.ts'; +import { + type RPMRepositoryType, + type RPMRepositoryUpsertType, + createRepositoryAPI, +} from './repository.ts'; + +describe('Integration: RPM Repository API Client', () => { + describe('RPM Repository - list()', () => { + const testRpmRepoName = 'test-rpm-repo-existent-list'; + let repositoryHref: string; + + before(async () => { + const res = await testAxiosClient('repositories/rpm/rpm/', { + method: 'POST', + data: JSON.stringify({ name: testRpmRepoName }), + }); + + if (!res.data.pulp_href) { + throw new Error('Failed to create test repository'); + } + + repositoryHref = res.data.pulp_href; + }); + + after(async () => { + await testAxiosClient(repositoryHref, { method: 'DELETE' }); + }); + + it('list() find the created repository by exact name', async () => { + const client = createRepositoryAPI(testPulpAPI()); + + const res = await client.list({ name: testRpmRepoName }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.data.count, 1); + assert.strictEqual(res.data.count, res.data.results.length); + assert.strictEqual(res.data.results[0].name, testRpmRepoName); + assert.strictEqual(res.data.results[0].pulp_href, repositoryHref); + }); + + it('list() when passed a non-existent repository name returns empty result', async () => { + const nonExistentRepositoryName = 'test-rpm-repo-non-existent-list'; + const client = createRepositoryAPI(testPulpAPI()); + + const res = await client.list({ name: nonExistentRepositoryName }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.data.count, 0); + assert.strictEqual(res.data.results.length, 0); + assert.strictEqual(res.data.count, res.data.results.length); + }); + }); + + describe('RPM Repository - retrieve()', () => { + const testRpmRepoName = 'test-rpm-repo-existent-retrieve'; + let repositoryPrn: string; + let repositoryHref: string; + + before(async () => { + const res = await testAxiosClient('repositories/rpm/rpm/', { + method: 'POST', + data: JSON.stringify({ name: testRpmRepoName }), + }); + + if (!res.data.prn || !res.data.pulp_href) { + throw new Error('Failed to create test repository'); + } + + repositoryPrn = res.data.prn; + repositoryHref = res.data.pulp_href; + }); + + after(async () => { + await testAxiosClient(repositoryHref, { method: 'DELETE' }); + }); + + it('retrieve() when passed correct identifier returns expected repository', async () => { + const repositoryIdentifier = repositoryPrn.split(':', 3)[2]; + assert.strictEqual(typeof repositoryIdentifier, 'string'); + + const client = createRepositoryAPI(testPulpAPI()); + + const res = await client.retrieve(repositoryIdentifier); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.data.prn, repositoryPrn); + assert.strictEqual(res.data.name, testRpmRepoName); + }); + + it('retrieve() when passed an non-existent identifier returns empty object', async () => { + const nonExistentRepositoryIdentifier = '1234567890'; + + const client = createRepositoryAPI(testPulpAPI()); + + await assert.rejects( + () => client.retrieve(nonExistentRepositoryIdentifier), + (err: unknown) => { + assert.ok(isAxiosError(err)); + assert.strictEqual(err.response?.status, 404); + return true; + }, + ); + }); + }); + + describe('RPM Repository - create()', () => { + let repositoryHref: string | undefined; + + afterEach(async () => { + if (repositoryHref) { + await testAxiosClient(repositoryHref, { method: 'DELETE' }); + repositoryHref = undefined; + } + }); + + it('create() when giving only the repository name returns with the created repository defaults', async () => { + const testRpmRepoName = 'test-rpm-repo-existent-create-minimal'; + const client = createRepositoryAPI(testPulpAPI()); + + const res = await client.create({ name: testRpmRepoName }); + assert.notStrictEqual(res.data.pulp_href, undefined); + repositoryHref = res.data.pulp_href; + + assert.strictEqual(res.status, 201); + assert.strictEqual(res.data.name, testRpmRepoName); + assert.strictEqual(res.data.autopublish, false); + assert.strictEqual(res.data.retain_package_versions, 0); + assert.strictEqual(res.data.checksum_type, null); + }); + + it('create() when giving additional data fields from required returns them back on created repository', async () => { + const testRpmRepoName = 'test-rpm-existent-create-full'; + const client = createRepositoryAPI(testPulpAPI()); + const payload = { + name: testRpmRepoName, + autopublish: true, + retain_package_versions: 3, + checksum_type: 'sha256', + description: 'This is a test description', + } satisfies RPMRepositoryType; + + const res = await client.create(payload); + assert.notStrictEqual(res.data.pulp_href, repositoryHref); + repositoryHref = res.data.pulp_href; + + assert.strictEqual(res.status, 201); + assert.strictEqual(res.data.autopublish, true); + assert.strictEqual(res.data.name, testRpmRepoName); + assert.strictEqual(res.data.retain_package_versions, 3); + assert.strictEqual(res.data.checksum_type, 'sha256'); + assert.strictEqual(res.data.description, 'This is a test description'); + }); + + it('create() when passed with a duplicate name returns 400', async () => { + const testRpmRepoName = 'test-rpm-existent-create-duplicate'; + const client = createRepositoryAPI(testPulpAPI()); + + const res = await client.create({ name: testRpmRepoName }); + assert.notStrictEqual(res.data.pulp_href, undefined); + repositoryHref = res.data.pulp_href; + + await assert.rejects( + () => client.create({ name: testRpmRepoName }), + (err: unknown) => { + assert.ok(isAxiosError(err)); + assert.strictEqual(err.response?.status, 400); + return true; + }, + ); + }); + + it('create() when missing the required name field returns 400', async () => { + const client = createRepositoryAPI(testPulpAPI()); + + await assert.rejects( + () => client.create({} as RPMRepositoryUpsertType), + (err: unknown) => { + assert.ok(isAxiosError(err)); + assert.strictEqual(err.response?.status, 400); + return true; + }, + ); + }); + + it('create() when an invalid field is passed returns 400', async () => { + const testRpmRepoName = 'test-rpm-existent-create-duplicate'; + const client = createRepositoryAPI(testPulpAPI()); + const invalidPayload = { + name: testRpmRepoName, + compression_type: + 'I am a test' as RPMRepositoryUpsertType['compression_type'], + }; + + await assert.rejects( + () => client.create(invalidPayload), + (err: unknown) => { + assert.ok(isAxiosError(err)); + assert.strictEqual(err.response?.status, 400); + return true; + }, + ); + }); + + it('create() when a not allowed checksum_type is passed returns 400', async () => { + const testRpmRepoName = 'test-rpm-existent-create-duplicate'; + const client = createRepositoryAPI(testPulpAPI()); + const invalidPayload = { + name: testRpmRepoName, + checksum_type: 'md5' as RPMRepositoryUpsertType['checksum_type'], + }; + + await assert.rejects( + () => client.create(invalidPayload), + (err: unknown) => { + assert.ok(isAxiosError(err)); + assert.strictEqual(err.response?.status, 400); + return true; + }, + ); + }); + }); + + describe('RPM Repository - update()', () => { + let repositoryHref: string | undefined; + let repositoryPrn: string | undefined; + + beforeEach(async () => { + const res = await testAxiosClient('repositories/rpm/rpm/', { + method: 'POST', + data: JSON.stringify({ name: 'test-rpm-repo-update' }), + }); + + if (!res.data.prn || !res.data.pulp_href) { + throw new Error('Failed to create test repository'); + } + + repositoryHref = res.data.pulp_href; + repositoryPrn = res.data.prn; + }); + + afterEach(async () => { + await testAxiosClient(repositoryHref, { method: 'DELETE' }); + repositoryHref = undefined; + repositoryPrn = undefined; + }); + + it('update() when updating a field with the same value returns 200', async () => { + const sameNameValue = 'test-rpm-repo-update'; + const repositoryIdentifier = repositoryPrn.split(':', 3)[2]; + assert.strictEqual(typeof repositoryIdentifier, 'string'); + const client = createRepositoryAPI(testPulpAPI()); + + const res = await client.update(repositoryIdentifier, { + name: sameNameValue, + }); + const expected = await client.retrieve(repositoryIdentifier); + + assert.strictEqual(res.status, 200); + assert.strictEqual(expected.data.name, sameNameValue); + }); + + it('update() with a single changed field returns 202 and updates only that field once the task completes', async () => { + const repositoryIdentifier = repositoryPrn.split(':', 3)[2]; + assert.strictEqual(typeof repositoryIdentifier, 'string'); + const client = createRepositoryAPI(testPulpAPI()); + + const res = await client.update(repositoryIdentifier, { + description: 'Updated description', + }); + + let actual: RPMRepositoryType; + assert.strictEqual(res.status, 202); + if (res.status === 202 && 'task' in res.data) { + await waitForTaskCompletion(res.data.task); + actual = (await client.retrieve(repositoryIdentifier)).data; + } + + assert.strictEqual(res.status, 202); + assert.strictEqual(actual.description, 'Updated description'); + }); + + it('update() when changing multiple fields returns 202, and all changes are reflected after the task completes', async () => { + const repositoryIdentifier = repositoryPrn.split(':', 3)[2]; + assert.strictEqual(typeof repositoryPrn, 'string'); + const client = createRepositoryAPI(testPulpAPI()); + const payload = { + autopublish: true, + retain_package_versions: 5, + checksum_type: 'sha256', + compression_type: 'zstd', + } satisfies Partial; + + const res = await client.update(repositoryIdentifier, payload); + + let actual: RPMRepositoryType; + assert.strictEqual(res.status, 202); + if (res.status === 202 && 'task' in res.data) { + await waitForTaskCompletion(res.data.task); + actual = (await client.retrieve(repositoryIdentifier)).data; + } + + assert.strictEqual(actual.autopublish, true); + assert.strictEqual(actual.retain_package_versions, 5); + assert.strictEqual(actual.checksum_type, 'sha256'); + assert.strictEqual(actual.compression_type, 'zstd'); + }); + + it('update() when a not allowed checksum_type is passed returns 400', async () => { + const repositoryIdentifier = repositoryPrn.split(':', 3)[2]; + assert.strictEqual(typeof repositoryPrn, 'string'); + const client = createRepositoryAPI(testPulpAPI()); + const invalidPayload = { + checksum_type: 'sha1' as RPMRepositoryUpsertType['checksum_type'], + }; + + await assert.rejects( + () => client.update(repositoryIdentifier, invalidPayload), + (err: unknown) => { + assert.ok(isAxiosError(err)); + assert.strictEqual(err.response?.status, 400); + return true; + }, + ); + }); + + it('update() when passed a non-existent identifier returns 404', async () => { + const nonExistentRepositoryIdentifier = '1234567890'; + const client = createRepositoryAPI(testPulpAPI()); + + await assert.rejects( + () => + client.update(nonExistentRepositoryIdentifier, { + description: 'This will not update', + }), + (err: unknown) => { + assert.ok(isAxiosError(err)); + assert.strictEqual(err.response?.status, 404); + return true; + }, + ); + }); + }); + + describe('RPM Repository - delete()', () => { + it('delete() returns 202 and dispatches a task', async () => { + const created = await testAxiosClient('repositories/rpm/rpm/', { + method: 'POST', + data: JSON.stringify({ name: 'test-rpm-repo-delete-valid' }), + }); + const repositoryIdentifier = created.data.prn.split(':', 3)[2]; + const client = createRepositoryAPI(testPulpAPI()); + + const res = await client.delete(repositoryIdentifier); + + assert.strictEqual(res.status, 202); + assert.notStrictEqual(res.data.task, undefined); + }); + + it('delete() when passed a non-existent identifier returns 404', async () => { + const nonExistentRepositoryIdentifier = '1234567890'; + const client = createRepositoryAPI(testPulpAPI()); + + await assert.rejects( + () => client.delete(nonExistentRepositoryIdentifier), + (err: unknown) => { + assert.ok(isAxiosError(err)); + assert.strictEqual(err.response?.status, 404); + return true; + }, + ); + }); + + it('delete() removes the repository once the dispatched task completes', async () => { + const created = await testAxiosClient('repositories/rpm/rpm/', { + method: 'POST', + data: JSON.stringify({ name: 'test-rpm-repo-delete-completion' }), + }); + const repositoryIdentifier = created.data.prn.split(':', 3)[2]; + const client = createRepositoryAPI(testPulpAPI()); + + const res = await client.delete(repositoryIdentifier); + await waitForTaskCompletion(res.data.task); + + await assert.rejects( + () => client.retrieve(repositoryIdentifier), + (err: unknown) => { + assert.ok(isAxiosError(err)); + assert.strictEqual(err.response?.status, 404); + return true; + }, + ); + }); + }); +}); diff --git a/src/api/plugins/rpm/repository.ts b/src/api/plugins/rpm/repository.ts new file mode 100644 index 00000000..d8b047ee --- /dev/null +++ b/src/api/plugins/rpm/repository.ts @@ -0,0 +1,88 @@ +import type { AxiosResponse } from 'axios'; +import type { + DispatchedTaskResponse, + GenericRepository, + GenericRepositoryFilterParams, + PaginatedResponse, +} from '../../common'; +import type { PulpAPI } from '../../pulp'; + +type RPMChecksumType = + | 'unknown' + | 'md5' + | 'sha' + | 'sha1' + | 'sha224' + | 'sha256' + | 'sha384' + | 'sha512'; +type RPMAllowedUpsertChecksumsType = 'sha256' | 'sha384' | 'sha512'; +type RPMCompressionType = 'zstd' | 'gz' | 'none'; +type RPMLayoutType = 'nested_alphabetically' | 'flat' | 'nested_by_digest'; + +/** + * RPM Repository Type. + * + * @see https://github.com/pulp/pulp_rpm/blob/dc333a99db6c44d70d6103540cb592f6e55a8682/pulp_rpm/app/serializers/repository.py#L177 + */ +interface RPMRepositoryType extends GenericRepository { + autopublish?: boolean; + metadata_signing_service?: string | null; + package_signing_service?: string | null; + package_signing_fingerprint?: string | null; + retain_package_versions?: number; + checksum_type?: RPMChecksumType | null; + compression_type?: RPMCompressionType | null; + layout?: RPMLayoutType | null; + repo_config?: Record; + osv_config?: { name: string; releases: unknown }[] | null; +} + +/** + * RPM Create / Update Type. + * + * @see https://github.com/pulp/pulp_rpm/blob/dc333a99db6c44d70d6103540cb592f6e55a8682/pulp_rpm/app/serializers/repository.py#L326 + */ +interface RPMRepositoryUpsertType extends Omit< + RPMRepositoryType, + 'checksum_type' +> { + checksum_type?: RPMAllowedUpsertChecksumsType | null; +} + +// FIXME: Move AxiosResponse type to PulpAPI Base Class. +// NOTE: The FIXME implementation is not easy as to avoid major type issues with legacy API calls. +interface RPMRepositoryClient { + list: ( + params?: GenericRepositoryFilterParams, + ) => Promise>>; + retrieve: (id: string) => Promise>; + create: ( + data: RPMRepositoryUpsertType, + ) => Promise>; + update: ( + id: string, + data: Partial, + ) => Promise>; + delete: (id: string) => Promise>; +} + +/** + * RPM Repository API Client + * @param {PulpAPI} base + * @returns {RPMRepositoryClient} + * + * @see https://github.com/pulp/pulp_rpm/blob/dc333a99db6c44d70d6103540cb592f6e55a8682/pulp_rpm/app/viewsets/repository.py#L76 + */ +function createRepositoryAPI(base: PulpAPI): RPMRepositoryClient { + return { + list: (params?) => base.list(`repositories/rpm/rpm/`, params), + retrieve: (id) => base.http.get(`repositories/rpm/rpm/${id}/`), + create: (data) => base.http.post(`repositories/rpm/rpm/`, data), + update: (id, data) => base.http.patch(`repositories/rpm/rpm/${id}/`, data), + delete: (id) => base.http.delete(`repositories/rpm/rpm/${id}/`), + }; +} + +export { createRepositoryAPI }; +export type { RPMRepositoryType, RPMRepositoryUpsertType }; diff --git a/src/api/rpm-repository.ts b/src/api/rpm-repository.ts index 9e32c898..c553f25d 100644 --- a/src/api/rpm-repository.ts +++ b/src/api/rpm-repository.ts @@ -2,6 +2,9 @@ import { PulpAPI } from './pulp'; const base = new PulpAPI(); +/** + * @deprecated Use `RpmClient` from `src/api/plugins/rpm/client.ts` instead. + */ export const RPMRepositoryAPI = { list: (params?) => base.list(`repositories/rpm/rpm/`, params), }; diff --git a/src/api/test-utils/integration-client.ts b/src/api/test-utils/integration-client.ts new file mode 100644 index 00000000..a52311e7 --- /dev/null +++ b/src/api/test-utils/integration-client.ts @@ -0,0 +1,104 @@ +import axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios'; +import type { PulpAPI } from '../pulp.ts'; + +const baseUrl: string = + process.env.PULP_BASE_URL ?? 'http://localhost:8080/pulp/api/v3/'; +const testUsername: string = process.env.PULP_USERNAME ?? 'admin'; +const testPassword: string = process.env.PULP_PASSWORD ?? 'admin'; +const authorization: string = + 'Basic ' + Buffer.from(`${testUsername}:${testPassword}`).toString('base64'); + +async function testAxiosClient( + path: string, + config: AxiosRequestConfig = {}, +): Promise { + const url: string = new URL(path, baseUrl).toString(); + const axiosRequest = { + ...config, + url, + headers: { + Authorization: authorization, + 'Content-Type': 'application/json', + ...config.headers, + }, + } satisfies AxiosRequestConfig; + const res = await axios.request(axiosRequest); + + return res; +} + +function testPulpAPI(): PulpAPI { + return { + list: (url: string, params?: Record) => + testAxiosClient(`${url}${buildQueryParams(params)}`, { + method: 'GET', + }), + http: { + get: (url: string, config: { params?: Record }) => + testAxiosClient(`${url}${buildQueryParams(config?.params)}`, { + method: 'GET', + }), + post: (url: string, data: unknown) => + testAxiosClient(url, { method: 'POST', data }), + patch: ( + url: string, + data: unknown, + config: { params?: Record }, + ) => + testAxiosClient(`${url}${buildQueryParams(config?.params)}`, { + method: 'PATCH', + data, + }), + delete: (url: string, config: { params?: Record }) => + testAxiosClient(`${url}${buildQueryParams(config?.params)}`, { + method: 'DELETE', + }), + }, + } as unknown as PulpAPI; +} + +function buildQueryParams(params?: Record): string { + const search = new URLSearchParams(); + + for (const [key, value] of Object.entries(params ?? {})) { + if (value !== undefined) { + search.set(key, String(value)); + } + } + + const queryString = search.toString(); + return queryString ? `?${queryString}` : ''; +} + +async function waitForTaskCompletion( + taskHref: string, + { + waitMs = 500, + attemptsLeft = 10, + }: { waitMs?: number; attemptsLeft?: number } = {}, +): Promise { + const res = await testAxiosClient(taskHref, { method: 'GET' }); + const state: string = res.data.state; + + if (['skipped', 'failed', 'canceled'].includes(state)) { + throw new Error(`Task ${taskHref} ended with state "${state}"`); + } + + if (state === 'completed') { + return; + } + + if (attemptsLeft <= 0) { + throw new Error( + `Task ${taskHref} did not complete within the allowed attempts`, + ); + } + + await new Promise((r) => setTimeout(r, waitMs)); + return waitForTaskCompletion(taskHref, { + waitMs: Math.round(waitMs * 1.5), + attemptsLeft: attemptsLeft - 1, + }); +} + +export { testPulpAPI, testAxiosClient, waitForTaskCompletion }; diff --git a/tsconfig.json b/tsconfig.json index 934db47d..00ff66ef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ "lib": ["es2021", "dom"], "module": "es2020", "moduleResolution": "node", + "allowImportingTsExtensions": true, "noImplicitAny": false, "outDir": "./dist/", "paths": {