|
| 1 | +package recon_test |
| 2 | + |
| 3 | +import ( |
| 4 | + "os" |
| 5 | + "path/filepath" |
| 6 | + "testing" |
| 7 | + |
| 8 | + "github.com/hamza-hafeez82/cortex/internal/engine/recon" |
| 9 | + "github.com/hamza-hafeez82/cortex/internal/walker" |
| 10 | +) |
| 11 | + |
| 12 | +// makeRepo creates a temp directory from a map of path → content, |
| 13 | +// walks it, and returns the RepoMap. |
| 14 | +func makeRepo(t *testing.T, files map[string]string) *walker.RepoMap { |
| 15 | + t.Helper() |
| 16 | + root := t.TempDir() |
| 17 | + for rel, content := range files { |
| 18 | + abs := filepath.Join(root, filepath.FromSlash(rel)) |
| 19 | + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { |
| 20 | + t.Fatalf("mkdir: %v", err) |
| 21 | + } |
| 22 | + if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { |
| 23 | + t.Fatalf("write %s: %v", rel, err) |
| 24 | + } |
| 25 | + } |
| 26 | + |
| 27 | + w := walker.New(walker.DefaultOptions()) |
| 28 | + repo, err := w.Walk(root) |
| 29 | + if err != nil { |
| 30 | + t.Fatalf("Walk: %v", err) |
| 31 | + } |
| 32 | + return repo |
| 33 | +} |
| 34 | + |
| 35 | +// ── Dependency tests ────────────────────────────────────────────────────────── |
| 36 | + |
| 37 | +func TestParsePackageJSON(t *testing.T) { |
| 38 | + repo := makeRepo(t, map[string]string{ |
| 39 | + "package.json": `{ |
| 40 | + "dependencies": { |
| 41 | + "express": "^4.18.0", |
| 42 | + "lodash": "^4.17.21" |
| 43 | + }, |
| 44 | + "devDependencies": { |
| 45 | + "jest": "^29.0.0" |
| 46 | + } |
| 47 | +}`, |
| 48 | + }) |
| 49 | + |
| 50 | + deps := recon.ParseDependencies(repo) |
| 51 | + if len(deps) != 3 { |
| 52 | + t.Errorf("expected 3 deps, got %d", len(deps)) |
| 53 | + } |
| 54 | + |
| 55 | + byName := make(map[string]recon.Dependency) |
| 56 | + for _, d := range deps { |
| 57 | + byName[d.Name] = d |
| 58 | + } |
| 59 | + |
| 60 | + if byName["express"].Ecosystem != "npm" { |
| 61 | + t.Error("expected express ecosystem to be npm") |
| 62 | + } |
| 63 | + if byName["express"].Dev { |
| 64 | + t.Error("express should not be a dev dependency") |
| 65 | + } |
| 66 | + if !byName["jest"].Dev { |
| 67 | + t.Error("jest should be a dev dependency") |
| 68 | + } |
| 69 | +} |
| 70 | + |
| 71 | +func TestParseGoMod(t *testing.T) { |
| 72 | + repo := makeRepo(t, map[string]string{ |
| 73 | + "go.mod": `module github.com/example/app |
| 74 | +
|
| 75 | +go 1.22 |
| 76 | +
|
| 77 | +require ( |
| 78 | + github.com/gin-gonic/gin v1.9.1 |
| 79 | + github.com/redis/go-redis/v9 v9.0.0 |
| 80 | +) |
| 81 | +
|
| 82 | +require github.com/stretchr/testify v1.8.4 |
| 83 | +`, |
| 84 | + }) |
| 85 | + |
| 86 | + deps := recon.ParseDependencies(repo) |
| 87 | + if len(deps) != 3 { |
| 88 | + t.Errorf("expected 3 deps, got %d", len(deps)) |
| 89 | + } |
| 90 | + |
| 91 | + byName := make(map[string]recon.Dependency) |
| 92 | + for _, d := range deps { |
| 93 | + byName[d.Name] = d |
| 94 | + } |
| 95 | + |
| 96 | + if byName["github.com/gin-gonic/gin"].Version != "v1.9.1" { |
| 97 | + t.Errorf("expected gin version v1.9.1, got %q", byName["github.com/gin-gonic/gin"].Version) |
| 98 | + } |
| 99 | + if byName["github.com/gin-gonic/gin"].Ecosystem != "go" { |
| 100 | + t.Error("expected go ecosystem") |
| 101 | + } |
| 102 | +} |
| 103 | + |
| 104 | +func TestParseRequirementsTxt(t *testing.T) { |
| 105 | + repo := makeRepo(t, map[string]string{ |
| 106 | + "requirements.txt": `# Production deps |
| 107 | +fastapi==0.100.0 |
| 108 | +uvicorn>=0.22.0 |
| 109 | +sqlalchemy~=2.0.0 |
| 110 | +
|
| 111 | +# skip options |
| 112 | +--index-url https://pypi.org/simple |
| 113 | +-r base.txt |
| 114 | +`, |
| 115 | + }) |
| 116 | + |
| 117 | + deps := recon.ParseDependencies(repo) |
| 118 | + if len(deps) != 3 { |
| 119 | + t.Errorf("expected 3 deps, got %d", len(deps)) |
| 120 | + } |
| 121 | + |
| 122 | + byName := make(map[string]recon.Dependency) |
| 123 | + for _, d := range deps { |
| 124 | + byName[d.Name] = d |
| 125 | + } |
| 126 | + |
| 127 | + if byName["fastapi"].Version != "==0.100.0" { |
| 128 | + t.Errorf("expected fastapi version ==0.100.0, got %q", byName["fastapi"].Version) |
| 129 | + } |
| 130 | + if byName["fastapi"].Ecosystem != "pypi" { |
| 131 | + t.Error("expected pypi ecosystem") |
| 132 | + } |
| 133 | +} |
| 134 | + |
| 135 | +func TestParseCargoToml(t *testing.T) { |
| 136 | + repo := makeRepo(t, map[string]string{ |
| 137 | + "Cargo.toml": `[package] |
| 138 | +name = "my-app" |
| 139 | +version = "0.1.0" |
| 140 | +
|
| 141 | +[dependencies] |
| 142 | +serde = { version = "1.0", features = ["derive"] } |
| 143 | +tokio = "1.28" |
| 144 | +
|
| 145 | +[dev-dependencies] |
| 146 | +mockall = "0.11" |
| 147 | +`, |
| 148 | + }) |
| 149 | + |
| 150 | + deps := recon.ParseDependencies(repo) |
| 151 | + |
| 152 | + byName := make(map[string]recon.Dependency) |
| 153 | + for _, d := range deps { |
| 154 | + byName[d.Name] = d |
| 155 | + } |
| 156 | + |
| 157 | + if _, ok := byName["tokio"]; !ok { |
| 158 | + t.Error("expected tokio in deps") |
| 159 | + } |
| 160 | + if byName["tokio"].Ecosystem != "cargo" { |
| 161 | + t.Error("expected cargo ecosystem") |
| 162 | + } |
| 163 | + if !byName["mockall"].Dev { |
| 164 | + t.Error("mockall should be a dev dependency") |
| 165 | + } |
| 166 | +} |
| 167 | + |
| 168 | +// ── Tech stack tests ────────────────────────────────────────────────────────── |
| 169 | + |
| 170 | +func TestDetectTechStackFromFiles(t *testing.T) { |
| 171 | + repo := makeRepo(t, map[string]string{ |
| 172 | + "package.json": `{"name":"app"}`, |
| 173 | + "tsconfig.json": `{}`, |
| 174 | + "next.config.js": `module.exports = {}`, |
| 175 | + "tailwind.config.js": `module.exports = {}`, |
| 176 | + }) |
| 177 | + |
| 178 | + techs := recon.DetectTechStack(repo) |
| 179 | + techNames := make(map[string]bool) |
| 180 | + for _, t2 := range techs { |
| 181 | + techNames[t2.Name] = true |
| 182 | + } |
| 183 | + |
| 184 | + for _, expected := range []string{"Node.js", "TypeScript", "Next.js", "Tailwind CSS"} { |
| 185 | + if !techNames[expected] { |
| 186 | + t.Errorf("expected %q in tech stack", expected) |
| 187 | + } |
| 188 | + } |
| 189 | +} |
| 190 | + |
| 191 | +func TestDetectTechStackFromImports(t *testing.T) { |
| 192 | + repo := makeRepo(t, map[string]string{ |
| 193 | + "src/app.ts": `import express from "express" |
| 194 | +import { PrismaClient } from "@prisma/client" |
| 195 | +
|
| 196 | +const app = express() |
| 197 | +`, |
| 198 | + }) |
| 199 | + |
| 200 | + techs := recon.DetectTechStack(repo) |
| 201 | + techNames := make(map[string]bool) |
| 202 | + for _, t2 := range techs { |
| 203 | + techNames[t2.Name] = true |
| 204 | + } |
| 205 | + |
| 206 | + if !techNames["Express"] { |
| 207 | + t.Error("expected Express in tech stack from imports") |
| 208 | + } |
| 209 | + if !techNames["Prisma"] { |
| 210 | + t.Error("expected Prisma in tech stack from imports") |
| 211 | + } |
| 212 | +} |
| 213 | + |
| 214 | +func TestDetectInfrastructure(t *testing.T) { |
| 215 | + repo := makeRepo(t, map[string]string{ |
| 216 | + "Dockerfile": "FROM node:18\n", |
| 217 | + "docker-compose.yml": "version: '3'\n", |
| 218 | + ".github/workflows/ci.yml": "name: CI\n", |
| 219 | + "infra/main.tf": `provider "aws" {}`, |
| 220 | + }) |
| 221 | + |
| 222 | + runner := recon.NewRunner() |
| 223 | + result, err := runner.Run(repo) |
| 224 | + if err != nil { |
| 225 | + t.Fatalf("Run: %v", err) |
| 226 | + } |
| 227 | + |
| 228 | + if !result.HasDocker { |
| 229 | + t.Error("expected HasDocker = true") |
| 230 | + } |
| 231 | + if !result.HasDockerCompose { |
| 232 | + t.Error("expected HasDockerCompose = true") |
| 233 | + } |
| 234 | + if !result.HasCI { |
| 235 | + t.Error("expected HasCI = true") |
| 236 | + } |
| 237 | + if !result.HasTerraform { |
| 238 | + t.Error("expected HasTerraform = true") |
| 239 | + } |
| 240 | + |
| 241 | + ciFound := false |
| 242 | + for _, ci := range result.CIProviders { |
| 243 | + if ci == "github-actions" { |
| 244 | + ciFound = true |
| 245 | + } |
| 246 | + } |
| 247 | + if !ciFound { |
| 248 | + t.Error("expected github-actions in CI providers") |
| 249 | + } |
| 250 | +} |
| 251 | + |
| 252 | +// ── Endpoint tests ──────────────────────────────────────────────────────────── |
| 253 | + |
| 254 | +func TestDetectExpressEndpoints(t *testing.T) { |
| 255 | + repo := makeRepo(t, map[string]string{ |
| 256 | + "src/routes.js": `const express = require('express') |
| 257 | +const router = express.Router() |
| 258 | +
|
| 259 | +router.get('/users', getUsers) |
| 260 | +router.post('/users', createUser) |
| 261 | +router.put('/users/:id', updateUser) |
| 262 | +router.delete('/users/:id', deleteUser) |
| 263 | +
|
| 264 | +module.exports = router |
| 265 | +`, |
| 266 | + }) |
| 267 | + |
| 268 | + endpoints := recon.DetectEndpoints(repo) |
| 269 | + if len(endpoints) != 4 { |
| 270 | + t.Errorf("expected 4 endpoints, got %d", len(endpoints)) |
| 271 | + } |
| 272 | + |
| 273 | + methods := make(map[string]bool) |
| 274 | + for _, e := range endpoints { |
| 275 | + methods[e.Method] = true |
| 276 | + } |
| 277 | + for _, m := range []string{"GET", "POST", "PUT", "DELETE"} { |
| 278 | + if !methods[m] { |
| 279 | + t.Errorf("expected method %s", m) |
| 280 | + } |
| 281 | + } |
| 282 | +} |
| 283 | + |
| 284 | +func TestDetectFastAPIEndpoints(t *testing.T) { |
| 285 | + repo := makeRepo(t, map[string]string{ |
| 286 | + "app/main.py": `from fastapi import FastAPI |
| 287 | +app = FastAPI() |
| 288 | +
|
| 289 | +@app.get("/health") |
| 290 | +async def health_check(): |
| 291 | + return {"status": "ok"} |
| 292 | +
|
| 293 | +@app.post("/users") |
| 294 | +async def create_user(user: UserCreate): |
| 295 | + pass |
| 296 | +`, |
| 297 | + }) |
| 298 | + |
| 299 | + endpoints := recon.DetectEndpoints(repo) |
| 300 | + if len(endpoints) != 2 { |
| 301 | + t.Errorf("expected 2 endpoints, got %d", len(endpoints)) |
| 302 | + } |
| 303 | +} |
| 304 | + |
| 305 | +// ── Runner integration test ─────────────────────────────────────────────────── |
| 306 | + |
| 307 | +func TestRunnerFullScan(t *testing.T) { |
| 308 | + repo := makeRepo(t, map[string]string{ |
| 309 | + "main.go": `package main |
| 310 | +import "github.com/gin-gonic/gin" |
| 311 | +func main() { |
| 312 | + r := gin.Default() |
| 313 | + r.GET("/ping", pingHandler) |
| 314 | +}`, |
| 315 | + "go.mod": `module example.com/app |
| 316 | +go 1.22 |
| 317 | +require github.com/gin-gonic/gin v1.9.1`, |
| 318 | + "Dockerfile": "FROM golang:1.22\n", |
| 319 | + }) |
| 320 | + |
| 321 | + runner := recon.NewRunner() |
| 322 | + result, err := runner.Run(repo) |
| 323 | + if err != nil { |
| 324 | + t.Fatalf("Run() error: %v", err) |
| 325 | + } |
| 326 | + |
| 327 | + if result.TotalFiles == 0 { |
| 328 | + t.Error("expected TotalFiles > 0") |
| 329 | + } |
| 330 | + if len(result.Dependencies) == 0 { |
| 331 | + t.Error("expected at least one dependency from go.mod") |
| 332 | + } |
| 333 | + if !result.HasDocker { |
| 334 | + t.Error("expected HasDocker = true") |
| 335 | + } |
| 336 | + if result.Summary() == "" { |
| 337 | + t.Error("Summary() should not be empty") |
| 338 | + } |
| 339 | +} |
| 340 | + |
| 341 | +func TestRunnerNilRepo(t *testing.T) { |
| 342 | + runner := recon.NewRunner() |
| 343 | + _, err := runner.Run(nil) |
| 344 | + if err == nil { |
| 345 | + t.Error("expected error for nil repo") |
| 346 | + } |
| 347 | +} |
0 commit comments