diff --git a/tests/app_test.go b/app_test.go similarity index 100% rename from tests/app_test.go rename to app_test.go diff --git a/tests/array_test.go b/array_test.go similarity index 100% rename from tests/array_test.go rename to array_test.go diff --git a/tests/cli_test.go b/cli_test.go similarity index 90% rename from tests/cli_test.go rename to cli_test.go index 1ed18e6..46f01de 100644 --- a/tests/cli_test.go +++ b/cli_test.go @@ -1,6 +1,7 @@ package core_test import ( + "bytes" "testing" . "dappco.re/go/core" @@ -74,3 +75,11 @@ func TestCli_PrintHelp_Good(t *testing.T) { c.Command("serve", Command{Action: func(_ Options) Result { return Result{OK: true} }}) c.Cli().PrintHelp() } + +func TestCli_SetOutput_Good(t *testing.T) { + c := New() + var buf bytes.Buffer + c.Cli().SetOutput(&buf) + c.Cli().Print("hello %s", "world") + assert.Contains(t, buf.String(), "hello world") +} diff --git a/tests/command_test.go b/command_test.go similarity index 62% rename from tests/command_test.go rename to command_test.go index 62be0a9..57966a5 100644 --- a/tests/command_test.go +++ b/command_test.go @@ -121,6 +121,93 @@ func TestCommand_Lifecycle_NoImpl_Good(t *testing.T) { assert.False(t, cmd.Signal("HUP").OK) } +// --- Lifecycle with Implementation --- + +type testLifecycle struct { + started bool + stopped bool + restarted bool + reloaded bool + signalled string +} + +func (l *testLifecycle) Start(opts Options) Result { + l.started = true + return Result{Value: "started", OK: true} +} +func (l *testLifecycle) Stop() Result { + l.stopped = true + return Result{OK: true} +} +func (l *testLifecycle) Restart() Result { + l.restarted = true + return Result{OK: true} +} +func (l *testLifecycle) Reload() Result { + l.reloaded = true + return Result{OK: true} +} +func (l *testLifecycle) Signal(sig string) Result { + l.signalled = sig + return Result{Value: sig, OK: true} +} + +func TestCommand_Lifecycle_WithImpl_Good(t *testing.T) { + c := New() + lc := &testLifecycle{} + c.Command("daemon", Command{Lifecycle: lc}) + cmd := c.Command("daemon").Value.(*Command) + + r := cmd.Start(Options{}) + assert.True(t, r.OK) + assert.True(t, lc.started) + + assert.True(t, cmd.Stop().OK) + assert.True(t, lc.stopped) + + assert.True(t, cmd.Restart().OK) + assert.True(t, lc.restarted) + + assert.True(t, cmd.Reload().OK) + assert.True(t, lc.reloaded) + + r = cmd.Signal("HUP") + assert.True(t, r.OK) + assert.Equal(t, "HUP", lc.signalled) +} + +func TestCommand_Duplicate_Bad(t *testing.T) { + c := New() + c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }}) + r := c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }}) + assert.False(t, r.OK) +} + +func TestCommand_InvalidPath_Bad(t *testing.T) { + c := New() + assert.False(t, c.Command("/leading", Command{}).OK) + assert.False(t, c.Command("trailing/", Command{}).OK) + assert.False(t, c.Command("double//slash", Command{}).OK) +} + +// --- Cli Run with Lifecycle --- + +func TestCli_Run_Lifecycle_Good(t *testing.T) { + c := New() + lc := &testLifecycle{} + c.Command("serve", Command{Lifecycle: lc}) + r := c.Cli().Run("serve") + assert.True(t, r.OK) + assert.True(t, lc.started) +} + +func TestCli_Run_NoActionNoLifecycle_Bad(t *testing.T) { + c := New() + c.Command("empty", Command{}) + r := c.Cli().Run("empty") + assert.False(t, r.OK) +} + // --- Empty path --- func TestCommand_EmptyPath_Bad(t *testing.T) { diff --git a/tests/config_test.go b/config_test.go similarity index 100% rename from tests/config_test.go rename to config_test.go diff --git a/tests/core_test.go b/core_test.go similarity index 100% rename from tests/core_test.go rename to core_test.go diff --git a/tests/data_test.go b/data_test.go similarity index 100% rename from tests/data_test.go rename to data_test.go diff --git a/tests/drive_test.go b/drive_test.go similarity index 100% rename from tests/drive_test.go rename to drive_test.go diff --git a/tests/embed_test.go b/embed_test.go similarity index 62% rename from tests/embed_test.go rename to embed_test.go index 8531962..99fc7cd 100644 --- a/tests/embed_test.go +++ b/embed_test.go @@ -157,6 +157,94 @@ func TestGeneratePack_WithFiles_Good(t *testing.T) { assert.Contains(t, r.Value.(string), "core.AddAsset") } +// --- Extract (template + nested) --- + +func TestExtract_WithTemplate_Good(t *testing.T) { + dir := t.TempDir() + + // Create an in-memory FS with a template file and a plain file + tmplDir := os.DirFS(t.TempDir()) + + // Use a real temp dir with files + srcDir := t.TempDir() + os.WriteFile(srcDir+"/plain.txt", []byte("static content"), 0644) + os.WriteFile(srcDir+"/greeting.tmpl", []byte("Hello {{.Name}}!"), 0644) + os.MkdirAll(srcDir+"/sub", 0755) + os.WriteFile(srcDir+"/sub/nested.txt", []byte("nested"), 0644) + + _ = tmplDir + fsys := os.DirFS(srcDir) + data := map[string]string{"Name": "World"} + + r := Extract(fsys, dir, data) + assert.True(t, r.OK) + + // Plain file copied + content, err := os.ReadFile(dir + "/plain.txt") + assert.NoError(t, err) + assert.Equal(t, "static content", string(content)) + + // Template processed and .tmpl stripped + greeting, err := os.ReadFile(dir + "/greeting") + assert.NoError(t, err) + assert.Equal(t, "Hello World!", string(greeting)) + + // Nested directory preserved + nested, err := os.ReadFile(dir + "/sub/nested.txt") + assert.NoError(t, err) + assert.Equal(t, "nested", string(nested)) +} + +func TestExtract_BadTargetDir_Ugly(t *testing.T) { + srcDir := t.TempDir() + os.WriteFile(srcDir+"/f.txt", []byte("x"), 0644) + r := Extract(os.DirFS(srcDir), "/nonexistent/deeply/nested/impossible", nil) + // Should fail gracefully, not panic + _ = r +} + +func TestEmbed_PathTraversal_Ugly(t *testing.T) { + emb := Mount(testFS, "testdata").Value.(*Embed) + r := emb.ReadFile("../../etc/passwd") + assert.False(t, r.OK) +} + +func TestEmbed_Sub_BaseDir_Good(t *testing.T) { + emb := Mount(testFS, "testdata").Value.(*Embed) + r := emb.Sub("scantest") + assert.True(t, r.OK) + sub := r.Value.(*Embed) + assert.Equal(t, ".", sub.BaseDirectory()) +} + +func TestEmbed_Open_Bad(t *testing.T) { + emb := Mount(testFS, "testdata").Value.(*Embed) + r := emb.Open("nonexistent.txt") + assert.False(t, r.OK) +} + +func TestEmbed_ReadDir_Bad(t *testing.T) { + emb := Mount(testFS, "testdata").Value.(*Embed) + r := emb.ReadDir("nonexistent") + assert.False(t, r.OK) +} + +func TestEmbed_EmbedFS_Original_Good(t *testing.T) { + emb := Mount(testFS, "testdata").Value.(*Embed) + efs := emb.EmbedFS() + _, err := efs.ReadFile("testdata/test.txt") + assert.NoError(t, err) +} + +func TestExtract_NilData_Good(t *testing.T) { + dir := t.TempDir() + srcDir := t.TempDir() + os.WriteFile(srcDir+"/file.txt", []byte("no template"), 0644) + + r := Extract(os.DirFS(srcDir), dir, nil) + assert.True(t, r.OK) +} + func mustCompress(input string) string { var buf bytes.Buffer b64 := base64.NewEncoder(base64.StdEncoding, &buf) diff --git a/tests/error_test.go b/error_test.go similarity index 81% rename from tests/error_test.go rename to error_test.go index 3d559d9..7213486 100644 --- a/tests/error_test.go +++ b/error_test.go @@ -227,3 +227,46 @@ func TestErrorPanic_CrashFile_Good(t *testing.T) { assert.Nil(t, r.Value) _ = path } + +// --- Error formatting branches --- + +func TestErr_Error_WithCode_Good(t *testing.T) { + err := WrapCode(errors.New("bad"), "INVALID", "validate", "input failed") + assert.Contains(t, err.Error(), "[INVALID]") + assert.Contains(t, err.Error(), "validate") + assert.Contains(t, err.Error(), "bad") +} + +func TestErr_Error_CodeNoCause_Good(t *testing.T) { + err := NewCode("NOT_FOUND", "resource missing") + assert.Contains(t, err.Error(), "[NOT_FOUND]") + assert.Contains(t, err.Error(), "resource missing") +} + +func TestErr_Error_NoOp_Good(t *testing.T) { + err := &Err{Message: "bare error"} + assert.Equal(t, "bare error", err.Error()) +} + +func TestWrapCode_NilErr_EmptyCode_Good(t *testing.T) { + err := WrapCode(nil, "", "op", "msg") + assert.Nil(t, err) +} + +func TestWrap_PreservesCode_Good(t *testing.T) { + inner := WrapCode(errors.New("root"), "AUTH_FAIL", "auth", "denied") + outer := Wrap(inner, "handler", "request failed") + assert.Equal(t, "AUTH_FAIL", ErrorCode(outer)) +} + +func TestErrorLog_Warn_Nil_Good(t *testing.T) { + c := New() + r := c.LogWarn(nil, "op", "msg") + assert.True(t, r.OK) +} + +func TestErrorLog_Error_Nil_Good(t *testing.T) { + c := New() + r := c.LogError(nil, "op", "msg") + assert.True(t, r.OK) +} diff --git a/fs.go b/fs.go index 8642cdc..249ddaf 100644 --- a/fs.go +++ b/fs.go @@ -15,15 +15,20 @@ type Fs struct { // path sanitises and returns the full path. // Absolute paths are sandboxed under root (unless root is "/"). +// Empty root defaults to "/" — the zero value of Fs is usable. func (m *Fs) path(p string) string { + root := m.root + if root == "" { + root = "/" + } if p == "" { - return m.root + return root } // If the path is relative and the medium is rooted at "/", // treat it as relative to the current working directory. // This makes io.Local behave more like the standard 'os' package. - if m.root == "/" && !filepath.IsAbs(p) { + if root == "/" && !filepath.IsAbs(p) { cwd, _ := os.Getwd() return filepath.Join(cwd, p) } @@ -33,23 +38,27 @@ func (m *Fs) path(p string) string { clean := filepath.Clean("/" + p) // If root is "/", allow absolute paths through - if m.root == "/" { + if root == "/" { return clean } // Strip leading "/" so Join works correctly with root - return filepath.Join(m.root, clean[1:]) + return filepath.Join(root, clean[1:]) } // validatePath ensures the path is within the sandbox, following symlinks if they exist. func (m *Fs) validatePath(p string) Result { - if m.root == "/" { + root := m.root + if root == "" { + root = "/" + } + if root == "/" { return Result{m.path(p), true} } // Split the cleaned path into components parts := Split(filepath.Clean("/"+p), string(os.PathSeparator)) - current := m.root + current := root for _, part := range parts { if part == "" { @@ -70,7 +79,7 @@ func (m *Fs) validatePath(p string) Result { } // Verify the resolved part is still within the root - rel, err := filepath.Rel(m.root, realNext) + rel, err := filepath.Rel(root, realNext) if err != nil || HasPrefix(rel, "..") { // Security event: sandbox escape attempt username := "unknown" @@ -78,7 +87,7 @@ func (m *Fs) validatePath(p string) Result { username = u.Username } Print(os.Stderr, "[%s] SECURITY sandbox escape detected root=%s path=%s attempted=%s user=%s", - time.Now().Format(time.RFC3339), m.root, p, realNext, username) + time.Now().Format(time.RFC3339), root, p, realNext, username) if err == nil { err = E("fs.validatePath", Concat("sandbox escape: ", p, " resolves outside ", m.root), nil) } diff --git a/tests/fs_test.go b/fs_test.go similarity index 74% rename from tests/fs_test.go rename to fs_test.go index 447bd42..99160b9 100644 --- a/tests/fs_test.go +++ b/fs_test.go @@ -184,3 +184,74 @@ func TestFs_WriteMode_Good(t *testing.T) { assert.True(t, r.OK) assert.Equal(t, "secret.txt", r.Value.(os.FileInfo).Name()) } + +// --- Zero Value --- + +func TestFs_ZeroValue_Good(t *testing.T) { + dir := t.TempDir() + zeroFs := &Fs{} + + path := filepath.Join(dir, "zero.txt") + assert.True(t, zeroFs.Write(path, "zero value works").OK) + r := zeroFs.Read(path) + assert.True(t, r.OK) + assert.Equal(t, "zero value works", r.Value.(string)) + assert.True(t, zeroFs.IsFile(path)) + assert.True(t, zeroFs.Exists(path)) + assert.True(t, zeroFs.IsDir(dir)) +} + +func TestFs_ZeroValue_List_Good(t *testing.T) { + dir := t.TempDir() + zeroFs := &Fs{} + + os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0644) + r := zeroFs.List(dir) + assert.True(t, r.OK) + entries := r.Value.([]fs.DirEntry) + assert.Len(t, entries, 1) +} + +func TestFs_Exists_NotFound_Bad(t *testing.T) { + c := New() + assert.False(t, c.Fs().Exists("/nonexistent/path/xyz")) +} + +// --- Fs path/validatePath edge cases --- + +func TestFs_Read_EmptyPath_Ugly(t *testing.T) { + c := New() + r := c.Fs().Read("") + assert.False(t, r.OK) +} + +func TestFs_Write_EmptyPath_Ugly(t *testing.T) { + c := New() + r := c.Fs().Write("", "data") + assert.False(t, r.OK) +} + +func TestFs_Delete_Protected_Ugly(t *testing.T) { + c := New() + r := c.Fs().Delete("/") + assert.False(t, r.OK) +} + +func TestFs_DeleteAll_Protected_Ugly(t *testing.T) { + c := New() + r := c.Fs().DeleteAll("/") + assert.False(t, r.OK) +} + +func TestFs_ReadStream_WriteStream_Good(t *testing.T) { + dir := t.TempDir() + c := New() + path := filepath.Join(dir, "stream.txt") + c.Fs().Write(path, "streamed") + + r := c.Fs().ReadStream(path) + assert.True(t, r.OK) + + w := c.Fs().WriteStream(path) + assert.True(t, w.OK) +} diff --git a/tests/i18n_test.go b/i18n_test.go similarity index 100% rename from tests/i18n_test.go rename to i18n_test.go diff --git a/tests/ipc_test.go b/ipc_test.go similarity index 100% rename from tests/ipc_test.go rename to ipc_test.go diff --git a/tests/lock_test.go b/lock_test.go similarity index 100% rename from tests/lock_test.go rename to lock_test.go diff --git a/tests/log_test.go b/log_test.go similarity index 85% rename from tests/log_test.go rename to log_test.go index 1bd5463..70e6103 100644 --- a/tests/log_test.go +++ b/log_test.go @@ -142,6 +142,24 @@ func TestLogPanic_Recover_Good(t *testing.T) { func TestLog_SetOutput_Good(t *testing.T) { l := NewLog(LogOptions{Level: LevelInfo}) l.SetOutput(os.Stderr) - // Should not panic — just changes where logs go l.Info("redirected") } + +// --- Log suppression by level --- + +func TestLog_Quiet_Suppresses_Ugly(t *testing.T) { + l := NewLog(LogOptions{Level: LevelQuiet}) + // These should not panic even though nothing is logged + l.Debug("suppressed") + l.Info("suppressed") + l.Warn("suppressed") + l.Error("suppressed") +} + +func TestLog_ErrorLevel_Suppresses_Ugly(t *testing.T) { + l := NewLog(LogOptions{Level: LevelError}) + l.Debug("suppressed") // below threshold + l.Info("suppressed") // below threshold + l.Warn("suppressed") // below threshold + l.Error("visible") // at threshold +} diff --git a/tests/options_test.go b/options_test.go similarity index 100% rename from tests/options_test.go rename to options_test.go diff --git a/path_test.go b/path_test.go index 3f18b06..fdc8725 100644 --- a/path_test.go +++ b/path_test.go @@ -4,6 +4,7 @@ package core_test import ( "os" + "path/filepath" "testing" core "dappco.re/go/core" @@ -72,6 +73,39 @@ func TestPathExt(t *testing.T) { } func TestPath_EnvConsistency(t *testing.T) { - // Path() and Env("DIR_HOME") should agree assert.Equal(t, core.Env("DIR_HOME"), core.Path()) } + +func TestPathGlob_Good(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0644) + os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b"), 0644) + os.WriteFile(filepath.Join(dir, "c.log"), []byte("c"), 0644) + + matches := core.PathGlob(filepath.Join(dir, "*.txt")) + assert.Len(t, matches, 2) +} + +func TestPathGlob_NoMatch(t *testing.T) { + matches := core.PathGlob("/nonexistent/pattern-*.xyz") + assert.Empty(t, matches) +} + +func TestPathIsAbs_Good(t *testing.T) { + assert.True(t, core.PathIsAbs("/tmp")) + assert.True(t, core.PathIsAbs("/")) + assert.False(t, core.PathIsAbs("relative")) + assert.False(t, core.PathIsAbs("")) +} + +func TestCleanPath_Good(t *testing.T) { + assert.Equal(t, "/a/b", core.CleanPath("/a//b", "/")) + assert.Equal(t, "/a/c", core.CleanPath("/a/b/../c", "/")) + assert.Equal(t, "/", core.CleanPath("/", "/")) + assert.Equal(t, ".", core.CleanPath("", "/")) +} + +func TestPathDir_TrailingSlash(t *testing.T) { + result := core.PathDir("/Users/snider/Code/") + assert.Equal(t, "/Users/snider/Code", result) +} diff --git a/tests/runtime_test.go b/runtime_test.go similarity index 57% rename from tests/runtime_test.go rename to runtime_test.go index 10cc848..2d18f56 100644 --- a/tests/runtime_test.go +++ b/runtime_test.go @@ -74,3 +74,48 @@ func TestRuntime_Lifecycle_Good(t *testing.T) { assert.True(t, result.OK) assert.True(t, started) } + +func TestRuntime_ServiceShutdown_Good(t *testing.T) { + stopped := false + r := NewWithFactories(nil, map[string]ServiceFactory{ + "test": func() Result { + return Result{Value: Service{ + OnStart: func() Result { return Result{OK: true} }, + OnStop: func() Result { stopped = true; return Result{OK: true} }, + }, OK: true} + }, + }) + assert.True(t, r.OK) + rt := r.Value.(*Runtime) + + rt.ServiceStartup(context.Background(), nil) + result := rt.ServiceShutdown(context.Background()) + assert.True(t, result.OK) + assert.True(t, stopped) +} + +func TestRuntime_ServiceShutdown_NilCore_Good(t *testing.T) { + rt := &Runtime{} + result := rt.ServiceShutdown(context.Background()) + assert.True(t, result.OK) +} + +func TestCore_ServiceShutdown_Good(t *testing.T) { + stopped := false + c := New() + c.Service("test", Service{ + OnStart: func() Result { return Result{OK: true} }, + OnStop: func() Result { stopped = true; return Result{OK: true} }, + }) + c.ServiceStartup(context.Background(), nil) + result := c.ServiceShutdown(context.Background()) + assert.True(t, result.OK) + assert.True(t, stopped) +} + +func TestCore_Context_Good(t *testing.T) { + c := New() + c.ServiceStartup(context.Background(), nil) + assert.NotNil(t, c.Context()) + c.ServiceShutdown(context.Background()) +} diff --git a/tests/service_test.go b/service_test.go similarity index 100% rename from tests/service_test.go rename to service_test.go diff --git a/tests/string_test.go b/string_test.go similarity index 100% rename from tests/string_test.go rename to string_test.go diff --git a/tests/task_test.go b/task_test.go similarity index 50% rename from tests/task_test.go rename to task_test.go index 1f62afb..37876ad 100644 --- a/tests/task_test.go +++ b/task_test.go @@ -1,6 +1,7 @@ package core_test import ( + "context" "sync" "testing" "time" @@ -46,6 +47,61 @@ func TestPerformAsync_Progress_Good(t *testing.T) { c.Progress(taskID, 0.5, "halfway", "work") } +func TestPerformAsync_Completion_Good(t *testing.T) { + c := New() + completed := make(chan ActionTaskCompleted, 1) + + c.RegisterTask(func(_ *Core, task Task) Result { + return Result{Value: "result", OK: true} + }) + c.RegisterAction(func(_ *Core, msg Message) Result { + if evt, ok := msg.(ActionTaskCompleted); ok { + completed <- evt + } + return Result{OK: true} + }) + + c.PerformAsync("work") + + select { + case evt := <-completed: + assert.Nil(t, evt.Error) + assert.Equal(t, "result", evt.Result) + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for completion") + } +} + +func TestPerformAsync_NoHandler_Good(t *testing.T) { + c := New() + completed := make(chan ActionTaskCompleted, 1) + + c.RegisterAction(func(_ *Core, msg Message) Result { + if evt, ok := msg.(ActionTaskCompleted); ok { + completed <- evt + } + return Result{OK: true} + }) + + c.PerformAsync("unhandled") + + select { + case evt := <-completed: + assert.NotNil(t, evt.Error) + case <-time.After(2 * time.Second): + t.Fatal("timed out") + } +} + +func TestPerformAsync_AfterShutdown_Bad(t *testing.T) { + c := New() + c.ServiceStartup(context.Background(), nil) + c.ServiceShutdown(context.Background()) + + r := c.PerformAsync("should not run") + assert.False(t, r.OK) +} + // --- RegisterAction + RegisterActions --- func TestRegisterAction_Good(t *testing.T) { diff --git a/tests/testdata/cli_clir.go.bak b/testdata/cli_clir.go.bak similarity index 100% rename from tests/testdata/cli_clir.go.bak rename to testdata/cli_clir.go.bak diff --git a/tests/testdata/scantest/sample.go b/testdata/scantest/sample.go similarity index 100% rename from tests/testdata/scantest/sample.go rename to testdata/scantest/sample.go diff --git a/tests/testdata/test.txt b/testdata/test.txt similarity index 100% rename from tests/testdata/test.txt rename to testdata/test.txt diff --git a/tests/utils_test.go b/utils_test.go similarity index 100% rename from tests/utils_test.go rename to utils_test.go