diff --git a/app/Http/Controllers/Mobile/CourseController.php b/app/Http/Controllers/Mobile/CourseController.php index d632434..50ecf93 100755 --- a/app/Http/Controllers/Mobile/CourseController.php +++ b/app/Http/Controllers/Mobile/CourseController.php @@ -22,8 +22,10 @@ use App\Models\CourseSign; use App\Models\CourseType; use App\Models\Notice; use App\Models\Order; +use App\Http\Resources\Mobile\PublicCourseResource; use App\Http\Resources\Mobile\UserListResource; use App\Models\User; +use App\Models\Upload; use App\Services\SchoolmateAccessService; use EasyWeChat\Factory; use Illuminate\Support\Carbon; @@ -75,18 +77,36 @@ class CourseController extends CommonController public function course() { $all = request()->all(); - $list = Course::with('image', 'typeDetail')->withCount('courseSigns')->where(function ($query) use ($all) { - if (isset($all['type'])) { - $query->where('type', $all['type']); - } - if (isset($all['status'])) { - $query->where('status', $all['status']); - } - })->whereIn('sign_status', [10, 40]) + $validator = Validator::make($all, [ + 'page_size' => 'nullable|integer|min:1|max:50', + 'page' => 'nullable|integer|min:1', + 'type' => 'nullable|integer', + 'status' => 'nullable|integer|in:1', + ], [ + 'page_size.max' => '每页最多查询50条课程', + 'status.in' => '课程状态参数无效', + ]); + if ($validator->fails()) { + return $this->fail([ResponseCode::ERROR_PARAMETER, implode(',', $validator->errors()->all())]); + } + + $list = Course::with('image', 'typeDetail', 'teacher') + ->withCount('courseSigns') + ->where('status', 1) + ->whereIn('sign_status', [10, 40]) ->where('is_virtual', 0) + ->where(function ($query) use ($all) { + if (isset($all['type'])) { + $query->where('type', $all['type']); + } + }) ->orderBy('sign_status', 'asc') ->orderBy('start_date', 'desc') - ->paginate($all['page_size'] ?? 20); + ->paginate((int) ($all['page_size'] ?? 20)); + $list->setCollection($list->getCollection()->map(function ($course) { + return (new PublicCourseResource($course))->resolve(); + })); + return $this->success($list); } @@ -109,29 +129,30 @@ class CourseController extends CommonController 'course_id.required' => '课程id必填', ]; $validator = Validator::make($all, [ - 'course_id' => 'required' + 'course_id' => 'required|integer|min:1' ], $messages); if ($validator->fails()) { return $this->fail([ResponseCode::ERROR_PARAMETER, implode(',', $validator->errors()->all())]); } $detail = Course::with([ + 'image', 'qunImage', 'typeDetail', 'courseForms', 'teacher', - 'courseContentEvaluation' => function ($query) { - $query->with([ - 'courseContentEvaluationAsks' => function ($q) { - $q->orderBy('sort', 'asc'); - } - ]); - } ])->withCount([ - 'courseSigns as my_user' => function ($query) { - $query->where('user_id', $this->getUserId()); - } - ])->find($all['course_id']); - return $this->success($detail); + 'courseSigns as my_user' => function ($query) { + $query->where('user_id', $this->getUserId()); + } + ])->where('status', 1) + ->whereIn('sign_status', [10, 40]) + ->where('is_virtual', 0) + ->find($all['course_id']); + if (!$detail) { + return response()->json(['message' => '课程不存在'], 404); + } + $this->attachPublicAssets($detail); + return $this->success((new PublicCourseResource($detail))->resolve()); } /** @@ -186,13 +207,34 @@ class CourseController extends CommonController 'course_id.required' => '课程id必填', ]; $validator = Validator::make($all, [ - 'course_id' => 'required' + 'course_id' => 'required|integer|min:1' ], $messages); if ($validator->fails()) { return $this->fail([ResponseCode::ERROR_PARAMETER, implode(',', $validator->errors()->all())]); } - $detail = Course::with('typeDetail', 'courseForms', 'teacher')->find($all['course_id']); - return $this->success($detail); + $detail = Course::with('typeDetail', 'courseForms', 'teacher', 'image', 'qunImage') + ->where('status', 1) + ->whereIn('sign_status', [10, 40]) + ->where('is_virtual', 0) + ->find($all['course_id']); + if (!$detail) { + return response()->json(['message' => '课程不存在'], 404); + } + $this->attachPublicAssets($detail); + return $this->success((new PublicCourseResource($detail))->resolve()); + } + + private function attachPublicAssets(Course $course): void + { + $ids = collect($course->publicize_ids ?: []) + ->filter(fn ($id) => is_numeric($id) && (int) $id > 0) + ->map(fn ($id) => (int) $id) + ->values(); + + $course->setRelation( + 'publicAssets', + $ids->isEmpty() ? collect() : Upload::whereIn('id', $ids->all())->get() + ); } /** diff --git a/app/Http/Resources/Mobile/AdminCourseResource.php b/app/Http/Resources/Mobile/AdminCourseResource.php new file mode 100644 index 0000000..658d3ff --- /dev/null +++ b/app/Http/Resources/Mobile/AdminCourseResource.php @@ -0,0 +1,23 @@ +admin_id; + $data['department_id'] = $this->department_id; + $data['teacher_id'] = $this->teacher_id; + $data['is_virtual'] = (bool) $this->is_virtual; + $data['show_txl'] = (bool) $this->show_txl; + $data['show_mobile'] = (bool) $this->show_mobile; + + return $data; + } +} diff --git a/app/Http/Resources/Mobile/AuthenticatedCourseResource.php b/app/Http/Resources/Mobile/AuthenticatedCourseResource.php new file mode 100644 index 0000000..e6c6591 --- /dev/null +++ b/app/Http/Resources/Mobile/AuthenticatedCourseResource.php @@ -0,0 +1,24 @@ +when( + isset($this->my_user), + (int) $this->my_user + ); + + return $data; + } +} diff --git a/app/Http/Resources/Mobile/PublicCourseResource.php b/app/Http/Resources/Mobile/PublicCourseResource.php new file mode 100644 index 0000000..2a70f1f --- /dev/null +++ b/app/Http/Resources/Mobile/PublicCourseResource.php @@ -0,0 +1,110 @@ +resource; + + return [ + 'id' => (int) $this->id, + 'name' => $this->name, + 'start_date' => $this->start_date, + 'end_date' => $this->end_date, + 'content' => $this->content, + 'publicize_content' => $this->publicize_content, + 'type' => $this->type === null ? null : (int) $this->type, + 'is_fee' => $this->is_fee === null ? null : (int) $this->is_fee, + 'total' => $this->total === null ? null : (int) $this->total, + 'show_txl' => $this->show_txl === null ? null : (int) $this->show_txl, + 'show_mobile' => $this->show_mobile === null ? null : (int) $this->show_mobile, + 'url' => $this->url, + 'url_title' => $this->url_title, + 'status' => (int) $this->status, + 'course_status' => (int) $this->course_status, + 'sign_status' => (int) $this->sign_status, + 'my_user' => isset($this->my_user) ? (int) $this->my_user : 0, + 'type_detail' => $this->whenLoaded('typeDetail', function () { + if (!$this->typeDetail) { + return null; + } + + return [ + 'id' => (int) $this->typeDetail->id, + 'name' => $this->typeDetail->name, + ]; + }), + 'image' => $this->whenLoaded('image', function () { + if (!$this->image) { + return null; + } + + return [ + 'id' => (int) $this->image->id, + 'url' => $this->image->url, + ]; + }), + 'qun_image_id' => $this->qun_image_id === null ? null : (int) $this->qun_image_id, + 'qun_image' => $this->whenLoaded('qunImage', function () { + return $this->safeUpload($this->qunImage); + }), + 'publicize' => $course->relationLoaded('publicAssets') + ? $course->getRelation('publicAssets')->map(fn ($upload) => $this->safeUpload($upload))->values()->all() + : [], + 'teacher' => $this->whenLoaded('teacher', function () { + if (!$this->teacher) { + return null; + } + + return [ + 'id' => (int) $this->teacher->id, + 'name' => $this->teacher->name, + 'introduce' => $this->teacher->introduce, + ]; + }), + 'course_forms' => $this->whenLoaded('courseForms', function () { + return $this->courseForms->map(function ($form) { + // Keep only fields needed to render the public sign-up form. + return [ + 'id' => (int) $form->id, + 'name' => $form->name, + 'field' => $form->field, + 'edit_input' => $form->edit_input, + 'rule' => $form->rule, + 'sort' => (int) $form->sort, + 'help' => $form->help, + 'select_item' => $form->select_item, + 'need_fill' => (bool) $form->need_fill, + 'belong_user' => (bool) $form->belong_user, + ]; + })->values()->all(); + }), + 'course_signs_count' => $this->when( + isset($this->course_signs_count), + (int) $this->course_signs_count + ), + ]; + } + + private function safeUpload($upload): ?array + { + if (!$upload) { + return null; + } + + return [ + 'id' => (int) $upload->id, + 'url' => $upload->url, + ]; + } +} diff --git a/public/.DS_Store b/public/.DS_Store new file mode 100644 index 0000000..046e7fc Binary files /dev/null and b/public/.DS_Store differ diff --git a/scripts/vuln01_regression.sh b/scripts/vuln01_regression.sh new file mode 100755 index 0000000..179a59a --- /dev/null +++ b/scripts/vuln01_regression.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${BASE_URL:-https://wx.sstbc.com}" +COURSE_ID="${COURSE_ID:-}" +CURL_TIMEOUT="${CURL_TIMEOUT:-20}" +CURL_RESOLVE="${CURL_RESOLVE:-}" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +if ! command -v php >/dev/null 2>&1; then + printf 'ERROR: php CLI is required to parse API responses\n' >&2 + exit 2 +fi + +CURL_ARGS=(-sS -L --max-time "$CURL_TIMEOUT") +if [[ "${CURL_INSECURE:-0}" == "1" ]]; then + CURL_ARGS+=(-k) +fi +if [[ -n "$CURL_RESOLVE" ]]; then + CURL_ARGS+=(--resolve "$CURL_RESOLVE") +fi + +request() { + local name="$1" + local url="$2" + local body="$TMP_DIR/$name.json" + local status + + if ! status="$(curl "${CURL_ARGS[@]}" -o "$body" -w '%{http_code}' "$url")"; then + status="000" + : > "$body" + printf '%s' "$status" > "$TMP_DIR/$name.status" + printf '%-28s HTTP %s REQUEST_FAILED\n' "$name" "$status" + printf '%s\n' "$body" + return 0 + fi + printf '%s' "$status" > "$TMP_DIR/$name.status" + printf '%-28s HTTP %s\n' "$name" "$status" + printf '%s\n' "$body" +} + +request course-list "$BASE_URL/api/mobile/course/course?page=1&page_size=1" +request course-list-page-size-51 "$BASE_URL/api/mobile/course/course?page=1&page_size=51" +request course-detail-invalid "$BASE_URL/api/mobile/course/course-detail?course_id=not-an-id" +request course-detail-missing "$BASE_URL/api/mobile/course/course-detail?course_id=2147483647" + +scan_sensitive_keys() { + local name="$1" + local body="$TMP_DIR/$name.json" + local forbidden + + forbidden="$(php -r ' + $data = json_decode(file_get_contents($argv[1]), true); + if (!is_array($data)) { exit(2); } + $blocked = ["mobile", "idcard", "id_card", "identity_card", "remark", "admin_id", "department_id", "teacher_id", "deleted_at", "password", "remember_token"]; + $found = []; + $walk = function ($value) use (&$walk, &$found, $blocked) { + if (!is_array($value)) { return; } + foreach ($value as $key => $child) { + if (in_array(strtolower((string) $key), $blocked, true)) { $found[(string) $key] = true; } + $walk($child); + } + }; + $walk($data); + ksort($found); + echo implode(PHP_EOL, array_keys($found)); + ' "$body" || true)" + if [[ -n "$forbidden" ]]; then + printf '%s FORBIDDEN_KEYS\n%s\n' "$name" "$forbidden" + exit 1 + fi + printf '%s SENSITIVE_SCAN PASS\n' "$name" +} + +list_body="$TMP_DIR/course-list.json" +if php -r '$d=json_decode(file_get_contents($argv[1]), true); exit(is_array($d) && isset($d["data"]) && is_array($d["data"]) ? 0 : 1);' "$list_body"; then + scan_sensitive_keys course-list + if [[ -z "$COURSE_ID" ]]; then + COURSE_ID="$(php -r '$d=json_decode(file_get_contents($argv[1]), true); echo $d["data"][0]["id"] ?? "";' "$list_body")" + fi + + if [[ -n "$COURSE_ID" ]]; then + request course-detail-valid "$BASE_URL/api/mobile/course/course-detail?course_id=$COURSE_ID" + request course-detail-pc-valid "$BASE_URL/api/mobile/course/course-detail-pc?course_id=$COURSE_ID" + scan_sensitive_keys course-detail-valid + scan_sensitive_keys course-detail-pc-valid + php -r '$d=json_decode(file_get_contents($argv[1]), true); exit(is_array($d) && isset($d["id"], $d["name"]) ? 0 : 1);' "$TMP_DIR/course-detail-valid.json" + php -r '$d=json_decode(file_get_contents($argv[1]), true); exit(is_array($d) && isset($d["id"], $d["name"]) ? 0 : 1);' "$TMP_DIR/course-detail-pc-valid.json" + printf 'DETAIL_CORE_FIELDS PASS\n' + else + printf 'DETAIL_SCAN SKIP: course list is empty\n' + fi +else + printf 'SENSITIVE_SCAN SKIP: course list did not return a data array\n' +fi + +if php -r '$d=json_decode(file_get_contents($argv[1]), true); exit(is_array($d) && (($d["errcode"] ?? null) === 10001) ? 0 : 1);' "$TMP_DIR/course-list-page-size-51.json"; then + printf 'PAGE_SIZE_VALIDATION PASS\n' +else + printf 'PAGE_SIZE_VALIDATION FAIL\n' + exit 1 +fi + +if php -r '$d=json_decode(file_get_contents($argv[1]), true); exit(is_array($d) && (($d["errcode"] ?? null) === 10001) ? 0 : 1);' "$TMP_DIR/course-detail-invalid.json"; then + printf 'COURSE_ID_VALIDATION PASS\n' +else + printf 'COURSE_ID_VALIDATION FAIL\n' + exit 1 +fi + +if [[ "$(cat "$TMP_DIR/course-detail-missing.status")" == "404" ]] || php -r '$d=json_decode(file_get_contents($argv[1]), true); exit(is_array($d) && (($d["message"] ?? null) === "课程不存在") ? 0 : 1);' "$TMP_DIR/course-detail-missing.json"; then + printf 'MISSING_COURSE_VALIDATION PASS\n' +else + printf 'MISSING_COURSE_VALIDATION FAIL\n' + exit 1 +fi diff --git a/tests/Feature/MobileCourseEndpointValidationTest.php b/tests/Feature/MobileCourseEndpointValidationTest.php new file mode 100644 index 0000000..65462f3 --- /dev/null +++ b/tests/Feature/MobileCourseEndpointValidationTest.php @@ -0,0 +1,40 @@ +getJson('/api/mobile/course/course?page_size=51'); + + $response->assertOk() + ->assertJsonPath('errcode', 10001); + } + + public function test_course_list_rejects_non_public_status_filter(): void + { + $response = $this->getJson('/api/mobile/course/course?status=0'); + + $response->assertOk() + ->assertJsonPath('errcode', 10001); + } + + public function test_course_detail_rejects_non_integer_course_id(): void + { + $response = $this->getJson('/api/mobile/course/course-detail?course_id=not-an-id'); + + $response->assertOk() + ->assertJsonPath('errcode', 10001); + } + + public function test_pc_course_detail_rejects_non_integer_course_id(): void + { + $response = $this->getJson('/api/mobile/course/course-detail-pc?course_id=not-an-id'); + + $response->assertOk() + ->assertJsonPath('errcode', 10001); + } +} diff --git a/tests/Feature/MobileCoursePublicDataTest.php b/tests/Feature/MobileCoursePublicDataTest.php new file mode 100644 index 0000000..78a7488 --- /dev/null +++ b/tests/Feature/MobileCoursePublicDataTest.php @@ -0,0 +1,292 @@ + 'sqlite', + 'database.connections.sqlite.database' => ':memory:', + ]); + DB::purge('sqlite'); + $this->createSchema(); + $this->seedCourseData(); + } + + protected function tearDown(): void + { + Auth::guard('mobile')->forgetUser(); + Auth::guard('admin')->forgetUser(); + DB::disconnect('sqlite'); + + parent::tearDown(); + } + + public function test_anonymous_user_sees_only_published_public_courses(): void + { + $response = $this->getJson('/api/mobile/course/course'); + + $response->assertOk(); + $data = $response->json('data'); + $this->assertNoSensitiveKeys($response->json()); + + $this->assertCount(1, $data); + $this->assertSame(150, $data[0]['id']); + $this->assertSame('张老师', $data[0]['teacher']['name']); + $this->assertArrayNotHasKey('mobile', $data[0]['teacher']); + $this->assertArrayNotHasKey('admin_id', $data[0]); + } + + public function test_regular_mobile_user_cannot_read_unpublished_course_details(): void + { + $user = new User(); + $user->forceFill(['id' => 200]); + Auth::guard('mobile')->setUser($user); + + $response = $this->getJson('/api/mobile/course/course-detail?course_id=151'); + + $response->assertNotFound(); + } + + public function test_registered_mobile_user_still_receives_safe_course_detail(): void + { + $user = new User(); + $user->forceFill(['id' => 201]); + Auth::guard('mobile')->setUser($user); + DB::table('course_signs')->insert([ + 'id' => 1, + 'course_id' => 150, + 'user_id' => 201, + ]); + + $response = $this->getJson('/api/mobile/course/course-detail?course_id=150'); + + $response->assertOk(); + $this->assertNoSensitiveKeys($response->json()); + $this->assertSame('公开课程', $response->json('name')); + $this->assertSame('张老师', $response->json('teacher.name')); + $this->assertStringEndsWith('/course/public.jpg', $response->json('publicize.0.url')); + $this->assertStringEndsWith('/course/group.jpg', $response->json('qun_image.url')); + $this->assertArrayNotHasKey('folder', $response->json('publicize.0')); + $this->assertArrayNotHasKey('name', $response->json('qun_image')); + $this->assertArrayNotHasKey('mobile', $response->json('teacher')); + $this->assertArrayNotHasKey('admin_id', $response->json()); + $this->assertArrayNotHasKey('department_id', $response->json()); + $this->assertArrayNotHasKey('deleted_at', $response->json()); + $this->assertArrayNotHasKey('admin_id', $response->json('course_forms.0')); + } + + public function test_admin_user_does_not_expand_public_mobile_response(): void + { + $admin = new Admin(); + $admin->forceFill(['id' => 1]); + Auth::guard('admin')->setUser($admin); + + $response = $this->getJson('/api/mobile/course/course-detail-pc?course_id=150'); + + $response->assertOk(); + $this->assertNoSensitiveKeys($response->json()); + $this->assertArrayNotHasKey('mobile', $response->json('teacher')); + $this->assertArrayNotHasKey('admin_id', $response->json()); + $this->assertArrayNotHasKey('teacher_id', $response->json()); + } + + public function test_deleted_virtual_and_not_started_courses_are_not_publicly_addressable(): void + { + foreach ([152, 153, 154] as $courseId) { + $response = $this->getJson('/api/mobile/course/course-detail?course_id=' . $courseId); + + $response->assertNotFound(); + } + } + + private function createSchema(): void + { + Schema::create('courses', function (Blueprint $table) { + $table->increments('id'); + $table->integer('admin_id')->nullable(); + $table->integer('department_id')->nullable(); + $table->string('name')->nullable(); + $table->integer('image_id')->nullable(); + $table->integer('qun_image_id')->nullable(); + $table->date('start_date')->nullable(); + $table->date('end_date')->nullable(); + $table->integer('type')->nullable(); + $table->text('content')->nullable(); + $table->text('publicize_content')->nullable(); + $table->json('publicize_ids')->nullable(); + $table->tinyInteger('is_fee')->nullable(); + $table->integer('total')->nullable(); + $table->string('url')->nullable(); + $table->string('url_title')->nullable(); + $table->integer('teacher_id')->nullable(); + $table->tinyInteger('status')->default(0); + $table->tinyInteger('course_status')->default(0); + $table->tinyInteger('sign_status')->default(0); + $table->boolean('is_virtual')->default(0); + $table->boolean('show_txl')->default(1); + $table->boolean('show_mobile')->default(1); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('teachers', function (Blueprint $table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('mobile')->nullable(); + $table->text('introduce')->nullable(); + $table->string('remark')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('course_types', function (Blueprint $table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('wait_tip')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('course_forms', function (Blueprint $table) { + $table->increments('id'); + $table->integer('course_id')->nullable(); + $table->integer('admin_id')->nullable(); + $table->integer('department_id')->nullable(); + $table->string('name')->nullable(); + $table->string('field')->nullable(); + $table->string('edit_input')->nullable(); + $table->string('rule')->nullable(); + $table->integer('sort')->default(1); + $table->string('help')->nullable(); + $table->text('select_item')->nullable(); + $table->boolean('need_fill')->default(0); + $table->boolean('belong_user')->default(0); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('uploads', function (Blueprint $table) { + $table->increments('id'); + $table->string('folder')->nullable(); + $table->string('name')->nullable(); + $table->string('original_name')->nullable(); + $table->timestamps(); + }); + + DB::table('uploads')->insert([ + ['id' => 21, 'folder' => 'course', 'name' => 'public.jpg'], + ['id' => 22, 'folder' => 'course', 'name' => 'group.jpg'], + ]); + + Schema::create('course_signs', function (Blueprint $table) { + $table->increments('id'); + $table->integer('course_id')->nullable(); + $table->integer('user_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('course_content_evaluations', function (Blueprint $table) { + $table->increments('id'); + $table->integer('course_id')->nullable(); + $table->string('course_content_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('course_content_evaluation_asks', function (Blueprint $table) { + $table->increments('id'); + $table->integer('course_content_evaluation_id')->nullable(); + $table->integer('course_content_id')->nullable(); + $table->integer('sort')->default(1); + $table->timestamps(); + $table->softDeletes(); + }); + } + + private function seedCourseData(): void + { + DB::table('teachers')->insert([ + 'id' => 11, + 'name' => '张老师', + 'mobile' => '13800138000', + 'introduce' => '公开介绍', + 'remark' => '内部备注', + ]); + DB::table('course_types')->insert(['id' => 3, 'name' => '管理类']); + DB::table('course_forms')->insert([ + 'id' => 1, + 'course_id' => 150, + 'admin_id' => 9, + 'department_id' => 10, + 'name' => '姓名', + 'field' => 'name', + 'edit_input' => 'input', + 'rule' => 'required', + 'sort' => 1, + 'help' => '请填写姓名', + 'need_fill' => 1, + 'belong_user' => 1, + ]); + + $base = [ + 'name' => '公开课程', + 'start_date' => '2026-08-01', + 'end_date' => '2026-08-02', + 'type' => 3, + 'content' => '公开介绍', + 'publicize_content' => '宣传介绍', + 'teacher_id' => 11, + 'status' => 1, + 'course_status' => 20, + 'sign_status' => 10, + 'is_virtual' => 0, + 'show_txl' => 1, + 'show_mobile' => 1, + 'is_fee' => 0, + 'total' => 30, + 'url' => 'https://example.test/review', + 'url_title' => '精彩回顾', + 'publicize_ids' => json_encode([21]), + 'qun_image_id' => 22, + 'deleted_at' => null, + ]; + + DB::table('courses')->insert([ + array_merge($base, ['id' => 150]), + array_merge($base, ['id' => 151, 'status' => 0]), + array_merge($base, ['id' => 152, 'is_virtual' => 1]), + array_merge($base, ['id' => 153, 'sign_status' => 30]), + array_merge($base, ['id' => 154, 'deleted_at' => now()]), + ]); + } + + private function assertNoSensitiveKeys($value): void + { + $forbidden = [ + 'mobile', 'idcard', 'id_card', 'identity_card', 'remark', + 'admin_id', 'department_id', 'teacher_id', 'deleted_at', + 'password', 'remember_token', + ]; + + if (is_array($value)) { + foreach ($value as $key => $item) { + $this->assertNotContains(strtolower((string) $key), $forbidden); + $this->assertNoSensitiveKeys($item); + } + } + } +} diff --git a/tests/Unit/Http/Resources/Mobile/PublicCourseResourceTest.php b/tests/Unit/Http/Resources/Mobile/PublicCourseResourceTest.php new file mode 100644 index 0000000..0f4b1fb --- /dev/null +++ b/tests/Unit/Http/Resources/Mobile/PublicCourseResourceTest.php @@ -0,0 +1,99 @@ +setRawAttributes([ + 'id' => 150, + 'name' => '公开课程', + 'start_date' => '2026-08-01', + 'end_date' => '2026-08-02', + 'content' => '公开介绍', + 'publicize_content' => '宣传介绍', + 'status' => 1, + 'course_status' => 20, + 'sign_status' => 10, + 'admin_id' => 9, + 'department_id' => 10, + 'teacher_id' => 11, + 'show_mobile' => 1, + ]); + + $teacher = new Teacher(); + $teacher->setRawAttributes([ + 'id' => 11, + 'name' => '张老师', + 'mobile' => '13800138000', + 'remark' => '内部备注', + ]); + + $form = new CourseForm(); + $form->setRawAttributes([ + 'id' => 1, + 'name' => '姓名', + 'field' => 'name', + 'edit_input' => 'input', + 'rule' => 'required', + 'sort' => 1, + 'help' => '请填写姓名', + 'select_item' => null, + 'need_fill' => 1, + 'belong_user' => 1, + 'admin_id' => 9, + 'department_id' => 10, + ]); + + $type = new CourseType(); + $type->setRawAttributes(['id' => 3, 'name' => '管理类', 'wait_tip' => '内部提示']); + + $course->setRelation('teacher', $teacher); + $course->setRelation('courseForms', new Collection([$form])); + $course->setRelation('typeDetail', $type); + + $payload = (new PublicCourseResource($course))->resolve(); + + $this->assertSame(150, $payload['id']); + $this->assertSame('公开课程', $payload['name']); + $this->assertSame('张老师', $payload['teacher']['name']); + $this->assertArrayNotHasKey('mobile', $payload['teacher']); + $this->assertArrayNotHasKey('remark', $payload['teacher']); + $this->assertArrayNotHasKey('admin_id', $payload); + $this->assertArrayNotHasKey('department_id', $payload); + $this->assertArrayNotHasKey('teacher_id', $payload); + $this->assertArrayNotHasKey('wait_tip', $payload['type_detail']); + $this->assertArrayNotHasKey('admin_id', $payload['course_forms'][0]); + $this->assertArrayNotHasKey('department_id', $payload['course_forms'][0]); + $this->assertArrayNotHasKey('deleted_at', $payload['course_forms'][0]); + } + + public function test_public_resource_does_not_include_model_appends(): void + { + $course = new Course(); + $course->setRawAttributes([ + 'id' => 1, + 'name' => '公开课程', + 'status' => 1, + 'course_status' => 20, + 'sign_status' => 10, + ]); + + $payload = (new PublicCourseResource($course))->resolve(); + + $this->assertArrayNotHasKey('teacher_detail', $payload); + $this->assertArrayNotHasKey('show_mobile_text', $payload); + $this->assertSame([], $payload['publicize']); + $this->assertArrayNotHasKey('qrcode', $payload); + } +}