1
0
Эх сурвалжийг харах

Phase 7: audit viewer + security headers + PHPUnit

/audit admin page:
- AuditRepository (read-only): findPaged with user / action / entity_type /
  entity_id substring / date-range filters, distinct column helpers for
  the filter dropdowns, 50-row pages, reverse-chronological order.
- AuditController::index guarded by requireAdmin (HTML redirect for
  unauth, 403 text for non-admin).
- views/audit/index.php — filter form at top, colour-coded action chips
  (CREATE green, UPDATE blue, DELETE red, LOGIN slate, LOGIN_FAILED
  amber, BOOTSTRAP_ADMIN purple), collapsible <details> for pretty-
  printed before/after JSON, "Previous/Next" pagination.
- Header nav picks up an "Audit log" link for admins.

Security headers (spec §9):
- public/index.php applies X-Content-Type-Options: nosniff,
  X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin,
  a Content-Security-Policy that allows the two CDNs (Tailwind Play,
  jQuery / jQuery UI), and Strict-Transport-Security when APP_BASE_URL
  is https. CSP still needs 'unsafe-inline' for Tailwind's runtime
  <style> injection and the one inline onclick in home.php — noted as
  follow-up tightening.

CSRF coverage:
- Reviewed every mutation endpoint. Form routes (logout, local login,
  workers create/update, sprints create) call SessionGuard::verifyCsrf.
  All JSON endpoints run through SessionGuard::requireAdminJson
  (TaskController, AuditController) or SprintController's
  gateJsonAdmin — both enforce CSRF. 18/18 mutations covered.

PHPUnit harness:
- phpunit.xml with autoload + source coverage config.
- tests/TestCase with in-memory SQLite + migrations bootstrap.
- tests/Services/CapacityCalculatorTest: 15 roundHalf data-provider
  cases (incl. spec §6.5 examples 20/0.2 and 21*0.8=16.8→17), forWorker
  edge cases (empty sprint, zero/full reserve, over-commit, prio-2
  neutrality), 12 half-step validation cases including spec §10
  rejections (0.3, 1.7, -0.5), 9 RTB-step cases.
- tests/Services/AuditLoggerTest: real UPDATE writes exactly one row,
  no-op UPDATE writes zero, no-op is key-order insensitive,
  CREATE/DELETE always write even with equal before/after, DELETE
  carries before_json + null after_json, LOGIN writes with null
  before/after, LOGIN_FAILED supports null user_id, occurred_at is
  ISO-UTC Zulu.
- tests/Repositories/UserRepositoryTest: first user becomes admin,
  second does not, re-login does not regress admin status, forceAdmin
  re-promotes a demoted user on update (local-admin path), upsert
  updates email/name without touching is_admin, count() honours
  re-upserts.

Result: 59 tests, 90 assertions, all green.

ACCEPTANCE.md:
- The spec §10 manual acceptance checklist, written up as a runnable
  walkthrough with expected outcomes per step, including the security-
  header curl check and the Excel spot-check procedure.

Verified via ad-hoc smoke tests: AuditRepository filters/pagination/
distincts correct, view renders admin+data; php -l clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
achiappa 2 долоо хоног өмнө
parent
commit
21d0c4ac33

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
.phpunit.cache/test-results


+ 122 - 0
ACCEPTANCE.md

@@ -0,0 +1,122 @@
+# Acceptance checklist (spec §10) — walkthrough
+
+This is the checklist from the spec. Run it against a fresh container after
+Phase 7 lands. Expected outcome is noted next to each step; edit this file
+(and open a bug) if any step deviates.
+
+## Setup
+
+```bash
+docker compose down && rm -rf data/app.sqlite
+docker compose up --build
+```
+
+`.env` should have EITHER valid `ENTRA_*` values OR `LOCAL_ADMIN_EMAIL` +
+`LOCAL_ADMIN_PASSWORD` set. Below, "sign in" means whichever flow you
+configured.
+
+## Steps
+
+1. **Fresh container, empty DB → sign in → you're the admin.**
+   - Open http://localhost:8080.
+   - If OIDC configured, click **Sign in with Microsoft** → complete Entra
+     round-trip. If local admin configured, click **Sign in as local admin**
+     and enter the env credentials.
+   - Expected: header shows your display name + `admin` badge.
+     `SELECT is_admin FROM users LIMIT 1` in `data/app.sqlite` returns 1.
+     `audit_log` has rows: `CREATE user / BOOTSTRAP_ADMIN user / LOGIN user`.
+
+2. **Create a sprint with 4 weeks.**
+   - Header → **New sprint** → name "Sprint 1", reasonable dates, reserve 20%,
+     weeks = 4 → Create sprint.
+   - Expected: redirect to `/sprints/1`; empty-state banner says "No workers".
+     Clicking **Settings** opens the settings page. The weeks table shows
+     4 rows with max_working_days 5.0 each and ISO week numbers computed
+     from the start date.
+
+3. **Add 6 workers, reorder by dragging — reload persists order.**
+   - Header → **Workers** → add Alice, Bob, Carol, Dan, Eve, Frank.
+   - Back to `/sprints/1/settings` → add all 6 to the sprint via **Add →**
+     buttons.
+   - Drag the "≡" handles to reorder (e.g. reverse order).
+   - Reload the page.
+   - Expected: the reordered sequence persists.
+     `audit_log` shows `UPDATE sprint_worker` rows for every moved row
+     (unchanged rows emit nothing thanks to the no-op rule).
+
+4. **Fill Arbeitstage; Ressourcen / Reserven / Available update live.**
+   - Open `/sprints/1` → edit a day cell (0.5-step input).
+   - Blur the input.
+   - Expected: Σ column updates immediately, the capacity strip updates
+     (`Ressourcen`, `− Reserven`, `Available`). Status pill flashes "Saved N
+     cell(s)". `audit_log` has one `CREATE sprint_worker_days` row per edited
+     cell.
+
+5. **Add tasks, assign days — Tot updates; over-commit turns Available red
+   but still saves.**
+   - In Section B: click **+ Add task** → focus jumps to the title input →
+     type "Report for Q1", set priority 1, assign 10 days to one worker
+     whose capacity is 8.
+   - Expected: `Tot` cell shows 10; that worker's `Available` in the capacity
+     strip goes negative and turns red. The assignment is persisted
+     (reload still shows 10); `audit_log` has `CREATE task` + `CREATE
+     task_assignment`.
+
+6. **Sort task table by Owner, then by a worker column; clear → original
+   drag order returns.**
+   - Click the `Owner` header — rows sort asc, header shows "↑".
+   - Click again — desc, "↓".
+   - Click any worker column header — sorts by that cell's days.
+   - Click the same header a third time (or any other until cleared) — the
+     rows return to `data-sort-order` (the drag-persisted order).
+   - Expected: no 500s, no layout shift, drag is disabled while a sort is
+     active.
+
+7. **Filter tasks: Prio=1, Owner=X, free-text "report".**
+   - Set prio filter to "Prio 1 only", owner to a specific worker, type
+     "report" into the search box.
+   - Expected: only matching rows stay visible. The "No tasks match the
+     current filters" banner appears when every row is hidden.
+
+8. **Rename a worker in `/workers` — reflected everywhere.**
+   - Header → **Workers** → change "Alice" to "Alice Cooper" → Save.
+   - Open `/sprints/1` in another tab.
+   - Expected: Arbeitstage row label, task list column header, and the
+     Owner dropdowns all show the new name. `audit_log` has an `UPDATE
+     worker` row.
+
+9. **/audit shows one row per change with diffs visible.**
+   - Header → **Audit log**.
+   - Expected: reverse-chronological table with 50/page pagination. Every
+     change you made is there. Each row's "before / after" `<details>` opens
+     to show pretty-printed JSON snapshots.
+   - Filter by user / action / entity type / date range / entity_id substring
+     — narrow the list.
+
+10. **Sign out; unauthenticated → redirect to /auth/login.**
+    - Click **Sign out**.
+    - Expected: session cleared, `audit_log` has a `LOGOUT user` row.
+    - Try visiting `/workers` or `/sprints/1/settings` while anonymous →
+      redirected to `/auth/login` (and then Entra, or the local form).
+
+## Spot-check (spec §12)
+
+If you have `Tool_Sprint_PlanningSample.xlsx` handy:
+- Pick a worker with known weekly availability (e.g. 4-4-5-5-2).
+  `Ressourcen` should match Excel: sum across weeks.
+- `− Reserven` should match: `round_to_0.5(Ressourcen * 0.8)` with reserve = 20 %.
+- For a prio-1 task totalling N days on that worker, `Available` should be
+  `(Ressourcen − Reserven) − N`, and turn red when negative.
+
+## Security headers (spec §9)
+
+With the app running, `curl -I http://localhost:8080/` should report:
+
+```
+X-Content-Type-Options: nosniff
+X-Frame-Options: DENY
+Referrer-Policy: strict-origin-when-cross-origin
+Content-Security-Policy: default-src 'self'; script-src 'self' …; …
+```
+
+`Strict-Transport-Security` appears only when `APP_BASE_URL` uses `https://`.

+ 17 - 0
phpunit.xml

@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
+         bootstrap="vendor/autoload.php"
+         colors="true"
+         cacheDirectory=".phpunit.cache">
+    <testsuites>
+        <testsuite name="unit">
+            <directory>tests</directory>
+        </testsuite>
+    </testsuites>
+    <source>
+        <include>
+            <directory suffix=".php">src</directory>
+        </include>
+    </source>
+</phpunit>

+ 36 - 0
public/index.php

@@ -5,6 +5,7 @@ declare(strict_types=1);
 use App\Auth\LocalAdmin;
 use App\Auth\OidcClient;
 use App\Auth\SessionGuard;
+use App\Controllers\AuditController;
 use App\Controllers\AuthController;
 use App\Controllers\SprintController;
 use App\Controllers\TaskController;
@@ -15,6 +16,7 @@ use App\Http\Request;
 use App\Http\Response;
 use App\Http\Router;
 use App\Http\View;
+use App\Repositories\AuditRepository;
 use App\Repositories\SprintRepository;
 use App\Repositories\SprintWeekRepository;
 use App\Repositories\SprintWorkerDayRepository;
@@ -88,6 +90,7 @@ $sprintWorkers  = new SprintWorkerRepository($pdo);
 $swDays         = new SprintWorkerDayRepository($pdo);
 $tasks          = new TaskRepository($pdo);
 $taskAssign     = new TaskAssignmentRepository($pdo);
+$auditRepo      = new AuditRepository($pdo);
 $audit          = new AuditLogger($pdo);
 $auth           = new AuthController($pdo, $users, $audit, $view);
 $workerCtrl     = new WorkerController($pdo, $users, $workers, $audit, $view);
@@ -99,6 +102,7 @@ $taskCtrl       = new TaskController(
     $pdo, $users, $sprints, $sprintWorkers, $swDays,
     $tasks, $taskAssign, $workers, $audit,
 );
+$auditCtrl      = new AuditController($users, $auditRepo, $view);
 
 // ---------------------------------------------------------------------------
 // Routing
@@ -157,6 +161,7 @@ $router->patch('/sprints/{id}/week-cells',            $sprintCtrl->updateWeekCel
 $router->patch('/sprints/{id}/week/{week_id}',        $sprintCtrl->updateWeekMax(...));
 
 // Phase 6 — Task list:
+$router->get('/audit',                                $auditCtrl->index(...));
 $router->post('/sprints/{id}/tasks',                  $taskCtrl->create(...));
 $router->post('/sprints/{id}/tasks/reorder',          $taskCtrl->reorder(...));
 $router->patch('/tasks/{id}',                         $taskCtrl->update(...));
@@ -168,6 +173,37 @@ $router->patch('/tasks/{id}/assignments',             $taskCtrl->updateAssignmen
 // ---------------------------------------------------------------------------
 $request  = Request::fromGlobals();
 $response = $router->dispatch($request);
+
+// Apply security headers to every response (spec §9). Kept here (instead of
+// Response::send) so the policy is visible + editable in one place.
+$isHttps = str_starts_with((string) (getenv('APP_BASE_URL') ?: ''), 'https://');
+
+// A strict-ish CSP. Tailwind Play CDN injects <style> blocks at runtime and
+// `home.php` uses an inline onclick attribute on the sprint rows — both need
+// 'unsafe-inline'. Tighten later by vendoring Tailwind and moving that
+// onclick to an event listener.
+$csp = implode('; ', [
+    "default-src 'self'",
+    "script-src 'self' https://cdn.tailwindcss.com https://code.jquery.com 'unsafe-inline'",
+    "style-src 'self' https://code.jquery.com 'unsafe-inline'",
+    "img-src 'self' data:",
+    "font-src 'self' data: https://code.jquery.com",
+    "connect-src 'self'",
+    "frame-ancestors 'none'",
+    "base-uri 'self'",
+    "form-action 'self' https://login.microsoftonline.com",
+]);
+
+$response
+    ->withHeader('X-Content-Type-Options', 'nosniff')
+    ->withHeader('X-Frame-Options', 'DENY')
+    ->withHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
+    ->withHeader('Content-Security-Policy', $csp);
+
+if ($isHttps) {
+    $response->withHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
+}
+
 $response->send();
 
 // Flush the output buffer opened at the top.

+ 66 - 0
src/Controllers/AuditController.php

@@ -0,0 +1,66 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\Auth\SessionGuard;
+use App\Http\Request;
+use App\Http\Response;
+use App\Http\View;
+use App\Repositories\AuditRepository;
+use App\Repositories\UserRepository;
+
+/**
+ * /audit — admin-only read-only viewer.
+ *
+ * Never writes to the audit log (that goes through AuditLogger), and there
+ * is NO admin action here that truncates or deletes rows. The spec forbids
+ * that.
+ */
+final class AuditController
+{
+    private const PAGE_SIZE = 50;
+
+    public function __construct(
+        private readonly UserRepository   $users,
+        private readonly AuditRepository  $audit,
+        private readonly View             $view,
+    ) {
+    }
+
+    public function index(Request $req): Response
+    {
+        $actor = SessionGuard::requireAdmin($this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+
+        $filters = [
+            'user_email'  => $req->queryString('user_email'),
+            'action'      => $req->queryString('action'),
+            'entity_type' => $req->queryString('entity_type'),
+            'entity_id'   => $req->queryString('entity_id'),
+            'from_date'   => $req->queryString('from_date'),
+            'to_date'     => $req->queryString('to_date'),
+        ];
+
+        $page = (int) ($req->queryString('page') ?: '1');
+        $result = $this->audit->findPaged($filters, $page, self::PAGE_SIZE);
+
+        return Response::html($this->view->render('audit/index', [
+            'title'       => 'Audit log',
+            'currentUser' => $actor,
+            'csrfToken'   => SessionGuard::csrfToken(),
+            'filters'     => $filters,
+            'page'        => $result['page'],
+            'pages'       => $result['pages'],
+            'pageSize'    => $result['pageSize'],
+            'total'       => $result['total'],
+            'rows'        => $result['rows'],
+            'actions'     => $this->audit->distinctActions(),
+            'entityTypes' => $this->audit->distinctEntityTypes(),
+            'users'       => $this->audit->distinctUserEmails(),
+        ]));
+    }
+}

+ 163 - 0
src/Repositories/AuditRepository.php

@@ -0,0 +1,163 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Repositories;
+
+use PDO;
+
+/**
+ * Read-only view over audit_log. There is no write API on purpose — writes
+ * go through App\Services\AuditLogger, and the spec forbids ever truncating
+ * or deleting audit rows.
+ */
+final class AuditRepository
+{
+    public function __construct(private readonly PDO $pdo)
+    {
+    }
+
+    /**
+     * Paged listing with optional filters.
+     *
+     * @param array{
+     *   user_email?:    string,
+     *   action?:        string,
+     *   entity_type?:   string,
+     *   entity_id?:     string,    // substring match, free text
+     *   from_date?:     string,    // ISO date 'Y-m-d' (inclusive, UTC)
+     *   to_date?:       string,    // ISO date 'Y-m-d' (inclusive, UTC)
+     * } $filters
+     *
+     * @return array{
+     *   rows:  list<array<string,mixed>>,
+     *   total: int,
+     *   page:  int,
+     *   pageSize: int,
+     *   pages: int,
+     * }
+     */
+    public function findPaged(array $filters, int $page, int $pageSize): array
+    {
+        $page     = max(1, $page);
+        $pageSize = max(1, min(200, $pageSize));
+        $offset   = ($page - 1) * $pageSize;
+
+        [$where, $params] = self::buildWhere($filters);
+
+        $countStmt = $this->pdo->prepare(
+            'SELECT COUNT(*) FROM audit_log' . $where
+        );
+        $countStmt->execute($params);
+        $total = (int) $countStmt->fetchColumn();
+
+        $dataStmt = $this->pdo->prepare(
+            'SELECT id, occurred_at, user_id, user_email, action, entity_type,
+                    entity_id, before_json, after_json, ip_address, user_agent
+               FROM audit_log'
+            . $where
+            . ' ORDER BY id DESC LIMIT ? OFFSET ?'
+        );
+        $i = 1;
+        foreach ($params as $p) {
+            $dataStmt->bindValue($i++, $p);
+        }
+        $dataStmt->bindValue($i++, $pageSize, PDO::PARAM_INT);
+        $dataStmt->bindValue($i,   $offset,   PDO::PARAM_INT);
+        $dataStmt->execute();
+
+        $rows  = $dataStmt->fetchAll();
+        $pages = (int) max(1, ceil($total / $pageSize));
+
+        return [
+            'rows'     => is_array($rows) ? $rows : [],
+            'total'    => $total,
+            'page'     => $page,
+            'pageSize' => $pageSize,
+            'pages'    => $pages,
+        ];
+    }
+
+    /** @return list<string> */
+    public function distinctActions(): array
+    {
+        return $this->distinctColumn('action');
+    }
+
+    /** @return list<string> */
+    public function distinctEntityTypes(): array
+    {
+        return $this->distinctColumn('entity_type');
+    }
+
+    /** @return list<string> */
+    public function distinctUserEmails(): array
+    {
+        $stmt = $this->pdo->query(
+            'SELECT DISTINCT user_email FROM audit_log
+             WHERE user_email IS NOT NULL AND user_email != ""
+             ORDER BY user_email ASC'
+        );
+        $out = [];
+        foreach ($stmt as $row) {
+            $out[] = (string) $row['user_email'];
+        }
+        return $out;
+    }
+
+    /** @return list<string> */
+    private function distinctColumn(string $col): array
+    {
+        $stmt = $this->pdo->query(
+            "SELECT DISTINCT {$col} FROM audit_log ORDER BY {$col} ASC"
+        );
+        $out = [];
+        foreach ($stmt as $row) {
+            $v = (string) $row[$col];
+            if ($v !== '') {
+                $out[] = $v;
+            }
+        }
+        return $out;
+    }
+
+    /**
+     * @param array<string,mixed> $filters
+     * @return array{0:string, 1:list<mixed>}  [' WHERE …', [bindings...]]
+     */
+    private static function buildWhere(array $filters): array
+    {
+        $clauses = [];
+        $params  = [];
+
+        if (!empty($filters['user_email'])) {
+            $clauses[] = 'user_email = ?';
+            $params[]  = (string) $filters['user_email'];
+        }
+        if (!empty($filters['action'])) {
+            $clauses[] = 'action = ?';
+            $params[]  = (string) $filters['action'];
+        }
+        if (!empty($filters['entity_type'])) {
+            $clauses[] = 'entity_type = ?';
+            $params[]  = (string) $filters['entity_type'];
+        }
+        if (!empty($filters['entity_id'])) {
+            $clauses[] = 'CAST(entity_id AS TEXT) LIKE ?';
+            $params[]  = '%' . (string) $filters['entity_id'] . '%';
+        }
+        if (!empty($filters['from_date'])) {
+            $clauses[] = 'occurred_at >= ?';
+            $params[]  = (string) $filters['from_date'] . 'T00:00:00Z';
+        }
+        if (!empty($filters['to_date'])) {
+            $clauses[] = 'occurred_at <= ?';
+            $params[]  = (string) $filters['to_date'] . 'T23:59:59Z';
+        }
+
+        if ($clauses === []) {
+            return ['', []];
+        }
+        return [' WHERE ' . implode(' AND ', $clauses), $params];
+    }
+}

+ 101 - 0
tests/Repositories/UserRepositoryTest.php

@@ -0,0 +1,101 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Repositories;
+
+use App\Repositories\UserRepository;
+use App\Tests\TestCase;
+
+/**
+ * Covers the first-user-is-admin bootstrap rule from spec §4.
+ */
+final class UserRepositoryTest extends TestCase
+{
+    public function testFirstUserBecomesAdminWhenPromoted(): void
+    {
+        $pdo   = $this->makeDb();
+        $users = new UserRepository($pdo);
+
+        $this->assertSame(0, $users->count());
+
+        $r = $users->upsertFromOidc(
+            oid:            'oid-alice',
+            email:          'alice@example.com',
+            name:           'Alice',
+            promoteToAdmin: true, // caller's decision — normally based on count === 0
+        );
+
+        $this->assertTrue($r['user']->isAdmin);
+        $this->assertNull($r['before']);
+    }
+
+    public function testSecondUserDoesNotBecomeAdmin(): void
+    {
+        $pdo   = $this->makeDb();
+        $users = new UserRepository($pdo);
+
+        $users->upsertFromOidc('oid-alice', 'alice@x', 'Alice', true);
+        $secondPromote = $users->count() === 0;  // false
+        $r2 = $users->upsertFromOidc('oid-bob', 'bob@x', 'Bob', $secondPromote);
+
+        $this->assertFalse($r2['user']->isAdmin);
+    }
+
+    public function testReLoginDoesNotRegressAdminStatus(): void
+    {
+        $pdo   = $this->makeDb();
+        $users = new UserRepository($pdo);
+
+        $users->upsertFromOidc('oid-alice', 'alice@x', 'Alice', true);
+        // simulate a later login: count > 0 so promoteToAdmin=false
+        $r = $users->upsertFromOidc('oid-alice', 'alice@x', 'Alice', false);
+
+        $this->assertTrue($r['user']->isAdmin, 're-login must not demote admin');
+        $this->assertNotNull($r['before']);
+    }
+
+    public function testForceAdminPromotesEvenOnUpdate(): void
+    {
+        // Local-admin login path sets forceAdmin=true so a demoted user gets
+        // promoted back on next sign-in.
+        $pdo   = $this->makeDb();
+        $users = new UserRepository($pdo);
+
+        $r1 = $users->upsertFromOidc('local:admin@x', 'admin@x', 'Admin', true, true);
+        $this->assertTrue($r1['user']->isAdmin);
+
+        // Manually demote.
+        $pdo->exec('UPDATE users SET is_admin = 0 WHERE id = ' . $r1['user']->id);
+
+        $r2 = $users->upsertFromOidc('local:admin@x', 'admin@x', 'Admin', false, true);
+        $this->assertTrue($r2['user']->isAdmin, 'forceAdmin must re-promote on update');
+    }
+
+    public function testUpsertUpdatesEmailAndName(): void
+    {
+        $pdo   = $this->makeDb();
+        $users = new UserRepository($pdo);
+
+        $users->upsertFromOidc('oid-alice', 'old@x', 'Old Name', true);
+        $r = $users->upsertFromOidc('oid-alice', 'new@x', 'New Name', false);
+
+        $this->assertSame('new@x',    $r['user']->email);
+        $this->assertSame('New Name', $r['user']->displayName);
+    }
+
+    public function testCountReflectsInsertedUsers(): void
+    {
+        $pdo   = $this->makeDb();
+        $users = new UserRepository($pdo);
+
+        $this->assertSame(0, $users->count());
+        $users->upsertFromOidc('oid-1', 'a@x', 'A', true);
+        $this->assertSame(1, $users->count());
+        $users->upsertFromOidc('oid-2', 'b@x', 'B', false);
+        $this->assertSame(2, $users->count());
+        // Re-upsert existing user shouldn't add a row.
+        $users->upsertFromOidc('oid-1', 'a@x', 'A', false);
+        $this->assertSame(2, $users->count());
+    }
+}

+ 160 - 0
tests/Services/AuditLoggerTest.php

@@ -0,0 +1,160 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Services;
+
+use App\Services\AuditLogger;
+use App\Tests\TestCase;
+
+final class AuditLoggerTest extends TestCase
+{
+    public function testRealUpdateProducesExactlyOneRow(): void
+    {
+        $pdo    = $this->makeDb();
+        $logger = new AuditLogger($pdo);
+
+        $id = $logger->record(
+            action:     'UPDATE',
+            entityType: 'worker',
+            entityId:   1,
+            before:     ['name' => 'Alice', 'is_active' => 1],
+            after:      ['name' => 'Alice Cooper', 'is_active' => 1],
+        );
+
+        $this->assertIsInt($id);
+        $this->assertSame(1, (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn());
+    }
+
+    public function testNoopUpdateWritesNoRow(): void
+    {
+        $pdo    = $this->makeDb();
+        $logger = new AuditLogger($pdo);
+
+        $snap = ['name' => 'Alice', 'is_active' => 1, 'default_rtb' => 0.1];
+
+        $result = $logger->record(
+            action:     'UPDATE',
+            entityType: 'worker',
+            entityId:   1,
+            before:     $snap,
+            after:      $snap,
+        );
+
+        $this->assertNull($result);
+        $this->assertSame(0, (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn());
+    }
+
+    public function testNoopUpdateIsKeyOrderInsensitive(): void
+    {
+        $pdo    = $this->makeDb();
+        $logger = new AuditLogger($pdo);
+
+        $before = ['name' => 'Alice', 'is_active' => 1];
+        $after  = ['is_active' => 1, 'name' => 'Alice']; // reordered keys
+
+        $result = $logger->record('UPDATE', 'worker', 1, $before, $after);
+        $this->assertNull($result);
+        $this->assertSame(0, (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn());
+    }
+
+    public function testCreateActionAlwaysWritesEvenWhenBeforeEqualsAfter(): void
+    {
+        // The no-op rule applies to UPDATE only. CREATE/DELETE always write.
+        $pdo    = $this->makeDb();
+        $logger = new AuditLogger($pdo);
+
+        $logger->record(
+            action:     'CREATE',
+            entityType: 'worker',
+            entityId:   1,
+            before:     null,
+            after:      ['name' => 'Alice'],
+        );
+
+        $this->assertSame(1, (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn());
+    }
+
+    public function testDeleteCarriesBeforeJsonAndNullAfterJson(): void
+    {
+        $pdo    = $this->makeDb();
+        $logger = new AuditLogger($pdo);
+
+        $logger->record(
+            action:     'DELETE',
+            entityType: 'worker',
+            entityId:   42,
+            before:     ['id' => 42, 'name' => 'Bob'],
+            after:      null,
+            userId:     7,
+            userEmail:  'admin@x',
+            ipAddress:  '1.2.3.4',
+            userAgent:  'test-ua',
+        );
+
+        $row = $pdo->query('SELECT * FROM audit_log LIMIT 1')->fetch();
+        $this->assertSame('DELETE', $row['action']);
+        $this->assertSame('worker', $row['entity_type']);
+        $this->assertSame(42, (int) $row['entity_id']);
+        $this->assertSame(7,  (int) $row['user_id']);
+        $this->assertSame('admin@x', $row['user_email']);
+        $this->assertSame('1.2.3.4', $row['ip_address']);
+        $this->assertSame('test-ua', $row['user_agent']);
+        $this->assertIsString($row['before_json']);
+        $this->assertSame('{"id":42,"name":"Bob"}', $row['before_json']);
+        $this->assertNull($row['after_json']);
+    }
+
+    public function testLoginEventWritesWithNullBeforeAndAfter(): void
+    {
+        // LOGIN events are not mutations and carry no before/after.
+        $pdo    = $this->makeDb();
+        $logger = new AuditLogger($pdo);
+
+        $logger->record(
+            action:     'LOGIN',
+            entityType: 'user',
+            entityId:   1,
+            before:     null,
+            after:      null,
+        );
+
+        $row = $pdo->query('SELECT * FROM audit_log LIMIT 1')->fetch();
+        $this->assertSame('LOGIN', $row['action']);
+        $this->assertNull($row['before_json']);
+        $this->assertNull($row['after_json']);
+    }
+
+    public function testLoginFailedSupportsNullUser(): void
+    {
+        $pdo    = $this->makeDb();
+        $logger = new AuditLogger($pdo);
+
+        $logger->record(
+            action:     'LOGIN_FAILED',
+            entityType: 'user',
+            entityId:   null,
+            before:     null,
+            after:      ['reason' => 'bad_signature'],
+        );
+
+        $row = $pdo->query('SELECT * FROM audit_log LIMIT 1')->fetch();
+        $this->assertSame('LOGIN_FAILED', $row['action']);
+        $this->assertNull($row['entity_id']);
+        $this->assertNull($row['user_id']);
+        $this->assertSame('{"reason":"bad_signature"}', $row['after_json']);
+    }
+
+    public function testOccurredAtIsIsoUtc(): void
+    {
+        $pdo    = $this->makeDb();
+        $logger = new AuditLogger($pdo);
+        $logger->record('CREATE', 'worker', 1, null, ['name' => 'Alice']);
+
+        $ts = (string) $pdo->query('SELECT occurred_at FROM audit_log LIMIT 1')->fetchColumn();
+        $this->assertMatchesRegularExpression(
+            '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/',
+            $ts,
+        );
+    }
+}

+ 164 - 0
tests/Services/CapacityCalculatorTest.php

@@ -0,0 +1,164 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Services;
+
+use App\Services\CapacityCalculator;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+
+final class CapacityCalculatorTest extends TestCase
+{
+    // -------------------------------------------------------------------
+    // roundHalf
+    // -------------------------------------------------------------------
+
+    /** @return list<array{float,float}> */
+    public static function roundHalfCases(): array
+    {
+        return [
+            [0.0,  0.0],
+            [0.24, 0.0],
+            [0.25, 0.5],   // round(0.5) = 1 -> /2 = 0.5
+            [0.26, 0.5],
+            [0.75, 1.0],
+            [1.24, 1.0],
+            [1.25, 1.5],
+            [1.74, 1.5],
+            [1.75, 2.0],
+            [3.10, 3.0],
+            [3.30, 3.5],
+            [5.00, 5.0],
+            [16.8, 17.0],  // 21 * 0.8
+            [14.4, 14.5],  // 18 * 0.8
+            [13.6, 13.5],  // 17 * 0.8
+        ];
+    }
+
+    #[DataProvider('roundHalfCases')]
+    public function testRoundHalf(float $input, float $expected): void
+    {
+        $this->assertSame($expected, CapacityCalculator::roundHalf($input));
+    }
+
+    // -------------------------------------------------------------------
+    // forWorker — spec §6.5 examples
+    // -------------------------------------------------------------------
+
+    public function testForWorkerEmptySprint(): void
+    {
+        $r = CapacityCalculator::forWorker(0.0, 0.2, 0.0);
+        $this->assertSame(0.0, $r['ressourcen']);
+        $this->assertSame(0.0, $r['after_reserves']);
+        $this->assertSame(0.0, $r['committed_prio1']);
+        $this->assertSame(0.0, $r['available']);
+    }
+
+    public function testForWorkerSpecExample(): void
+    {
+        // 20 days raw, 20% reserve → 20*0.8 = 16 → available = 16 (no prio-1)
+        $r = CapacityCalculator::forWorker(20.0, 0.2, 0.0);
+        $this->assertSame(16.0, $r['after_reserves']);
+        $this->assertSame(16.0, $r['available']);
+    }
+
+    public function testForWorkerFractionalReserveRoundsToHalfStep(): void
+    {
+        // 21 * 0.8 = 16.8 → round_half = 17.0
+        $r = CapacityCalculator::forWorker(21.0, 0.2, 0.0);
+        $this->assertSame(17.0, $r['after_reserves']);
+    }
+
+    public function testForWorkerPrio1CommitmentsReduceAvailable(): void
+    {
+        // 20 res, 20% reserve → after=16; 17 committed → available = -1
+        $r = CapacityCalculator::forWorker(20.0, 0.2, 17.0);
+        $this->assertSame(-1.0, $r['available']);
+    }
+
+    public function testForWorkerOverCommitmentStillCompletesCleanly(): void
+    {
+        $r = CapacityCalculator::forWorker(10.0, 0.2, 50.0);
+        // Negative available is intentional (shown in red, not blocked).
+        $this->assertLessThan(0.0, $r['available']);
+    }
+
+    public function testForWorkerPrio2DoesNotAppearInCommitted(): void
+    {
+        // The caller is responsible for only passing prio-1 committed days;
+        // this method doesn't filter anything. What we verify here is that
+        // a prio-1 value of 0 (because there were no prio-1 tasks) leaves
+        // Available unaffected by whatever prio-2 allocations existed.
+        $r = CapacityCalculator::forWorker(20.0, 0.2, 0.0);
+        $this->assertSame(16.0, $r['available']);
+    }
+
+    public function testForWorkerZeroReserveFraction(): void
+    {
+        $r = CapacityCalculator::forWorker(18.0, 0.0, 3.0);
+        $this->assertSame(18.0, $r['after_reserves']);
+        $this->assertSame(15.0, $r['available']);
+    }
+
+    public function testForWorkerFullReserveFraction(): void
+    {
+        $r = CapacityCalculator::forWorker(18.0, 1.0, 0.0);
+        $this->assertSame(0.0, $r['after_reserves']);
+        $this->assertSame(0.0, $r['available']);
+    }
+
+    // -------------------------------------------------------------------
+    // Day-value validation (spec §3 constraints enforced in PHP)
+    // -------------------------------------------------------------------
+
+    /** @return list<array{float,bool}> */
+    public static function halfStepCases(): array
+    {
+        return [
+            [0.0,  true],
+            [0.5,  true],
+            [1.0,  true],
+            [2.5,  true],
+            [5.0,  true],
+            // rejected
+            [0.3,  false],   // §10 literal example
+            [1.7,  false],   // §10 literal example
+            [-0.5, false],   // §10 literal example: negative
+            [-1.0, false],
+            [5.5,  false],
+            [6.0,  false],
+            [0.25, false],
+            [0.75, false],
+        ];
+    }
+
+    #[DataProvider('halfStepCases')]
+    public function testIsHalfStep(float $v, bool $expected): void
+    {
+        $this->assertSame($expected, CapacityCalculator::isHalfStep($v, 0.0, 5.0));
+    }
+
+    /** @return list<array{float,bool}> */
+    public static function rtbStepCases(): array
+    {
+        return [
+            [0.0,  true],
+            [0.05, true],
+            [0.10, true],
+            [0.35, true],
+            [1.0,  true],
+            // rejected
+            [0.03, false],
+            [1.1,  false],
+            [-0.05, false],
+            [0.07, false],
+        ];
+    }
+
+    #[DataProvider('rtbStepCases')]
+    public function testIsRtbStep(float $v, bool $expected): void
+    {
+        $this->assertSame($expected, CapacityCalculator::isRtbStep($v));
+    }
+}

+ 33 - 0
tests/TestCase.php

@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests;
+
+use PDO;
+use PHPUnit\Framework\TestCase as PhpUnitTestCase;
+
+/**
+ * Base TestCase with helpers for building an in-memory SQLite DB loaded with
+ * the same migrations the app runs. Every test gets a fresh isolated database.
+ */
+abstract class TestCase extends PhpUnitTestCase
+{
+    protected function makeDb(): PDO
+    {
+        $pdo = new PDO('sqlite::memory:', null, null, [
+            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+            PDO::ATTR_EMULATE_PREPARES => false,
+        ]);
+        $pdo->exec('PRAGMA foreign_keys = ON');
+
+        $sql = file_get_contents(__DIR__ . '/../migrations/001_init.sql');
+        if ($sql === false) {
+            $this->fail('Could not read migrations/001_init.sql');
+        }
+        $pdo->exec($sql);
+
+        return $pdo;
+    }
+}

+ 218 - 0
views/audit/index.php

@@ -0,0 +1,218 @@
+<?php
+/** @var array{user_email:string,action:string,entity_type:string,entity_id:string,from_date:string,to_date:string} $filters */
+/** @var int $page */
+/** @var int $pages */
+/** @var int $pageSize */
+/** @var int $total */
+/** @var list<array<string,mixed>> $rows */
+/** @var list<string> $actions */
+/** @var list<string> $entityTypes */
+/** @var list<string> $users */
+use function App\Http\e;
+
+/** Pretty-print JSON for display; tolerate non-JSON values gracefully. */
+$prettyJson = static function (?string $raw): string {
+    if ($raw === null || $raw === '') { return ''; }
+    try {
+        $v = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
+    } catch (\JsonException) {
+        return $raw;
+    }
+    return json_encode($v, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $raw;
+};
+
+/** Build the current query string minus one key (for pagination links). */
+$qsWithout = static function (array $filters, string $drop, array $extra = []): string {
+    $params = array_filter(array_merge($filters, $extra), fn($v) => $v !== '' && $v !== null);
+    unset($params[$drop]);
+    return $params === [] ? '' : '?' . http_build_query($params);
+};
+
+$anyFilter = array_filter($filters, fn($v) => $v !== '' && $v !== null);
+?>
+<section class="space-y-5">
+    <header class="flex items-end justify-between gap-4">
+        <div>
+            <h1 class="text-2xl font-semibold tracking-tight">Audit log</h1>
+            <p class="text-slate-600 text-sm mt-1">
+                <?= (int) $total ?> matching row<?= $total === 1 ? '' : 's' ?>
+                · page <?= (int) $page ?> / <?= (int) $pages ?>
+                · <?= (int) $pageSize ?> per page
+            </p>
+        </div>
+    </header>
+
+    <!-- Filter form -->
+    <form method="get" action="/audit"
+          class="rounded-lg border bg-white p-4 grid grid-cols-1 md:grid-cols-6 gap-3">
+        <label class="block">
+            <span class="text-xs text-slate-600">User</span>
+            <select name="user_email"
+                    class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-slate-400">
+                <option value="">Any</option>
+                <?php foreach ($users as $u): ?>
+                    <option value="<?= e($u) ?>" <?= $filters['user_email'] === $u ? 'selected' : '' ?>><?= e($u) ?></option>
+                <?php endforeach; ?>
+            </select>
+        </label>
+
+        <label class="block">
+            <span class="text-xs text-slate-600">Action</span>
+            <select name="action"
+                    class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-slate-400">
+                <option value="">Any</option>
+                <?php foreach ($actions as $a): ?>
+                    <option value="<?= e($a) ?>" <?= $filters['action'] === $a ? 'selected' : '' ?>><?= e($a) ?></option>
+                <?php endforeach; ?>
+            </select>
+        </label>
+
+        <label class="block">
+            <span class="text-xs text-slate-600">Entity type</span>
+            <select name="entity_type"
+                    class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-slate-400">
+                <option value="">Any</option>
+                <?php foreach ($entityTypes as $t): ?>
+                    <option value="<?= e($t) ?>" <?= $filters['entity_type'] === $t ? 'selected' : '' ?>><?= e($t) ?></option>
+                <?php endforeach; ?>
+            </select>
+        </label>
+
+        <label class="block">
+            <span class="text-xs text-slate-600">Entity ID (contains)</span>
+            <input name="entity_id" type="text"
+                   value="<?= e($filters['entity_id']) ?>"
+                   class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400">
+        </label>
+
+        <label class="block">
+            <span class="text-xs text-slate-600">From date</span>
+            <input name="from_date" type="date"
+                   value="<?= e($filters['from_date']) ?>"
+                   class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400">
+        </label>
+
+        <label class="block">
+            <span class="text-xs text-slate-600">To date</span>
+            <input name="to_date" type="date"
+                   value="<?= e($filters['to_date']) ?>"
+                   class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400">
+        </label>
+
+        <div class="md:col-span-6 flex items-center justify-end gap-2">
+            <?php if ($anyFilter): ?>
+                <a href="/audit" class="text-sm text-slate-600 hover:underline">Clear</a>
+            <?php endif; ?>
+            <button type="submit"
+                    class="rounded bg-slate-900 text-white px-4 py-1.5 text-sm font-medium hover:bg-slate-800">
+                Apply
+            </button>
+        </div>
+    </form>
+
+    <!-- Rows -->
+    <div class="rounded-lg border bg-white overflow-hidden">
+        <?php if ($rows === []): ?>
+            <div class="p-8 text-center text-slate-500 text-sm">No audit rows match.</div>
+        <?php else: ?>
+            <table class="min-w-full text-sm">
+                <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider">
+                    <tr>
+                        <th class="text-left px-3 py-2 font-semibold">When (UTC)</th>
+                        <th class="text-left px-3 py-2 font-semibold">User</th>
+                        <th class="text-left px-3 py-2 font-semibold">Action</th>
+                        <th class="text-left px-3 py-2 font-semibold">Entity</th>
+                        <th class="text-left px-3 py-2 font-semibold">Diff</th>
+                        <th class="text-left px-3 py-2 font-semibold">Origin</th>
+                    </tr>
+                </thead>
+                <tbody class="divide-y divide-slate-100 align-top">
+                    <?php foreach ($rows as $r): ?>
+                        <tr>
+                            <td class="px-3 py-2 font-mono text-xs whitespace-nowrap">
+                                <?= e((string) $r['occurred_at']) ?>
+                            </td>
+                            <td class="px-3 py-2">
+                                <?= $r['user_email'] !== null && $r['user_email'] !== ''
+                                    ? e((string) $r['user_email'])
+                                    : '<span class="text-slate-400">—</span>' ?>
+                            </td>
+                            <td class="px-3 py-2">
+                                <span class="inline-block px-1.5 py-0.5 rounded text-xs font-mono
+                                    <?php
+                                    $action = (string) $r['action'];
+                                    echo match ($action) {
+                                        'CREATE'          => 'bg-green-100 text-green-800',
+                                        'UPDATE'          => 'bg-blue-100 text-blue-800',
+                                        'DELETE'          => 'bg-red-100 text-red-800',
+                                        'LOGIN'           => 'bg-slate-100 text-slate-700',
+                                        'LOGOUT'          => 'bg-slate-100 text-slate-700',
+                                        'LOGIN_FAILED'    => 'bg-amber-100 text-amber-800',
+                                        'BOOTSTRAP_ADMIN' => 'bg-purple-100 text-purple-800',
+                                        default           => 'bg-slate-100 text-slate-700',
+                                    };
+                                    ?>">
+                                    <?= e($action) ?>
+                                </span>
+                            </td>
+                            <td class="px-3 py-2 font-mono text-xs whitespace-nowrap">
+                                <?= e((string) $r['entity_type']) ?>
+                                <?php if ($r['entity_id'] !== null): ?>
+                                    <span class="text-slate-500">/</span>
+                                    <?= e((string) $r['entity_id']) ?>
+                                <?php endif; ?>
+                            </td>
+                            <td class="px-3 py-2">
+                                <?php $b = $prettyJson($r['before_json'] ?? null); $a = $prettyJson($r['after_json'] ?? null); ?>
+                                <?php if ($b === '' && $a === ''): ?>
+                                    <span class="text-slate-400 text-xs">—</span>
+                                <?php else: ?>
+                                    <details class="text-xs">
+                                        <summary class="cursor-pointer text-slate-600 hover:text-slate-900">
+                                            <?= $b !== '' && $a !== '' ? 'before / after'
+                                                : ($b !== '' ? 'before only' : 'after only') ?>
+                                        </summary>
+                                        <?php if ($b !== ''): ?>
+                                            <div class="mt-1 text-[11px] text-slate-500">before</div>
+                                            <pre class="mt-1 p-2 bg-slate-50 rounded overflow-x-auto"><?= e($b) ?></pre>
+                                        <?php endif; ?>
+                                        <?php if ($a !== ''): ?>
+                                            <div class="mt-1 text-[11px] text-slate-500">after</div>
+                                            <pre class="mt-1 p-2 bg-slate-50 rounded overflow-x-auto"><?= e($a) ?></pre>
+                                        <?php endif; ?>
+                                    </details>
+                                <?php endif; ?>
+                            </td>
+                            <td class="px-3 py-2 text-xs text-slate-500 whitespace-nowrap">
+                                <?= e((string) ($r['ip_address'] ?? '')) ?>
+                                <?php if (!empty($r['user_agent'])): ?>
+                                    <span class="text-slate-300"
+                                          title="<?= e((string) $r['user_agent']) ?>">(UA)</span>
+                                <?php endif; ?>
+                            </td>
+                        </tr>
+                    <?php endforeach; ?>
+                </tbody>
+            </table>
+        <?php endif; ?>
+    </div>
+
+    <!-- Pagination -->
+    <?php if ($pages > 1): ?>
+        <nav class="flex items-center justify-between text-sm">
+            <?php
+            $prevQs = $qsWithout($filters, 'page', ['page' => max(1, $page - 1)]);
+            $nextQs = $qsWithout($filters, 'page', ['page' => min($pages, $page + 1)]);
+            ?>
+            <a href="/audit<?= e($prevQs) ?>"
+               class="<?= $page <= 1 ? 'pointer-events-none text-slate-300' : 'text-blue-700 hover:underline' ?>">
+                ← Previous
+            </a>
+            <span class="text-slate-600">Page <?= (int) $page ?> of <?= (int) $pages ?></span>
+            <a href="/audit<?= e($nextQs) ?>"
+               class="<?= $page >= $pages ? 'pointer-events-none text-slate-300' : 'text-blue-700 hover:underline' ?>">
+                Next →
+            </a>
+        </nav>
+    <?php endif; ?>
+</section>

+ 1 - 0
views/layout.php

@@ -30,6 +30,7 @@ $csrfToken   = $csrfToken   ?? '';
                     <?php if ($currentUser->isAdmin): ?>
                         <a href="/workers" class="text-slate-600 hover:text-slate-900 hover:underline">Workers</a>
                         <a href="/sprints/new" class="text-slate-600 hover:text-slate-900 hover:underline">New sprint</a>
+                        <a href="/audit" class="text-slate-600 hover:text-slate-900 hover:underline">Audit log</a>
                     <?php endif; ?>
                     <span class="text-slate-400">·</span>
                     <span class="text-slate-600">

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно