fix(security,delivery): scope improvement actions by site; correct upgrade procedure

프로젝트 8개 차원 감사(종합 82/100)에서 확정된 상위 3건 수정.

1. 개선 조치(move_actions)가 유일하게 site 스코프 규율에서 이탈 — 스코프 운영자가
   타 현장의 조치 기록(카메라 ID·메모·작성자)을 목록에서 읽고, 타 현장 카메라에
   조치를 기록해 그 현장 리포트의 전후 효과 지표를 오염시킬 수 있었다.
   - move_actions.site 컬럼 + (site, taken_at) 인덱스, 기존 행 백필 마이그레이션
   - 카메라 소유 현장은 이벤트에서 해석 (move_cameras는 등록 플로우가 없어 항상 비어 있음)
   - 오타 카메라는 400: 전후 효과가 영원히 0/0이 되는 조치를 애초에 못 만들게 한다
   - 타 현장 카메라는 404: 존재 여부를 숨기는 기존 규약 유지

2. README-UPLOAD 업그레이드 절차가 마이그레이션 7종 중 1종만 지시했다. move_ 접두
   리네임을 건너뛰면 이후 전부 실패하고 전 요청이 500이 된다 — 순서·적용조건·재실행
   비안전성을 표로 명시. 문서 4곳의 테이블 수(10→11)·비프리픽스 표기도 정정.

3. 두 납품 번들 재생성: 8/6 이후 4개 기능 커밋이 반영되지 않아 Report/Export/Actions
   컨트롤러와 8/7 마이그레이션이 빠진 채였다. 엣지 소스 패리티도 회복.

검증: pytest 235 passed / PHPUnit 147 passed (PDO 4 skip), 번들 산출물 내부 검증
(28 클래스 해석·전 파일 php -l·SHA256SUMS 55행·휠 11개·HEF 다이제스트 일치).
This commit is contained in:
유창욱 2026-08-07 18:06:32 +09:00
parent 6b69f22275
commit 6b1f4c483f
13 changed files with 203 additions and 30 deletions

View file

@ -1,5 +1,13 @@
# INDEX — 작업 기록 (완료 결과만) # INDEX — 작업 기록 (완료 결과만)
- **2026-08-08** 프로젝트 8개 차원 감사(종합 82/100) 후 상위 3건 수정. ①개선 조치 site 스코프
누락(스코프 운영자가 타 현장 조치 기록 열람·기록 가능) → `move_actions.site` 추가
(`2026-08-08-actions-site.sql`, 이벤트에서 카메라 소유 현장 해석, 오타 카메라 400·타 현장
404). ②README-UPLOAD 업그레이드 절차가 마이그레이션 1/7만 지시 → 순서·적용조건 표로 교체
(`move_` 접두 리네임 누락 시 전 요청 500). ③두 납품 번들 재생성(4커밋 스테일 해소 —
Report/Export/Actions 컨트롤러·마이그레이션 누락 복구, 엣지 소스 패리티 회복).
문서 테이블 수·프리픽스 표기 정정. pytest 235 / PHPUnit 147.
- **2026-08-07** 데이터 활용 5안 중 4안 구현: ②시간패턴 히트맵+전조 급증 경보 ③KPI 전기간 - **2026-08-07** 데이터 활용 5안 중 4안 구현: ②시간패턴 히트맵+전조 급증 경보 ③KPI 전기간
추이(`d8b8e1f`) ①라벨 JSONL 익스포트(`/api/export/labels`, 관리자) ④개선 조치 기록·전후 추이(`d8b8e1f`) ①라벨 JSONL 익스포트(`/api/export/labels`, 관리자) ④개선 조치 기록·전후
효과 비교(`move_actions`, 동일 창 비교, 3일 미만 "측정 중"). ⑤멀티사이트 벤치마크는 다현장 효과 비교(`move_actions`, 동일 창 비교, 3일 미만 "측정 중"). ⑤멀티사이트 벤치마크는 다현장

View file

@ -58,15 +58,15 @@ COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose.
로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은 로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은
[server/docs/server-deploy.md](../server/docs/server-deploy.md) 참조 — 여기선 로컬 최소 절차만. [server/docs/server-deploy.md](../server/docs/server-deploy.md) 참조 — 여기선 로컬 최소 절차만.
1. DB 생성 후 스키마 임포트: `mysql <db> < server/schema.sql` (10개 테이블). 1. DB 생성 후 스키마 임포트: `mysql <db> < server/schema.sql` (11개 테이블, 전부 `move_` 접두사).
2. `server/config.php`를 `server/config.example.php`에서 복사, 로컬 DB DSN/계정 기입 2. `server/config.php`를 `server/config.example.php`에서 복사, 로컬 DB DSN/계정 기입
(`snapshot_dir`는 쓰기 가능 경로로). (`snapshot_dir`는 쓰기 가능 경로로).
3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다. 3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다.
CLI 스니펫과 SQL은 server-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용: CLI 스니펫과 SQL은 server-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용:
- 시크릿 생성: `php -r "echo bin2hex(random_bytes(32)), \"\n\";"` - 시크릿 생성: `php -r "echo bin2hex(random_bytes(32)), \"\n\";"`
- `INSERT INTO devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);` - `INSERT INTO move_devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);`
- 관리자 해시: `php -r "echo password_hash('바꿀-암구호', PASSWORD_ARGON2ID), \"\n\";"` - 관리자 해시: `php -r "echo password_hash('바꿀-암구호', PASSWORD_ARGON2ID), \"\n\";"`
- `INSERT INTO users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);` - `INSERT INTO move_users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);`
4. 기동: `php -S localhost:8000 -t server/public` 4. 기동: `php -S localhost:8000 -t server/public`
- 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개 - 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개
`localhost`가 secure-context로 취급돼 동작하지만, 안 되면 server-deploy.md §8(HTTPS) 참조. `localhost`가 secure-context로 취급돼 동작하지만, 안 되면 server-deploy.md §8(HTTPS) 참조.

View file

@ -70,19 +70,40 @@ DB 세 줄만 카페24 관리자 페이지의 MySQL 정보로 바꿉니다.
1. 카페24 관리자 → **phpMyAdmin** 열기 1. 카페24 관리자 → **phpMyAdmin** 열기
2. 이 앱용 DB 선택 2. 이 앱용 DB 선택
3. **Import(가져오기)** 탭 → `schema.sql` 선택 → 실행 3. **Import(가져오기)** 탭 → `schema.sql` 선택 → 실행
4. 표 10개가 생겼는지 확인 4. 표 11개가 생겼는지 확인
5. **서버에서 `schema.sql` 삭제** — 남겨둘 이유가 없습니다 5. **서버에서 `schema.sql` 삭제** — 남겨둘 이유가 없습니다
`schema.sql` 은 항상 최신 상태입니다. 처음 설치라면 아래 마이그레이션은 **실행하지 마세요**
(이미 반영되어 있어 충돌합니다).
### 이미 쓰던 DB 업그레이드(기존 데이터 있음) ### 이미 쓰던 DB 업그레이드(기존 데이터 있음)
> ⚠️ 기존 DB에 `schema.sql` 을 다시 Import 하면 `CREATE TABLE` 충돌로 실패합니다. > ⚠️ 기존 DB에 `schema.sql` 을 다시 Import 하면 `CREATE TABLE` 충돌로 실패합니다.
> 대신 **마이그레이션**을 실행하세요. > 대신 **마이그레이션**을 실행하세요.
**반드시 아래 순서대로, 아직 적용하지 않은 것부터 전부 실행합니다.** 하나라도 건너뛰면
그 컬럼·표를 쓰는 요청이 전부 500 이 됩니다. 순서가 중요한 이유는 뒤 파일이 앞 파일이 만든
표 이름(`move_` 접두사)을 전제하기 때문입니다.
| 순서 | 파일 | 무엇이 바뀌나 |
|---|---|---|
| 1 | `2026-07-17-p1-9-p3-2b.sql` | site / event_uuid 컬럼 + 인덱스, 기존 이벤트 site 백필 |
| 2 | `2026-07-20-move-table-prefix.sql` | 모든 표 이름에 `move_` 접두사 부여 (**이 단계를 건너뛰면 이후 전부 실패**) |
| 3 | `2026-08-06-zone-rules.sql` | 존 점유 규칙(`rules_json`) |
| 4 | `2026-08-06-heatmap-spatial.sql` | 히트맵 좌표(`spatial_x`, `spatial_y`) |
| 5 | `2026-08-06-false-positive-tag.sql` | 오탐 태그 컬럼 |
| 6 | `2026-08-07-improvement-actions.sql` | 개선 조치 표(`move_actions`) |
| 7 | `2026-08-08-actions-site.sql` | 개선 조치의 현장(site) 스코프 |
1. phpMyAdmin → 해당 DB → **SQL** 탭 1. phpMyAdmin → 해당 DB → **SQL** 탭
2. `migrations/2026-07-17-p1-9-p3-2b.sql` 내용을 붙여넣고 실행 2. 위 표의 순서대로 파일 내용을 **하나씩** 붙여넣고 실행합니다
(site / event_uuid 컬럼 + 인덱스 추가, 기존 이벤트의 site 백필 포함) (각 파일 첫머리에 적용 조건이 주석으로 적혀 있습니다)
3. 파일 맨 아래 **검증 쿼리**로 컬럼·인덱스가 생겼는지 확인 3. 각 파일 맨 아래 **검증 쿼리**로 컬럼·표가 생겼는지 확인
4. 그 다음에 새 코드를 배포합니다 — 컬럼이 없으면 모든 요청이 500 이 됩니다 4. 전부 끝난 뒤에 새 코드를 배포합니다
> 이미 적용한 마이그레이션을 다시 실행하면 "Duplicate column" 같은 오류가 납니다 —
> 재실행은 안전하지 않으니, 어디까지 적용했는지 기록해 두세요. 어디까지 했는지 모르겠으면
> phpMyAdmin 의 **Structure(구조)** 탭에서 위 표의 컬럼·표가 있는지 하나씩 확인하면 됩니다.
--- ---

View file

@ -139,13 +139,14 @@ or version control.
## 4. Import the schema ## 4. Import the schema
`server/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`, `server/schema.sql` contains the full MySQL 8.0+ DDL. All tables carry the `move_` prefix
`events`, `event_snapshots`, `thresholds`, `nonces`, `rate_limits`, `audit_log`). (`move_users`, `move_devices`, `move_cameras`, `move_actions`, `move_zones`, `move_events`,
`move_event_snapshots`, `move_thresholds`, `move_nonces`, `move_rate_limits`, `move_audit_log`).
1. Open **phpMyAdmin** from the Cafe24 control panel, select the database created for this 1. Open **phpMyAdmin** from the Cafe24 control panel, select the database created for this
app. app.
2. **Import** tab → choose file → upload `schema.sql` → Go. 2. **Import** tab → choose file → upload `schema.sql` → Go.
3. Confirm all 10 tables were created (phpMyAdmin's table list on the left, or 3. Confirm all 11 tables were created (phpMyAdmin's table list on the left, or
`SHOW TABLES;` in the SQL tab). `SHOW TABLES;` in the SQL tab).
4. Delete `schema.sql` from the server once the import succeeds — it doesn't need to live 4. Delete `schema.sql` from the server once the import succeeds — it doesn't need to live
on the host afterwards, and leaving DDL lying around outside the web root is unnecessary on the host afterwards, and leaving DDL lying around outside the web root is unnecessary

View file

@ -0,0 +1,22 @@
-- 개선 조치(move_actions)에 site 스코프 추가.
-- 이 표만 site 컬럼 없이 추가되어, 스코프 운영자가 타 현장의 조치 기록(카메라 ID·메모·
-- 작성자)을 목록에서 그대로 읽을 수 있었다. 다른 표(events/devices/zones)와 동일하게
-- site 를 비정규화해 SQL 단계에서 걸러낸다.
--
-- 적용 조건: 2026-08-07-improvement-actions.sql 이후.
ALTER TABLE move_actions
ADD COLUMN site VARCHAR(64) NULL AFTER camera_id,
ADD KEY ix_actions_site_taken (site, taken_at);
-- 기존 행 백필: 조치가 달린 카메라가 이벤트를 남긴 현장을 그 조치의 현장으로 본다.
-- (카메라는 별도 등록 표가 없고 events.site 가 유일한 소유 근거다.)
UPDATE move_actions a
SET a.site = (SELECT e.site FROM move_events e
WHERE e.camera_id = a.camera_id AND e.site IS NOT NULL
ORDER BY e.event_id DESC LIMIT 1)
WHERE a.site IS NULL;
-- 검증: site 가 채워졌는지 확인한다. NULL 로 남은 행은 해당 카메라의 이벤트가 없거나
-- 현장 미지정(devices.site IS NULL)인 경우로, 전역 관리자에게만 보인다.
-- SELECT site, COUNT(*) FROM move_actions GROUP BY site;

View file

@ -101,12 +101,14 @@ CREATE TABLE move_cameras (
CREATE TABLE move_actions ( CREATE TABLE move_actions (
action_id INT UNSIGNED NOT NULL AUTO_INCREMENT, action_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
camera_id VARCHAR(64) NOT NULL, camera_id VARCHAR(64) NOT NULL,
site VARCHAR(64) NULL, -- access-control scope, resolved from the camera's events at insert
note VARCHAR(500) NOT NULL, note VARCHAR(500) NOT NULL,
taken_at BIGINT UNSIGNED NOT NULL, -- when the fix went in (epoch s) taken_at BIGINT UNSIGNED NOT NULL, -- when the fix went in (epoch s)
created_by VARCHAR(190) NOT NULL, created_by VARCHAR(190) NOT NULL,
created_at BIGINT UNSIGNED NOT NULL, created_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (action_id), PRIMARY KEY (action_id),
KEY ix_actions_taken (taken_at) KEY ix_actions_taken (taken_at),
KEY ix_actions_site_taken (site, taken_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE move_zones ( CREATE TABLE move_zones (

View file

@ -4,7 +4,7 @@ namespace App\Dashboard;
use App\Http\{Request, Response}; use App\Http\{Request, Response};
use App\Auth\{SessionStore, Rbac}; use App\Auth\{SessionStore, Rbac};
use App\Security\Csrf; use App\Security\{Csrf, SiteScope};
use App\Repository\{ActionRepository, EventRepository}; use App\Repository\{ActionRepository, EventRepository};
/** /**
@ -44,8 +44,22 @@ final class ActionsController
if ($takenAt > $now || $takenAt < $now - 365 * 86400) { if ($takenAt > $now || $takenAt < $now - 365 * 86400) {
return Response::json(['error' => 'invalid_taken_at'], 400); return Response::json(['error' => 'invalid_taken_at'], 400);
} }
// The camera MUST be one that has actually reported: a mistyped id would record an action
// whose before/after effect is 0/0 forever — a safety investment that silently never gets
// measured. Cameras have no registry table, so their events are the ownership record.
// 400 (client input error), matching ZoneConfigController's unknown_device.
if (!$this->events->cameraExists($camera)) {
return Response::json(['error' => 'unknown_camera'], 400);
}
// Scope BEFORE the write: a scoped operator must not record an action against another
// site's camera (it would pollute that site's report). 404 hides existence, as everywhere.
$site = $this->events->siteForCamera($camera);
$scope = $this->sess->get('site_scope');
if (!SiteScope::allows(is_string($scope) ? $scope : null, $site)) {
return Response::json(['error' => 'not_found'], 404);
}
$id = $this->actions->insert([ $id = $this->actions->insert([
'camera_id' => $camera, 'note' => $note, 'taken_at' => $takenAt, 'camera_id' => $camera, 'site' => $site, 'note' => $note, 'taken_at' => $takenAt,
'created_by' => (string)$this->sess->get('username'), 'created_by' => (string)$this->sess->get('username'),
]); ]);
return Response::json(['action_id' => $id], 201); return Response::json(['action_id' => $id], 201);
@ -58,7 +72,7 @@ final class ActionsController
$site = is_string($scope) ? $scope : null; $site = is_string($scope) ? $scope : null;
$now = time(); $now = time();
$out = []; $out = [];
foreach ($this->actions->recent(self::LIST_LIMIT) as $a) { foreach ($this->actions->recent(self::LIST_LIMIT, $site) as $a) {
// 동일 길이의 전후 창으로만 비교해야 공정하다: 시행 후 경과가 W보다 짧으면 // 동일 길이의 전후 창으로만 비교해야 공정하다: 시행 후 경과가 W보다 짧으면
// 전 창도 그만큼으로 줄인다. 3일 미만은 표본이 아니라 소음 — "측정 중". // 전 창도 그만큼으로 줄인다. 3일 미만은 표본이 아니라 소음 — "측정 중".
$elapsed = $now - $a['taken_at']; $elapsed = $now - $a['taken_at'];

View file

@ -4,8 +4,12 @@ namespace App\Repository;
interface ActionRepository interface ActionRepository
{ {
/** {camera_id, note, taken_at, created_by} -> action_id */ /** {camera_id, site, note, taken_at, created_by} -> action_id */
public function insert(array $action): int; public function insert(array $action): int;
/** Most recent actions, newest taken_at first, capped at $limit. */ /**
public function recent(int $limit): array; * Most recent actions, newest taken_at first, capped at $limit.
* $site (non-null) restricts to that site — the SQL half of the site-scope access
* control, same contract as EventRepository::page() (see App\Security\SiteScope).
*/
public function recent(int $limit, ?string $site): array;
} }

View file

@ -59,4 +59,17 @@ interface EventRepository
public function exportRows(float $sinceOccurredAt, ?string $site, int $limit): array; public function exportRows(float $sinceOccurredAt, ?string $site, int $limit): array;
/** Detections (non-ergo) for one camera in [from, to) — the action before/after counter. */ /** Detections (non-ergo) for one camera in [from, to) — the action before/after counter. */
public function countForCamera(string $cameraId, float $from, float $to, ?string $site): int; public function countForCamera(string $cameraId, float $from, float $to, ?string $site): int;
/**
* True if this camera has ever reported an event — the closest thing to "does this camera
* exist", playing the role DeviceRepository::exists() plays for devices.
* NOT move_cameras: that table has no registration flow and no repository (see schema.sql),
* so it is empty in every deployment — checking it would reject every real camera.
*/
public function cameraExists(string $cameraId): bool;
/**
* The site this camera's events are recorded under (from its most recent event), or null
* when the camera is unknown OR its device has no site assigned. Pair with cameraExists()
* to tell those two apart, exactly as DeviceRepository::exists()/siteFor() are paired.
*/
public function siteForCamera(string $cameraId): ?string;
} }

View file

@ -12,11 +12,12 @@ final class PdoActionRepository implements ActionRepository
public function insert(array $action): int public function insert(array $action): int
{ {
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'INSERT INTO move_actions (camera_id, note, taken_at, created_by, created_at) 'INSERT INTO move_actions (camera_id, site, note, taken_at, created_by, created_at)
VALUES (?, ?, ?, ?, ?)' VALUES (?, ?, ?, ?, ?, ?)'
); );
$st->execute([ $st->execute([
(string)$action['camera_id'], (string)$action['camera_id'],
$action['site'] ?? null, // denormalized from the camera's events by ActionsController
(string)$action['note'], (string)$action['note'],
(int)$action['taken_at'], (int)$action['taken_at'],
(string)$action['created_by'], (string)$action['created_by'],
@ -25,17 +26,26 @@ final class PdoActionRepository implements ActionRepository
return (int)$this->pdo->lastInsertId(); return (int)$this->pdo->lastInsertId();
} }
public function recent(int $limit): array public function recent(int $limit, ?string $site): array
{ {
$st = $this->pdo->prepare( $sql = 'SELECT action_id, camera_id, site, note, taken_at, created_by FROM move_actions';
'SELECT action_id, camera_id, note, taken_at, created_by $binds = [];
FROM move_actions ORDER BY taken_at DESC, action_id DESC LIMIT ?' if ($site !== null) {
); // Fail-closed like every other scoped list: an empty-string scope matches nothing,
$st->bindValue(1, $limit, PDO::PARAM_INT); // and a row with no site is never visible to a scoped caller.
$sql .= ' WHERE site = ?';
$binds[] = $site;
}
$sql .= ' ORDER BY taken_at DESC, action_id DESC LIMIT ?';
$st = $this->pdo->prepare($sql);
$pos = 1;
foreach ($binds as $b) $st->bindValue($pos++, $b, PDO::PARAM_STR);
$st->bindValue($pos, $limit, PDO::PARAM_INT);
$st->execute(); $st->execute();
return array_map(static fn ($r) => [ return array_map(static fn ($r) => [
'action_id' => (int)$r['action_id'], 'action_id' => (int)$r['action_id'],
'camera_id' => $r['camera_id'], 'camera_id' => $r['camera_id'],
'site' => $r['site'],
'note' => $r['note'], 'note' => $r['note'],
'taken_at' => (int)$r['taken_at'], 'taken_at' => (int)$r['taken_at'],
'created_by' => $r['created_by'], 'created_by' => $r['created_by'],

View file

@ -336,6 +336,27 @@ final class PdoEventRepository implements EventRepository
return (int)$st->fetchColumn(); return (int)$st->fetchColumn();
} }
public function cameraExists(string $cameraId): bool
{
$st = $this->pdo->prepare('SELECT 1 FROM move_events WHERE camera_id = ? LIMIT 1');
$st->execute([$cameraId]);
return $st->fetchColumn() !== false;
}
public function siteForCamera(string $cameraId): ?string
{
// Newest event wins: if a camera is re-homed to another site, later actions belong to
// the site it reports to NOW. event_id DESC (not occurred_at) because ingest order is
// the authoritative "most recently seen" — occurred_at comes from the edge clock.
$st = $this->pdo->prepare(
'SELECT site FROM move_events WHERE camera_id = ? AND site IS NOT NULL
ORDER BY event_id DESC LIMIT 1'
);
$st->execute([$cameraId]);
$site = $st->fetchColumn();
return $site === false ? null : (string)$site;
}
private static function mapRow(array $row): array private static function mapRow(array $row): array
{ {
return [ return [

View file

@ -67,19 +67,63 @@ final class ExportActionsTest extends TestCase
return new Request('POST', '/api/actions', ['X-Csrf-Token' => 'tok'], json_encode($body), [], []); return new Request('POST', '/api/actions', ['X-Csrf-Token' => 'tok'], json_encode($body), [], []);
} }
/** 카메라는 등록 표가 없다 — 이벤트가 곧 존재·소유 근거이므로 테스트도 이벤트로 만든다. */
private function eventsWithCameras(array $cameraSites): InMemoryEventRepository
{
$events = new InMemoryEventRepository();
foreach ($cameraSites as $cam => $site) {
$events->insert(['camera_id' => $cam, 'site' => $site, 'event_type' => 'near_fall',
'severity' => 'info', 'occurred_at' => (float)time(), 'metrics' => []]);
}
return $events;
}
public function testActionSaveValidation(): void public function testActionSaveValidation(): void
{ {
$c = new ActionsController(new InMemoryActionRepository(), new InMemoryEventRepository(), $this->sess('operator')); $events = $this->eventsWithCameras(['cam-1' => null]);
$c = new ActionsController(new InMemoryActionRepository(), $events, $this->sess('operator'));
$this->assertSame(201, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => '매트 설치']))->status()); $this->assertSame(201, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => '매트 설치']))->status());
$this->assertSame(400, $c->save($this->actionReq(['camera_id' => '', 'note' => 'x']))->status()); $this->assertSame(400, $c->save($this->actionReq(['camera_id' => '', 'note' => 'x']))->status());
$this->assertSame(400, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => '']))->status()); $this->assertSame(400, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => '']))->status());
$this->assertSame(400, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => 'x', $this->assertSame(400, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => 'x',
'taken_at' => time() + 999]))->status()); // 미래 시각 거부 'taken_at' => time() + 999]))->status()); // 미래 시각 거부
// 오타 카메라: 전후 효과가 영원히 0/0이 되므로 기록 자체를 막는다
$this->assertSame(400, $c->save($this->actionReq(['camera_id' => 'cam-오타', 'note' => 'x']))->status());
// viewer는 기록 불가 // viewer는 기록 불가
$cv = new ActionsController(new InMemoryActionRepository(), new InMemoryEventRepository(), $this->sess('viewer')); $cv = new ActionsController(new InMemoryActionRepository(), $events, $this->sess('viewer'));
$this->assertSame(403, $cv->save($this->actionReq(['camera_id' => 'cam-1', 'note' => 'x']))->status()); $this->assertSame(403, $cv->save($this->actionReq(['camera_id' => 'cam-1', 'note' => 'x']))->status());
} }
public function testScopedOperatorCannotWriteActionForAnotherSiteCamera(): void
{
$events = $this->eventsWithCameras(['cam-a' => 'site-a', 'cam-b' => 'site-b']);
$actions = new InMemoryActionRepository();
$c = new ActionsController($actions, $events, $this->sess('operator', 'site-a'));
$this->assertSame(201, $c->save($this->actionReq(['camera_id' => 'cam-a', 'note' => '매트']))->status());
// 타 현장 카메라: 존재 여부를 숨기며 거부(404) — 그 현장 리포트 오염 방지
$this->assertSame(404, $c->save($this->actionReq(['camera_id' => 'cam-b', 'note' => '침입']))->status());
$this->assertCount(1, $actions->rows);
$this->assertSame('site-a', $actions->rows[1]['site']); // 카메라에서 해석해 비정규화
}
public function testScopedOperatorSeesOnlyOwnSiteActions(): void
{
$actions = new InMemoryActionRepository();
$actions->insert(['camera_id' => 'cam-a', 'site' => 'site-a', 'note' => '우리 현장',
'taken_at' => time() - 86400, 'created_by' => 'a']);
$actions->insert(['camera_id' => 'cam-b', 'site' => 'site-b', 'note' => '남의 현장',
'taken_at' => time() - 3600, 'created_by' => 'b']);
$req = new Request('GET', '/api/actions', [], '', [], []);
$scoped = new ActionsController($actions, new InMemoryEventRepository(), $this->sess('operator', 'site-a'));
$rows = json_decode($scoped->json($req)->body(), true)['actions'];
$this->assertCount(1, $rows);
$this->assertSame('우리 현장', $rows[0]['note']); // 타 현장 메모·작성자 미노출
$global = new ActionsController($actions, new InMemoryEventRepository(), $this->sess('admin'));
$this->assertCount(2, json_decode($global->json($req)->body(), true)['actions']);
}
public function testActionEffectComparesEqualWindows(): void public function testActionEffectComparesEqualWindows(): void
{ {
$actions = new InMemoryActionRepository(); $actions = new InMemoryActionRepository();

View file

@ -129,6 +129,18 @@ final class InMemoryEventRepository implements EventRepository {
&& ($x['event_type'] ?? '') !== 'ergo_report' && ($x['event_type'] ?? '') !== 'ergo_report'
&& ($site === null || ($x['site'] ?? null) === $site))); && ($site === null || ($x['site'] ?? null) === $site)));
} }
public function cameraExists(string $cam): bool {
foreach ($this->rows as $r) if (($r['camera_id'] ?? '') === $cam) return true;
return false;
}
public function siteForCamera(string $cam): ?string {
$best = null;
foreach ($this->rows as $r) {
if (($r['camera_id'] ?? '') !== $cam || ($r['site'] ?? null) === null) continue;
if ($best === null || ($r['event_id'] ?? 0) >= ($best['event_id'] ?? 0)) $best = $r;
}
return $best === null ? null : (string)$best['site'];
}
public function occurrenceTimes(float $since, ?string $site, int $limit): array { public function occurrenceTimes(float $since, ?string $site, int $limit): array {
$r = array_filter($this->rows, fn($x) => ($x['occurred_at'] ?? 0) >= $since $r = array_filter($this->rows, fn($x) => ($x['occurred_at'] ?? 0) >= $since
&& ($x['event_type'] ?? '') !== 'ergo_report' && ($x['event_type'] ?? '') !== 'ergo_report'
@ -218,8 +230,9 @@ final class InMemoryZoneRepository implements ZoneRepository {
final class InMemoryActionRepository implements ActionRepository { final class InMemoryActionRepository implements ActionRepository {
public array $rows = []; private int $seq = 0; public array $rows = []; private int $seq = 0;
public function insert(array $a): int { $id = ++$this->seq; $this->rows[$id] = $a + ['action_id' => $id]; return $id; } public function insert(array $a): int { $id = ++$this->seq; $this->rows[$id] = $a + ['action_id' => $id]; return $id; }
public function recent(int $limit): array { public function recent(int $limit, ?string $site): array {
$r = array_values($this->rows); $r = array_values(array_filter($this->rows,
fn($a) => $site === null || ($a['site'] ?? null) === $site));
usort($r, fn($a, $b) => [$b['taken_at'], $b['action_id']] <=> [$a['taken_at'], $a['action_id']]); usort($r, fn($a, $b) => [$b['taken_at'], $b['action_id']] <=> [$a['taken_at'], $a['action_id']]);
return array_slice($r, 0, $limit); return array_slice($r, 0, $limit);
} }