Skip to content

Commit c6f4271

Browse files
Merge branch 'ToeiRei:main' into main
2 parents e582524 + 6c2097c commit c6f4271

38 files changed

+1144
-20
lines changed

cmd/keymaster/adapters_cli.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ func (c *cliStoreAdapter) AssignKeyToAccount(keyID, accountID int) error {
5858
}
5959
return km.AssignKeyToAccount(keyID, accountID)
6060
}
61+
func (c *cliStoreAdapter) UpdateAccountIsDirty(id int, dirty bool) error {
62+
return db.UpdateAccountIsDirty(id, dirty)
63+
}
6164
func (c *cliStoreAdapter) CreateSystemKey(publicKey, privateKey string) (int, error) {
6265
return db.CreateSystemKey(publicKey, privateKey)
6366
}
@@ -193,8 +196,17 @@ func (w *dbStoreWrapper) GetAccount(id int) (*model.Account, error) {
193196
func (w *dbStoreWrapper) AddAccount(username, hostname, label, tags string) (int, error) {
194197
return w.inner.AddAccount(username, hostname, label, tags)
195198
}
196-
func (w *dbStoreWrapper) DeleteAccount(accountID int) error { return w.inner.DeleteAccount(accountID) }
197-
func (w *dbStoreWrapper) AssignKeyToAccount(keyID, accountID int) error { return nil }
199+
func (w *dbStoreWrapper) DeleteAccount(accountID int) error { return w.inner.DeleteAccount(accountID) }
200+
func (w *dbStoreWrapper) AssignKeyToAccount(keyID, accountID int) error {
201+
km := db.DefaultKeyManager()
202+
if km == nil {
203+
return fmt.Errorf("no key manager available")
204+
}
205+
return km.AssignKeyToAccount(keyID, accountID)
206+
}
207+
func (w *dbStoreWrapper) UpdateAccountIsDirty(id int, dirty bool) error {
208+
return w.inner.UpdateAccountIsDirty(id, dirty)
209+
}
198210
func (w *dbStoreWrapper) CreateSystemKey(publicKey, privateKey string) (int, error) {
199211
return w.inner.CreateSystemKey(publicKey, privateKey)
200212
}

coverage.svg

Lines changed: 2 additions & 2 deletions
Loading

internal/core/core_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) 2025 ToeiRei
2+
// Keymaster - SSH key management system
3+
// This source code is licensed under the MIT license found in the LICENSE file.
4+
5+
package core
6+
7+
import (
8+
"errors"
9+
"testing"
10+
11+
"github.com/toeirei/keymaster/internal/model"
12+
)
13+
14+
func TestFilterAccounts_LocalAndSearcher(t *testing.T) {
15+
accounts := []model.Account{
16+
{ID: 1, Username: "alice", Hostname: "host1", Label: "dev", Tags: "team-a"},
17+
{ID: 2, Username: "bob", Hostname: "host2", Label: "ops", Tags: "team-b"},
18+
{ID: 3, Username: "carol", Hostname: "host3", Label: "qa", Tags: "team-a"},
19+
}
20+
21+
// Empty query returns all
22+
res := FilterAccounts(accounts, "", nil)
23+
if len(res) != 3 {
24+
t.Fatalf("expected 3 accounts for empty query, got %d", len(res))
25+
}
26+
27+
// Local match: by username
28+
res = FilterAccounts(accounts, "bob", nil)
29+
if len(res) != 1 || res[0].Username != "bob" {
30+
t.Fatalf("expected bob in local results, got %+v", res)
31+
}
32+
33+
// Searcher provided and returns results -> should prefer searcher
34+
searcher := func(q string) ([]model.Account, error) {
35+
return []model.Account{{ID: 99, Username: "x"}}, nil
36+
}
37+
res = FilterAccounts(accounts, "nonexistent", searcher)
38+
if len(res) != 1 || res[0].ID != 99 {
39+
t.Fatalf("expected searcher result to be returned, got %+v", res)
40+
}
41+
42+
// Searcher error -> fallback to local
43+
searcherErr := func(q string) ([]model.Account, error) { return nil, errors.New("boom") }
44+
res = FilterAccounts(accounts, "carol", searcherErr)
45+
if len(res) != 1 || res[0].Username != "carol" {
46+
t.Fatalf("expected local fallback on searcher error, got %+v", res)
47+
}
48+
49+
// Searcher returns empty -> fallback to local
50+
searcherEmpty := func(q string) ([]model.Account, error) { return []model.Account{}, nil }
51+
res = FilterAccounts(accounts, "team-a", searcherEmpty)
52+
if len(res) != 2 {
53+
t.Fatalf("expected local fallback when searcher empty, got %d", len(res))
54+
}
55+
}
56+
57+
// Note: ContainsIgnoreCase and FilterKeys already have dedicated tests
58+
// in other files; keep this file focused on searcher/selection behaviors.
59+
60+
func TestEnsureCursorInView(t *testing.T) {
61+
// viewport height 5
62+
h := 5
63+
64+
// cursor above top
65+
if got := EnsureCursorInView(0, 2, h); got != 0 {
66+
t.Fatalf("expected top when cursor above, got %d", got)
67+
}
68+
69+
// cursor below bottom
70+
// top=0 bottom=4, cursor=7 => expect 7-5+1 = 3
71+
if got := EnsureCursorInView(7, 0, h); got != 3 {
72+
t.Fatalf("expected 3 when cursor below, got %d", got)
73+
}
74+
75+
// cursor within view -> unchanged
76+
if got := EnsureCursorInView(3, 0, h); got != 0 {
77+
t.Fatalf("expected unchanged yOffset 0, got %d", got)
78+
}
79+
}

internal/core/deploy_dirty.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (c) 2025 ToeiRei
2+
// Keymaster - SSH key management system
3+
// This source code is licensed under the MIT license found in the LICENSE file.
4+
5+
package core
6+
7+
import "github.com/toeirei/keymaster/internal/model"
8+
9+
// DirtyAccounts returns the subset of accounts whose `IsDirty` flag is true.
10+
// This is a pure helper and performs no side-effects.
11+
func DirtyAccounts(accts []model.Account) []model.Account {
12+
var out []model.Account
13+
for _, a := range accts {
14+
if a.IsDirty {
15+
out = append(out, a)
16+
}
17+
}
18+
return out
19+
}
20+
21+
// DeployList deploys the provided accounts using the given DeployerManager.
22+
// It returns a slice of `DeployResult` (the core-level type) preserving the
23+
// order of the input accounts. Core intentionally does not clear `IsDirty` or
24+
// update the database; callers are responsible for persisting any desired
25+
// post-deploy side-effects.
26+
func DeployList(dm DeployerManager, accounts []model.Account) []DeployResult {
27+
results := make([]DeployResult, 0, len(accounts))
28+
for _, a := range accounts {
29+
err := dm.DeployForAccount(a, false)
30+
results = append(results, DeployResult{Account: a, Error: err})
31+
}
32+
return results
33+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright (c) 2025 ToeiRei
2+
// Keymaster - SSH key management system
3+
// This source code is licensed under the MIT license found in the LICENSE file.
4+
5+
package core
6+
7+
import (
8+
"context"
9+
"fmt"
10+
)
11+
12+
// DeployDirtyAccounts fetches all active accounts from the store, selects
13+
// accounts marked `IsDirty`, deploys to each using the provided DeployerManager,
14+
// and clears the `is_dirty` flag for accounts that deployed successfully.
15+
// It returns the per-account DeployResult slice and an error if fetching
16+
// accounts failed.
17+
func DeployDirtyAccounts(ctx context.Context, st Store, dm DeployerManager, rep Reporter) ([]DeployResult, error) {
18+
accounts, err := st.GetAllActiveAccounts()
19+
if err != nil {
20+
return nil, fmt.Errorf("get accounts: %w", err)
21+
}
22+
23+
dirty := DirtyAccounts(accounts)
24+
results := make([]DeployResult, 0, len(dirty))
25+
for _, acc := range dirty {
26+
err := dm.DeployForAccount(acc, false)
27+
results = append(results, DeployResult{Account: acc, Error: err})
28+
if err == nil {
29+
// Best-effort: clear is_dirty; log/store error ignored for now
30+
_ = st.UpdateAccountIsDirty(acc.ID, false)
31+
}
32+
}
33+
return results, nil
34+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2025 ToeiRei
2+
// Keymaster - SSH key management system
3+
// This source code is licensed under the MIT license found in the LICENSE file.
4+
5+
package core
6+
7+
import (
8+
"context"
9+
"testing"
10+
11+
"github.com/toeirei/keymaster/internal/model"
12+
)
13+
14+
type fakeStoreForDirty struct {
15+
accounts []model.Account
16+
cleared []int
17+
}
18+
19+
func (f *fakeStoreForDirty) GetAllActiveAccounts() ([]model.Account, error) { return f.accounts, nil }
20+
func (f *fakeStoreForDirty) UpdateAccountIsDirty(id int, dirty bool) error {
21+
if !dirty {
22+
f.cleared = append(f.cleared, id)
23+
}
24+
return nil
25+
}
26+
27+
// implement remaining Store methods as no-ops to satisfy interface
28+
func (f *fakeStoreForDirty) GetAccounts() ([]model.Account, error) { return nil, nil }
29+
func (f *fakeStoreForDirty) GetAllAccounts() ([]model.Account, error) { return nil, nil }
30+
func (f *fakeStoreForDirty) GetAccount(id int) (*model.Account, error) { return nil, nil }
31+
func (f *fakeStoreForDirty) AddAccount(username, hostname, label, tags string) (int, error) {
32+
return 0, nil
33+
}
34+
func (f *fakeStoreForDirty) DeleteAccount(accountID int) error { return nil }
35+
func (f *fakeStoreForDirty) AssignKeyToAccount(keyID, accountID int) error { return nil }
36+
func (f *fakeStoreForDirty) CreateSystemKey(publicKey, privateKey string) (int, error) { return 0, nil }
37+
func (f *fakeStoreForDirty) RotateSystemKey(publicKey, privateKey string) (int, error) { return 0, nil }
38+
func (f *fakeStoreForDirty) GetActiveSystemKey() (*model.SystemKey, error) { return nil, nil }
39+
func (f *fakeStoreForDirty) AddKnownHostKey(hostname, key string) error { return nil }
40+
func (f *fakeStoreForDirty) ExportDataForBackup() (*model.BackupData, error) { return nil, nil }
41+
func (f *fakeStoreForDirty) ImportDataFromBackup(*model.BackupData) error { return nil }
42+
func (f *fakeStoreForDirty) IntegrateDataFromBackup(*model.BackupData) error { return nil }
43+
44+
type fakeDMForDirty struct{ called []int }
45+
46+
func (f *fakeDMForDirty) DeployForAccount(account model.Account, keepFile bool) error {
47+
f.called = append(f.called, account.ID)
48+
return nil
49+
}
50+
func (f *fakeDMForDirty) AuditSerial(account model.Account) error { return nil }
51+
func (f *fakeDMForDirty) AuditStrict(account model.Account) error { return nil }
52+
func (f *fakeDMForDirty) DecommissionAccount(account model.Account, systemPrivateKey string, options interface{}) (DecommissionResult, error) {
53+
return DecommissionResult{}, nil
54+
}
55+
func (f *fakeDMForDirty) BulkDecommissionAccounts(accounts []model.Account, systemPrivateKey string, options interface{}) ([]DecommissionResult, error) {
56+
return nil, nil
57+
}
58+
func (f *fakeDMForDirty) CanonicalizeHostPort(host string) string { return host }
59+
func (f *fakeDMForDirty) ParseHostPort(host string) (string, string, error) { return host, "22", nil }
60+
func (f *fakeDMForDirty) GetRemoteHostKey(host string) (string, error) { return "", nil }
61+
func (f *fakeDMForDirty) FetchAuthorizedKeys(account model.Account) ([]byte, error) { return nil, nil }
62+
func (f *fakeDMForDirty) ImportRemoteKeys(account model.Account) ([]model.PublicKey, int, string, error) {
63+
return nil, 0, "", nil
64+
}
65+
func (f *fakeDMForDirty) IsPassphraseRequired(err error) bool { return false }
66+
67+
func TestDeployDirtyAccounts_ClearsOnSuccess(t *testing.T) {
68+
st := &fakeStoreForDirty{accounts: []model.Account{{ID: 1, IsDirty: false}, {ID: 2, IsDirty: true}, {ID: 3, IsDirty: true}}}
69+
dm := &fakeDMForDirty{}
70+
71+
res, err := DeployDirtyAccounts(context.Background(), st, dm, nil)
72+
if err != nil {
73+
t.Fatalf("unexpected error: %v", err)
74+
}
75+
if len(res) != 2 {
76+
t.Fatalf("expected 2 results, got %d", len(res))
77+
}
78+
if len(dm.called) != 2 || dm.called[0] != 2 || dm.called[1] != 3 {
79+
t.Fatalf("unexpected deploy calls: %v", dm.called)
80+
}
81+
if len(st.cleared) != 2 {
82+
t.Fatalf("expected 2 cleared flags, got %d", len(st.cleared))
83+
}
84+
}

internal/core/deploy_dirty_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright (c) 2025 ToeiRei
2+
// Keymaster - SSH key management system
3+
// This source code is licensed under the MIT license found in the LICENSE file.
4+
5+
package core
6+
7+
import (
8+
"testing"
9+
10+
"github.com/toeirei/keymaster/internal/model"
11+
)
12+
13+
type fakeDM struct {
14+
called []int
15+
}
16+
17+
func (f *fakeDM) DeployForAccount(account model.Account, keepFile bool) error {
18+
f.called = append(f.called, account.ID)
19+
return nil
20+
}
21+
func (f *fakeDM) AuditSerial(account model.Account) error { return nil }
22+
func (f *fakeDM) AuditStrict(account model.Account) error { return nil }
23+
func (f *fakeDM) DecommissionAccount(account model.Account, systemPrivateKey string, options interface{}) (DecommissionResult, error) {
24+
return DecommissionResult{}, nil
25+
}
26+
func (f *fakeDM) BulkDecommissionAccounts(accounts []model.Account, systemPrivateKey string, options interface{}) ([]DecommissionResult, error) {
27+
return nil, nil
28+
}
29+
func (f *fakeDM) CanonicalizeHostPort(host string) string { return host }
30+
func (f *fakeDM) ParseHostPort(host string) (string, string, error) { return host, "22", nil }
31+
func (f *fakeDM) GetRemoteHostKey(host string) (string, error) { return "", nil }
32+
func (f *fakeDM) FetchAuthorizedKeys(account model.Account) ([]byte, error) { return nil, nil }
33+
func (f *fakeDM) ImportRemoteKeys(account model.Account) ([]model.PublicKey, int, string, error) {
34+
return nil, 0, "", nil
35+
}
36+
func (f *fakeDM) IsPassphraseRequired(err error) bool { return false }
37+
38+
func TestDirtyAccountsAndDeployList(t *testing.T) {
39+
accounts := []model.Account{
40+
{ID: 1, Username: "a", Hostname: "h1", IsDirty: false},
41+
{ID: 2, Username: "b", Hostname: "h2", IsDirty: true},
42+
{ID: 3, Username: "c", Hostname: "h3", IsDirty: true},
43+
}
44+
45+
dirty := DirtyAccounts(accounts)
46+
if len(dirty) != 2 || dirty[0].ID != 2 || dirty[1].ID != 3 {
47+
t.Fatalf("unexpected dirty accounts: %+v", dirty)
48+
}
49+
50+
f := &fakeDM{}
51+
results := DeployList(f, dirty)
52+
if len(results) != 2 {
53+
t.Fatalf("expected 2 results, got %d", len(results))
54+
}
55+
if len(f.called) != 2 || f.called[0] != 2 || f.called[1] != 3 {
56+
t.Fatalf("deploy called for unexpected ids: %v", f.called)
57+
}
58+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright (c) 2025 ToeiRei
2+
// Keymaster - SSH key management system
3+
// This source code is licensed under the MIT license found in the LICENSE file.
4+
5+
package core
6+
7+
import (
8+
"testing"
9+
10+
"github.com/toeirei/keymaster/internal/db"
11+
"github.com/toeirei/keymaster/internal/model"
12+
)
13+
14+
type fakeGetterDeployer struct {
15+
content []byte
16+
}
17+
18+
func (f *fakeGetterDeployer) DeployAuthorizedKeys(content string) error { return nil }
19+
func (f *fakeGetterDeployer) GetAuthorizedKeys() ([]byte, error) { return f.content, nil }
20+
func (f *fakeGetterDeployer) Close() {}
21+
22+
func TestBuiltinDeployerManager_FetchAuthorizedKeys(t *testing.T) {
23+
if err := db.InitDB("sqlite", ":memory:"); err != nil {
24+
t.Fatalf("InitDB failed: %v", err)
25+
}
26+
// create active system key
27+
if _, err := db.CreateSystemKey("pubdata", "privdata"); err != nil {
28+
t.Fatalf("CreateSystemKey failed: %v", err)
29+
}
30+
31+
// override NewDeployerFactory to return a fake deployer
32+
orig := NewDeployerFactory
33+
NewDeployerFactory = func(host, user, privateKey string, passphrase []byte) (RemoteDeployer, error) {
34+
return &fakeGetterDeployer{content: []byte("authorized-keys-content")}, nil
35+
}
36+
defer func() { NewDeployerFactory = orig }()
37+
38+
acct := model.Account{ID: 1, Username: "u", Hostname: "h", Serial: 0}
39+
dm := builtinDeployerManager{}
40+
out, err := dm.FetchAuthorizedKeys(acct)
41+
if err != nil {
42+
t.Fatalf("FetchAuthorizedKeys failed: %v", err)
43+
}
44+
if string(out) != "authorized-keys-content" {
45+
t.Fatalf("unexpected content: %q", string(out))
46+
}
47+
}
48+
49+
func TestPerformDecommissionWithKeys_Delegates(t *testing.T) {
50+
acc := model.Account{ID: 1, Username: "u"}
51+
called := false
52+
decomm := func(a model.Account, keep map[int]bool) (DecommissionResult, error) {
53+
called = true
54+
return DecommissionResult{Account: a, AccountID: a.ID, Skipped: false}, nil
55+
}
56+
res, err := PerformDecommissionWithKeys(acc, map[int]bool{1: true}, decomm)
57+
if err != nil {
58+
t.Fatalf("unexpected error: %v", err)
59+
}
60+
if !called {
61+
t.Fatalf("expected decommander to be called")
62+
}
63+
if res.AccountID != acc.ID {
64+
t.Fatalf("unexpected result account id: %d", res.AccountID)
65+
}
66+
}

0 commit comments

Comments
 (0)