A cached list is shown at once and reloaded in the background; the selected secret's details stayed as first shown, old web page included, until another secret was selected. When the reloaded list has it changed, the details pane takes the new listing (a value shown stays shown) and describes it afresh: its cached details came from the old one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
193 lines
5.5 KiB
Go
193 lines
5.5 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
|
)
|
|
|
|
// counting records how often lists and details are fetched.
|
|
type counting struct {
|
|
provider.Provider
|
|
containers, objects, describes, values, versions int
|
|
}
|
|
|
|
func (c *counting) Versions(ctx context.Context, o provider.Object) ([]provider.Object, error) {
|
|
c.versions++
|
|
return c.Provider.Versions(ctx, o)
|
|
}
|
|
|
|
func (c *counting) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
|
c.containers++
|
|
return c.Provider.Containers(ctx, src)
|
|
}
|
|
|
|
func (c *counting) Objects(ctx context.Context, ct provider.Container, t provider.ObjectType) ([]provider.Object, error) {
|
|
c.objects++
|
|
return c.Provider.Objects(ctx, ct, t)
|
|
}
|
|
|
|
func (c *counting) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
|
|
c.describes++
|
|
return c.Provider.Describe(ctx, o)
|
|
}
|
|
|
|
func (c *counting) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
|
c.values++
|
|
return c.Provider.Value(ctx, o)
|
|
}
|
|
|
|
func newCachingDriver(t *testing.T) (*driver, *counting, *time.Time) {
|
|
t.Helper()
|
|
p := &counting{Provider: fake.Azure(0)}
|
|
d := newDriver(t, p)
|
|
// Entries from the initial load were stamped with the real clock.
|
|
clock := time.Now()
|
|
d.m.now = func() time.Time { return clock }
|
|
return d, p, &clock
|
|
}
|
|
|
|
func TestCacheAvoidsRefetchAndRestoresCursor(t *testing.T) {
|
|
t.Parallel()
|
|
d, p, _ := newCachingDriver(t)
|
|
d.keys("2", "j", "3", "j") // kv-shared-prod... then back
|
|
d.keys("2", "k", "3", "j") // kv-app-prod, cursor to api-key
|
|
d.keys("1", "j", "k") // away to platform-dev and back
|
|
before := *p
|
|
|
|
d.keys("j", "k")
|
|
if p.containers != before.containers || p.objects != before.objects {
|
|
t.Errorf("fresh cache refetched: containers %d→%d, objects %d→%d",
|
|
before.containers, p.containers, before.objects, p.objects)
|
|
}
|
|
if _, c, s := d.selected(); c != "kv-app-prod" || s != "api-key" {
|
|
t.Errorf("cursor not restored: %s/%s", c, s)
|
|
}
|
|
}
|
|
|
|
func TestStaleCacheShownThenReloaded(t *testing.T) {
|
|
t.Parallel()
|
|
d, p, clock := newCachingDriver(t)
|
|
d.keys("j") // platform-dev
|
|
*clock = clock.Add(cacheTTL + time.Second)
|
|
before := p.containers
|
|
d.keys("k") // back to platform-prod: stale
|
|
if p.containers != before+1 {
|
|
t.Errorf("stale entry not reloaded: %d calls, want %d", p.containers, before+1)
|
|
}
|
|
if _, c, _ := d.selected(); c != "kv-app-prod" {
|
|
t.Errorf("selection lost across background reload: %q", c)
|
|
}
|
|
}
|
|
|
|
func TestRefreshBypassesCache(t *testing.T) {
|
|
t.Parallel()
|
|
d, p, _ := newCachingDriver(t)
|
|
before := p.objects
|
|
d.keys("3", "r")
|
|
if p.objects != before+1 {
|
|
t.Errorf("r did not reload: %d calls, want %d", p.objects, before+1)
|
|
}
|
|
}
|
|
|
|
func TestValuesAndErrorsNotCached(t *testing.T) {
|
|
t.Parallel()
|
|
d, p, _ := newCachingDriver(t)
|
|
d.keys("space", "space", "space")
|
|
if p.values != 2 {
|
|
t.Errorf("value fetched %d times for two reveals, want 2", p.values)
|
|
}
|
|
|
|
d.keys("2", "G") // kv-restricted: 403
|
|
if _, ok := d.m.cache.objects[d.m.selContainer]; ok {
|
|
t.Error("error result cached")
|
|
}
|
|
before := p.objects
|
|
d.keys("k", "j")
|
|
if p.objects != before+2 {
|
|
t.Errorf("failed list not retried: %d calls, want %d", p.objects, before+2)
|
|
}
|
|
}
|
|
|
|
func TestSignInDropsCache(t *testing.T) {
|
|
t.Parallel()
|
|
d, _, _ := newCachingDriver(t)
|
|
d.send(loginDoneMsg{})
|
|
// Everything is reloaded fresh, so only the current selection is cached.
|
|
if n := len(d.m.cache.containers); n != 1 {
|
|
t.Errorf("containers cached after sign-in = %d, want 1", n)
|
|
}
|
|
}
|
|
|
|
// runAll runs cmd and the commands it batches, ignoring their messages.
|
|
func runAll(cmd tea.Cmd) {
|
|
if cmd == nil {
|
|
return
|
|
}
|
|
if b, ok := cmd().(tea.BatchMsg); ok {
|
|
for _, c := range b {
|
|
runAll(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMovingOnDropsUnsettledLoads(t *testing.T) {
|
|
t.Parallel()
|
|
c := &counting{Provider: fake.Azure(0)}
|
|
d := newDriverOpts(t, Options{}, c)
|
|
d.m.settle = 20 * time.Millisecond
|
|
before := c.containers
|
|
|
|
// j to platform-dev and straight back: its load never goes out.
|
|
m1, passed := d.m.Update(tea.KeyPressMsg{Code: 'j', Text: "j"})
|
|
_, back := m1.Update(tea.KeyPressMsg{Code: 'k', Text: "k"})
|
|
runAll(back)
|
|
runAll(passed)
|
|
if c.containers != before {
|
|
t.Errorf("passing over a subscription loaded it: %d calls", c.containers-before)
|
|
}
|
|
|
|
// Stopping on it loads it once the cursor settles.
|
|
_, stop := d.m.Update(tea.KeyPressMsg{Code: 'j', Text: "j"})
|
|
runAll(stop)
|
|
if c.containers != before+1 {
|
|
t.Errorf("stopping on a subscription: %d calls, want 1", c.containers-before)
|
|
}
|
|
}
|
|
|
|
func TestReloadedListRefreshesShownSecret(t *testing.T) {
|
|
t.Parallel()
|
|
d, _, clock := newCachingDriver(t)
|
|
d.keys("3") // kv-app-prod's secrets, db-password selected
|
|
shown, _ := d.m.detail.Secret()
|
|
// What an older lazysecrets listed and cached for it.
|
|
const old = "https://portal.example/old"
|
|
key := d.m.selContainer
|
|
e := d.m.cache.objects[key]
|
|
items := slices.Clone(e.value)
|
|
for i := range items {
|
|
if items[i].ID == shown.ID {
|
|
items[i].URL = old
|
|
}
|
|
}
|
|
d.m.cache.objects[key] = entry[[]provider.Object]{items, e.at}
|
|
stale := shown
|
|
stale.URL = old
|
|
d.m.cache.details[detailKey(shown)] = entry[provider.Object]{stale, *clock}
|
|
|
|
// Away and back once the list is stale: it's shown from the cache,
|
|
// then reloaded, and the details pane follows.
|
|
d.keys("1", "j")
|
|
*clock = clock.Add(cacheTTL + time.Second)
|
|
d.keys("k", "3")
|
|
if got, _ := d.m.detail.Secret(); got.ID != shown.ID || got.URL == old {
|
|
t.Errorf("shown %s with %q after the reload", got.Name, got.URL)
|
|
}
|
|
}
|