diff --git a/pkg/app/web/src/__fixtures__/dummy-api-key.ts b/pkg/app/web/src/__fixtures__/dummy-api-key.ts new file mode 100644 index 0000000000..76daf8fe2a --- /dev/null +++ b/pkg/app/web/src/__fixtures__/dummy-api-key.ts @@ -0,0 +1,13 @@ +import { APIKeyModel } from "../modules/api-keys"; + +export const dummyAPIKey = { + id: "api-key-1", + name: "API_KEY_1", + keyHash: "KEY_HASH", + projectId: "pipecd", + role: APIKeyModel.Role.READ_WRITE, + creator: "user", + disabled: false, + createdAt: 0, + updatedAt: 0, +}; diff --git a/pkg/app/web/src/api/api-keys.ts b/pkg/app/web/src/api/api-keys.ts new file mode 100644 index 0000000000..2945f15b7a --- /dev/null +++ b/pkg/app/web/src/api/api-keys.ts @@ -0,0 +1,46 @@ +import { apiClient, apiRequest } from "./client"; +import { + GenerateAPIKeyRequest, + GenerateAPIKeyResponse, + DisableAPIKeyRequest, + DisableAPIKeyResponse, + ListAPIKeysRequest, + ListAPIKeysResponse, +} from "pipe/pkg/app/web/api_client/service_pb"; +import * as google_protobuf_wrappers_pb from "google-protobuf/google/protobuf/wrappers_pb"; + +export const getAPIKeys = ({ + options, +}: { + options: { + enabled: boolean; + }; +}): Promise => { + const req = new ListAPIKeysRequest(); + const opt = new ListAPIKeysRequest.Options(); + const enabled = new google_protobuf_wrappers_pb.BoolValue(); + enabled.setValue(options.enabled); + opt.setEnabled(enabled); + req.setOptions(opt); + return apiRequest(req, apiClient.listAPIKeys); +}; + +export const generateAPIKey = ({ + name, + role, +}: GenerateAPIKeyRequest.AsObject): Promise< + GenerateAPIKeyResponse.AsObject +> => { + const req = new GenerateAPIKeyRequest(); + req.setName(name); + req.setRole(role); + return apiRequest(req, apiClient.generateAPIKey); +}; + +export const disableAPIKey = ({ + id, +}: DisableAPIKeyRequest.AsObject): Promise => { + const req = new DisableAPIKeyRequest(); + req.setId(id); + return apiRequest(req, apiClient.disableAPIKey); +}; diff --git a/pkg/app/web/src/modules/api-keys.test.ts b/pkg/app/web/src/modules/api-keys.test.ts new file mode 100644 index 0000000000..4d41e80e0f --- /dev/null +++ b/pkg/app/web/src/modules/api-keys.test.ts @@ -0,0 +1,161 @@ +import { dummyAPIKey } from "../__fixtures__/dummy-api-key"; +import { + APIKeyModel, + apiKeysSlice, + disableAPIKey, + generateAPIKey, + fetchAPIKeys, +} from "./api-keys"; + +const baseState = { + error: null, + items: [], + generatedKey: null, + loading: false, + generating: false, + disabling: false, +}; + +describe("apiKeysSlice reducer", () => { + it("should handle initial state", () => { + expect( + apiKeysSlice.reducer(undefined, { + type: "TEST_ACTION", + }) + ).toEqual(baseState); + }); + + describe("generateAPIKey", () => { + const arg = { + name: "new API key", + role: APIKeyModel.Role.READ_ONLY, + }; + it(`should handle ${generateAPIKey.pending.type}`, () => { + expect( + apiKeysSlice.reducer(baseState, { + type: generateAPIKey.pending.type, + meta: { + arg, + }, + }) + ).toEqual({ ...baseState, generating: true }); + }); + + it(`should handle ${generateAPIKey.rejected.type}`, () => { + expect( + apiKeysSlice.reducer( + { ...baseState, generating: true }, + { + type: generateAPIKey.rejected.type, + error: { message: "API_ERROR" }, + meta: { + arg, + }, + } + ) + ).toEqual({ ...baseState, error: { message: "API_ERROR" } }); + }); + + it(`should handle ${generateAPIKey.fulfilled.type}`, () => { + expect( + apiKeysSlice.reducer( + { ...baseState, generating: true }, + { + type: generateAPIKey.fulfilled.type, + payload: "API_KEY", + meta: { arg }, + } + ) + ).toEqual({ ...baseState, generatedKey: "API_KEY", generating: false }); + }); + }); + + describe("fetchAPIKeys", () => { + const arg = { + enabled: true, + }; + it(`should handle ${fetchAPIKeys.pending.type}`, () => { + expect( + apiKeysSlice.reducer(baseState, { + type: fetchAPIKeys.pending.type, + meta: { + arg, + }, + }) + ).toEqual({ ...baseState, loading: true }); + }); + + it(`should handle ${fetchAPIKeys.rejected.type}`, () => { + expect( + apiKeysSlice.reducer( + { ...baseState, loading: true }, + { + type: fetchAPIKeys.rejected.type, + error: { message: "API_ERROR" }, + meta: { + arg, + }, + } + ) + ).toEqual({ ...baseState, error: { message: "API_ERROR" } }); + }); + + it(`should handle ${fetchAPIKeys.fulfilled.type}`, () => { + expect( + apiKeysSlice.reducer( + { ...baseState, loading: true }, + { + type: fetchAPIKeys.fulfilled.type, + payload: [dummyAPIKey], + meta: { arg }, + } + ) + ).toEqual({ ...baseState, items: [dummyAPIKey] }); + }); + }); + + describe("disableAPIKey", () => { + const arg = { + id: "api-key-1", + }; + + it(`should handle ${disableAPIKey.pending.type}`, () => { + expect( + apiKeysSlice.reducer(baseState, { + type: disableAPIKey.pending.type, + meta: { + arg, + }, + }) + ).toEqual({ ...baseState, disabling: true }); + }); + + it(`should handle ${disableAPIKey.rejected.type}`, () => { + expect( + apiKeysSlice.reducer( + { ...baseState, disabling: true }, + { + type: disableAPIKey.rejected.type, + error: { message: "API_ERROR" }, + meta: { + arg, + }, + } + ) + ).toEqual({ ...baseState, error: { message: "API_ERROR" } }); + }); + + it(`should handle ${disableAPIKey.fulfilled.type}`, () => { + expect( + apiKeysSlice.reducer( + { ...baseState, disabling: true }, + { + type: disableAPIKey.fulfilled.type, + payload: [], + meta: { arg }, + } + ) + ).toEqual(baseState); + }); + }); +}); diff --git a/pkg/app/web/src/modules/api-keys.ts b/pkg/app/web/src/modules/api-keys.ts new file mode 100644 index 0000000000..ae900a467a --- /dev/null +++ b/pkg/app/web/src/modules/api-keys.ts @@ -0,0 +1,99 @@ +import { + createAsyncThunk, + createSlice, + SerializedError, +} from "@reduxjs/toolkit"; +import { APIKey as APIKeyModel } from "pipe/pkg/app/web/model/apikey_pb"; +import * as APIKeysAPI from "../api/api-keys"; + +const MODULE_NAME = "apiKeys"; + +export type APIKey = APIKeyModel.AsObject; +interface ApiKeys { + items: APIKey[]; + generatedKey: string | null; + loading: boolean; + generating: boolean; + disabling: boolean; + error: null | SerializedError; +} + +const initialState: ApiKeys = { + items: [], + generatedKey: null, + loading: false, + generating: false, + disabling: false, + error: null, +}; + +export const generateAPIKey = createAsyncThunk< + string, + { name: string; role: APIKeyModel.Role } +>(`${MODULE_NAME}/generate`, async ({ name, role }) => { + const res = await APIKeysAPI.generateAPIKey({ name, role }); + return res.key; +}); + +export const fetchAPIKeys = createAsyncThunk( + `${MODULE_NAME}/getList`, + async (options) => { + const res = await APIKeysAPI.getAPIKeys({ options }); + return res.keysList; + } +); + +export const disableAPIKey = createAsyncThunk( + `${MODULE_NAME}/disable`, + async ({ id }) => { + await APIKeysAPI.disableAPIKey({ id }); + } +); + +export const apiKeysSlice = createSlice({ + name: "apiKeys", + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + // generateAPIKey + .addCase(generateAPIKey.pending, (state) => { + state.generating = true; + state.generatedKey = null; + state.error = null; + }) + .addCase(generateAPIKey.rejected, (state, action) => { + state.generating = false; + state.error = action.error; + }) + .addCase(generateAPIKey.fulfilled, (state, action) => { + state.generating = false; + state.generatedKey = action.payload; + }) + // fetchAPIKeys + .addCase(fetchAPIKeys.pending, (state) => { + state.loading = true; + }) + .addCase(fetchAPIKeys.rejected, (state, action) => { + state.loading = false; + state.error = action.error; + }) + .addCase(fetchAPIKeys.fulfilled, (state, action) => { + state.loading = false; + state.items = action.payload; + }) + // disableAPIKey + .addCase(disableAPIKey.pending, (state) => { + state.disabling = true; + }) + .addCase(disableAPIKey.rejected, (state, action) => { + state.disabling = false; + state.error = action.error; + }) + .addCase(disableAPIKey.fulfilled, (state) => { + state.disabling = false; + }); + }, +}); + +export { APIKey as APIKeyModel } from "pipe/pkg/app/web/model/apikey_pb";