Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
# ── Python: lint, test, security ─────────────────────────────────────────
python:
name: Python (lint + test + audit)
runs-on: ubuntu-latest

defaults:
run:
working-directory: backend

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
cache-dependency-path: backend/requirements.txt

- name: Install dependencies
run: pip install -r requirements.txt

- name: Ruff — lint
run: ruff check .

- name: Ruff — format check
run: ruff format --check .

- name: Check for bare noqa (without justification comment)
# Fail if any line has `# noqa` not followed by an inline comment
# e.g. `# noqa: E501` alone is a bare suppress; `# noqa: E501 — reason` passes
run: |
if grep -rn --include="*.py" '# noqa' . | grep -v '# noqa.*—\|# noqa.*--\|# noqa.*:.*#'; then
echo "ERROR: bare '# noqa' found without inline justification comment."
exit 1
fi

- name: pytest — unit + integration (mocked I/O only)
run: |
pytest -m "unit or integration" \
--cov=app \
--cov-report=term-missing \
--cov-fail-under=80 \
-v

- name: pip-audit — fail on HIGH severity CVEs
run: pip-audit --require-hashes -r requirements.txt --vulnerability-service osv 2>/dev/null \
|| pip-audit -r requirements.txt --vulnerability-service osv


# ── JavaScript / TypeScript: lint, test, security ────────────────────────
javascript:
name: JS/TS (lint + test + audit)
runs-on: ubuntu-latest

defaults:
run:
working-directory: frontend

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Install dependencies
run: npm ci

- name: ESLint
run: npm run lint

- name: Prettier — format check
run: npx prettier --check "src/**/*.{ts,tsx,css}"

- name: Check for bare eslint-disable (without justification comment)
# Allow: `// eslint-disable-next-line rule — reason`
# Deny: `// eslint-disable-next-line rule` with no trailing comment
run: |
if grep -rn --include="*.ts" --include="*.tsx" 'eslint-disable' src/ \
| grep -v 'eslint-disable.*—\|eslint-disable.*--\|eslint-disable.*: '; then
echo "ERROR: bare 'eslint-disable' found without inline justification comment."
exit 1
fi

- name: Vitest — run tests with coverage
run: npm run test:coverage

- name: npm audit — fail on HIGH severity
run: npm audit --audit-level=high
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ htmlcov/
node_modules/
dist/
.vite/
coverage/

# SQLite
*.db
Expand Down
3 changes: 2 additions & 1 deletion backend/app/db.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""Database connection and initialisation."""

import os

from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
import os

DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./networkcrawler.db")

Expand Down
16 changes: 11 additions & 5 deletions backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
"""NetworkCrawler — FastAPI application entrypoint."""

from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.db import init_db


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
init_db()
yield


app = FastAPI(
title="NetworkCrawler",
description="LAN security posture scanner for home lab operators.",
version="0.1.0",
lifespan=lifespan,
)

app.add_middleware(
Expand All @@ -19,11 +30,6 @@
)


@app.on_event("startup")
async def startup() -> None:
init_db()


@app.get("/health")
async def health() -> dict:
return {"status": "ok"}
134 changes: 134 additions & 0 deletions backend/tests/test_db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""
Unit and integration tests for database initialisation and session management.

Markers: unit, integration
"""

import pytest
from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import sessionmaker


@pytest.fixture()
def in_memory_engine():
"""Provide a fresh in-memory SQLite engine with tables created."""
import app.models.device # noqa: F401 — register ORM models
from app.db import Base

engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)


@pytest.mark.unit()
def test_init_db_creates_devices_table(in_memory_engine):
inspector = inspect(in_memory_engine)
assert "devices" in inspector.get_table_names()


@pytest.mark.unit()
def test_init_db_creates_ports_table(in_memory_engine):
inspector = inspect(in_memory_engine)
assert "ports" in inspector.get_table_names()


@pytest.mark.unit()
def test_devices_table_columns(in_memory_engine):
inspector = inspect(in_memory_engine)
cols = {c["name"] for c in inspector.get_columns("devices")}
assert {
"id",
"ip_address",
"mac_address",
"hostname",
"os_guess",
"first_seen",
"last_seen",
} <= cols


@pytest.mark.unit()
def test_ports_table_columns(in_memory_engine):
inspector = inspect(in_memory_engine)
cols = {c["name"] for c in inspector.get_columns("ports")}
assert {"id", "device_id", "port_number", "protocol", "service_name", "version_banner"} <= cols


@pytest.mark.integration()
def test_get_db_yields_and_closes(in_memory_engine, monkeypatch):
"""get_db dependency yields a session and closes it after iteration."""
from app import db as db_module

test_session_factory = sessionmaker(bind=in_memory_engine)
monkeypatch.setattr(db_module, "SessionLocal", test_session_factory)

gen = db_module.get_db()
session = next(gen)
assert session is not None
# Exhaust the generator to trigger the finally block (close)
try:
next(gen)
except StopIteration:
pass


@pytest.mark.integration()
def test_device_crud(in_memory_engine):
"""Basic Device create/read round-trip against in-memory DB."""
from app.models.device import Device

session_factory = sessionmaker(bind=in_memory_engine)
with session_factory() as session:
device = Device(ip_address="192.168.1.1", hostname="router")
session.add(device)
session.commit()
session.refresh(device)

fetched = session.get(Device, device.id)
assert fetched.ip_address == "192.168.1.1"
assert fetched.hostname == "router"


@pytest.mark.integration()
def test_port_crud_with_device(in_memory_engine):
"""Port linked to Device creates FK relationship correctly."""
from app.models.device import Device, Port

session_factory = sessionmaker(bind=in_memory_engine)
with session_factory() as session:
device = Device(ip_address="192.168.1.2")
session.add(device)
session.flush()

port = Port(device_id=device.id, port_number=22, protocol="tcp", service_name="ssh")
session.add(port)
session.commit()

fetched_port = session.get(Port, port.id)
assert fetched_port.port_number == 22
assert fetched_port.device_id == device.id


@pytest.mark.integration()
def test_device_cascade_deletes_ports(in_memory_engine):
"""Deleting a Device cascades to its Ports."""
from app.models.device import Device, Port

session_factory = sessionmaker(bind=in_memory_engine)
with session_factory() as session:
device = Device(ip_address="192.168.1.3")
session.add(device)
session.flush()
port = Port(device_id=device.id, port_number=80)
session.add(port)
session.commit()

port_id = port.id
session.delete(device)
session.commit()

assert session.get(Port, port_id) is None
46 changes: 46 additions & 0 deletions backend/tests/test_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""
Unit tests for the FastAPI application entrypoint and health endpoint.

Markers: unit
"""

from unittest.mock import patch

import pytest
from fastapi.testclient import TestClient


# Patch init_db so tests don't need a real DB file
@pytest.fixture(scope="module")
def client():
with patch("app.db.init_db"):
from app.main import app

with TestClient(app) as c:
yield c


@pytest.mark.unit()
def test_health_returns_200(client):
response = client.get("/health")
assert response.status_code == 200


@pytest.mark.unit()
def test_health_body(client):
response = client.get("/health")
assert response.json() == {"status": "ok"}


@pytest.mark.unit()
def test_openapi_schema_accessible(client):
response = client.get("/openapi.json")
assert response.status_code == 200
data = response.json()
assert data["info"]["title"] == "NetworkCrawler"


@pytest.mark.unit()
def test_docs_accessible(client):
response = client.get("/docs")
assert response.status_code == 200
12 changes: 12 additions & 0 deletions frontend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import tsPlugin from '@typescript-eslint/eslint-plugin'
import tsParser from '@typescript-eslint/parser'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import globals from 'globals'

export default [
js.configs.recommended,
Expand All @@ -14,6 +15,9 @@ export default [
ecmaVersion: 'latest',
sourceType: 'module',
},
globals: {
...globals.browser,
},
},
plugins: {
'@typescript-eslint': tsPlugin,
Expand All @@ -23,7 +27,15 @@ export default [
rules: {
...tsPlugin.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
// Entry file (main.tsx) is intentionally not a component-only file
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
{
// Entry point — react-refresh warning is expected and acceptable here
files: ['src/main.tsx'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
]
Loading
Loading