package protect import ( "bytes" "encoding/json" "os" "path/filepath" "strconv" "strings" "sync" "testing" "time" "github.com/sophotechlabs/spinoza/internal/api" "github.com/sophotechlabs/spinoza/internal/filetx" ) const remote = "https://10.0.0.5:6443" func storePath(t *testing.T) string { t.Helper() return filepath.Join(t.TempDir(), "spinoza", "protected.json") } func openStore(t *testing.T, path string) *Store { t.Helper() store, err := Open(t.Context(), path) if err != nil { t.Fatalf("open: %v", err) } return store } func holdTransaction(t *testing.T, path string) func() { t.Helper() entered := make(chan struct{}) release := make(chan struct{}) done := make(chan error, 1) go func() { done <- filetx.Exclusive(t.Context(), path, func() error { close(entered) <-release return nil }) }() select { case <-entered: case err := <-done: t.Fatalf("hold transaction: %v", err) } return func() { close(release) if err := <-done; err != nil { t.Fatalf("release transaction: %v", err) } } } func TestAClusterNobodyDecidedOnIsUnknown(t *testing.T) { store := openStore(t, storePath(t)) if store.Verdict(remote) != api.ProtectionUnknown { t.Fatalf("verdict = %q, want unknown so the app can ask", store.Verdict(remote)) } } func TestALocalClusterIsLeftOpenWithoutAsking(t *testing.T) { store := openStore(t, storePath(t)) for _, server := range []string{"https://127.0.0.1:6443", "https://localhost:6443", "https://[::1]:6443"} { if store.Verdict(server) != api.ProtectionOpen { t.Fatalf("verdict(%s) = %q, want open; kind and docker-desktop are not worth a prompt", server, store.Verdict(server)) } } } func TestAPrivateAddressIsNotTreatedAsLocal(t *testing.T) { store := openStore(t, storePath(t)) if store.Verdict("https://10.10.0.1:6443") != api.ProtectionUnknown { t.Fatal("a cluster on a private address was assumed local; a vpn-reachable production cluster looks exactly like this") } } func TestProtectingAClusterSticks(t *testing.T) { path := storePath(t) store := openStore(t, path) err := store.Set(remote, true) if err != nil { t.Fatalf("set: %v", err) } if store.Verdict(remote) != api.ProtectionProtected { t.Fatalf("verdict = %q", store.Verdict(remote)) } if openStore(t, path).Verdict(remote) != api.ProtectionProtected { t.Fatal("the answer did not survive a restart") } } func TestAnsweringNoIsRememberedToo(t *testing.T) { path := storePath(t) store := openStore(t, path) err := store.Set(remote, false) if err != nil { t.Fatalf("set: %v", err) } if openStore(t, path).Verdict(remote) != api.ProtectionOpen { t.Fatal("saying no was forgotten, so the prompt would come back every time") } } func TestUnprotectingAClusterAgain(t *testing.T) { path := storePath(t) store := openStore(t, path) if err := store.Set(remote, true); err != nil { t.Fatalf("set: %v", err) } if err := store.Set(remote, false); err != nil { t.Fatalf("unset: %v", err) } if openStore(t, path).Verdict(remote) != api.ProtectionOpen { t.Fatal("the cluster stayed protected after it was opened up") } } func TestEachClusterIsAnsweredSeparately(t *testing.T) { store := openStore(t, storePath(t)) if err := store.Set(remote, true); err != nil { t.Fatalf("set: %v", err) } if store.Verdict("https://10.0.0.6:6443") != api.ProtectionUnknown { t.Fatal("protecting one cluster answered for another") } } func TestWithoutAClusterThereIsNothingToSay(t *testing.T) { store := openStore(t, storePath(t)) if store.Verdict("") != api.ProtectionUnknown { t.Fatalf("verdict = %q", store.Verdict("")) } if store.Set("", true) == nil { t.Fatal("protecting nothing reported success") } } func TestTheListIsWrittenForTheOwnerOnly(t *testing.T) { path := storePath(t) store := openStore(t, path) if err := store.Set(remote, true); err != nil { t.Fatalf("set: %v", err) } info, err := os.Stat(path) if err != nil { t.Fatalf("stat: %v", err) } if info.Mode().Perm() != 0o600 { t.Fatalf("mode = %v, want 0600", info.Mode().Perm()) } } func TestTheListIsReadableJSON(t *testing.T) { path := storePath(t) store := openStore(t, path) if err := store.Set(remote, true); err != nil { t.Fatalf("set: %v", err) } body, err := os.ReadFile(path) if err != nil { t.Fatalf("read: %v", err) } var saved state if unmarshalErr := json.Unmarshal(body, &saved); unmarshalErr != nil { t.Fatalf("unmarshal %s: %v", body, unmarshalErr) } if !saved.Clusters[remote] { t.Fatalf("saved = %v", saved.Clusters) } } func TestABrokenListSurfacesAndProtectsNothing(t *testing.T) { path := storePath(t) if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { t.Fatalf("write: %v", err) } store, err := Open(t.Context(), path) if err == nil { t.Fatal("an unreadable list was reported as an empty one") } if store == nil { t.Fatal("spinoza refused to start over a broken protected-cluster list") } if store.Verdict(remote) != api.ProtectionUnknown { t.Fatalf("verdict = %q", store.Verdict(remote)) } } func TestAnUnreadableListSurfaces(t *testing.T) { path := filepath.Join(t.TempDir(), "protected.json") if err := os.Mkdir(path, 0o700); err != nil { t.Fatalf("mkdir: %v", err) } _, err := Open(t.Context(), path) if err == nil { t.Fatal("a directory in place of the list read as an empty list") } } func TestAnAnswerThatCannotBeSavedIsNotKept(t *testing.T) { blocked := filepath.Join(t.TempDir(), "file") if err := os.WriteFile(blocked, []byte("not a directory"), 0o600); err != nil { t.Fatalf("write: %v", err) } store := &Store{ctx: t.Context(), path: filepath.Join(blocked, "spinoza", "protected.json"), clusters: map[string]bool{}} err := store.Set(remote, true) if err == nil { t.Fatal("an answer that could not be written reported success") } if store.Verdict(remote) != api.ProtectionUnknown { t.Fatal("the answer was kept in memory after it failed to save, so a restart would forget it silently") } } func TestAnAnswerThatCannotReplaceTheFileIsNotKept(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "protected.json") if err := os.Mkdir(path, 0o700); err != nil { t.Fatalf("mkdir: %v", err) } store := &Store{ctx: t.Context(), path: path, clusters: map[string]bool{}} err := store.Set(remote, true) if err == nil { t.Fatal("an answer that could not replace its file reported success") } leftovers, globErr := filepath.Glob(filepath.Join(dir, "protected-*.json")) if globErr != nil { t.Fatalf("glob: %v", globErr) } if len(leftovers) != 0 { t.Fatalf("leftovers = %v, want the half-written file cleaned up", leftovers) } } func TestWithNoPathTheAnswersLastForThisRunOnly(t *testing.T) { store := openStore(t, "") if err := store.Set(remote, true); err != nil { t.Fatalf("set: %v", err) } if store.Verdict(remote) != api.ProtectionProtected { t.Fatalf("verdict = %q", store.Verdict(remote)) } } func TestAServerThatIsNotAURLIsNotLocal(t *testing.T) { if Local("://nonsense") { t.Fatal("an unparseable server was treated as local") } if Local("https://kubernetes.default.svc") { t.Fatal("a named host that is not localhost was treated as local") } } func TestDefaultPathSitsUnderTheUserConfigDirectory(t *testing.T) { path, err := DefaultPath() if err != nil { t.Fatalf("DefaultPath: %v", err) } want := filepath.Join("spinoza", "protected.json") if filepath.Join(filepath.Base(filepath.Dir(path)), filepath.Base(path)) != want { t.Fatalf("path = %q, want it under a spinoza directory", path) } } func TestManyClustersCanBeAnswered(t *testing.T) { path := storePath(t) store := openStore(t, path) for i := range 5 { if err := store.Set("https://10.0.0."+strconv.Itoa(i)+":6443", i%2 == 0); err != nil { t.Fatalf("set: %v", err) } } reopened := openStore(t, path) if reopened.Verdict("https://10.0.0.0:6443") != api.ProtectionProtected { t.Fatal("the first answer was lost") } if reopened.Verdict("https://10.0.0.1:6443") != api.ProtectionOpen { t.Fatal("a later answer overwrote an earlier one") } } func TestConcurrentProtectionDecisionsAreAllPersisted(t *testing.T) { path := storePath(t) store := openStore(t, path) const clusters = 32 var group sync.WaitGroup for index := range clusters { server := "https://10.0.1." + strconv.Itoa(index) + ":6443" protected := index%2 == 0 group.Go(func() { if err := store.Set(server, protected); err != nil { t.Errorf("set %s: %v", server, err) } }) } group.Wait() reopened := openStore(t, path) for index := range clusters { server := "https://10.0.1." + strconv.Itoa(index) + ":6443" want := api.ProtectionOpen if index%2 == 0 { want = api.ProtectionProtected } if got := reopened.Verdict(server); got != want { t.Fatalf("%s verdict = %q, want %q", server, got, want) } } } func TestSimultaneousProcessesDoNotLoseProtectionDecisions(t *testing.T) { path := storePath(t) first := openStore(t, path) second := openStore(t, path) unlock := holdTransaction(t, path) done := make(chan error, 2) go func() { done <- first.Set("https://10.0.2.1:6443", true) }() go func() { done <- second.Set("https://10.0.2.2:6443", false) }() select { case err := <-done: unlock() t.Fatalf("a write escaped the transaction lock: %v", err) case <-time.After(50 * time.Millisecond): } unlock() for range 2 { if err := <-done; err != nil { t.Fatalf("set: %v", err) } } reopened := openStore(t, path) if reopened.Verdict("https://10.0.2.1:6443") != api.ProtectionProtected { t.Fatal("the first process's protection decision was lost") } if reopened.Verdict("https://10.0.2.2:6443") != api.ProtectionOpen { t.Fatal("the second process's protection decision was lost") } } func TestAVerdictPicksUpAnotherProcessesDecision(t *testing.T) { path := storePath(t) first := openStore(t, path) second := openStore(t, path) if err := second.Set(remote, true); err != nil { t.Fatalf("set: %v", err) } if first.Verdict(remote) != api.ProtectionProtected { t.Fatal("the first process retained its stale protection verdict") } } func readOnlyDir(t *testing.T) string { t.Helper() dir := filepath.Join(t.TempDir(), "locked") if err := os.MkdirAll(dir, 0o500); err != nil { t.Fatalf("mkdir: %v", err) } t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) return dir } func TestAFileThatCannotBeReadIsReported(t *testing.T) { path := filepath.Join(t.TempDir(), "protected.json") if err := os.Mkdir(path, 0o700); err != nil { t.Fatalf("mkdir: %v", err) } _, err := Open(t.Context(), path) if err == nil { t.Fatal("a file that could not be read opened without complaint") } } func TestAFileThatIsNotJsonIsReported(t *testing.T) { path := filepath.Join(t.TempDir(), "protected.json") if err := os.WriteFile(path, []byte("not json at all"), 0o600); err != nil { t.Fatalf("write: %v", err) } store, err := Open(t.Context(), path) if err == nil { t.Fatal("a file that is not json opened without complaint") } if store == nil { t.Fatal("a store that could not be read must still be usable") } } func TestSetDoesNotReplaceMalformedDecisionsFromStaleMemory(t *testing.T) { path := storePath(t) store := openStore(t, path) if err := store.Set(remote, true); err != nil { t.Fatalf("initial set: %v", err) } broken := []byte("{not json") if err := os.WriteFile(path, broken, 0o600); err != nil { t.Fatalf("break decisions: %v", err) } err := store.Set("https://10.0.0.6:6443", true) if err == nil { t.Fatal("a write over malformed decisions reported success") } body, readErr := os.ReadFile(path) if readErr != nil { t.Fatalf("read broken decisions: %v", readErr) } if !bytes.Equal(body, broken) { t.Fatalf("malformed decisions were replaced with %q", body) } if store.Verdict(remote) != api.ProtectionProtected { t.Fatal("the last readable decision was discarded") } } func TestDecisionsLargerThanTheReadLimitAreNotWritten(t *testing.T) { store := openStore(t, storePath(t)) err := store.write(map[string]bool{strings.Repeat("x", maxFileBytes): true}) if err == nil { t.Fatal("oversized protection decisions were written") } } func TestAnAnswerThatCannotBeSavedIsReported(t *testing.T) { store := openStore(t, filepath.Join(readOnlyDir(t), "nested", "protected.json")) err := store.Set(remote, true) if err == nil { t.Fatal("an answer that could not be written was reported as saved") } if store.Verdict(remote) != api.ProtectionUnknown { t.Fatal("an answer that was not written was remembered anyway") } } func TestAStoreWithNowhereToWriteKeepsTheAnswerInMemory(t *testing.T) { store, err := Open(t.Context(), "") if err != nil { t.Fatalf("open: %v", err) } if setErr := store.Set(remote, true); setErr != nil { t.Fatalf("set: %v", setErr) } if store.Verdict(remote) != api.ProtectionProtected { t.Fatalf("verdict = %q", store.Verdict(remote)) } } func writeRaw(t *testing.T, path string, clusters map[string]bool) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { t.Fatalf("mkdir: %v", err) } body, err := json.Marshal(map[string]any{"clusters": clusters}) if err != nil { t.Fatalf("marshal: %v", err) } if writeErr := os.WriteFile(path, body, 0o600); writeErr != nil { t.Fatalf("write: %v", writeErr) } } func readRaw(t *testing.T, path string) map[string]bool { t.Helper() body, err := os.ReadFile(path) if err != nil { t.Fatalf("read: %v", err) } var saved struct { Clusters map[string]bool `json:"clusters"` } if unmarshalErr := json.Unmarshal(body, &saved); unmarshalErr != nil { t.Fatalf("unmarshal: %v", unmarshalErr) } return saved.Clusters } func TestAnAnswerWrittenBeforeTheKeyWasNormalisedIsStillFound(t *testing.T) { path := storePath(t) writeRaw(t, path, map[string]bool{"https://10.0.0.5:6443/": true}) store := openStore(t, path) if store.Verdict(remote) != api.ProtectionProtected { t.Fatalf("verdict = %q, want protected; an older file must not silently lose its answer", store.Verdict(remote)) } } func TestTheSameClusterSpeltTwoWaysIsOneAnswer(t *testing.T) { store := openStore(t, storePath(t)) if err := store.Set("HTTPS://10.0.0.5:6443/", true); err != nil { t.Fatalf("set: %v", err) } if store.Verdict(remote) != api.ProtectionProtected { t.Fatal("a trailing slash and a capital letter made a second cluster out of one") } } func TestWritingAnAnswerLeavesOnlyTheNormalisedKey(t *testing.T) { path := storePath(t) stale := "https://10.0.0.5:6443/" writeRaw(t, path, map[string]bool{stale: true}) store := openStore(t, path) if err := store.Set(stale, true); err != nil { t.Fatalf("set: %v", err) } held := readRaw(t, path) if _, found := held[stale]; found { t.Fatalf("the old key survived the write: %v", held) } if !held[remote] { t.Fatalf("the normalised key was not written: %v", held) } } func TestAClusterBehindARancherPathIsItsOwnAnswer(t *testing.T) { store := openStore(t, storePath(t)) one := "https://rancher.example.com/k8s/clusters/c-m-aaaaaaaa" two := "https://rancher.example.com/k8s/clusters/c-m-bbbbbbbb" if err := store.Set(one, true); err != nil { t.Fatalf("set: %v", err) } if store.Verdict(two) != api.ProtectionUnknown { t.Fatal("protecting one Rancher cluster answered for another on the same host") } } func TestTwoSpellingsOfOneClusterResolveToProtected(t *testing.T) { path := storePath(t) writeRaw(t, path, map[string]bool{ "https://10.0.0.5:6443/": true, "HTTPS://10.0.0.5:6443": false, }) store := openStore(t, path) if store.Verdict(remote) != api.ProtectionProtected { t.Fatalf("verdict = %q, want protected; a conflict must never quietly open a cluster", store.Verdict(remote)) } } func TestAnEmptyKeyInTheFileIsIgnored(t *testing.T) { path := storePath(t) writeRaw(t, path, map[string]bool{"": true, remote: true}) store := openStore(t, path) if store.Verdict("") != api.ProtectionUnknown { t.Fatalf("verdict = %q, want unknown", store.Verdict("")) } if store.Verdict(remote) != api.ProtectionProtected { t.Fatal("a junk key stopped the real ones being read") } } func TestProtectedWinsWhateverOrderTheKeysAreRead(t *testing.T) { protectedFirst := map[string]bool{} adopt(protectedFirst, map[string]bool{"https://10.0.0.5:6443/": true}) adopt(protectedFirst, map[string]bool{"HTTPS://10.0.0.5:6443": false}) openFirst := map[string]bool{} adopt(openFirst, map[string]bool{"HTTPS://10.0.0.5:6443": false}) adopt(openFirst, map[string]bool{"https://10.0.0.5:6443/": true}) if !protectedFirst[remote] { t.Fatal("reading the protected spelling first left the cluster open") } if !openFirst[remote] { t.Fatal("reading the open spelling first left the cluster open") } }