diff --git a/Projects/DVPresentation/Resources/Localizable.xcstrings b/Projects/DVPresentation/Resources/Localizable.xcstrings index 913bfa52..62dc6fda 100644 --- a/Projects/DVPresentation/Resources/Localizable.xcstrings +++ b/Projects/DVPresentation/Resources/Localizable.xcstrings @@ -9,9 +9,6 @@ }, "De" : { - }, - "Detail" : { - }, "Failed to load" : { "comment" : "A message displayed when loading projects fails.", @@ -26,6 +23,10 @@ }, "LOGO" : { + }, + "No secret selected" : { + "comment" : "A message displayed when a secret is not selected.", + "isCommentAutoGenerated" : true }, "No secrets" : { "comment" : "A message displayed when there are no secrets.", diff --git a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift index aaa7a8a0..ef110fe5 100644 --- a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift +++ b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift @@ -16,6 +16,9 @@ public struct SecretClient: Sendable { public var softDelete: @Sendable (_ id: Secret.ID) async throws -> Secret public var restore: @Sendable (_ id: Secret.ID) async throws -> Secret public var permanentlyDelete: @Sendable (_ id: Secret.ID) async throws -> Void + /// 생체인증 후 Secret의 암호화된 payload를 복호화해 `CreateSecretPayload`로 반환한다. + /// secretType/subType에 따라 적절한 도메인 payload 타입으로 dispatch하고 metadata JSON을 병합한다. + public var revealPayload: @Sendable (_ secret: Secret) async throws -> CreateSecretPayload // MARK: - Project @@ -53,9 +56,39 @@ private extension SecretClient { softDelete: { _ in .preview }, restore: { _ in .preview }, permanentlyDelete: { _ in }, + // secretType만 분기하므로 live의 dispatchRevealPayload와 달리 subType별 case를 + // 구분하지 않는다(.serviceAccount / .sslTlsCertificate / .custom / .accessToken / + // .webhookSecret). Preview·Test에서 payload 필드 구성이 실제 동작과 어긋나므로, + // SecretDetailView가 payload를 실제로 렌더링하는 후속 이슈에서 + // (secretType, subType) 쌍 기준으로 맞춘다. + revealPayload: { secret in + switch secret.secretType { + case .apiKeyToken: + return .apiKey(APIKeyPayload(value: "preview_api_key"), nil) + case .oauth: + return .oauthClient( + OAuthClientPayload(clientId: "preview_id", clientSecret: "preview_secret"), + nil + ) + case .database: + return .database(DatabasePayload(linkString: "postgresql://preview:5432/db"), nil) + case .sshAndCredentials: + return .sshKey( + SSHKeyPayload( + privateKey: "-----BEGIN RSA PRIVATE KEY-----\npreview\n-----END RSA PRIVATE KEY-----", + passphrase: nil + ), + nil + ) + case .environmentVariableSet: + return .environmentVariableSet(EnvSetPayload(content: "KEY=value\nOTHER=123")) + case .etc: + return .licenseKey(LicenseKeyPayload(licenseKey: "PREVIEW-LICENSE-1234"), nil) + } + }, fetchProjects: { .preview }, createProject: { name in - Project(id: UUID(), name: name, createdAt: .now, updatedAt: .now) + Project(id: UUID(), name: name, createdAt: .now, updatedAt: .now) }, linkProject: { _, _ in } ) diff --git a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift index 57b4b35d..bb5ec443 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift @@ -20,6 +20,8 @@ public struct MainFeature { @Presents var createProject: CreateProjectFeature.State? /// sheet가 아닌 2-column NavigationSplitView 전환 용도이므로 @Presents 미사용 var createSecret: CreateSecretFeature.State? + /// sheet가 아닌 3-column NavigationSplitView detail 컬럼 용도이므로 @Presents 미사용 + var secretDetail: SecretDetailFeature.State? public init() {} } @@ -40,6 +42,7 @@ public struct MainFeature { case selectSecretType(SelectSecretTypeFeature.Action) case createProject(PresentationAction) case createSecret(CreateSecretFeature.Action) + case secretDetail(SecretDetailFeature.Action) // MARK: - Delegate @@ -76,9 +79,28 @@ public struct MainFeature { case .sidebar: return .none + case .secretList(.delegate(.secretSelected(let id))): + if let id, case .loaded(let secrets) = state.secretList.secretsState, let secret = secrets[id: id] { + state.secretDetail = SecretDetailFeature.State(secret: secret) + } else { + state.secretDetail = nil + } + return .none + case .secretList: return .none + case .secretDetail(.delegate(.closed)): + state.secretDetail = nil + state.secretList.selectedSecretID = nil + return .none + + case .secretDetail(.delegate(.secretUpdated)): + return .none + + case .secretDetail: + return .none + case .selectSecretType(.delegate(.typeSelected(let secretType))): state.createSecret = CreateSecretFeature.State(secretType: secretType) return .none @@ -123,6 +145,9 @@ public struct MainFeature { .ifLet(\.createSecret, action: \.createSecret) { CreateSecretFeature() } + .ifLet(\.secretDetail, action: \.secretDetail) { + SecretDetailFeature() + } } } @@ -138,6 +163,7 @@ extension MainFeature { case .selectionChanged(let selection): state.selectSecretType = nil state.createSecret = nil + state.secretDetail = nil state.secretList = makeSecretListState(selection: selection, projects: state.sidebar.projects) return .send(.sidebar(.setCreatingSecret(false))) diff --git a/Projects/DVPresentation/Sources/Features/Main/MainView.swift b/Projects/DVPresentation/Sources/Features/Main/MainView.swift index bfebf1c3..40e3597e 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainView.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainView.swift @@ -77,8 +77,18 @@ extension MainView { } private var detailColumn: some View { - Text("Detail") - .navigationTitle("") + Group { + if let detailStore = store.scope(state: \.secretDetail, action: \.secretDetail) { + SecretDetailView(store: detailStore) + } else { + Text(.module("No secret selected")) + .dvFont(.captionLG) + .foregroundStyle(Color.dv(.gray700)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .navigationTitle("") + .navigationSplitViewColumnWidth(min: 420, ideal: 480) } } diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretDetailError.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretDetailError.swift new file mode 100644 index 00000000..55fec325 --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretDetailError.swift @@ -0,0 +1,99 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +import ComposableArchitecture +import DVDomain + +// MARK: - SecretDetailError + +/// Secret 조회/복호화 실패 시 Presentation 계층 오류. +enum SecretDetailError: Equatable { + /// 생체인증·패스코드 인증 요구. + case authRequired + /// 복호화 실패 (키 없음·복호화 오류·디코딩 실패). + case decryptionFailed + /// 그 외 예기치 않은 오류. + case unexpected + + static func map(_ error: SecretUseCaseError) -> SecretDetailError { + switch error { + case .authenticationFailure: + return .authRequired + case .cryptoFailure(let crypto): + switch crypto { + case .keyUnavailable, .keychainFailure, + .decryptionFailed, .decodingFailed: + return .decryptionFailed + case .encryptionFailed, .encodingFailed: + return .unexpected + } + case .secretNotFound, .repositoryFailure, + .invalidName, .invalidSecretType, .unexpected: + return .unexpected + } + } +} + +// MARK: - AlertState Presets + +extension AlertState where Action == SecretDetailFeature.Action.Alert { + + static func payloadRevealFailed(_ error: SecretDetailError) -> Self { + switch error { + case .authRequired: + return Self { + TextState("Authentication required", bundle: .module) + } actions: { + ButtonState(role: .cancel) { TextState("OK", bundle: .module) } + } message: { + TextState( + "Please authenticate to view the secret.", + bundle: .module + ) + } + + case .decryptionFailed: + return Self { + TextState("Failed to reveal secret", bundle: .module) + } actions: { + ButtonState(role: .cancel) { TextState("OK", bundle: .module) } + } message: { + TextState( + "The secret could not be decrypted. Check that your device passcode is enabled.", + bundle: .module + ) + } + + case .unexpected: + return Self { + TextState("Failed to reveal secret", bundle: .module) + } actions: { + ButtonState(role: .cancel) { TextState("OK", bundle: .module) } + } message: { + TextState( + "An unexpected error occurred. Please try again.", + bundle: .module + ) + } + } + } + + static var confirmDiscard: Self { + Self { + TextState("Discard changes?", bundle: .module) + } actions: { + ButtonState(role: .destructive, action: .confirmDiscard) { + TextState("Discard", bundle: .module) + } + ButtonState(role: .cancel) { + TextState("Keep editing", bundle: .module) + } + } message: { + TextState( + "Your unsaved changes will be lost.", + bundle: .module + ) + } + } +} diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift new file mode 100644 index 00000000..82dcf44f --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift @@ -0,0 +1,146 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +import ComposableArchitecture +import DVDomain + +// MARK: - SecretDetailFeature + +@Reducer +public struct SecretDetailFeature { + + // MARK: - Mode + + public enum Mode: Equatable { + /// 조회 모드: Text 전용 뷰 트리. 인터랙티브 컨트롤 없음. + case viewing + /// 수정 모드: 기존 SectionView 재사용. editFields 바인딩 있음. + case editing + } + + // MARK: - State + + @ObservableState + public struct State: Equatable { + /// 원본 시크릿. let — 절대 바인딩 대상이 아님. + public let secret: Secret + public var mode: Mode = .viewing + /// 수정 모드에서만 유효. viewing일 때는 반드시 nil. + var editFields: SecretMetaFields? + public var availableProjects: [Project] = [] + public var isSaving = false + public var isLoadingProjects = false + /// 복호화된 payload. .idle → .loading → .loaded / .failed 순서로 전이. + public var payloadState: LoadingState = .idle + @Presents public var alert: AlertState? + + public init(secret: Secret) { + self.secret = secret + } + } + + // MARK: - Action + + public enum Action: BindableAction, Equatable { + + // MARK: View + case task + case binding(BindingAction) + case didTapClose + case didTapEdit + case didTapCancelEdit + case didTapSave + + // MARK: Internal + case projectsResponse(Result<[Project], ProjectUseCaseError>) + case payloadResponse(Result) + + // MARK: Child + case alert(PresentationAction) + + // MARK: Delegate + case delegate(Delegate) + + public enum Alert: Equatable { + case confirmDiscard + } + + public enum Delegate: Equatable { + case closed + case secretUpdated(Secret) + } + } + + // MARK: - Dependencies + + @Dependency(\.projectClient) var projectClient + @Dependency(\.secretClient) var secretClient + + // MARK: - Init + + public init() {} + + // MARK: - Body + + public var body: some ReducerOf { + BindingReducer() + Reduce { state, action in + switch action { + case .task: + state.isLoadingProjects = true + state.payloadState = .loading + return .merge( + .run { send in + do { + let projects = try await projectClient.fetchProjects() + await send(.projectsResponse(.success(projects))) + } catch is CancellationError { + } catch { + await send(.projectsResponse(.failure(.unexpected))) + } + }, + .run { [secret = state.secret] send in + do { + let payload = try await secretClient.revealPayload(secret) + await send(.payloadResponse(.success(payload))) + } catch is CancellationError { + } catch { + await send(.payloadResponse(.failure(SecretUseCaseError.map(error)))) + } + } + ) + + case .didTapClose: + return .send(.delegate(.closed)) + + case .projectsResponse(.success(let projects)): + state.isLoadingProjects = false + state.availableProjects = projects + return .none + + case .projectsResponse(.failure): + state.isLoadingProjects = false + return .none + + case .payloadResponse(.success(let payload)): + state.payloadState = .loaded(payload) + return .none + + case .payloadResponse(.failure(let error)): + state.payloadState = .failed(error) + state.alert = .payloadRevealFailed(SecretDetailError.map(error)) + return .none + + // 편집 모드는 후속 이슈 범위다. 상태 전이가 없으므로 SecretDetailView는 + // Edit / Cancel / Save 컨트롤을 노출하지 않으며, 여기서는 액션 case만 예약해 둔다. + case .didTapEdit, .didTapCancelEdit, .didTapSave: + return .none + + case .binding, .alert, .delegate: + return .none + } + } + .ifLet(\.$alert, action: \.alert) + } +} diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift new file mode 100644 index 00000000..b1eb2b05 --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift @@ -0,0 +1,151 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +import ComposableArchitecture +import DVDesign +import DVDomain + +// MARK: - SecretDetailView + +public struct SecretDetailView: View { + + // MARK: - Properties + + @Bindable public var store: StoreOf + + // MARK: - Init + + public init(store: StoreOf) { + self.store = store + } + + // MARK: - Body + + public var body: some View { + VStack(spacing: 0) { + topBar + Group { + if store.mode == .viewing { + viewingBody + } else { + editingBody + } + } + if store.mode == .editing { + Divider() + HStack { + Spacer() + Button("Cancel") { store.send(.didTapCancelEdit) } + .buttonStyle(.plain) + Button("Save") { store.send(.didTapSave) } + .buttonStyle(.borderedProminent) + } + .padding() + } + } + .task { store.send(.task) } + .alert($store.scope(state: \.alert, action: \.alert)) + } +} + +// MARK: - Subviews + +extension SecretDetailView { + + private var topBar: some View { + VStack(spacing: 0) { + HStack { + Button { store.send(.didTapClose) } label: { + Image(systemName: "xmark") + .imageScale(.medium) + } + .buttonStyle(.plain) + Spacer() + // 편집 모드는 후속 이슈에서 구현한다. 상태 전이가 없는 동안 + // 눌러도 반응하지 않는 컨트롤을 노출하지 않기 위해 Edit 버튼을 숨긴다. + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + Divider() + } + } + + // MARK: Viewing + + /// 현재는 name만 노출하는 뼈대다. 후속 이슈에서 `store.payloadState`의 + /// loading / loaded / failed를 분기하고, loaded에서 `CreateSecretPayload` + /// 유형별 필드와 `store.secret` 메타 정보를 렌더링한다. + @ViewBuilder + private var viewingBody: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Text(store.secret.name) + .dvFont(.headingLG) + .foregroundStyle(Color.dv(.gray900)) + .padding(.horizontal, 20) + .padding(.top, 20) + } + } + } + + // MARK: Editing + + /// 편집 모드 진입 경로가 아직 없어 도달하지 않는다. 후속 이슈에서 + /// `editFields` 바인딩과 SectionView 재사용으로 채운다. + @ViewBuilder + private var editingBody: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Text(store.secret.name) + .dvFont(.headingLG) + .foregroundStyle(Color.dv(.gray900)) + .padding(.horizontal, 20) + .padding(.top, 20) + } + } + } +} + +// MARK: - Preview + +#if DEBUG + +private let _previewSecret = Secret( + id: UUID(), + name: "GitHub Personal Token", + secretType: .apiKeyToken, + service: "GitHub", + environment: "production", + createdAt: Date(), + updatedAt: Date(), + payload: SecretPayload(encryptedData: Data(), keyTag: "preview", schemaVersion: 1) +) + +#Preview("SecretDetail · Viewing") { + SecretDetailView( + store: Store( + initialState: SecretDetailFeature.State(secret: _previewSecret) + ) { + SecretDetailFeature() + } + ) + .frame(width: 420, height: 700) +} + +#Preview("SecretDetail · Editing") { + SecretDetailView( + store: Store( + initialState: { + var state = SecretDetailFeature.State(secret: _previewSecret) + state.mode = .editing + return state + }() + ) { + SecretDetailFeature() + } + ) + .frame(width: 420, height: 700) +} + +#endif diff --git a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift index a4f86b78..cd7363ca 100644 --- a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift @@ -1,6 +1,7 @@ // Copyright © 2026 Devault. All rights reserved import ComposableArchitecture +import DVDomain import Foundation import Testing @@ -195,6 +196,87 @@ struct MainFeatureTests { } } + // MARK: - SecretDetail Routing + + @Test("secretSelected(id)는 해당 Secret으로 secretDetail을 세팅한다") + func secretSelectedSetsSecretDetail() async { + let secret = Secret( + id: UUID(), + name: "Test Token", + secretType: .apiKeyToken, + createdAt: Date(), + updatedAt: Date(), + payload: SecretPayload(encryptedData: Data(), keyTag: "test", schemaVersion: 1) + ) + + var initial = MainFeature.State() + initial.secretList.secretsState = .loaded([secret]) + + let store = TestStore(initialState: initial) { + MainFeature() + } + + await store.send(.secretList(.didSelectSecret(id: secret.id))) { + $0.secretList.selectedSecretID = secret.id + } + await store.receive(.secretList(.delegate(.secretSelected(secret.id)))) { + $0.secretDetail = SecretDetailFeature.State(secret: secret) + } + } + + @Test("secretSelected(nil)은 secretDetail을 닫는다") + func secretSelectedNilClearsSecretDetail() async { + let secret = Secret( + id: UUID(), + name: "Test Token", + secretType: .apiKeyToken, + createdAt: Date(), + updatedAt: Date(), + payload: SecretPayload(encryptedData: Data(), keyTag: "test", schemaVersion: 1) + ) + + var initial = MainFeature.State() + initial.secretList.secretsState = .loaded([secret]) + initial.secretList.selectedSecretID = secret.id + initial.secretDetail = SecretDetailFeature.State(secret: secret) + + let store = TestStore(initialState: initial) { + MainFeature() + } + + await store.send(.secretList(.didSelectSecret(id: nil))) { + $0.secretList.selectedSecretID = nil + } + await store.receive(.secretList(.delegate(.secretSelected(nil)))) { + $0.secretDetail = nil + } + } + + @Test("secretDetail closed delegate는 secretDetail과 selectedSecretID를 초기화한다") + func secretDetailClosedClearsDetail() async { + let secret = Secret( + id: UUID(), + name: "Test Token", + secretType: .apiKeyToken, + createdAt: Date(), + updatedAt: Date(), + payload: SecretPayload(encryptedData: Data(), keyTag: "test", schemaVersion: 1) + ) + + var initial = MainFeature.State() + initial.secretList.selectedSecretID = secret.id + initial.secretDetail = SecretDetailFeature.State(secret: secret) + + let store = TestStore(initialState: initial) { + MainFeature() + } + + await store.send(.secretDetail(.delegate(.closed))) { + $0.secretDetail = nil + $0.secretList.selectedSecretID = nil + } + } + // MARK: - Rename Delegate @Test("projectRenamed는 현재 선택된 프로젝트의 이름이면 secretList 타이틀을 갱신한다") diff --git a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift new file mode 100644 index 00000000..6ccf9a1d --- /dev/null +++ b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift @@ -0,0 +1,135 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation +import Testing + +import ComposableArchitecture +import DVDomain + +@testable import DVPresentation + +@Suite("SecretDetailFeature") +@MainActor +struct SecretDetailFeatureTests { + + // MARK: - Helpers + + private static func makeSecret( + name: String = "Test Secret", + secretType: SecretType = .apiKeyToken + ) -> Secret { + Secret( + id: UUID(), + name: name, + secretType: secretType, + createdAt: Date(), + updatedAt: Date(), + payload: SecretPayload(encryptedData: Data(), keyTag: "test", schemaVersion: 1) + ) + } + + // MARK: - Viewing 진입 + + @Test("초기 mode는 .viewing이다") + func initialModeIsViewing() async { + let secret = Self.makeSecret() + let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { + SecretDetailFeature() + } + + store.assert { state in + #expect(state.mode == .viewing) + #expect(state.editFields == nil) + } + } + + @Test("task 성공: projects + payload 모두 로드됨") + func task_success() async { + let secret = Self.makeSecret() + let projects = [ + Project(id: UUID(), name: "Backend", createdAt: Date(), updatedAt: Date()), + ] + let payload = CreateSecretPayload.apiKey(APIKeyPayload(value: "test_token"), nil) + + let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { + SecretDetailFeature() + } withDependencies: { + $0.projectClient.fetchProjects = { projects } + $0.secretClient.revealPayload = { _ in payload } + } + + await store.send(.task) { + $0.isLoadingProjects = true + $0.payloadState = .loading + } + // mock이 동기 반환이므로 .merge 선언 순서대로 수신됨 (projects → payload) + await store.receive(.projectsResponse(.success(projects))) { + $0.isLoadingProjects = false + $0.availableProjects = projects + } + await store.receive(.payloadResponse(.success(payload))) { + $0.payloadState = .loaded(payload) + } + } + + @Test("task payload 실패: payloadState .failed + alert 노출") + func task_payloadFailure() async { + let secret = Self.makeSecret() + + let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { + SecretDetailFeature() + } withDependencies: { + $0.projectClient.fetchProjects = { [] } + $0.secretClient.revealPayload = { _ in throw SecretUseCaseError.cryptoFailure(.decryptionFailed) } + } + + await store.send(.task) { + $0.isLoadingProjects = true + $0.payloadState = .loading + } + await store.receive(.projectsResponse(.success([]))) { + $0.isLoadingProjects = false + } + await store.receive(.payloadResponse(.failure(.cryptoFailure(.decryptionFailed)))) { + $0.payloadState = .failed(.cryptoFailure(.decryptionFailed)) + $0.alert = .payloadRevealFailed(.decryptionFailed) + } + } + + @Test("task payload 인증 실패: authRequired alert 노출") + func task_payloadAuthFailure() async { + let secret = Self.makeSecret() + + let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { + SecretDetailFeature() + } withDependencies: { + $0.projectClient.fetchProjects = { [] } + $0.secretClient.revealPayload = { _ in throw SecretUseCaseError.authenticationFailure(.cancelled) } + } + + await store.send(.task) { + $0.isLoadingProjects = true + $0.payloadState = .loading + } + await store.receive(.projectsResponse(.success([]))) { + $0.isLoadingProjects = false + } + await store.receive(.payloadResponse(.failure(.authenticationFailure(.cancelled)))) { + $0.payloadState = .failed(.authenticationFailure(.cancelled)) + $0.alert = .payloadRevealFailed(.authRequired) + } + } + + // MARK: - Close delegate + + @Test("didTapClose는 delegate(.closed)를 emit한다") + func didTapClose_emitsClosedDelegate() async { + let secret = Self.makeSecret() + let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { + SecretDetailFeature() + } + + await store.send(.didTapClose) + await store.receive(.delegate(.closed)) + } +} diff --git a/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift index bf033fe7..4e4ab913 100644 --- a/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift +++ b/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift @@ -44,7 +44,9 @@ extension SecretClient: @retroactive DependencyKey { permanentlyDelete: { id in try await deleteSecretUseCase.permanentlyDelete(id: id) }, - fetchProjects: { + revealPayload: { secret in + try await dispatchRevealPayload(secret: secret, useCase: fetchSecretUseCase) + }, fetchProjects: { try await fetchProjectUseCase.fetchAll() }, createProject: { name in @@ -56,3 +58,53 @@ extension SecretClient: @retroactive DependencyKey { ) }() } + +// MARK: - Payload Dispatch + +private func dispatchRevealPayload( + secret: Secret, + useCase: any FetchSecretUseCase +) async throws -> CreateSecretPayload { + func decodeMeta(_ type: M.Type) -> M? { + secret.metadata.flatMap { try? JSONDecoder().decode(M.self, from: $0.metadataJSON) } + } + + switch (secret.secretType, secret.subType) { + case (.apiKeyToken, .apiKey), (.apiKeyToken, nil): + let p = try await useCase.revealPayload(id: secret.id, as: APIKeyPayload.self) + return .apiKey(p, decodeMeta(APIKeyMetadata.self)) + case (.apiKeyToken, .accessToken): + let p = try await useCase.revealPayload(id: secret.id, as: APIKeyPayload.self) + return .accessToken(p, decodeMeta(APIKeyMetadata.self)) + case (.apiKeyToken, .webhookSecret): + let p = try await useCase.revealPayload(id: secret.id, as: APIKeyPayload.self) + return .webhookSecret(p, decodeMeta(APIKeyMetadata.self)) + case (.oauth, .oauthClient), (.oauth, nil): + let p = try await useCase.revealPayload(id: secret.id, as: OAuthClientPayload.self) + return .oauthClient(p, decodeMeta(OAuthClientMetadata.self)) + case (.oauth, .serviceAccount): + let p = try await useCase.revealPayload(id: secret.id, as: ServiceAccountPayload.self) + return .serviceAccount(p, decodeMeta(ServiceAccountMetadata.self)) + case (.database, _): + let p = try await useCase.revealPayload(id: secret.id, as: DatabasePayload.self) + return .database(p, decodeMeta(DatabaseMetadata.self)) + case (.sshAndCredentials, .sshKey), (.sshAndCredentials, nil): + let p = try await useCase.revealPayload(id: secret.id, as: SSHKeyPayload.self) + return .sshKey(p, decodeMeta(SSHKeyMetadata.self)) + case (.sshAndCredentials, .sslTlsCertificate): + let p = try await useCase.revealPayload(id: secret.id, as: SSLCertPayload.self) + return .sslTlsCertificate(p, decodeMeta(SSLCertMetadata.self)) + case (.environmentVariableSet, _): + let p = try await useCase.revealPayload(id: secret.id, as: EnvSetPayload.self) + return .environmentVariableSet(p) + case (.etc, .licenseKey), (.etc, nil): + let p = try await useCase.revealPayload(id: secret.id, as: LicenseKeyPayload.self) + return .licenseKey(p, decodeMeta(LicenseKeyMetadata.self)) + case (.etc, .custom): + let p = try await useCase.revealPayload(id: secret.id, as: CustomPayload.self) + return .custom(p) + default: + assertionFailure("Unexpected (secretType, subType) combination: \(secret.secretType), \(String(describing: secret.subType))") + throw SecretUseCaseError.unexpected + } +}