diff --git a/src/meilisearch.ts b/src/meilisearch.ts index 018e02a0f..c2ca5e2db 100644 --- a/src/meilisearch.ts +++ b/src/meilisearch.ts @@ -7,19 +7,14 @@ import { Index } from "./indexes.js"; import type { - KeyCreation, Config, IndexOptions, IndexObject, - Key, Health, Stats, Version, - KeyUpdate, IndexesQuery, IndexesResults, - KeysQuery, - KeysResults, IndexSwap, MultiSearchParams, FederatedMultiSearchParams, @@ -28,6 +23,11 @@ import type { ExtraRequestInit, Network, RecordAny, + CreateApiKey, + KeyView, + KeyViewList, + ListApiKeys, + PatchApiKey, RuntimeTogglableFeatures, } from "./types/index.js"; import { ErrorStatusCode } from "./types/index.js"; @@ -303,72 +303,41 @@ export class MeiliSearch { /// KEYS /// - /** - * Get all API keys - * - * @param parameters - Parameters to browse the indexes - * @returns Promise returning an object with keys - */ - async getKeys(parameters?: KeysQuery): Promise { - const keys = await this.httpRequest.get({ + /** {@link https://www.meilisearch.com/docs/reference/api/keys#get-all-keys} */ + async getKeys(listApiKeys?: ListApiKeys): Promise { + return await this.httpRequest.get({ path: "keys", - params: parameters, + params: listApiKeys, }); - - keys.results = keys.results.map((key) => ({ - ...key, - createdAt: new Date(key.createdAt), - updatedAt: new Date(key.updatedAt), - })); - - return keys; } - /** - * Get one API key - * - * @param keyOrUid - Key or uid of the API key - * @returns Promise returning a key - */ - async getKey(keyOrUid: string): Promise { - return await this.httpRequest.get({ + /** {@link https://www.meilisearch.com/docs/reference/api/keys#get-one-key} */ + async getKey(keyOrUid: string): Promise { + return await this.httpRequest.get({ path: `keys/${keyOrUid}`, }); } - /** - * Create one API key - * - * @param options - Key options - * @returns Promise returning a key - */ - async createKey(options: KeyCreation): Promise { - return await this.httpRequest.post({ + /** {@link https://www.meilisearch.com/docs/reference/api/keys#create-a-key} */ + async createKey(createApiKey: CreateApiKey): Promise { + return await this.httpRequest.post({ path: "keys", - body: options, + body: createApiKey, }); } - /** - * Update one API key - * - * @param keyOrUid - Key - * @param options - Key options - * @returns Promise returning a key - */ - async updateKey(keyOrUid: string, options: KeyUpdate): Promise { - return await this.httpRequest.patch({ + /** {@link https://www.meilisearch.com/docs/reference/api/keys#update-a-key} */ + async updateKey( + keyOrUid: string, + patchApiKey: PatchApiKey, + ): Promise { + return await this.httpRequest.patch({ path: `keys/${keyOrUid}`, - body: options, + body: patchApiKey, }); } - /** - * Delete one API key - * - * @param keyOrUid - Key - * @returns - */ + /** {@link https://www.meilisearch.com/docs/reference/api/keys#delete-a-key} */ async deleteKey(keyOrUid: string): Promise { await this.httpRequest.delete({ path: `keys/${keyOrUid}` }); } diff --git a/src/types/index.ts b/src/types/index.ts index 8aaa23dba..d54c97df1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,5 @@ export * from "./experimental-features.js"; +export * from "./keys.js"; export * from "./task_and_batch.js"; export * from "./token.js"; export * from "./types.js"; diff --git a/src/types/keys.ts b/src/types/keys.ts new file mode 100644 index 000000000..04a8a64bc --- /dev/null +++ b/src/types/keys.ts @@ -0,0 +1,85 @@ +type ALL = "*"; +type GET = "get"; +type CREATE = "create"; +type UPDATE = "update"; +type DELETE = "delete"; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/keys#actions} + * + * @see `meilisearch_types::keys::Action` + */ +export type Action = + | ALL + | "search" + | `documents.${ALL | "add" | GET | DELETE}` + | `indexes.${ALL | CREATE | GET | UPDATE | DELETE | "swap"}` + | `tasks.${ALL | "cancel" | DELETE | GET}` + | `settings.${ALL | GET | UPDATE}` + | `stats.${GET}` + | `metrics.${GET}` + | `dumps.${CREATE}` + | `snapshots.${CREATE}` + | "version" + | `keys.${CREATE | GET | UPDATE | DELETE}` + | `experimental.${GET | UPDATE}` + | `network.${GET | UPDATE}`; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/keys#body} + * + * @see `meilisearch_types::keys::CreateApiKey` + */ +export type CreateApiKey = { + description?: string | null; + name?: string | null; + uid?: string; + actions: Action[]; + indexes: string[]; + expiresAt: string | null; +}; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/keys#key-object} + * + * @see `meilisearch::routes::api_key::KeyView` + */ +export type KeyView = { + name: string | null; + description: string | null; + key: string; + uid: string; + actions: Action[]; + indexes: string[]; + expiresAt: string | null; + createdAt: string; + updatedAt: string; +}; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/keys#query-parameters} + * + * @see `meilisearch::routes::api_key::ListApiKeys` + */ +export type ListApiKeys = { + offset?: number; + limit?: number; +}; + +/** @see `meilisearch::routes::PaginationView` */ +export type PaginationView = { + results: T[]; + offset: number; + limit: number; + total: number; +}; + +/** {@link https://www.meilisearch.com/docs/reference/api/keys#response} */ +export type KeyViewList = PaginationView; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/keys#body-1} + * + * @see `meilisearch_types::keys::PatchApiKey` + */ +export type PatchApiKey = Pick; diff --git a/src/types/types.ts b/src/types/types.ts index 91d29f4b5..e628dac6d 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -697,40 +697,6 @@ export type Stats = { }; }; -/* - ** Keys - */ - -export type Key = { - uid: string; - description: string; - name: string | null; - key: string; - actions: string[]; - indexes: string[]; - expiresAt: Date; - createdAt: Date; - updatedAt: Date; -}; - -export type KeyCreation = { - uid?: string; - name?: string; - description?: string; - actions: string[]; - indexes: string[]; - expiresAt: Date | null; -}; - -export type KeyUpdate = { - name?: string; - description?: string; -}; - -export type KeysQuery = ResourceQuery & {}; - -export type KeysResults = ResourceResults & {}; - /* ** version */ diff --git a/tests/keys.test.ts b/tests/keys.test.ts index a1e305d07..0d5de208a 100644 --- a/tests/keys.test.ts +++ b/tests/keys.test.ts @@ -1,258 +1,250 @@ -import { expect, test, describe, beforeEach, afterAll } from "vitest"; -import { MeiliSearch } from "../src/index.js"; -import { ErrorStatusCode } from "../src/types/index.js"; +import { randomUUID } from "node:crypto"; +import { describe, test } from "vitest"; +import type { + Action, + CreateApiKey, + KeyView, + ListApiKeys, +} from "../src/index.js"; import { - clearAllIndexes, - config, + assert as extAssert, getClient, - getKey, - HOST, + objectEntries, + objectKeys, } from "./utils/meilisearch-test-utils.js"; -beforeEach(async () => { - await clearAllIndexes(config); -}); - -afterAll(() => { - return clearAllIndexes(config); -}); - -describe.each([{ permission: "Master" }, { permission: "Admin" }])( - "Test on keys", - ({ permission }) => { - beforeEach(async () => { - const client = await getClient("Master"); - await clearAllIndexes(config); - - const keys = await client.getKeys(); - - const customKeys = keys.results.filter( - (key) => - key.name !== "Default Search API Key" && - key.name !== "Default Admin API Key", - ); - - // Delete all custom keys - await Promise.all(customKeys.map((key) => client.deleteKey(key.uid))); - }); - - test(`${permission} key: get keys`, async () => { - const client = await getClient(permission); - const keys = await client.getKeys(); - - const searchKey = keys.results.find( - (key) => key.name === "Default Search API Key", - ); - - expect(searchKey).toBeDefined(); - expect(searchKey).toHaveProperty( - "description", - "Use it to search from the frontend", - ); - expect(searchKey).toHaveProperty("key"); - expect(searchKey).toHaveProperty("actions"); - expect(searchKey).toHaveProperty("indexes"); - expect(searchKey).toHaveProperty("expiresAt", null); - expect(searchKey).toHaveProperty("createdAt"); - expect(searchKey?.createdAt).toBeInstanceOf(Date); - expect(searchKey).toHaveProperty("updatedAt"); - expect(searchKey?.updatedAt).toBeInstanceOf(Date); - - const adminKey = keys.results.find( - (key) => key.name === "Default Admin API Key", - ); - - expect(adminKey).toBeDefined(); - expect(adminKey).toHaveProperty( - "description", - "Use it for anything that is not a search operation. Caution! Do not expose it on a public frontend", - ); - expect(adminKey).toHaveProperty("key"); - expect(adminKey).toHaveProperty("actions"); - expect(adminKey).toHaveProperty("indexes"); - expect(adminKey).toHaveProperty("expiresAt", null); - expect(adminKey).toHaveProperty("createdAt"); - expect(searchKey?.createdAt).toBeInstanceOf(Date); - expect(adminKey).toHaveProperty("updatedAt"); - expect(searchKey?.updatedAt).toBeInstanceOf(Date); - }); - - test(`${permission} key: get keys with pagination`, async () => { - const client = await getClient(permission); - const keys = await client.getKeys({ limit: 1, offset: 2 }); - - expect(keys.limit).toEqual(1); - expect(keys.offset).toEqual(2); - expect(keys.total).toEqual(2); - }); - - test(`${permission} key: get on key`, async () => { - const client = await getClient(permission); - const apiKey = await getKey("Admin"); - - const key = await client.getKey(apiKey); - - expect(key).toBeDefined(); - expect(key).toHaveProperty( - "description", - "Use it for anything that is not a search operation. Caution! Do not expose it on a public frontend", - ); - expect(key).toHaveProperty("key"); - expect(key).toHaveProperty("actions"); - expect(key).toHaveProperty("indexes"); - expect(key).toHaveProperty("expiresAt", null); - expect(key).toHaveProperty("createdAt"); - expect(key).toHaveProperty("updatedAt"); - }); - - test(`${permission} key: create key with no expiresAt`, async () => { - const client = await getClient(permission); - const uid = "3db051e0-423d-4b5c-a63a-f82a7043dce6"; - - const key = await client.createKey({ - uid, - description: "Indexing Products API key", - actions: ["documents.add"], - indexes: ["products"], - expiresAt: null, - }); +const customAssert = { + isKeyView(value: KeyView) { + extAssert.lengthOf(Object.keys(value), 9); + const { + name, + description, + key, + uid, + actions, + indexes, + expiresAt, + createdAt, + updatedAt, + } = value; + + extAssert( + name === null || typeof name === "string", + "expected name to be null or string", + ); + extAssert( + description === null || typeof description === "string", + "expected description to be null or string", + ); + + extAssert.typeOf(key, "string"); + extAssert.typeOf(uid, "string"); + + for (const action of actions) { + extAssert.oneOf(action, possibleActions); + } + + for (const index of indexes) { + extAssert.typeOf(index, "string"); + } + + extAssert( + expiresAt === null || typeof expiresAt === "string", + "expected expiresAt to be null or string", + ); + + extAssert.typeOf(createdAt, "string"); + extAssert.typeOf(updatedAt, "string"); + }, +}; - expect(key).toBeDefined(); - expect(key).toHaveProperty("description", "Indexing Products API key"); - expect(key).toHaveProperty("uid", uid); - expect(key).toHaveProperty("expiresAt", null); - }); +const assert: typeof extAssert & typeof customAssert = Object.assign( + extAssert, + customAssert, +); - test(`${permission} key: create key with actions using wildcards to provide rights`, async () => { - const client = await getClient(permission); - const uid = "3db051e0-423d-4b5c-a63a-f82a7043dce6"; +const KEY_UID = randomUUID(); + +type TestRecord = { + [TKey in keyof CreateApiKey]-?: [ + name: string | undefined, + value: CreateApiKey[TKey], + assertion: (a: CreateApiKey[TKey], b: CreateApiKey[TKey]) => void, + ][]; +}; + +type SimplifiedTestRecord = Record< + keyof CreateApiKey, + [ + name: string | undefined, + value: CreateApiKey[keyof CreateApiKey], + assertion: ( + a: CreateApiKey[keyof CreateApiKey], + b: CreateApiKey[keyof CreateApiKey], + ) => void, + ][] +>; + +const possibleActions = objectKeys({ + "*": null, + search: null, + "documents.*": null, + "documents.add": null, + "documents.get": null, + "documents.delete": null, + "indexes.*": null, + "indexes.get": null, + "indexes.delete": null, + "indexes.create": null, + "indexes.update": null, + "indexes.swap": null, + "tasks.*": null, + "tasks.get": null, + "tasks.delete": null, + "tasks.cancel": null, + "settings.*": null, + "settings.get": null, + "settings.update": null, + "stats.get": null, + "metrics.get": null, + "dumps.create": null, + "snapshots.create": null, + version: null, + "keys.get": null, + "keys.delete": null, + "keys.create": null, + "keys.update": null, + "experimental.get": null, + "experimental.update": null, + "network.get": null, + "network.update": null, +}); - const key = await client.createKey({ - uid, - description: "Indexing Products API key", - actions: ["indexes.*", "tasks.*", "documents.*"], - indexes: ["wildcard_keys_permission"], +const testRecord = { + description: [ + [ + undefined, + "The Skeleton Key is an unbreakable lockpick and Daedric Artifact in The Elder Scrolls IV: Oblivion.", + (a, b) => { + assert.strictEqual(a, b); + }, + ], + ], + name: [ + [ + undefined, + "Skeleton Key", + (a, b) => { + assert.strictEqual(a, b); + }, + ], + ], + uid: [ + [ + undefined, + KEY_UID, + (a, b) => { + assert.strictEqual(a, b); + }, + ], + ], + actions: possibleActions.map((action) => [ + action, + [action], + (a, b) => { + assert.sameMembers(a, b); + }, + ]), + indexes: [ + [ + undefined, + ["indexEins", "indexZwei"], + (a, b) => { + assert.sameMembers(a, b); + }, + ], + ], + expiresAt: [ + [ + undefined, + new Date("9999-12-5").toISOString(), + (a, b) => { + assert.strictEqual(Date.parse(a!), Date.parse(b!)); + }, + ], + ], +} satisfies TestRecord as SimplifiedTestRecord; + +// transform names +for (const testValues of Object.values(testRecord)) { + for (const testValue of testValues) { + testValue[0] = testValue[0] === undefined ? "" : ` with "${testValue[0]}"`; + } +} + +const ms = await getClient("Master"); + +describe.for(objectEntries(testRecord))("`%s`", ([key, values]) => { + test.for(values)( + `${ms.createKey.name} method%s`, + async ([, value, assertion]) => { + const keyView = await ms.createKey({ + actions: ["*"], + indexes: ["*"], expiresAt: null, + [key]: value, }); - const newClient = new MeiliSearch({ host: HOST, apiKey: key.key }); - await newClient.createIndex("wildcard_keys_permission"); // test index creation - const task = await newClient - .index("wildcard_keys_permission") - .addDocuments([{ id: 1 }]) - .waitTask(); // test document addition - - expect(key).toBeDefined(); - expect(task.status).toBe("succeeded"); - expect(key).toHaveProperty("description", "Indexing Products API key"); - expect(key).toHaveProperty("uid", uid); - expect(key).toHaveProperty("expiresAt", null); - }); - - test(`${permission} key: create key with an expiresAt`, async () => { - const client = await getClient(permission); - - const key = await client.createKey({ - description: "Indexing Products API key", - actions: ["documents.add"], - indexes: ["products"], - expiresAt: new Date("2050-11-13T00:00:00Z"), // Test will fail in 2050 - }); - - expect(key).toBeDefined(); - expect(key).toHaveProperty("description", "Indexing Products API key"); - expect(key).toHaveProperty("expiresAt", "2050-11-13T00:00:00Z"); - }); + assert.isKeyView(keyView); - test(`${permission} key: update a key`, async () => { - const client = await getClient(permission); + assertion(keyView[key as keyof typeof keyView], value); + }, + ); +}); - const key = await client.createKey({ - description: "Indexing Products API key", - actions: ["documents.add"], - indexes: ["products"], - expiresAt: new Date("2050-11-13T00:00:00Z"), // Test will fail in 2050 - }); +const pickedTestRecord = (() => { + const { name, description } = testRecord; + return { name, description }; +})(); - const updatedKey = await client.updateKey(key.key, { - description: "Indexing Products API key 2", - name: "Product admin", - }); +describe.for(objectEntries(pickedTestRecord))("`%s`", ([key, values]) => { + test.for(values)( + `${ms.updateKey.name} method%s`, + async ([, value, assertion]) => { + const keyView = await ms.updateKey(KEY_UID, { [key]: value }); - expect(updatedKey).toBeDefined(); - expect(updatedKey).toHaveProperty( - "description", - "Indexing Products API key 2", - ); - expect(updatedKey).toHaveProperty("name", "Product admin"); - }); + assert.isKeyView(keyView); - test(`${permission} key: delete a key`, async () => { - const client = await getClient(permission); + assertion(keyView[key as keyof typeof keyView], value); + }, + ); +}); - const key = await client.createKey({ - description: "Indexing Products API key", - actions: ["documents.add"], - indexes: ["products"], - expiresAt: new Date("2050-11-13T00:00:00Z"), // Test will fail in 2050 - }); +test(`${ms.getKeys.name}, ${ms.getKey.name} and ${ms.deleteKey.name} methods`, async () => { + const keyList = await ms.getKeys({ + offset: 0, + limit: 10_000, + } satisfies Required); - const deletedKey = await client.deleteKey(key.key); + for (const { uid, name } of keyList.results) { + const keyView = await ms.getKey(uid); - expect(deletedKey).toBeUndefined(); - }); - }, -); + // avoid deleting default keys that might be used by other tests + if (name !== "Default Search API Key" && name !== "Default Admin API Key") { + await ms.deleteKey(uid); + } -describe.each([{ permission: "Search" }])( - "Test on keys with search key", - ({ permission }) => { - test(`${permission} key: get keys denied`, async () => { - const client = await getClient(permission); - await expect(client.getKeys()).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.INVALID_API_KEY, - ); - }); + assert.isKeyView(keyView); + } - test(`${permission} key: create key denied`, async () => { - const client = await getClient(permission); - await expect( - client.createKey({ - description: "Indexing Products API key", - actions: ["documents.add"], - indexes: ["products"], - expiresAt: null, - }), - ).rejects.toHaveProperty("cause.code", ErrorStatusCode.INVALID_API_KEY); - }); - }, -); + assert.lengthOf(Object.keys(keyList), 4); + const { results, offset, limit, total } = keyList; -describe.each([{ permission: "No" }])( - "Test on keys with No key", - ({ permission }) => { - test(`${permission} key: get keys denied`, async () => { - const client = await getClient(permission); - await expect(client.getKeys()).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.MISSING_AUTHORIZATION_HEADER, - ); - }); + for (const keyView of results) { + assert.isKeyView(keyView); + } - test(`${permission} key: create key denied`, async () => { - const client = await getClient(permission); - await expect( - client.createKey({ - description: "Indexing Products API key", - actions: ["documents.add"], - indexes: ["products"], - expiresAt: null, - }), - ).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.MISSING_AUTHORIZATION_HEADER, - ); - }); - }, -); + assert.typeOf(offset, "number"); + assert.typeOf(limit, "number"); + assert.typeOf(total, "number"); +}); diff --git a/tests/utils/meilisearch-test-utils.ts b/tests/utils/meilisearch-test-utils.ts index e9faf9510..48348b3d7 100644 --- a/tests/utils/meilisearch-test-utils.ts +++ b/tests/utils/meilisearch-test-utils.ts @@ -244,7 +244,18 @@ export type Book = { author: string; }; +function objectKeys(o: { [TKey in T]: null }): T[] { + return Object.keys(o) as T[]; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const objectEntries = Object.entries as >( + o: T, +) => [key: keyof T, val: T[keyof T]][]; + export { + objectKeys, + objectEntries, clearAllIndexes, config, masterClient,