package webapp import ( "context" "os" "path/filepath" "strconv" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // makeFakeProc builds a minimal /proc tree suitable for // ScanProcApps. It writes: // // - a "comm" file for each pid // - a "cmdline" file (NUL-separated) // - a few sockets under fd/ so the listener scan can match // // We deliberately skip the cwd symlink (root-only) and accept that // the readlink call returns an error; ScanProcApps must tolerate // EACCES / ENOENT for permission-denied fds. func makeFakeProc(t *testing.T, pids []fakeProcEntry) string { t.Helper() root := t.TempDir() for _, e := range pids { pdir := filepath.Join(root, strconv.Itoa(e.pid)) require.NoError(t, os.MkdirAll(pdir, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(pdir, "comm"), []byte(e.comm+"\n"), 0o644)) if e.cmdline != "" { require.NoError(t, os.WriteFile(filepath.Join(pdir, "cmdline"), []byte(e.cmdline), 0o644)) } if len(e.sockets) > 0 { fdDir := filepath.Join(pdir, "fd") require.NoError(t, os.MkdirAll(fdDir, 0o755)) for i, sock := range e.sockets { // fake inode numbers are arbitrary, but must match the // /proc/net/tcp "inode" column for ScanProcApps to // resolve them. Use a stable mapping. target := "socket:[" + strconv.FormatInt(sock, 10) + "]" require.NoError(t, os.Symlink(target, filepath.Join(fdDir, strconv.Itoa(i)))) } } } // /proc/net/tcp with state 0A (LISTEN) entries pointing at the // fake inodes. We do this last so test setup is sequential. require.NoError(t, os.MkdirAll(filepath.Join(root, "net"), 0o755)) var lines []string lines = append(lines, " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode ") for _, e := range pids { for _, ino := range e.sockets { // "0100007F:1E61" -> 127.0.0.1:7777 in little-endian hex. lines = append(lines, fakeTCPLine(ino, "0100007F:1E61")) } } require.NoError(t, os.WriteFile( filepath.Join(root, "net", "tcp"), []byte(joinLines(lines)), 0o644)) return root } type fakeProcEntry struct { pid int comm string cmdline string sockets []int64 // fake inode numbers } func fakeTCPLine(inode int64, local string) string { // 4 hex chars for tx/rx queue (always 0), 8 hex for tr/tm->when, // 8 hex for retrnsmt, 1 hex for uid (0), 1 hex for timeout (0), // then inode (10 hex zero-padded). The trailing fields are zero- // filled so the scanner skips them. inodeHex := strconv.FormatInt(inode, 16) for len(inodeHex) < 8 { inodeHex = "0" + inodeHex } return " 0: " + local + " 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 " + inodeHex + " 0 0 0 0 0" } func joinLines(ls []string) string { out := "" for i, l := range ls { if i > 0 { out += "\n" } out += l } return out } func TestScanProcAppsEmpty(t *testing.T) { root := t.TempDir() apps, err := ScanProcApps(root) require.NoError(t, err) assert.Empty(t, apps) } func TestScanProcAppsSingleProcess(t *testing.T) { root := makeFakeProc(t, []fakeProcEntry{ {pid: 42, comm: "rsmon-worker", cmdline: "rsmon-worker --foo\x00bar", sockets: []int64{1001}}, }) apps, err := ScanProcApps(root) require.NoError(t, err) require.Len(t, apps, 1) assert.Equal(t, "rsmon-worker", apps[0].Name) assert.Equal(t, 42, apps[0].PID) assert.Equal(t, "rsmon-worker --foo bar", apps[0].Cmdline) // ports slice should have the resolved address. assert.Contains(t, apps[0].Ports, "127.0.0.1:7777/tcp") } func TestScanProcAppsSkipsKernelThreads(t *testing.T) { // A pid directory with no comm file is treated as a vanished // process; ScanProcApps must skip it rather than panic. root := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(root, "1"), 0o755)) apps, err := ScanProcApps(root) require.NoError(t, err) assert.Empty(t, apps) } func TestInventoryStoreReplace(t *testing.T) { dir := t.TempDir() store, err := OpenStore(filepath.Join(dir, "webapp.db")) require.NoError(t, err) defer store.Close() //nolint:errcheck inv := NewInventory(store, nil) _ = inv // currently no public method to inject scanned rows; // we exercise ReplaceApps directly via the store. now := mustParseTime(t) rows := []App{ {Name: "rsmon-worker", Source: "process", PID: 1, Ports: "7401/tcp", LastSeen: now}, {Name: "postgres", Source: "process", PID: 2, Ports: "5432/tcp", LastSeen: now}, } ctx := context.Background() require.NoError(t, store.ReplaceApps(ctx, rows)) got, err := store.ListApps(ctx) require.NoError(t, err) assert.Len(t, got, 2) assert.Equal(t, "rsmon-worker", got[0].Name) require.NoError(t, store.ReplaceApps(ctx, []App{ {Name: "redis", Source: "process", PID: 3, Ports: "6379/tcp", LastSeen: now}, })) got, err = store.ListApps(ctx) require.NoError(t, err) assert.Len(t, got, 1) assert.Equal(t, "redis", got[0].Name) } func mustParseTime(t *testing.T) time.Time { t.Helper() return time.Now().UTC() }