From 4c6aae51d2ef9552e6d1b5c461614ce908551150 Mon Sep 17 00:00:00 2001 From: lvlcn-t <75443136+lvlcn-t@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:15:06 +0200 Subject: [PATCH 1/2] feat(tui): add CRUD foundation (form, state, keys, delete ops) Foundation for adding create/edit/rename/delete to the TUI, wired per tab in follow-up PRs. No tab emits CRUD actions yet, so the TUI is behaviorally unchanged. - contexts.Manager: add DeleteTenant and DeleteCredential (deletion of an entry still referenced by a context is allowed and left for the caller to warn about); config.Config gains DeleteTenant/DeleteCredential. - tui/form: new reusable multi-field text-input form (focus navigation, required/validator checks, Submitted/Canceled messages), fully unit tested. - tui/state: add FormView and ConfirmView states plus a SetConfig setter to swap the store after a reload. - tui/keys: add Create (n), Edit (e), Rename (r), Delete (ctrl+d) bindings. - tui/tabs: add CRUD TabAction kinds, tabKeys fields, and a Reload() method on every tab to rebuild items from the current store after a write. The contexts.Manager dependency injection and handleAction dispatch land in PR2 alongside the first tab (tenants) that exercises them, keeping this PR free of unused code. Signed-off-by: lvlcn-t <75443136+lvlcn-t@users.noreply.github.com> --- config/config.go | 24 +++++ config/config_test.go | 20 ++++ contexts/contexts.go | 63 ++++++++++++- contexts/contexts_test.go | 42 +++++++++ tui/form/form.go | 191 ++++++++++++++++++++++++++++++++++++++ tui/form/form_test.go | 121 ++++++++++++++++++++++++ tui/keys/keys.go | 4 + tui/state/mode.go | 4 + tui/state/state.go | 7 ++ tui/styles/styles.go | 1 + tui/tabs/browse.go | 21 ++++- tui/tabs/contexts.go | 5 + tui/tabs/credentials.go | 5 +- tui/tabs/tab.go | 14 +++ tui/tabs/tenants.go | 5 +- 15 files changed, 511 insertions(+), 16 deletions(-) create mode 100644 tui/form/form.go create mode 100644 tui/form/form_test.go diff --git a/config/config.go b/config/config.go index 3a8a919..a23b6a4 100644 --- a/config/config.go +++ b/config/config.go @@ -119,6 +119,30 @@ func (cfg *Config) DeleteContext(name string) bool { return false } +// DeleteTenant removes a tenant by name. +func (cfg *Config) DeleteTenant(name string) bool { + for index, tenant := range cfg.Tenants { + if tenant.Name == name { + cfg.Tenants = append(cfg.Tenants[:index], cfg.Tenants[index+1:]...) + return true + } + } + + return false +} + +// DeleteCredential removes a credential by name. +func (cfg *Config) DeleteCredential(name string) bool { + for index, credential := range cfg.Credentials { + if credential.Name == name { + cfg.Credentials = append(cfg.Credentials[:index], cfg.Credentials[index+1:]...) + return true + } + } + + return false +} + // RenameContext renames an existing context. func (cfg *Config) RenameContext(oldName, newName string) bool { for index, context := range cfg.Contexts { diff --git a/config/config_test.go b/config/config_test.go index 610a851..25d1ac3 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -76,6 +76,26 @@ func TestUpsertsAndDeletes(t *testing.T) { assert.False(t, cfg.DeleteContext(ctx2.Name)) } +func TestDeleteTenant(t *testing.T) { + cfg := newTestConfig(t). + withTenant("corp"). + build() + + assert.True(t, cfg.DeleteTenant("corp")) + assert.Empty(t, cfg.Tenants) + assert.False(t, cfg.DeleteTenant("corp")) +} + +func TestDeleteCredential(t *testing.T) { + cfg := newTestConfig(t). + withUserCredential("me"). + build() + + assert.True(t, cfg.DeleteCredential("me")) + assert.Empty(t, cfg.Credentials) + assert.False(t, cfg.DeleteCredential("me")) +} + func TestRenameContext(t *testing.T) { cfg := newTestConfig(t). withContext("old", "corp", "ci"). diff --git a/contexts/contexts.go b/contexts/contexts.go index f23514f..f5088e9 100644 --- a/contexts/contexts.go +++ b/contexts/contexts.go @@ -131,11 +131,12 @@ func (m *Manager) RenameContext(store *config.Store, oldName, newName string) er return nil } -// DeleteResult reports the outcome of a DeleteContext call. +// DeleteResult reports the outcome of a delete call. type DeleteResult struct { - // Path is the config file the context was removed from. + // Path is the config file the entry was removed from. Path string - // WasActive reports whether the deleted context was the current one. + // WasActive reports whether the deleted context was the current one. It is + // always false for tenants and credentials. WasActive bool } @@ -159,6 +160,62 @@ func (m *Manager) DeleteContext(store *config.Store, name string) (DeleteResult, return DeleteResult{Path: path, WasActive: store.Config.CurrentContext == name}, nil } +// DeleteTenant removes a tenant entry and reports where it lived. Deleting a +// tenant that is still referenced by a context leaves that context dangling; +// callers should warn the user. +func (m *Manager) DeleteTenant(store *config.Store, name string) (DeleteResult, error) { + _, found := store.Config.TenantByName(name) + return m.deleteEntry(store, entryTarget{ + kind: "tenant", + name: name, + found: found, + path: store.PathForTenant(name), + deleteX: func(cfg *config.Config) bool { return cfg.DeleteTenant(name) }, + }) +} + +// DeleteCredential removes a credential entry and reports where it lived. +// Deleting a credential that is still referenced by a context leaves that +// context dangling; callers should warn the user. +func (m *Manager) DeleteCredential(store *config.Store, name string) (DeleteResult, error) { + _, found := store.Config.CredentialByName(name) + return m.deleteEntry(store, entryTarget{ + kind: "credential", + name: name, + found: found, + path: store.PathForCredential(name), + deleteX: func(cfg *config.Config) bool { return cfg.DeleteCredential(name) }, + }) +} + +// entryTarget describes a non-context entry to delete. +type entryTarget struct { + deleteX func(cfg *config.Config) bool + kind string + name string + path string + found bool +} + +// deleteEntry removes a tenant or credential entry. Contexts use DeleteContext +// directly because they additionally report whether the active context changed. +func (m *Manager) deleteEntry(store *config.Store, t entryTarget) (DeleteResult, error) { + if !t.found { + return DeleteResult{}, fmt.Errorf("%s %q not found", t.kind, t.name) + } + + cfg := store.FileConfig(t.path) + if deleted := t.deleteX(&cfg); !deleted { + return DeleteResult{}, fmt.Errorf("%s %q not found in %q", t.kind, t.name, t.path) + } + + if err := m.Writer.Write(t.path, &cfg); err != nil { + return DeleteResult{}, err + } + + return DeleteResult{Path: t.path}, nil +} + // merge resolves the effective context payload for an upsert. For a new context // it returns next unchanged. For an existing one it overlays only the fields // that were provided: non-empty tenant and credential, and the subscription diff --git a/contexts/contexts_test.go b/contexts/contexts_test.go index 7d8191a..403c808 100644 --- a/contexts/contexts_test.go +++ b/contexts/contexts_test.go @@ -238,3 +238,45 @@ func TestManager_DeleteContext_NotFound(t *testing.T) { _, err := New().DeleteContext(store, "ghost") require.Error(t, err) } + +func TestManager_DeleteTenant(t *testing.T) { + path := writeConfig(t, baseConfig()) + store := loadStore(t) + + result, err := New().DeleteTenant(store, "platform") + require.NoError(t, err) + assert.Equal(t, path, result.Path) + assert.False(t, result.WasActive) + + _, found := readConfig(t, path).TenantByName("platform") + assert.False(t, found) +} + +func TestManager_DeleteTenant_NotFound(t *testing.T) { + writeConfig(t, baseConfig()) + store := loadStore(t) + + _, err := New().DeleteTenant(store, "ghost") + require.Error(t, err) +} + +func TestManager_DeleteCredential(t *testing.T) { + path := writeConfig(t, baseConfig()) + store := loadStore(t) + + result, err := New().DeleteCredential(store, "sp") + require.NoError(t, err) + assert.Equal(t, path, result.Path) + assert.False(t, result.WasActive) + + _, found := readConfig(t, path).CredentialByName("sp") + assert.False(t, found) +} + +func TestManager_DeleteCredential_NotFound(t *testing.T) { + writeConfig(t, baseConfig()) + store := loadStore(t) + + _, err := New().DeleteCredential(store, "ghost") + require.Error(t, err) +} diff --git a/tui/form/form.go b/tui/form/form.go new file mode 100644 index 0000000..4852d97 --- /dev/null +++ b/tui/form/form.go @@ -0,0 +1,191 @@ +// Package form provides a reusable multi-field text-input form for the TUI. +// It orchestrates an ordered set of fields, handles focus navigation and +// validation, and emits a Submitted or Canceled message. It knows nothing +// about the domain: callers map the submitted values back to their types. +package form + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/lvlcn-t/azctx/tui/keys" + "github.com/lvlcn-t/azctx/tui/styles" +) + +// Field describes a single form input. +type Field struct { + Validate func(value string) error + Key string + Label string + Placeholder string + Value string + Required bool +} + +// Submitted is emitted when the form passes validation and the user submits. +type Submitted struct { + // Values maps each Field.Key to its final trimmed value. + Values map[string]string +} + +// Canceled is emitted when the user aborts the form. +type Canceled struct{} + +// Model is a focusable multi-field form. +type Model struct { + title string + err string + keys formKeys + fields []Field + inputs []textinput.Model + focus int + width int +} + +type formKeys struct { + Next key.Binding + Prev key.Binding + Submit key.Binding + Cancel key.Binding +} + +func newFormKeys() formKeys { + return formKeys{ + Next: keys.New(keys.Tab).WithHelp("next").WithAliases(keys.ArrowDown).Bind(), + Prev: keys.New(keys.ShiftTab).WithHelp("prev").WithAliases(keys.ArrowUp).Bind(), + Submit: keys.New(keys.Enter).WithHelp("submit").Bind(), + Cancel: keys.New(keys.Escape).WithHelp("cancel").Bind(), + } +} + +// New builds a form from the given title and fields. The first field is +// focused. +func New(title string, fields []Field) Model { + inputs := make([]textinput.Model, len(fields)) + for i, f := range fields { + in := textinput.New() + in.Placeholder = f.Placeholder + in.SetValue(f.Value) + if i == 0 { + in.Focus() + } + inputs[i] = in + } + + return Model{ + title: title, + fields: fields, + inputs: inputs, + keys: newFormKeys(), + } +} + +// SetWidth records the available width for layout. +func (m *Model) SetWidth(width int) { + m.width = width +} + +// Update handles navigation, editing, submission, and cancellation. It returns +// a Submitted or Canceled tea.Cmd when the form terminates. +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { //nolint:gocritic // Bubble Tea value-receiver idiom; refactor tracked separately + if _, ok := msg.(tea.KeyMsg); ok { + switch { + case keys.Matches(msg, m.keys.Cancel): + return m, cancel + + case keys.Matches(msg, m.keys.Submit): + return m.trySubmit() + + case keys.Matches(msg, m.keys.Next): + m.focusDelta(1) + return m, nil + + case keys.Matches(msg, m.keys.Prev): + m.focusDelta(-1) + return m, nil + } + } + + var cmd tea.Cmd + m.inputs[m.focus], cmd = m.inputs[m.focus].Update(msg) + return m, cmd +} + +// trySubmit validates every field and, on success, emits Submitted. +func (m Model) trySubmit() (Model, tea.Cmd) { //nolint:gocritic // Bubble Tea value-receiver idiom; refactor tracked separately + values := make(map[string]string, len(m.fields)) + for i, f := range m.fields { + value := strings.TrimSpace(m.inputs[i].Value()) + if f.Required && value == "" { + m.err = f.Label + " is required" + m.focusOn(i) + return m, nil + } + + if f.Validate != nil { + if err := f.Validate(value); err != nil { + m.err = err.Error() + m.focusOn(i) + return m, nil + } + } + + values[f.Key] = value + } + + return m, submit(values) +} + +func (m *Model) focusDelta(delta int) { + m.err = "" + m.focusOn((m.focus + delta + len(m.inputs)) % len(m.inputs)) +} + +// focusOn moves focus to index without touching the error message. +func (m *Model) focusOn(index int) { + m.inputs[m.focus].Blur() + m.focus = index + m.inputs[m.focus].Focus() +} + +// View renders the form as a bordered panel. +func (m Model) View() string { //nolint:gocritic // Bubble Tea value-receiver idiom; refactor tracked separately + labelWidth := 0 + for _, f := range m.fields { + if len(f.Label) > labelWidth { + labelWidth = len(f.Label) + } + } + + labelStyle := lipgloss.NewStyle().Width(labelWidth + 1).Foreground(styles.ColorPrimary) + + var rows []string + rows = append(rows, styles.TitleStyle.Render(m.title), "") + for i, f := range m.fields { + row := lipgloss.JoinHorizontal( + lipgloss.Top, + labelStyle.Render(f.Label), + m.inputs[i].View(), + ) + rows = append(rows, row) + } + + if m.err != "" { + rows = append(rows, "", styles.ErrorStyle.Render(m.err)) + } + + rows = append(rows, "", styles.HelpStyle.Render("tab/↑↓ move · enter submit · esc cancel")) + + return styles.ViewerStyle.Render(lipgloss.JoinVertical(lipgloss.Left, rows...)) +} + +func submit(values map[string]string) tea.Cmd { + return func() tea.Msg { return Submitted{Values: values} } +} + +func cancel() tea.Msg { + return Canceled{} +} diff --git a/tui/form/form_test.go b/tui/form/form_test.go new file mode 100644 index 0000000..c3e7e2c --- /dev/null +++ b/tui/form/form_test.go @@ -0,0 +1,121 @@ +package form + +import ( + "errors" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func keyMsg(k string) tea.KeyMsg { + switch k { + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "esc": + return tea.KeyMsg{Type: tea.KeyEsc} + case "tab": + return tea.KeyMsg{Type: tea.KeyTab} + case "shift+tab": + return tea.KeyMsg{Type: tea.KeyShiftTab} + default: + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)} + } +} + +// typeText feeds each rune of s to the focused input. +func typeText(m Model, s string) Model { //nolint:gocritic // mirrors the Bubble Tea value-receiver idiom under test + for _, r := range s { + m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + return m +} + +func twoFieldForm() Model { + return New("Test", []Field{ + {Key: "name", Label: "Name", Required: true}, + {Key: "id", Label: "ID"}, + }) +} + +func TestModel_Submit(t *testing.T) { + m := typeText(twoFieldForm(), "corp") + m, _ = m.Update(keyMsg("tab")) + m = typeText(m, "tenant-1") + + _, cmd := m.Update(keyMsg("enter")) + require.NotNil(t, cmd) + + msg := cmd() + submitted, ok := msg.(Submitted) + require.True(t, ok) + assert.Equal(t, "corp", submitted.Values["name"]) + assert.Equal(t, "tenant-1", submitted.Values["id"]) +} + +func TestModel_Submit_TrimsWhitespace(t *testing.T) { + m := typeText(twoFieldForm(), " corp ") + + _, cmd := m.Update(keyMsg("enter")) + require.NotNil(t, cmd) + + submitted, ok := cmd().(Submitted) + require.True(t, ok) + assert.Equal(t, "corp", submitted.Values["name"]) +} + +func TestModel_Cancel(t *testing.T) { + m := twoFieldForm() + + _, cmd := m.Update(keyMsg("esc")) + require.NotNil(t, cmd) + + _, ok := cmd().(Canceled) + assert.True(t, ok) +} + +func TestModel_RequiredFieldBlocksSubmit(t *testing.T) { + m := twoFieldForm() // name left empty + + next, cmd := m.Update(keyMsg("enter")) + assert.Nil(t, cmd, "submit must be blocked") + assert.NotEmpty(t, next.err, "an inline error must be shown") + assert.Equal(t, 0, next.focus, "focus returns to the invalid field") +} + +func TestModel_ValidatorBlocksSubmit(t *testing.T) { + sentinel := errors.New("bad value") + m := New("Test", []Field{ + {Key: "id", Label: "ID", Validate: func(string) error { return sentinel }}, + }) + m = typeText(m, "anything") + + next, cmd := m.Update(keyMsg("enter")) + assert.Nil(t, cmd) + assert.Equal(t, sentinel.Error(), next.err) +} + +func TestModel_Navigation(t *testing.T) { + m := twoFieldForm() + require.Equal(t, 0, m.focus) + + m, _ = m.Update(keyMsg("tab")) + assert.Equal(t, 1, m.focus) + + m, _ = m.Update(keyMsg("tab")) // wraps + assert.Equal(t, 0, m.focus) + + m, _ = m.Update(keyMsg("shift+tab")) // wraps back + assert.Equal(t, 1, m.focus) +} + +func TestModel_NavigationClearsError(t *testing.T) { + m := twoFieldForm() + + m, _ = m.Update(keyMsg("enter")) // triggers required error + require.NotEmpty(t, m.err) + + m, _ = m.Update(keyMsg("tab")) + assert.Empty(t, m.err, "moving focus clears the error") +} diff --git a/tui/keys/keys.go b/tui/keys/keys.go index 0cc32dc..0fb03ff 100644 --- a/tui/keys/keys.go +++ b/tui/keys/keys.go @@ -34,6 +34,10 @@ var ( Use key.Binding = key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "use")) View key.Binding = key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "view")) Describe key.Binding = key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "describe")) + Create key.Binding = key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "new")) + Edit key.Binding = key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")) + Rename key.Binding = key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "rename")) + Delete key.Binding = key.NewBinding(key.WithKeys("ctrl+d"), key.WithHelp("ctrl+d", "delete")) ) // Control keys diff --git a/tui/state/mode.go b/tui/state/mode.go index dcedd92..1a966fd 100644 --- a/tui/state/mode.go +++ b/tui/state/mode.go @@ -19,6 +19,10 @@ const ( Tabs // DetailView is the overlay view showing details of a selected item. DetailView + // FormView is the overlay view showing a create/edit form. + FormView + // ConfirmView is the overlay view prompting for delete confirmation. + ConfirmView // Quitting is the state when the TUI is in the process of exiting. Quitting ) diff --git a/tui/state/state.go b/tui/state/state.go index 8e61f38..0b367b2 100644 --- a/tui/state/state.go +++ b/tui/state/state.go @@ -49,6 +49,13 @@ func (u *UI) Config() *config.Store { return u.config } +// SetConfig replaces the store, e.g. after a write reloads it from disk. +func (u *UI) SetConfig(cfg *config.Store) { + u.mu.Lock() + defer u.mu.Unlock() + u.config = cfg +} + func (u *UI) Mode() Mode { u.mu.RLock() defer u.mu.RUnlock() diff --git a/tui/styles/styles.go b/tui/styles/styles.go index 9a6dd1e..54eefee 100644 --- a/tui/styles/styles.go +++ b/tui/styles/styles.go @@ -30,6 +30,7 @@ var ( TitleStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorTitle) DimStyle = lipgloss.NewStyle().Foreground(ColorDim) HelpStyle = lipgloss.NewStyle().Foreground(ColorDim).MarginTop(1) + ErrorStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "#D13438", Dark: "#F1707B"}) ) func NewHelpStyles() help.Styles { diff --git a/tui/tabs/browse.go b/tui/tabs/browse.go index 4bfacc8..e122155 100644 --- a/tui/tabs/browse.go +++ b/tui/tabs/browse.go @@ -4,32 +4,38 @@ import ( "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "github.com/lvlcn-t/azctx/config" "github.com/lvlcn-t/azctx/tui/details" "github.com/lvlcn-t/azctx/tui/keys" + "github.com/lvlcn-t/azctx/tui/state" ) var _ Tab = (*browseTab)(nil) type browseTab struct { - list list.Model - keys tabKeys + list list.Model + state *state.UI + rebuild func(*config.Store) []list.Item + keys tabKeys } -func newBrowseTab(l listBuilder) browseTab { //nolint:gocritic // irrelevant on startup +func newBrowseTab(s *state.UI, rebuild func(*config.Store) []list.Item, l listBuilder) browseTab { //nolint:gocritic // irrelevant on startup tk := newTabKeys( keys.New(keys.Enter).WithHelp("view").WithAliases(keys.View, keys.Describe).Bind(), key.Binding{}, keys.New(keys.Escape).WithHelp("close").Bind(), ) return browseTab{ - list: l. + list: l.WithItems(rebuild(s.Config())...). ShowStatusBar(true). ShowHelp(true). EnableFiltering(true). WithShortHelp(tk.Help()). WithFullHelp(tk.Help()). Build(), - keys: tk, + keys: tk, + state: s, + rebuild: rebuild, } } @@ -64,6 +70,11 @@ func (t *browseTab) Filtering() bool { return t.list.FilterState() == list.Filtering } +// Reload rebuilds the list items from the current store. +func (t *browseTab) Reload() { + t.list.SetItems(t.rebuild(t.state.Config())) +} + func (t *browseTab) Resize(width, height int) { t.list.SetSize(width, height) } diff --git a/tui/tabs/contexts.go b/tui/tabs/contexts.go index 2804bae..3e2698e 100644 --- a/tui/tabs/contexts.go +++ b/tui/tabs/contexts.go @@ -78,6 +78,11 @@ func (t *ContextsTab) Filtering() bool { return t.list.FilterState() == list.Filtering } +// Reload rebuilds the context items from the current store. +func (t *ContextsTab) Reload() { + t.list.SetItems(contextItems(t.state.Config())) +} + func (t *ContextsTab) View() string { return t.list.View() } diff --git a/tui/tabs/credentials.go b/tui/tabs/credentials.go index ee16864..1872184 100644 --- a/tui/tabs/credentials.go +++ b/tui/tabs/credentials.go @@ -4,15 +4,12 @@ import "github.com/lvlcn-t/azctx/tui/state" var _ Tab = (*CredentialsTab)(nil) -// TODO: Add edit capabilities for tenants. - type CredentialsTab struct { browseTab } func credentialsTab(s *state.UI, l listBuilder) *CredentialsTab { //nolint:gocritic // irrelevant on startup - items := credentialItems(s.Config()) return &CredentialsTab{ - browseTab: newBrowseTab(l.WithItems(items...)), + browseTab: newBrowseTab(s, credentialItems, l), } } diff --git a/tui/tabs/tab.go b/tui/tabs/tab.go index 49413af..c60f358 100644 --- a/tui/tabs/tab.go +++ b/tui/tabs/tab.go @@ -15,6 +15,8 @@ type Tab interface { Update(msg tea.Msg) (TabAction, tea.Cmd) View() string Filtering() bool + // Reload rebuilds the tab's items from the current store, e.g. after a write. + Reload() } type tabKeys struct { @@ -22,6 +24,10 @@ type tabKeys struct { Prev key.Binding Select key.Binding View key.Binding + Create key.Binding + Edit key.Binding + Rename key.Binding + Delete key.Binding Close key.Binding Quit key.Binding } @@ -64,6 +70,14 @@ const ( TabActionNone TabActionKind = iota TabActionShowDetails TabActionSelect + // TabActionCreate opens a form to create a new entry. + TabActionCreate + // TabActionEdit opens a form pre-filled from Item to edit it. + TabActionEdit + // TabActionRename opens a form to rename Item. + TabActionRename + // TabActionDelete requests confirmation to delete Item. + TabActionDelete ) func NoAction() TabAction { diff --git a/tui/tabs/tenants.go b/tui/tabs/tenants.go index f6f4377..39b3ef6 100644 --- a/tui/tabs/tenants.go +++ b/tui/tabs/tenants.go @@ -4,15 +4,12 @@ import "github.com/lvlcn-t/azctx/tui/state" var _ Tab = (*TenantsTab)(nil) -// TODO: Add edit capabilities for tenants. - type TenantsTab struct { browseTab } func tenantsTab(s *state.UI, l listBuilder) *TenantsTab { //nolint:gocritic // irrelevant on startup - items := tenantItems(s.Config()) return &TenantsTab{ - browseTab: newBrowseTab(l.WithItems(items...)), + browseTab: newBrowseTab(s, tenantItems, l), } } From b59dbe7e927530af2ba95481facde77019f2dc5d Mon Sep 17 00:00:00 2001 From: lvlcn-t <75443136+lvlcn-t@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:57:52 +0200 Subject: [PATCH 2/2] feat(contexts): reshape Manager into an intent API Replace the storage-shaped Set* verbs with intent-shaped operations so both UIs (cmd, tui) map user intent to one shared service instead of re-deriving CRUD semantics from upsert-by-name. This is the fix for the TUI edit-rename bug: renaming is a distinct operation, not an upsert with a different name. Per entity (tenant, credential, context): - CreateX -> fails with ErrXExists if present (POST). - UpdateX -> fails with ErrXNotFound if absent; never changes the name (PATCH). - RenameX -> the only identity change; cascades the new name to every referencing context across all affected config files (PATCH of identity). Reference integrity lives in the service: RenameTenant/RenameCredential rewrite context tenant/credential references (contexts only ever point at tenants/credentials, never the reverse), grouped by owning file so each file is written once. DeleteTenant/DeleteCredential now report OrphanedContexts so callers can warn (allow-and-warn, as agreed). Set*/SetContext are retained as thin create-or-update shims delegating to Create*/Update*, so the CLI and all existing tests keep working unchanged; the CLI migration onto the intent API is a separate later PR. config gains RenameTenant/RenameCredential, RetargetTenant/RetargetCredential, and ContextsReferencingTenant/Credential, each unit tested. New sentinels (ErrTenantExists/NotFound, etc.) enable errors.Is in callers and tests. Adds intent tests including same-file and cross-file rename cascade and delete orphan reporting; keeps all prior Set*/Delete*/RenameContext tests. Signed-off-by: lvlcn-t <75443136+lvlcn-t@users.noreply.github.com> --- config/config.go | 80 ++++++++++ config/config_test.go | 68 +++++++++ contexts/contexts.go | 308 +++++++++++++++++++++++++++++++++------ contexts/errors.go | 13 ++ contexts/helpers_test.go | 22 +++ contexts/intents_test.go | 169 +++++++++++++++++++++ 6 files changed, 616 insertions(+), 44 deletions(-) create mode 100644 contexts/intents_test.go diff --git a/config/config.go b/config/config.go index a23b6a4..d2d34f7 100644 --- a/config/config.go +++ b/config/config.go @@ -155,6 +155,86 @@ func (cfg *Config) RenameContext(oldName, newName string) bool { return false } +// RenameTenant renames an existing tenant. It does not update contexts that +// reference the tenant; use RetargetTenant for that. +func (cfg *Config) RenameTenant(oldName, newName string) bool { + for index, tenant := range cfg.Tenants { + if tenant.Name == oldName { + cfg.Tenants[index].Name = newName + return true + } + } + + return false +} + +// RenameCredential renames an existing credential. It does not update contexts +// that reference the credential; use RetargetCredential for that. +func (cfg *Config) RenameCredential(oldName, newName string) bool { + for index, credential := range cfg.Credentials { + if credential.Name == oldName { + cfg.Credentials[index].Name = newName + return true + } + } + + return false +} + +// RetargetTenant rewrites every context that references oldName to reference +// newName, returning the number of contexts updated. +func (cfg *Config) RetargetTenant(oldName, newName string) int { + updated := 0 + for index := range cfg.Contexts { + if cfg.Contexts[index].Details.Tenant == oldName { + cfg.Contexts[index].Details.Tenant = newName + updated++ + } + } + + return updated +} + +// RetargetCredential rewrites every context that references oldName to +// reference newName, returning the number of contexts updated. +func (cfg *Config) RetargetCredential(oldName, newName string) int { + updated := 0 + for index := range cfg.Contexts { + if cfg.Contexts[index].Details.Credential == oldName { + cfg.Contexts[index].Details.Credential = newName + updated++ + } + } + + return updated +} + +// ContextsReferencingTenant returns the names of contexts referencing the +// tenant, in declaration order. +func (cfg *Config) ContextsReferencingTenant(name string) []string { + var names []string + for _, context := range cfg.Contexts { + if context.Details.Tenant == name { + names = append(names, context.Name) + } + } + + return names +} + +// ContextsReferencingCredential returns the names of contexts referencing the +// credential, in declaration order. +func (cfg *Config) ContextsReferencingCredential(name string) []string { + var names []string + for _, context := range cfg.Contexts { + if context.Details.Credential == name { + names = append(names, context.Name) + } + } + + return names +} + // Merge merges another config into this config. func (cfg *Config) Merge(next *Config) error { if cfg == nil || next == nil { diff --git a/config/config_test.go b/config/config_test.go index 25d1ac3..957d893 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -106,6 +106,74 @@ func TestRenameContext(t *testing.T) { assert.False(t, cfg.RenameContext("old", "other")) } +func TestRenameTenant(t *testing.T) { + cfg := newTestConfig(t). + withTenant("corp"). + build() + + assert.True(t, cfg.RenameTenant("corp", "corporate")) + assert.Equal(t, "corporate", cfg.Tenants[0].Name) + assert.False(t, cfg.RenameTenant("corp", "other")) +} + +func TestRenameCredential(t *testing.T) { + cfg := newTestConfig(t). + withUserCredential("ci"). + build() + + assert.True(t, cfg.RenameCredential("ci", "ci-sp")) + assert.Equal(t, "ci-sp", cfg.Credentials[0].Name) + assert.False(t, cfg.RenameCredential("ci", "other")) +} + +func TestRetargetTenant(t *testing.T) { + cfg := newTestConfig(t). + withTenant("corp"). + withUserCredential("ci"). + withContext("dev", "corp", "ci"). + withContext("prod", "corp", "ci"). + withContext("other", "platform", "ci"). + build() + + assert.Equal(t, 2, cfg.RetargetTenant("corp", "corporate")) + assert.Equal(t, "corporate", cfg.Contexts[0].Details.Tenant) + assert.Equal(t, "corporate", cfg.Contexts[1].Details.Tenant) + assert.Equal(t, "platform", cfg.Contexts[2].Details.Tenant) +} + +func TestRetargetCredential(t *testing.T) { + cfg := newTestConfig(t). + withUserCredential("ci"). + withContext("dev", "corp", "ci"). + withContext("prod", "corp", "other"). + build() + + assert.Equal(t, 1, cfg.RetargetCredential("ci", "ci-sp")) + assert.Equal(t, "ci-sp", cfg.Contexts[0].Details.Credential) + assert.Equal(t, "other", cfg.Contexts[1].Details.Credential) +} + +func TestContextsReferencingTenant(t *testing.T) { + cfg := newTestConfig(t). + withContext("dev", "corp", "ci"). + withContext("prod", "corp", "ci"). + withContext("other", "platform", "ci"). + build() + + assert.Equal(t, []string{"dev", "prod"}, cfg.ContextsReferencingTenant("corp")) + assert.Nil(t, cfg.ContextsReferencingTenant("missing")) +} + +func TestContextsReferencingCredential(t *testing.T) { + cfg := newTestConfig(t). + withContext("dev", "corp", "ci"). + withContext("prod", "corp", "other"). + build() + + assert.Equal(t, []string{"dev"}, cfg.ContextsReferencingCredential("ci")) + assert.Nil(t, cfg.ContextsReferencingCredential("missing")) +} + func TestMerge(t *testing.T) { base := newTestConfig(t). withTenant("corp"). diff --git a/contexts/contexts.go b/contexts/contexts.go index f5088e9..8359ea8 100644 --- a/contexts/contexts.go +++ b/contexts/contexts.go @@ -21,76 +21,281 @@ func New() *Manager { return &Manager{Writer: config.NewWriter()} } -// SetContext creates or updates a context entry. When the context already -// exists in the merged config, next is merged onto the existing entry: empty -// tenant and credential are preserved, and the subscription is only replaced -// when subscriptionChanged is true. It reports whether an entry already existed. -func (m *Manager) SetContext(store *config.Store, next config.Context, subscriptionChanged bool) (bool, error) { +// CreateContext adds a new context entry. It fails with ErrContextExists if a +// context with the same name already exists. +func (m *Manager) CreateContext(store *config.Store, next config.Context) error { if next.Name == "" { - return false, ErrContextNameRequired + return ErrContextNameRequired + } + + if _, exists := store.Config.ContextByName(next.Name); exists { + return fmt.Errorf("%w: %q", ErrContextExists, next.Name) } - merged, existed := merge(&store.Config, next, subscriptionChanged) + return m.writeContext(store, next) +} + +// UpdateContext changes an existing context's references. It never changes the +// name and fails with ErrContextNotFound if the context does not exist. next is +// merged onto the existing entry: empty tenant and credential are preserved, and +// the subscription is only replaced when subscriptionChanged is true. +func (m *Manager) UpdateContext(store *config.Store, next config.Context, subscriptionChanged bool) error { + if next.Name == "" { + return ErrContextNameRequired + } - if err := store.Config.ValidateContextReferences(merged); err != nil { - return existed, err + if _, exists := store.Config.ContextByName(next.Name); !exists { + return fmt.Errorf("%w: %q", ErrContextNotFound, next.Name) } - tenant, _ := store.Config.TenantByName(merged.Details.Tenant) + merged, _ := merge(&store.Config, next, subscriptionChanged) + return m.writeContext(store, merged) +} + +// SetContext creates the context if it does not exist, otherwise updates it. It +// reports whether an entry already existed. Prefer CreateContext/UpdateContext. +func (m *Manager) SetContext(store *config.Store, next config.Context, subscriptionChanged bool) (bool, error) { + if _, existed := store.Config.ContextByName(next.Name); existed { + return true, m.UpdateContext(store, next, subscriptionChanged) + } + return false, m.CreateContext(store, next) +} + +// writeContext validates a context's references and persists it. +func (m *Manager) writeContext(store *config.Store, ctx config.Context) error { + if err := store.Config.ValidateContextReferences(ctx); err != nil { + return err + } + + tenant, _ := store.Config.TenantByName(ctx.Details.Tenant) if tenant.Details.ID == "" { - return existed, fmt.Errorf("tenant %q is missing id", tenant.Name) + return fmt.Errorf("tenant %q is missing id", tenant.Name) } - cred, _ := store.Config.CredentialByName(merged.Details.Credential) + cred, _ := store.Config.CredentialByName(ctx.Details.Credential) if err := cred.Validate(); err != nil { - return existed, err + return err } - path := store.PathForContext(next.Name) + path := store.PathForContext(ctx.Name) cfg := store.FileConfig(path) - cfg.UpsertContext(merged) + cfg.UpsertContext(ctx) - return existed, m.Writer.Write(path, &cfg) + return m.Writer.Write(path, &cfg) } -// SetTenant creates or updates a tenant entry. It reports whether an entry -// already existed. -func (m *Manager) SetTenant(store *config.Store, name, id string) (bool, error) { - if name == "" { - return false, ErrTenantNameRequired +// CreateTenant adds a new tenant entry. It fails with ErrTenantExists if a +// tenant with the same name already exists. +func (m *Manager) CreateTenant(store *config.Store, name, id string) error { + if err := validateTenant(name, id); err != nil { + return err } - if id == "" { - return false, ErrTenantIDRequired + if _, exists := store.Config.TenantByName(name); exists { + return fmt.Errorf("%w: %q", ErrTenantExists, name) } - _, existed := store.Config.TenantByName(name) + return m.writeTenant(store, name, id) +} +// UpdateTenant changes an existing tenant's id. It never changes the name and +// fails with ErrTenantNotFound if the tenant does not exist. Use RenameTenant +// to change a tenant's name. +func (m *Manager) UpdateTenant(store *config.Store, name, id string) error { + if err := validateTenant(name, id); err != nil { + return err + } + + if _, exists := store.Config.TenantByName(name); !exists { + return fmt.Errorf("%w: %q", ErrTenantNotFound, name) + } + + return m.writeTenant(store, name, id) +} + +// RenameTenant renames a tenant and cascades the new name to every context that +// referenced it, across all affected config files. +func (m *Manager) RenameTenant(store *config.Store, oldName, newName string) (RenameResult, error) { //nolint:dupl // tenant/credential renames intentionally parallel; sharing more would hurt clarity + return m.renameEntry(store, &renameTarget{ + oldName: oldName, + newName: newName, + emptyErr: ErrTenantNameRequired, + missErr: ErrTenantNotFound, + existsErr: ErrTenantExists, + exists: func(name string) bool { _, ok := store.Config.TenantByName(name); return ok }, + path: store.PathForTenant(oldName), + affected: store.Config.ContextsReferencingTenant(oldName), + rename: func(c *config.Config) { c.RenameTenant(oldName, newName) }, + retarget: func(c *config.Config) { c.RetargetTenant(oldName, newName) }, + }) +} + +// writeTenant upserts the tenant into its file and persists it. +func (m *Manager) writeTenant(store *config.Store, name, id string) error { path := store.PathForTenant(name) cfg := store.FileConfig(path) cfg.UpsertTenant(config.Tenant{Name: name, Details: config.TenantDetails{ID: id}}) + return m.Writer.Write(path, &cfg) +} - return existed, m.Writer.Write(path, &cfg) +// SetTenant creates the tenant if it does not exist, otherwise updates it. It +// reports whether an entry already existed. Prefer CreateTenant/UpdateTenant. +func (m *Manager) SetTenant(store *config.Store, name, id string) (bool, error) { + if _, existed := store.Config.TenantByName(name); existed { + return true, m.UpdateTenant(store, name, id) + } + return false, m.CreateTenant(store, name, id) } -// SetCredential validates and then creates or updates a credential entry. It -// reports whether an entry already existed. -func (m *Manager) SetCredential(store *config.Store, cred *config.Credential) (bool, error) { - if cred.Name == "" { - return false, ErrCredentialNameRequired +// validateTenant checks the required tenant fields. +func validateTenant(name, id string) error { + if name == "" { + return ErrTenantNameRequired + } + if id == "" { + return ErrTenantIDRequired } + return nil +} - if err := cred.Validate(); err != nil { - return false, err +// CreateCredential adds a new credential entry after validation. It fails with +// ErrCredentialExists if a credential with the same name already exists. +func (m *Manager) CreateCredential(store *config.Store, cred *config.Credential) error { + if err := validateCredential(cred); err != nil { + return err + } + + if _, exists := store.Config.CredentialByName(cred.Name); exists { + return fmt.Errorf("%w: %q", ErrCredentialExists, cred.Name) + } + + return m.writeCredential(store, cred) +} + +// UpdateCredential changes an existing credential's details. It never changes +// the name and fails with ErrCredentialNotFound if the credential does not +// exist. Use RenameCredential to change a credential's name. +func (m *Manager) UpdateCredential(store *config.Store, cred *config.Credential) error { + if err := validateCredential(cred); err != nil { + return err } - _, existed := store.Config.CredentialByName(cred.Name) + if _, exists := store.Config.CredentialByName(cred.Name); !exists { + return fmt.Errorf("%w: %q", ErrCredentialNotFound, cred.Name) + } + + return m.writeCredential(store, cred) +} + +// RenameCredential renames a credential and cascades the new name to every +// context that referenced it, across all affected config files. +func (m *Manager) RenameCredential(store *config.Store, oldName, newName string) (RenameResult, error) { //nolint:dupl // tenant/credential renames intentionally parallel; sharing more would hurt clarity + return m.renameEntry(store, &renameTarget{ + oldName: oldName, + newName: newName, + emptyErr: ErrCredentialNameRequired, + missErr: ErrCredentialNotFound, + existsErr: ErrCredentialExists, + exists: func(name string) bool { _, ok := store.Config.CredentialByName(name); return ok }, + path: store.PathForCredential(oldName), + affected: store.Config.ContextsReferencingCredential(oldName), + rename: func(c *config.Config) { c.RenameCredential(oldName, newName) }, + retarget: func(c *config.Config) { c.RetargetCredential(oldName, newName) }, + }) +} +// renameTarget describes an entity rename with reference cascade. +type renameTarget struct { + emptyErr error + missErr error + existsErr error + exists func(name string) bool + rename func(*config.Config) + retarget func(*config.Config) + oldName string + newName string + path string + affected []string +} + +// renameEntry validates and applies a tenant or credential rename, cascading +// references to affected contexts. +func (m *Manager) renameEntry(store *config.Store, t *renameTarget) (RenameResult, error) { + if t.newName == "" { + return RenameResult{}, t.emptyErr + } + + if !t.exists(t.oldName) { + return RenameResult{}, fmt.Errorf("%w: %q", t.missErr, t.oldName) + } + + if t.exists(t.newName) { + return RenameResult{}, fmt.Errorf("%w: %q", t.existsErr, t.newName) + } + + if err := m.renameAndRetarget(store, t.path, t.affected, t.rename, t.retarget); err != nil { + return RenameResult{}, err + } + + return RenameResult{Path: t.path, UpdatedContexts: t.affected}, nil +} + +// writeCredential upserts the credential into its file and persists it. +func (m *Manager) writeCredential(store *config.Store, cred *config.Credential) error { path := store.PathForCredential(cred.Name) cfg := store.FileConfig(path) cfg.UpsertCredential(cred) + return m.Writer.Write(path, &cfg) +} + +// SetCredential creates the credential if it does not exist, otherwise updates +// it. It reports whether an entry already existed. Prefer +// CreateCredential/UpdateCredential. +func (m *Manager) SetCredential(store *config.Store, cred *config.Credential) (bool, error) { + if _, existed := store.Config.CredentialByName(cred.Name); existed { + return true, m.UpdateCredential(store, cred) + } + return false, m.CreateCredential(store, cred) +} + +// validateCredential checks the required credential fields. +func validateCredential(cred *config.Credential) error { + if cred.Name == "" { + return ErrCredentialNameRequired + } + return cred.Validate() +} + +// renameAndRetarget renames an entry in its own file and rewrites references in +// every file that owns an affected context. Each file is written exactly once: +// a file that both defines the entry and holds referencing contexts gets both +// mutations applied before it is persisted. +func (m *Manager) renameAndRetarget( + store *config.Store, + entryPath string, + affected []string, + rename func(*config.Config), + retarget func(*config.Config), +) error { + // Collect every file that needs a write. + paths := map[string]struct{}{entryPath: {}} + for _, name := range affected { + paths[store.PathForContext(name)] = struct{}{} + } + + for path := range paths { + cfg := store.FileConfig(path) + if path == entryPath { + rename(&cfg) + } + retarget(&cfg) - return existed, m.Writer.Write(path, &cfg) + if err := m.Writer.Write(path, &cfg); err != nil { + return err + } + } + + return nil } // RenameContext renames an existing context. When the renamed context was the @@ -135,11 +340,23 @@ func (m *Manager) RenameContext(store *config.Store, oldName, newName string) er type DeleteResult struct { // Path is the config file the entry was removed from. Path string + // OrphanedContexts lists contexts that referenced a deleted tenant or + // credential and are now dangling. It is empty for context deletes. + OrphanedContexts []string // WasActive reports whether the deleted context was the current one. It is // always false for tenants and credentials. WasActive bool } +// RenameResult reports the outcome of a rename call. +type RenameResult struct { + // Path is the config file the renamed entry lives in. + Path string + // UpdatedContexts lists contexts whose tenant or credential reference was + // cascaded to the new name. It is empty for context renames. + UpdatedContexts []string +} + // DeleteContext removes a context entry and reports where it lived and whether // it was the active context. func (m *Manager) DeleteContext(store *config.Store, name string) (DeleteResult, error) { @@ -160,31 +377,33 @@ func (m *Manager) DeleteContext(store *config.Store, name string) (DeleteResult, return DeleteResult{Path: path, WasActive: store.Config.CurrentContext == name}, nil } -// DeleteTenant removes a tenant entry and reports where it lived. Deleting a -// tenant that is still referenced by a context leaves that context dangling; -// callers should warn the user. +// DeleteTenant removes a tenant entry and reports where it lived and which +// contexts it leaves dangling. Deleting a tenant referenced by a context is +// allowed; callers should warn the user using OrphanedContexts. func (m *Manager) DeleteTenant(store *config.Store, name string) (DeleteResult, error) { _, found := store.Config.TenantByName(name) - return m.deleteEntry(store, entryTarget{ + return m.deleteEntry(store, &entryTarget{ kind: "tenant", name: name, found: found, path: store.PathForTenant(name), deleteX: func(cfg *config.Config) bool { return cfg.DeleteTenant(name) }, + orphans: store.Config.ContextsReferencingTenant(name), }) } -// DeleteCredential removes a credential entry and reports where it lived. -// Deleting a credential that is still referenced by a context leaves that -// context dangling; callers should warn the user. +// DeleteCredential removes a credential entry and reports where it lived and +// which contexts it leaves dangling. Deleting a credential referenced by a +// context is allowed; callers should warn the user using OrphanedContexts. func (m *Manager) DeleteCredential(store *config.Store, name string) (DeleteResult, error) { _, found := store.Config.CredentialByName(name) - return m.deleteEntry(store, entryTarget{ + return m.deleteEntry(store, &entryTarget{ kind: "credential", name: name, found: found, path: store.PathForCredential(name), deleteX: func(cfg *config.Config) bool { return cfg.DeleteCredential(name) }, + orphans: store.Config.ContextsReferencingCredential(name), }) } @@ -194,12 +413,13 @@ type entryTarget struct { kind string name string path string + orphans []string found bool } // deleteEntry removes a tenant or credential entry. Contexts use DeleteContext // directly because they additionally report whether the active context changed. -func (m *Manager) deleteEntry(store *config.Store, t entryTarget) (DeleteResult, error) { +func (m *Manager) deleteEntry(store *config.Store, t *entryTarget) (DeleteResult, error) { if !t.found { return DeleteResult{}, fmt.Errorf("%s %q not found", t.kind, t.name) } @@ -213,7 +433,7 @@ func (m *Manager) deleteEntry(store *config.Store, t entryTarget) (DeleteResult, return DeleteResult{}, err } - return DeleteResult{Path: t.path}, nil + return DeleteResult{Path: t.path, OrphanedContexts: t.orphans}, nil } // merge resolves the effective context payload for an upsert. For a new context diff --git a/contexts/errors.go b/contexts/errors.go index cd72be7..7c3bc52 100644 --- a/contexts/errors.go +++ b/contexts/errors.go @@ -13,4 +13,17 @@ var ( ErrTenantIDRequired = errors.New("tenant id must not be empty") // ErrCredentialNameRequired indicates a missing credential name. ErrCredentialNameRequired = errors.New("credential name must not be empty") + + // ErrTenantExists indicates a create was attempted for an existing tenant. + ErrTenantExists = errors.New("tenant already exists") + // ErrTenantNotFound indicates an update or rename targeted a missing tenant. + ErrTenantNotFound = errors.New("tenant not found") + // ErrCredentialExists indicates a create was attempted for an existing credential. + ErrCredentialExists = errors.New("credential already exists") + // ErrCredentialNotFound indicates an update or rename targeted a missing credential. + ErrCredentialNotFound = errors.New("credential not found") + // ErrContextExists indicates a create was attempted for an existing context. + ErrContextExists = errors.New("context already exists") + // ErrContextNotFound indicates an update or rename targeted a missing context. + ErrContextNotFound = errors.New("context not found") ) diff --git a/contexts/helpers_test.go b/contexts/helpers_test.go index 0b2b0d6..f8b4d7c 100644 --- a/contexts/helpers_test.go +++ b/contexts/helpers_test.go @@ -1,7 +1,10 @@ package contexts import ( + "fmt" + "os" "path/filepath" + "strings" "testing" "github.com/lvlcn-t/azctx/config" @@ -37,6 +40,25 @@ func loadStore(t *testing.T) *config.Store { return &store } +// writeConfigs writes multiple config files to a temp dir and points azctx at +// all of them (colon-joined). It returns the paths in the given order. +func writeConfigs(t *testing.T, cfgs ...*config.Config) []string { + t.Helper() + + dir := t.TempDir() + writer := config.NewWriter() + paths := make([]string, len(cfgs)) + for i, cfg := range cfgs { + path := filepath.Join(dir, fmt.Sprintf("azctx-%d.yaml", i)) + require.NoError(t, writer.Write(path, cfg)) + paths[i] = path + } + + t.Setenv(config.ConfigEnvVar, strings.Join(paths, string(os.PathListSeparator))) + + return paths +} + // readConfig reads a single config file from disk. func readConfig(t *testing.T, path string) *config.Config { t.Helper() diff --git a/contexts/intents_test.go b/contexts/intents_test.go new file mode 100644 index 0000000..b21d3ec --- /dev/null +++ b/contexts/intents_test.go @@ -0,0 +1,169 @@ +package contexts + +import ( + "testing" + + "github.com/lvlcn-t/azctx/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestManager_CreateTenant(t *testing.T) { + path := writeConfig(t, baseConfig()) + store := loadStore(t) + + require.NoError(t, New().CreateTenant(store, "extra", "tenant-9")) + + got, found := readConfig(t, path).TenantByName("extra") + require.True(t, found) + assert.Equal(t, "tenant-9", got.Details.ID) +} + +func TestManager_CreateTenant_Errors(t *testing.T) { + writeConfig(t, baseConfig()) + + require.ErrorIs(t, New().CreateTenant(loadStore(t), "corp", "x"), ErrTenantExists) + require.ErrorIs(t, New().CreateTenant(loadStore(t), "", "x"), ErrTenantNameRequired) + require.ErrorIs(t, New().CreateTenant(loadStore(t), "new", ""), ErrTenantIDRequired) +} + +func TestManager_UpdateTenant(t *testing.T) { + path := writeConfig(t, baseConfig()) + store := loadStore(t) + + require.NoError(t, New().UpdateTenant(store, "corp", "tenant-updated")) + + got, _ := readConfig(t, path).TenantByName("corp") + assert.Equal(t, "tenant-updated", got.Details.ID) +} + +func TestManager_UpdateTenant_NotFound(t *testing.T) { + writeConfig(t, baseConfig()) + + require.ErrorIs(t, New().UpdateTenant(loadStore(t), "ghost", "x"), ErrTenantNotFound) +} + +func TestManager_RenameTenant_CascadesReferences(t *testing.T) { + path := writeConfig(t, baseConfig()) + store := loadStore(t) + + // dev references tenant corp; prod references platform. + result, err := New().RenameTenant(store, "corp", "corporate") + require.NoError(t, err) + assert.Equal(t, []string{devContext}, result.UpdatedContexts) + + cfg := readConfig(t, path) + _, found := cfg.TenantByName("corporate") + assert.True(t, found) + _, found = cfg.TenantByName("corp") + assert.False(t, found) + + // The referencing context now points at the new name; the other is untouched. + dev, _ := cfg.ContextByName(devContext) + assert.Equal(t, "corporate", dev.Details.Tenant) + prod, _ := cfg.ContextByName(prodContext) + assert.Equal(t, "platform", prod.Details.Tenant) +} + +func TestManager_RenameTenant_CrossFileCascade(t *testing.T) { + // tenant lives in file 0, the referencing context in file 1. + tenantsFile := &config.Config{ + Tenants: []config.Tenant{{Name: "corp", Details: config.TenantDetails{ID: "tenant-1"}}}, + Credentials: []config.Credential{ + {Name: "user", Details: config.CredentialDetails{Type: config.CredentialTypeUser}}, + }, + } + contextsFile := &config.Config{ + Contexts: []config.Context{ + {Name: devContext, Details: config.ContextDetails{Tenant: "corp", Credential: "user"}}, + }, + } + paths := writeConfigs(t, tenantsFile, contextsFile) + store := loadStore(t) + + result, err := New().RenameTenant(store, "corp", "corporate") + require.NoError(t, err) + assert.Equal(t, []string{devContext}, result.UpdatedContexts) + + // The tenant renamed in its own file. + _, found := readConfig(t, paths[0]).TenantByName("corporate") + assert.True(t, found) + + // The context in the OTHER file was retargeted. + dev, _ := readConfig(t, paths[1]).ContextByName(devContext) + assert.Equal(t, "corporate", dev.Details.Tenant) +} + +func TestManager_RenameTenant_Errors(t *testing.T) { + writeConfig(t, baseConfig()) + + _, err := New().RenameTenant(loadStore(t), "ghost", "x") + require.ErrorIs(t, err, ErrTenantNotFound) + + _, err = New().RenameTenant(loadStore(t), "corp", "platform") + require.ErrorIs(t, err, ErrTenantExists) +} + +func TestManager_CreateCredential_Errors(t *testing.T) { + writeConfig(t, baseConfig()) + + existing := &config.Credential{Name: "user", Details: config.CredentialDetails{Type: config.CredentialTypeUser}} + require.ErrorIs(t, New().CreateCredential(loadStore(t), existing), ErrCredentialExists) +} + +func TestManager_UpdateCredential_NotFound(t *testing.T) { + writeConfig(t, baseConfig()) + + cred := &config.Credential{Name: "ghost", Details: config.CredentialDetails{Type: config.CredentialTypeUser}} + require.ErrorIs(t, New().UpdateCredential(loadStore(t), cred), ErrCredentialNotFound) +} + +func TestManager_RenameCredential_CascadesReferences(t *testing.T) { + path := writeConfig(t, baseConfig()) + store := loadStore(t) + + // dev references credential user. + result, err := New().RenameCredential(store, "user", "personal") + require.NoError(t, err) + assert.Equal(t, []string{devContext}, result.UpdatedContexts) + + cfg := readConfig(t, path) + dev, _ := cfg.ContextByName(devContext) + assert.Equal(t, "personal", dev.Details.Credential) + _, found := cfg.CredentialByName("personal") + assert.True(t, found) +} + +func TestManager_CreateContext_Errors(t *testing.T) { + writeConfig(t, baseConfig()) + + dup := config.Context{Name: devContext, Details: config.ContextDetails{Tenant: "corp", Credential: "user"}} + require.ErrorIs(t, New().CreateContext(loadStore(t), dup), ErrContextExists) +} + +func TestManager_UpdateContext_NotFound(t *testing.T) { + writeConfig(t, baseConfig()) + + ctx := config.Context{Name: "ghost", Details: config.ContextDetails{Tenant: "corp", Credential: "user"}} + require.ErrorIs(t, New().UpdateContext(loadStore(t), ctx, false), ErrContextNotFound) +} + +func TestManager_DeleteTenant_ReportsOrphans(t *testing.T) { + writeConfig(t, baseConfig()) + store := loadStore(t) + + // corp is referenced by dev. + result, err := New().DeleteTenant(store, "corp") + require.NoError(t, err) + assert.Equal(t, []string{devContext}, result.OrphanedContexts) +} + +func TestManager_DeleteCredential_ReportsOrphans(t *testing.T) { + writeConfig(t, baseConfig()) + store := loadStore(t) + + // user is referenced by dev. + result, err := New().DeleteCredential(store, "user") + require.NoError(t, err) + assert.Equal(t, []string{devContext}, result.OrphanedContexts) +}