Skip to content

feat: Time Tracking MVP — CRUD backend + TimeLog frontend#22

Closed
rubenvdlinde wants to merge 8 commits intodevelopmentfrom
hydra/spec
Closed

feat: Time Tracking MVP — CRUD backend + TimeLog frontend#22
rubenvdlinde wants to merge 8 commits intodevelopmentfrom
hydra/spec

Conversation

@rubenvdlinde
Copy link
Copy Markdown

Summary

Implements the Time Tracking MVP for Planix. Users can log time entries against tasks and view a per-task time log. The backend provides CRUD endpoints via a TimeEntryController that delegates to OpenRegister's ObjectService through a TimeEntryService. The frontend adds a TimeLog.vue component (with log form, entry list, and delete capability) embedded in a new TaskDetail view, backed by a dedicated Pinia store.

Spec Reference

openspec/changes/spec/design.md

Changes

  • lib/Controller/TimeEntryController.php — new controller with POST/GET/DELETE endpoints for time entries, authentication checks, validation error handling
  • lib/Service/TimeEntryService.php — new service encapsulating OpenRegister CRUD for timeEntry objects, input validation, owner-only delete enforcement
  • appinfo/routes.php — added /api/time-entries routes (POST, GET, DELETE)
  • src/store/timeEntries.js — new Pinia store for time entry CRUD via @conduction/nextcloud-vue objectStore
  • src/components/TimeLog.vue — time log component with entry list, log form (minutes + date + description), empty state, owner-only delete
  • src/views/TaskDetail.vue — task detail view with breadcrumb, task meta, and embedded TimeLog component
  • src/router/index.js — added /tasks/:taskId route for task detail view
  • openspec/changes/spec/ — copied spec files, marked all tasks complete, status set to pr-created

Test Coverage

  • tests/unit/Controller/TimeEntryControllerTest.php — 10 test methods: auth checks (403 for unauthenticated on create/index/destroy), successful create (201), validation error (400), missing taskId (400), list entries, owner delete success, non-owner delete forbidden
  • tests/unit/Service/TimeEntryServiceTest.php — 6 test methods: getCurrentUserId with/without user, validation throws on missing taskId, zero duration, negative duration, missing date

🤖 Generated with Claude Code

Hydra Builder and others added 5 commits April 5, 2026 16:52
Implements the backend for time tracking MVP:
- TimeEntryService: CRUD operations via OpenRegister ObjectService
- TimeEntryController: REST endpoints POST/GET/DELETE /api/time-entries
- Routes registered in appinfo/routes.php
- Validation: duration > 0, date required, taskId must exist
- Owner-only delete enforcement
- Authentication check (403 for unauthenticated users)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- TimeEntryControllerTest: 10 test methods covering auth, CRUD, validation
- TimeEntryServiceTest: 6 test methods covering user session, validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- TimeLog.vue: displays time entries for a task with log form and delete
- TaskDetail.vue: task detail view with breadcrumb, meta, and TimeLog
- timeEntries.js: Pinia store for time entry CRUD via OpenRegister
- Router: added /tasks/:taskId route for task detail

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix docblock parameter order in TimeEntryController
- Use named parameters for all internal method calls in TimeEntryService
- Replace inline ternary with if/return block in findObject()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@rubenvdlinde rubenvdlinde mentioned this pull request Apr 5, 2026
@rubenvdlinde
Copy link
Copy Markdown
Author

No description provided.

@rubenvdlinde
Copy link
Copy Markdown
Author

[WARNING] IDOR — listTimeEntries does not enforce task/project membership

File: lib/Service/TimeEntryService.php line 227
Endpoint: GET /api/time-entries?taskId=<uuid>

Any authenticated Nextcloud user can call this endpoint with an arbitrary taskId and receive all time entries for that task, including the entries, descriptions, and usernames of every contributor — even if the caller is not a member of the project that owns the task.

// TimeEntryService.php:227-229
public function listTimeEntries(string ): array
{
    return $this->findObjects(schema: self::SCHEMA, filters: ['task' => $taskId]);
}

There is no check that the requesting user:

  • is a member of the project that contains the task, or
  • has any other access right to the task.

Impact: Authenticated users can enumerate time entries across all tasks and projects they do not belong to, leaking work descriptions and effort data of other users.

Recommended fix: Before returning entries, verify that the requesting user has read access to the parent task (e.g. call findObject(TASK_SCHEMA, $taskId) and check that $task['members'] includes the current user, or delegate to a project-membership guard).

Severity: WARNING — must be fixed before merge.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 5, 2026

Quality Report

Repository ConductionNL/planix
Commit ef7f2b2
Branch 22/merge
Event pull_request
Generated 2026-04-05 17:02 UTC
Workflow Run https://github.com/ConductionNL/planix/actions/runs/24006176069

Summary

Group Result
PHP Quality PASS
Vue Quality PASS
Security PASS
License PASS
PHPUnit FAIL
Newman SKIP
Playwright SKIP

PHP Quality

Tool Result
lint PASS
phpcs PASS
phpmd PASS
psalm PASS
phpstan PASS
phpmetrics PASS

Vue Quality

Tool Result
eslint PASS
stylelint PASS

Security

Ecosystem Result
composer PASS
npm PASS

License Compliance

Ecosystem Result
composer PASS
npm PASS

composer dependencies (100 total)

Metric Count
Approved (allowlist) 100
Approved (override) 0
Denied 0

npm dependencies (215 total)

Metric Count
Approved (allowlist) 215
Approved (override) 0
Denied 0

PHPUnit Tests

PHP Nextcloud Result
Overall FAIL

Code coverage: 0% (0 / 3 statements)

Integration Tests (Newman)

Newman integration tests were not enabled for this run.

E2E Tests (Playwright)

Playwright E2E tests were not enabled for this run.


Generated automatically by the Quality workflow.

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
Copy link
Copy Markdown
Author

[WARNING] Insufficient input validation — date field accepts arbitrary strings

File: lib/Service/TimeEntryService.php lines 278–280

The validateTimeEntryData() method only checks that the date field is non-empty; it does not validate the value is a well-formed ISO 8601 date string:

// TimeEntryService.php:278-280
if (empty($data['date']) === true) {
    throw new \InvalidArgumentException('date is required.');
}

An attacker (or misbehaving client) can submit any string as date, e.g. an extremely long string or one containing special characters. The raw value is persisted to OpenRegister and later retrieved by the frontend where it is passed to new Date(dateStr + 'T00:00:00') (TimeLog.vue:168). While Vue templates auto-escape output, the stored date string is also returned verbatim in API responses and visible to all users who can list entries for the task.

Additionally, the taskId and the id route parameter in destroy() are not validated to be UUID-formatted strings before being passed to OpenRegister. Depending on how OpenRegister handles non-UUID identifiers, this may allow unexpected query behaviour.

Recommended fix:

// Validate ISO 8601 date format (YYYY-MM-DD)
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['date']) !== 1) {
    throw new \InvalidArgumentException('date must be a valid ISO 8601 date (YYYY-MM-DD).');
}

Similarly, add a UUID-format check for taskId and for the id parameter in destroy():

if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $id) !== 1) {
    return new JSONResponse(['error' => 'Invalid identifier format.'], Http::STATUS_BAD_REQUEST);
}

Severity: WARNING — must be fixed before merge.

@rubenvdlinde
Copy link
Copy Markdown
Author

[SUGGESTION] Frontend store bypasses the new TimeEntryController

Files: src/store/timeEntries.js, lib/Controller/TimeEntryController.php

The timeEntries.js Pinia store uses useObjectStore() from @conduction/nextcloud-vue and calls OpenRegister's API directly, rather than routing through the new TimeEntryController endpoints at /api/time-entries:

// timeEntries.js:87-89
const entry = await objectStore.saveObject(TIME_ENTRY_SCHEMA, {
    ...data,
    user: uid,  // set client-side
})

Consequences:

  1. Task-existence check bypassed: TimeEntryController.create() verifies the referenced task exists before saving; the direct objectStore call skips this.
  2. Client-controlled user field: The frontend explicitly sets user: uid and sends it to OpenRegister. If OpenRegister does not strip or override the user field on write, a manipulated request could store an arbitrary user value, undermining the owner-only delete enforcement.
  3. Dead code: The three controller endpoints are never called by the bundled frontend, making their test coverage misleading about actual runtime behaviour.

Recommended fix: Have fetchEntries, createEntry, and deleteEntry call the Nextcloud controller endpoints (/apps/planix/api/time-entries) via @nextcloud/axios, so that validation, task-existence checks, and ownership are always enforced server-side on the canonical code path.

Severity: SUGGESTION — informational; not a blocker on its own, but worth addressing.

@rubenvdlinde
Copy link
Copy Markdown
Author

{
  "tool": "hydra-security-reviewer",
  "reviewer": "Clyde Barcode",
  "pr": "https://github.com/ConductionNL/planix/pull/22",
  "reviewed_at": "2026-04-05T00:00:00Z",
  "verdict": "CHANGES_REQUESTED",
  "sast": {
    "semgrep": { "status": "clean", "findings": 0 },
    "gitleaks": { "status": "clean", "findings": 0 },
    "trivy":    { "status": "not_run" }
  },
  "findings": [
    {
      "id": "SEC-001",
      "severity": "WARNING",
      "title": "IDOR — listTimeEntries does not enforce task/project membership",
      "file": "lib/Service/TimeEntryService.php",
      "line": 227,
      "description": "Any authenticated user can retrieve all time entries for any task UUID, including entries and descriptions belonging to users in projects they are not members of.",
      "owasp": "A01:2021 – Broken Access Control"
    },
    {
      "id": "SEC-002",
      "severity": "WARNING",
      "title": "Missing format validation on date field and UUID parameters",
      "file": "lib/Service/TimeEntryService.php",
      "line": 278,
      "description": "The date field is only checked for emptiness; arbitrary strings are persisted. Route parameter id in destroy() and taskId in createTimeEntry() are not validated as UUID-formatted strings before being passed to OpenRegister.",
      "owasp": "A03:2021 – Injection"
    },
    {
      "id": "SEC-003",
      "severity": "SUGGESTION",
      "title": "Frontend store bypasses TimeEntryController — client sets user field",
      "file": "src/store/timeEntries.js",
      "line": 87,
      "description": "The Pinia store calls OpenRegister directly, bypassing task-existence checks and allowing the client to supply an arbitrary user value. The three controller endpoints are dead code for the bundled frontend.",
      "owasp": "A01:2021 – Broken Access Control"
    }
  ],
  "summary": "Two WARNING-level findings must be addressed before merge: an IDOR that leaks time entries across project boundaries, and insufficient input validation on the date field and UUID parameters. One SUGGESTION is noted regarding the frontend bypassing the controller layer."
}

@rubenvdlinde
Copy link
Copy Markdown
Author

🔍 Hydra Code Review — Juan Claude van Damme

PR: feat: Time Tracking MVP — CRUD backend + TimeLog frontend
Branch: hydra/specdevelopment
Reviewer: Hydra Code Review Agent


✅ Quality Gate Results

Check Result Notes
composer cs:check (lib/) ✅ PASS All 11 lib/ files clean
composer cs:check (tests/) ❌ FAIL 16 new PHPCS errors in TimeEntryServiceTest.php
composer test:unit ⚠️ ENV Requires Nextcloud container (pre-existing; 26 test errors affect all Controller/Service tests)
npm run lint ⚠️ ENV ESLint could not run (npm install permissions issue in review environment)

The PHPCS failure in tests/unit/Service/TimeEntryServiceTest.php is new and introduced by this PR. The test-runner failures are pre-existing (require Nextcloud OCP in the test environment).


Individual findings follow in separate comments below.

@rubenvdlinde
Copy link
Copy Markdown
Author

[CRITICAL] src/store/timeEntries.js — Frontend bypasses TimeEntryController entirely

The store calls OpenRegister directly via objectStore, never hitting TimeEntryController or TimeEntryService:

// timeEntries.js lines 617, 641, 673
const results = await objectStore.fetchCollection(TIME_ENTRY_SCHEMA, { task: taskId })
const entry  = await objectStore.saveObject(TIME_ENTRY_SCHEMA, { ...data, user: uid })
const ok     = await objectStore.deleteObject(TIME_ENTRY_SCHEMA, id)

Consequences:

  • validateTimeEntryData() is never called for front-end requests — invalid durations (0, negative) and missing taskId reach OpenRegister unchecked
  • The owner-only delete guard in TimeEntryService::deleteTimeEntry() is never invoked — any authenticated user may delete any time entry if OpenRegister does not independently enforce ownership
  • TimeEntryController + TimeEntryService are dead code from the primary user flow

Required fix (pick one):

  1. Rewrite the store to call POST /api/time-entries, GET /api/time-entries?taskId=, and DELETE /api/time-entries/{id} via axios or @nextcloud/axios, so that validation and ACL logic is exercised; OR
  2. Remove the controller/service and explicitly document that OpenRegister enforces ownership for timeEntry schema objects (provide evidence that it does).

@rubenvdlinde
Copy link
Copy Markdown
Author

[CRITICAL] lib/Controller/TimeEntryController.php — Missing @NoCSRFRequired on all API methods

All three controller actions (create, index, destroy) are missing the @NoCSRFRequired annotation. In Nextcloud, CSRF protection is active by default for every controller method, even those marked @NoAdminRequired. Without this annotation, POST and DELETE requests from JavaScript (AJAX) will be rejected with a CSRF error unless the client explicitly sends the requesttoken header.

For JSON REST API endpoints intended to be called from a browser-side SPA, the correct annotations are:

/**
 * @NoAdminRequired
 * @NoCSRFRequired
 */
public function create(): JSONResponse

Add @NoCSRFRequired to create(), index(), and destroy().

(This finding is secondary to finding #1 — if the frontend is rewritten to call these endpoints, they will be broken without this fix.)

@rubenvdlinde
Copy link
Copy Markdown
Author

[WARNING] tests/unit/Service/TimeEntryServiceTest.php — 16 PHPCS violations (breaks composer cs:check)

composer cs:check fails on this file. The project PHPCS standard requires named arguments for expectException / expectExceptionMessage and enforces specific multi-line call formatting. All 4 exception-testing methods are affected.

Lines 121-180 — expectException / expectExceptionMessage must use named parameters:

// ❌ current
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('taskId is required.');

// ✅ required
$this->expectException(exception: \InvalidArgumentException::class);
$this->expectExceptionMessage(message: 'taskId is required.');

Lines 124-127, 141-145, 159-162, 176-180 — multi-line call formatting:

// ❌ current
$this->service->createTimeEntry([
    'duration' => 60,
    'date'     => '2026-04-01',
]);

// ✅ required (opening paren last on line, closing paren on its own line — already correct)
// The actual issue is the array body itself spans multiple lines from a single-line call start.
// Run: composer cs:fix -- tests/unit/Service/TimeEntryServiceTest.php

Run composer cs:fix to auto-fix the 8 fixable violations, then manually add named params to the 8 expectException/expectExceptionMessage calls.

@rubenvdlinde
Copy link
Copy Markdown
Author

[WARNING] tests/unit/Controller/TimeEntryControllerTest.php — Missing test: destroy() returns 404 when entry not found

TimeEntryService::deleteTimeEntry() throws \InvalidArgumentException when the entry does not exist, which the controller maps to HTTP 404. This code path has no test.

// TimeEntryController.php:149-152
} catch (\InvalidArgumentException $e) {
    return new JSONResponse(
        ['error' => $e->getMessage()],
        Http::STATUS_NOT_FOUND
    );

Add a test method:

public function testDestroyReturnsNotFoundWhenEntryDoesNotExist(): void
{
    $this->timeEntryService->expects($this->once())
        ->method(constraint: 'getCurrentUserId')
        ->willReturn(value: 'testuser');

    $this->timeEntryService->expects($this->once())
        ->method(constraint: 'deleteTimeEntry')
        ->with(self::identicalTo('missing-uuid'))
        ->willThrowException(new \InvalidArgumentException('Time entry not found.'));

    $result = $this->controller->destroy('missing-uuid');

    self::assertSame(expected: Http::STATUS_NOT_FOUND, actual: $result->getStatus());
}

@rubenvdlinde
Copy link
Copy Markdown
Author

[WARNING] lib/Service/TimeEntryService.php:278date field not validated for format

validateTimeEntryData() only checks that date is non-empty. An arbitrary string (e.g. "not-a-date", "tomorrow") passes validation and is persisted to OpenRegister.

// current — line 278
if (empty($data['date']) === true) {
    throw new \InvalidArgumentException('date is required.');
}

Add an ISO 8601 date format check:

if (empty($data['date']) === true) {
    throw new \InvalidArgumentException('date is required.');
}

if (\DateTime::createFromFormat('Y-m-d', $data['date']) === false) {
    throw new \InvalidArgumentException('date must be a valid ISO 8601 date (YYYY-MM-DD).');
}

This also needs a corresponding test in TimeEntryServiceTest.

@rubenvdlinde
Copy link
Copy Markdown
Author

[WARNING] src/components/TimeLog.vue:432 — No confirmation before destructive delete

Clicking the delete button immediately calls deleteEntry(entry.id) with no confirmation dialog. Accidental clicks will permanently remove time entries.

// TimeLog.vue:432
async deleteEntry(id) {
    await this.timeEntriesStore.deleteEntry(id)  // no confirm guard
},

Use Nextcloud's confirmation dialog:

import { showConfirmation } from '@nextcloud/dialogs'

async deleteEntry(id) {
    const confirmed = await showConfirmation(
        t('planix', 'Delete this time entry?'),
        { type: 'warning' }
    )
    if (!confirmed) return
    await this.timeEntriesStore.deleteEntry(id)
},

Or at minimum use the browser's native confirm(). Accidental data loss is a common UX complaint.

@rubenvdlinde
Copy link
Copy Markdown
Author

[SUGGESTION] src/components/TimeLog.vue:416 — No success feedback after form submission

After a successful createEntry() call the form silently resets. Users get no positive confirmation that their entry was saved.

async submitEntry() {
    if (!this.isFormValid) return
    await this.timeEntriesStore.createEntry({ ... })
    // form reset only — no toast on success
    this.form.duration = ''
    ...
}

Add a success toast:

import { showSuccess } from '@nextcloud/dialogs'

async submitEntry() {
    if (!this.isFormValid) return
    const entry = await this.timeEntriesStore.createEntry({ ... })
    if (entry) {
        showSuccess(t('planix', 'Time entry saved'))
        this.form.duration = ''
        ...
    }
}

@rubenvdlinde
Copy link
Copy Markdown
Author

[SUGGESTION] lib/Service/TimeEntryService.php:232 — No pagination on listTimeEntries

findObjects() is called with no limit or offset. A task with hundreds of time entries will return all of them in a single response, potentially causing memory pressure on both server and client.

Consider adding a limit parameter (e.g. default 100) with optional pagination support via offset, consistent with the Common Ground / NL API Strategie pagination conventions:

public function listTimeEntries(string $taskId, int $limit = 100, int $offset = 0): array
{
    return $this->findObjects(
        schema: self::SCHEMA,
        filters: ['task' => $taskId],
        limit: $limit,
        offset: $offset,
    );
}

This is not blocking for MVP but should be addressed before the feature sees heavy use.

@rubenvdlinde
Copy link
Copy Markdown
Author

Hydra Code Review — Final Verdict

{
  "reviewer": "Juan Claude van Damme (Hydra Code Review Agent)",
  "pr": "ConductionNL/planix#22",
  "reviewed_at": "2026-04-05",
  "verdict": "CHANGES_REQUESTED",
  "quality_gates": {
    "composer_cs_check_lib": "PASS",
    "composer_cs_check_tests": "FAIL",
    "phpunit": "ENVIRONMENT_UNAVAILABLE",
    "eslint": "ENVIRONMENT_UNAVAILABLE"
  },
  "findings": [
    {
      "id": "F-01",
      "severity": "CRITICAL",
      "file": "src/store/timeEntries.js",
      "title": "Frontend bypasses TimeEntryController — validation and owner ACL never invoked",
      "summary": "Store calls OpenRegister objectStore directly. TimeEntryController and TimeEntryService are dead code from the primary user flow. Owner-only delete is not enforced for frontend requests."
    },
    {
      "id": "F-02",
      "severity": "CRITICAL",
      "file": "lib/Controller/TimeEntryController.php",
      "title": "Missing @NoCSRFRequired on all API methods",
      "summary": "Without @NoCSRFRequired, POST and DELETE AJAX calls from the SPA will be blocked by Nextcloud CSRF protection."
    },
    {
      "id": "F-03",
      "severity": "WARNING",
      "file": "tests/unit/Service/TimeEntryServiceTest.php",
      "title": "16 PHPCS violations — breaks composer cs:check",
      "summary": "expectException/expectExceptionMessage must use named params per project standard. Multi-line call formatting violations. Run composer cs:fix + manual named-param fixes."
    },
    {
      "id": "F-04",
      "severity": "WARNING",
      "file": "tests/unit/Controller/TimeEntryControllerTest.php",
      "title": "Missing test: destroy() returns 404 when entry not found",
      "summary": "The InvalidArgumentException -> 404 path in destroy() has no test coverage."
    },
    {
      "id": "F-05",
      "severity": "WARNING",
      "file": "lib/Service/TimeEntryService.php",
      "title": "date field not validated for format",
      "summary": "validateTimeEntryData() only checks non-empty. Invalid date strings (e.g. 'not-a-date') are accepted and persisted."
    },
    {
      "id": "F-06",
      "severity": "WARNING",
      "file": "src/components/TimeLog.vue",
      "title": "No confirmation dialog before destructive delete",
      "summary": "deleteEntry() fires immediately on button click with no confirm guard. Risk of accidental data loss."
    },
    {
      "id": "F-07",
      "severity": "SUGGESTION",
      "file": "src/components/TimeLog.vue",
      "title": "No success feedback after time entry submission",
      "summary": "Form resets silently. Add showSuccess() toast on successful createEntry()."
    },
    {
      "id": "F-08",
      "severity": "SUGGESTION",
      "file": "lib/Service/TimeEntryService.php",
      "title": "No pagination on listTimeEntries",
      "summary": "All entries for a task returned in one response. Add limit/offset before heavy use."
    }
  ],
  "summary": {
    "critical": 2,
    "warning": 4,
    "suggestion": 2,
    "must_fix_before_merge": ["F-01", "F-02", "F-03", "F-04", "F-05", "F-06"]
  }
}

Two CRITICAL blockers must be resolved before this can merge:

  • F-01 is an architectural issue — the frontend and backend are decoupled in a way that leaves the owner-ACL and input validation unreachable from the main user flow
  • F-02 is a Nextcloud framework requirement — without @NoCSRFRequired, the API endpoints cannot be used from the browser

All 4 WARNINGs (F-03 through F-06) must also be addressed per the Hydra review policy.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 5, 2026

Quality Report

Repository ConductionNL/planix
Commit 8c86417
Branch 22/merge
Event pull_request
Generated 2026-04-05 17:55 UTC
Workflow Run https://github.com/ConductionNL/planix/actions/runs/24007116656

Summary

Group Result
PHP Quality PASS
Vue Quality PASS
Security PASS
License PASS
PHPUnit FAIL
Newman SKIP
Playwright SKIP

PHP Quality

Tool Result
lint PASS
phpcs PASS
phpmd PASS
psalm PASS
phpstan PASS
phpmetrics PASS

Vue Quality

Tool Result
eslint PASS
stylelint PASS

Security

Ecosystem Result
composer PASS
npm PASS

License Compliance

Ecosystem Result
composer PASS
npm PASS

composer dependencies (100 total)

Metric Count
Approved (allowlist) 100
Approved (override) 0
Denied 0

npm dependencies (215 total)

Metric Count
Approved (allowlist) 215
Approved (override) 0
Denied 0

PHPUnit Tests

PHP Nextcloud Result
Overall FAIL

Code coverage: 0% (0 / 3 statements)

Integration Tests (Newman)

Newman integration tests were not enabled for this run.

E2E Tests (Playwright)

Playwright E2E tests were not enabled for this run.


Generated automatically by the Quality workflow.

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
Copy link
Copy Markdown
Author

[WARNING] Wrong HTTP status for access-denial in create() — RuntimeException mapped to 500 instead of 403

File: lib/Controller/TimeEntryController.php, lines 82–87

} catch (\RuntimeException ) {
    return new JSONResponse(
        ['error' => ->getMessage()],
        Http::STATUS_INTERNAL_SERVER_ERROR   // ← should be STATUS_FORBIDDEN
    );
}

What this means:
TimeEntryService::assertProjectMembership() (and the authentication guard inside createTimeEntry()) throws \RuntimeException on access-denial. That exception bubbles up to the controller, where it is caught as a generic 500. The result: a non-member who POSTs to /api/time-entries receives HTTP 500 with the body {"error": "Access denied: you are not a member of this project."} instead of the correct HTTP 403.

Contrast with the other three actions, which all map RuntimeException → 403 correctly:

  • index() line 135–139: Http::STATUS_FORBIDDEN
  • update() line 182–186: Http::STATUS_FORBIDDEN
  • destroy() line 226–230: Http::STATUS_FORBIDDEN

Security impact:
The access-control decision itself is correct — no entry is created. However:

  1. Returning 500 for an authorization refusal is semantically wrong and will confuse monitoring/alerting tools (paging on-call for what is actually a normal 403).
  2. The raw RuntimeException message is returned to the client. In the access-denial path this is tolerable, but if OpenRegister ever throws a RuntimeException that contains an internal path or stack fragment, that detail would reach the user with a 500 label.

Fix: Change Http::STATUS_INTERNAL_SERVER_ERRORHttp::STATUS_FORBIDDEN in the RuntimeException catch block of create().

@rubenvdlinde
Copy link
Copy Markdown
Author

[SUGGESTION] Raw exception messages returned to client in create() error path

File: lib/Controller/TimeEntryController.php, lines 77–87

} catch (\InvalidArgumentException ) {
    return new JSONResponse(['error' => ->getMessage()], Http::STATUS_BAD_REQUEST);
} catch (\RuntimeException ) {
    return new JSONResponse(['error' => ->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
}

Observation:
\RuntimeException is also thrown by getObjectService() with the message "OpenRegister is not available.". That message is low-sensitivity, but the pattern of forwarding raw exception messages unconditionally to the client creates a fragile boundary: any future RuntimeException thrown deeper in OpenRegister (e.g. a message that includes a connection string or internal path) would reach the browser.

The other three endpoints (index, update, destroy) have the same pattern for their RuntimeException handlers and carry the same low-level risk.

Suggestion (not blocking): Log the full exception detail server-side and return a generic "An unexpected error occurred." message for the 500/internal path. The validation-error (InvalidArgumentException) path is fine to keep verbose since those messages are intentional user feedback.

Severity: SUGGESTION — no currently-exploitable disclosure, but worth addressing before V1.

@rubenvdlinde
Copy link
Copy Markdown
Author

[INFO] @NoCSRFRequired on index() GET endpoint — acceptable, noting for audit trail

File: lib/Controller/TimeEntryController.php, lines 96–99

 * @NoAdminRequired
 * @NoCSRFRequired
 *
 * @return JSONResponse
 */
public function index(): JSONResponse

Observation:
The @NoCSRFRequired annotation disables Nextcloud's CSRF token check for this endpoint. This is standard Nextcloud practice for GET/read endpoints (CSRF is a state-changing-request attack vector; a read-only GET is not a CSRF target). All other state-changing endpoints (create, update, destroy) correctly omit this annotation and therefore require a valid CSRF token.

Verdict: Acceptable. No action required.

@rubenvdlinde
Copy link
Copy Markdown
Author

Security Review Verdict

{
  "verdict": "PASS_WITH_WARNINGS",
  "pr": "https://github.com/ConductionNL/planix/pull/22",
  "reviewer": "Clyde Barcode — Hydra Security Reviewer",
  "review_date": "2026-04-05",
  "sast": {
    "semgrep": { "rules": ["p/security-audit", "p/secrets", "p/owasp-top-ten"], "findings": 0 },
    "gitleaks": { "findings": 0 },
    "trivy":    { "status": "not_present" }
  },
  "manual_findings": [
    {
      "id": "SEC-TIME-001",
      "severity": "WARNING",
      "title": "Wrong HTTP status for access-denial in create() — RuntimeException mapped to 500 instead of 403",
      "file": "lib/Controller/TimeEntryController.php",
      "lines": "82-87",
      "description": "The RuntimeException catch block in create() returns HTTP 500 instead of HTTP 403, causing access-control rejections to surface as internal server errors. The authorization logic itself is correct — no entry is created — but monitoring/alerting tools will treat this as an infrastructure failure rather than a normal denied request.",
      "recommendation": "Change Http::STATUS_INTERNAL_SERVER_ERROR to Http::STATUS_FORBIDDEN in the RuntimeException catch block of create().",
      "must_fix_before_merge": true
    },
    {
      "id": "SEC-TIME-002",
      "severity": "SUGGESTION",
      "title": "Raw exception messages returned to client in internal-error path",
      "file": "lib/Controller/TimeEntryController.php",
      "lines": "77-87",
      "description": "RuntimeException messages (e.g. 'OpenRegister is not available.') are forwarded directly to the API response body. No currently-exploitable disclosure exists, but the pattern creates fragility for future exceptions that might carry internal details.",
      "recommendation": "Log the full exception server-side; return a generic error string for the 500/internal path.",
      "must_fix_before_merge": false
    },
    {
      "id": "SEC-TIME-003",
      "severity": "INFO",
      "title": "@NoCSRFRequired on index() GET endpoint",
      "file": "lib/Controller/TimeEntryController.php",
      "lines": "96-99",
      "description": "Standard Nextcloud practice for read-only GET endpoints. All state-changing endpoints require a CSRF token. No action needed.",
      "must_fix_before_merge": false
    }
  ],
  "positive_observations": [
    "UUID validation on all path and query parameters before hitting service layer",
    "Project-membership gate (assertProjectMembership) enforced on list and create — fails closed by design",
    "Owner-only enforcement on update and delete — server-side, not client-gated",
    "user field on time entry is server-set from IUserSession, never from client input — mass-assignment prevented",
    "Whitelist merge in updateTimeEntry() — only duration, date, description accepted from client",
    "No secrets, credentials, or tokens found in diff",
    "CSRF protection active on POST/PUT/DELETE endpoints",
    "All Vue template output uses interpolation or NcTextField — no raw innerHTML / v-html",
    "isOwner() check in frontend is UI-only; real ownership enforced server-side"
  ],
  "summary": "The implementation is security-conscious overall. IDOR guards, project-membership checks, owner-only enforcement, and input validation are all in place and well-structured. One WARNING must be fixed before merge: the create() endpoint returns HTTP 500 for access-denial RuntimeExceptions instead of HTTP 403. This does not create an exploitable vulnerability but is semantically incorrect and will interfere with monitoring."
}

['name' => 'settings#load', 'url' => '/api/settings/load', 'verb' => 'POST'],

// Time entry CRUD endpoints.
['name' => 'time_entry#create', 'url' => '/api/time-entries', 'verb' => 'POST'],
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL] Missing OAS 3.0 OpenAPI specification

The Conduction company-wide conventions require an OAS 3.0 OpenAPI Specification for all new or changed endpoints. This PR adds four new REST endpoints (POST /api/time-entries, GET /api/time-entries, PUT /api/time-entries/{id}, DELETE /api/time-entries/{id}) with no accompanying OpenAPI spec file. Without the spec, downstream consumers, API gateways, and the Common Ground ecosystem cannot discover or validate against these endpoints.

Required action: Add an openapi.yaml documenting all four endpoints — request bodies, query parameters, response schemas, and error responses.

{
$taskId = 'task-uuid-1';
$entries = [
['id' => 'e1', 'task' => $taskId, 'duration' => 30, 'date' => '2026-04-01'],
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL] Test testIndexReturnsEntriesForTask uses an invalid UUID — will assert 400, not 200

The test supplies 'task-uuid-1' as taskId, but the controller validates UUID format at line 119 before delegating to the service:

if (preg_match('/^[0-9a-f]{8}-...$/i', $taskId) !== 1) {
    return new JSONResponse(['error' => 'taskId must be a valid UUID.'], Http::STATUS_BAD_REQUEST);
}

'task-uuid-1' does not match the pattern, so the controller returns 400. The test then calls listTimeEntries via mock (which would never be reached) and asserts 200.

This test documents incorrect behaviour and would fail if run inside the Nextcloud environment.

Required action: Replace 'task-uuid-1' with a valid UUID v4, e.g. 'a1b2c3d4-0000-4000-a000-000000000001'.

// Time entry CRUD endpoints.
['name' => 'time_entry#create', 'url' => '/api/time-entries', 'verb' => 'POST'],
['name' => 'time_entry#index', 'url' => '/api/time-entries', 'verb' => 'GET'],
['name' => 'time_entry#update', 'url' => '/api/time-entries/{id}', 'verb' => 'PUT'],
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] PUT used for partial-update semantics — should be PATCH (NL API strategie)

The route registers PUT /api/time-entries/{id} and the service only updates the fields that are present in $data (partial update). Per the NL API strategie (Dutch government API design guidelines) and RFC 7231:

  • PUT replaces the entire resource. The client must send the full representation.
  • PATCH applies a partial modification. Only supplied fields are changed.

The implementation is partial-update behaviour, which means the correct HTTP verb is PATCH.

Required action: Change the route verb from 'PUT' to 'PATCH' in routes.php and update the store (axios.putaxios.patch) accordingly.

throw new \InvalidArgumentException('Time entry not found.');
}

$userId = $this->getCurrentUserId();
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] deleteTimeEntry() lacks explicit $userId === null guard

Unlike createTimeEntry() (line 251) and listTimeEntries() (line 292) which both explicitly check $userId === null and throw with a clear message, deleteTimeEntry() jumps straight to the ownership comparison:

$userId = $this->getCurrentUserId();
if (($entry['user'] ?? '') !== $userId) {   // null !== 'owner' → throws (fine)
    throw new \RuntimeException('Only the owner may delete a time entry.');
}

This is correct for the normal case (unauthenticated → null → throws), but:

  1. It masks the true reason for denial — the error message says "only the owner" rather than "authentication required".
  2. If an entry ever has user = null (e.g. a legacy record), an unauthenticated user would pass the check (null !== null is false) and delete the entry.

Required action: Add the same null guard present in the other service methods:

if ($userId === null) {
    throw new \RuntimeException('Access denied: authentication required.');
}

* @param {string} taskId Task UUID
* @return {Promise<Array>}
*/
async fetchEntries(taskId) {
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] No frontend tests for new Vue components and Pinia store

The CLAUDE.md quality gates for JS/TS projects require:

npm ci
npm test    # Vitest / Jest
npm run lint

Three new frontend units are introduced with zero test coverage:

  • src/store/timeEntries.js — all five actions (fetchEntries, createEntry, updateEntry, deleteEntry) and the totalDuration getter
  • src/components/TimeLog.vue — form validation, sort order, owner-only delete visibility, edit flow
  • src/views/TaskDetail.vue — task fetch, breadcrumb, error state

Required action: Add Vitest unit tests covering at minimum the store actions (using vi.mock for axios) and the isOwner/isFormValid computed properties in TimeLog.vue.

if (this.task?.project && !this.projectsStore.activeProject) {
await this.projectsStore.fetchProject(this.task.project)
}
} catch (err) {
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] Silent error on task load — no user-visible feedback

When fetchTask() fails (network error, 403, 404), the error is only sent to console.error. The task stays null, which shows the "Task not found" empty state — but the user gets no indication of why (network failure vs. genuine missing task).

Consider calling showError(t('planix', 'Failed to load task')) from @nextcloud/dialogs inside the catch block, consistent with how the store handles create/update/delete errors.

const response = await axios.get(API_BASE, { params: { taskId } })
this.entries = response.data || []
return this.entries
} catch (err) {
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] fetchEntries silently swallows errors — inconsistent with other store actions

createEntry, updateEntry, and deleteEntry all call showError() on failure. fetchEntries only logs to console.error and returns an empty array, leaving the user staring at the empty state with no explanation.

Consider adding showError(t('planix', 'Failed to load time entries')) in the catch block for consistency.

@rubenvdlinde
Copy link
Copy Markdown
Author

Hydra Code Review — Juan Claude van Damme

{
  "reviewer": "Juan Claude van Damme (Hydra Code Reviewer)",
  "pr": "ConductionNL/planix#22",
  "reviewed_at": "2026-04-05",
  "verdict": "REQUEST_CHANGES",
  "quality_gates": {
    "phpcs_new_files": "PASS",
    "phpcs_routes_php": "PRE_EXISTING_VIOLATION (not introduced by this PR)",
    "phpunit": "CANNOT_RUN_OUTSIDE_NEXTCLOUD_ENV (pre-existing constraint)",
    "npm_lint": "NOT_RUN (no JS linter changes in scope)"
  },
  "findings": [
    {
      "id": "F-001",
      "severity": "CRITICAL",
      "file": "appinfo/routes.php",
      "line": 14,
      "title": "Missing OAS 3.0 OpenAPI specification",
      "description": "Company-wide convention requires an OpenAPI 3.0 spec for all new or changed endpoints. Four new endpoints are introduced with no spec file."
    },
    {
      "id": "F-002",
      "severity": "CRITICAL",
      "file": "tests/unit/Controller/TimeEntryControllerTest.php",
      "line": 212,
      "title": "testIndexReturnsEntriesForTask uses invalid UUID — test asserts wrong status code",
      "description": "taskId 'task-uuid-1' fails the controller's UUID regex at line 119 (returns 400). The test mocks listTimeEntries and asserts 200, documenting incorrect behavior. Would fail in the Nextcloud environment."
    },
    {
      "id": "F-003",
      "severity": "WARNING",
      "file": "appinfo/routes.php",
      "line": 16,
      "title": "PUT used for partial-update semantics — NL API strategie requires PATCH",
      "description": "The update endpoint only changes provided fields (partial update), but uses PUT which per HTTP/NL API strategie must replace the entire resource. Use PATCH."
    },
    {
      "id": "F-004",
      "severity": "WARNING",
      "file": "lib/Service/TimeEntryService.php",
      "line": 366,
      "title": "deleteTimeEntry() lacks explicit null-userId guard",
      "description": "Unlike createTimeEntry() and listTimeEntries(), deleteTimeEntry() skips the null check on $userId. If an entry has user=null a null-authenticated request would bypass the ownership check and delete the entry."
    },
    {
      "id": "F-005",
      "severity": "WARNING",
      "file": "src/store/timeEntries.js",
      "line": 44,
      "title": "No frontend tests for new Vue components and Pinia store",
      "description": "timeEntries.js, TimeLog.vue, and TaskDetail.vue have zero test coverage. Quality gates require npm test to pass."
    },
    {
      "id": "F-006",
      "severity": "SUGGESTION",
      "file": "src/views/TaskDetail.vue",
      "line": 124,
      "title": "Silent error on task fetch failure",
      "description": "fetchTask() catch block only calls console.error. Should call showError() for user-visible feedback, consistent with store error handling."
    },
    {
      "id": "F-007",
      "severity": "SUGGESTION",
      "file": "src/store/timeEntries.js",
      "line": 51,
      "title": "fetchEntries silently swallows errors",
      "description": "Unlike create/update/delete actions, fetchEntries does not call showError() on failure. Inconsistent UX."
    }
  ],
  "summary": {
    "critical": 2,
    "warning": 3,
    "suggestion": 2,
    "positives": [
      "PHPCS passes cleanly on all new PHP files",
      "Project membership IDOR guard (assertProjectMembership) correctly enforces read access — closes F-002/SEC-001",
      "UUID format validation in controller before delegating to service",
      "Owner-only edit and delete correctly enforced with 403 responses",
      "NcDialog confirmation before destructive delete — good UX",
      "EUPL-1.2 license headers present on all new files",
      "PHPDoc annotations complete and accurate throughout",
      "Test coverage for controller and service is structurally sound (10 + 7 methods)",
      "Pinia store does not send user field from client — server always sets it (correct)"
    ]
  }
}

@rubenvdlinde
Copy link
Copy Markdown
Author

Closing — new test run.

@rubenvdlinde rubenvdlinde deleted the hydra/spec branch April 6, 2026 05:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant