diff --git a/examples/general/api-tokens.ts b/examples/general/api-tokens.ts index 68701a1..f49c6ab 100644 --- a/examples/general/api-tokens.ts +++ b/examples/general/api-tokens.ts @@ -19,8 +19,12 @@ async function apiTokensFlow() { // Create a new API token scoped to specific resources. // The full `token` value is returned only in this response — store it securely. + // `expires_at` is optional: omit it for the server default (a 1-year default + // is being rolled out), pass an ISO 8601 date-time for a custom expiration, + // or pass `null` for a token that never expires. const created = await apiTokensClient.create({ name: "My token", + expires_at: "2027-06-01T00:00:00Z", resources: [ { resource_type: "account", resource_id: Number(ACCOUNT_ID), access_level: 10 }, ], @@ -36,6 +40,8 @@ async function apiTokensFlow() { // Reset the API token: expires the existing token and returns a new one // with the same permissions. The new `token` value is only returned here. + // Like create, reset accepts an optional `expires_at` for the new token, + // e.g. `reset(tokenId, { expires_at: null })` for a token that never expires. const reset = await apiTokensClient.reset(tokenId); console.log("Reset API token:", JSON.stringify(reset, null, 2)); console.log("New token value (store securely):", reset.token); diff --git a/src/__tests__/lib/api/resources/ApiTokens.test.ts b/src/__tests__/lib/api/resources/ApiTokens.test.ts index bc127f5..5ee22a0 100644 --- a/src/__tests__/lib/api/resources/ApiTokens.test.ts +++ b/src/__tests__/lib/api/resources/ApiTokens.test.ts @@ -130,6 +130,73 @@ describe("lib/api/resources/ApiTokens: ", () => { expect(result).toEqual(responseData); }); + it("omits expires_at from the request body when not provided.", async () => { + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/api_tokens`; + + expect.assertions(1); + + mock.onPost(endpoint).reply(200, responseData); + await apiTokensAPI.create(params); + + expect("expires_at" in JSON.parse(mock.history.post[0].data)).toEqual( + false + ); + }); + + it("sends expires_at when provided.", async () => { + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/api_tokens`; + const expiresAt = "2027-06-01T00:00:00Z"; + + expect.assertions(1); + + mock + .onPost(endpoint) + .reply(200, { ...responseData, expires_at: expiresAt }); + await apiTokensAPI.create({ ...params, expires_at: expiresAt }); + + expect(JSON.parse(mock.history.post[0].data).expires_at).toEqual( + expiresAt + ); + }); + + it("sends explicit null expires_at for a token that never expires.", async () => { + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/api_tokens`; + + expect.assertions(2); + + mock.onPost(endpoint).reply(200, responseData); + await apiTokensAPI.create({ ...params, expires_at: null }); + + const body = JSON.parse(mock.history.post[0].data); + + expect("expires_at" in body).toEqual(true); + expect(body.expires_at).toBeNull(); + }); + + it("fails with error when the server rejects expires_at.", async () => { + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/api_tokens`; + const expectedErrorMessage = "expires_at: must not be in the past"; + + expect.assertions(2); + + mock + .onPost(endpoint) + .reply(422, { errors: { expires_at: ["must not be in the past"] } }); + + try { + await apiTokensAPI.create({ + ...params, + expires_at: "2020-01-01T00:00:00Z", + }); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + } + } + }); + it("fails with error.", async () => { const expectedErrorMessage = "Request failed with status code 404"; @@ -223,6 +290,70 @@ describe("lib/api/resources/ApiTokens: ", () => { expect(result).toEqual(responseData); }); + it("sends no request body when params are omitted.", async () => { + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/api_tokens/${tokenId}/reset`; + + expect.assertions(1); + + mock.onPost(endpoint).reply(200, responseData); + await apiTokensAPI.reset(tokenId); + + expect(mock.history.post[0].data).toBeUndefined(); + }); + + it("sends expires_at in the request body when provided.", async () => { + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/api_tokens/${tokenId}/reset`; + const expiresAt = "2027-06-01T00:00:00Z"; + + expect.assertions(1); + + mock + .onPost(endpoint) + .reply(200, { ...responseData, expires_at: expiresAt }); + await apiTokensAPI.reset(tokenId, { expires_at: expiresAt }); + + expect(JSON.parse(mock.history.post[0].data)).toEqual({ + expires_at: expiresAt, + }); + }); + + it("sends explicit null expires_at for a token that never expires.", async () => { + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/api_tokens/${tokenId}/reset`; + + expect.assertions(2); + + mock.onPost(endpoint).reply(200, responseData); + await apiTokensAPI.reset(tokenId, { expires_at: null }); + + const body = JSON.parse(mock.history.post[0].data); + + expect("expires_at" in body).toEqual(true); + expect(body.expires_at).toBeNull(); + }); + + it("fails with error when the server rejects expires_at.", async () => { + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/api_tokens/${tokenId}/reset`; + const expectedErrorMessage = "expires_at: must not be in the past"; + + expect.assertions(2); + + mock + .onPost(endpoint) + .reply(422, { errors: { expires_at: ["must not be in the past"] } }); + + try { + await apiTokensAPI.reset(tokenId, { + expires_at: "2020-01-01T00:00:00Z", + }); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + } + } + }); + it("fails with error.", async () => { const expectedErrorMessage = "Request failed with status code 404"; diff --git a/src/lib/api/resources/ApiTokens.ts b/src/lib/api/resources/ApiTokens.ts index 205c30e..049618f 100644 --- a/src/lib/api/resources/ApiTokens.ts +++ b/src/lib/api/resources/ApiTokens.ts @@ -5,6 +5,7 @@ import { ApiToken, ApiTokenWithToken, CreateApiTokenRequest, + ResetApiTokenRequest, } from "../../../types/api/api-tokens"; const { CLIENT_SETTINGS } = CONFIG; @@ -33,6 +34,9 @@ export default class ApiTokensApi { /** * Create a new API token for the account with the given name and resource permissions. * The full token value is returned only in the response of this call — store it securely. + * Unless `expires_at` is provided, the token expiration falls back to the server + * default (a 1-year default is being rolled out); pass `expires_at: null` for a + * token that never expires. */ public async create(params: CreateApiTokenRequest) { const url = this.apiTokensURL; @@ -54,10 +58,20 @@ export default class ApiTokensApi { * Reset an API token: expires the existing token and returns a new one with * the same permissions. The new token value is returned only in this response — * store it securely. Only tokens that have not already been reset can be reset. + * Unless `expires_at` is provided, the new token expiration falls back to the + * server default (a 1-year default is being rolled out); pass `expires_at: null` + * for a token that never expires. */ - public async reset(id: number) { + public async reset(id: number, params?: ResetApiTokenRequest) { const url = `${this.apiTokensURL}/${id}/reset`; + if (params && "expires_at" in params) { + return this.client.post( + url, + params + ); + } + return this.client.post(url); } diff --git a/src/types/api/api-tokens.ts b/src/types/api/api-tokens.ts index 472b0f2..1651898 100644 --- a/src/types/api/api-tokens.ts +++ b/src/types/api/api-tokens.ts @@ -16,6 +16,13 @@ export type ResourcePermission = { export type CreateApiTokenRequest = { name: string; + /** + * Optional token expiration as an ISO 8601 date-time. + * Omit for the server default (a 1-year default is being rolled out). + * Pass explicit `null` for a token that never expires. + * Past or more-than-5-years-ahead values are rejected with 422. + */ + expires_at?: string | null; resources?: ResourcePermissionInput[]; }; @@ -31,3 +38,13 @@ export type ApiToken = { export type ApiTokenWithToken = ApiToken & { token: string; }; + +export type ResetApiTokenRequest = { + /** + * Optional expiration for the new token as an ISO 8601 date-time. + * Omit for the server default (a 1-year default is being rolled out). + * Pass explicit `null` for a token that never expires. + * Past or more-than-5-years-ahead values are rejected with 422. + */ + expires_at?: string | null; +};