diff --git a/apps/api/config/test.php b/apps/api/config/test.php index f95bc03..a0225be 100644 --- a/apps/api/config/test.php +++ b/apps/api/config/test.php @@ -25,11 +25,17 @@ 'showScriptName' => true, ], 'user' => [ - 'identityClass' => 'app\models\User', + // Mirror config/web.php: bearer tokens resolve to JwtIdentity without a DB lookup. + 'identityClass' => 'app\models\JwtIdentity', + 'enableAutoLogin' => false, ], 'request' => [ 'cookieValidationKey' => 'test', 'enableCsrfValidation' => false, + // Mirror config/web.php so JSON request bodies populate body params. + 'parsers' => [ + 'application/json' => 'yii\web\JsonParser', + ], // but if you absolutely need it set cookie domain to localhost /* 'csrfCookie' => [ diff --git a/apps/api/controllers/CourseController.php b/apps/api/controllers/CourseController.php index 9d80542..9a3926d 100644 --- a/apps/api/controllers/CourseController.php +++ b/apps/api/controllers/CourseController.php @@ -1863,6 +1863,34 @@ public function actionCreateResource() throw new NotFoundHttpException('Course not found.'); } + // Use the exact offering selected in the React form. Looking up an + // offering by course and term is ambiguous when a course has + // multiple sections in the same term. + $offeringId = filter_var( + $body['offering_id'] ?? null, + FILTER_VALIDATE_INT, + ['options' => ['min_range' => 1]] + ); + if ($offeringId === false) { + throw new BadRequestHttpException('A valid "offering_id" is required.'); + } + + $courseOffering = CourseOfferings::findOne($offeringId); + if (!$courseOffering) { + throw new NotFoundHttpException('Course offering not found.'); + } + if ((int)$courseOffering->course_id !== (int)$courseId) { + throw new BadRequestHttpException('The selected offering does not belong to this course.'); + } + + $folioCourseId = $folioData['courseListingId'] ?? null; + if (!$folioCourseId) { + throw new BadRequestHttpException('A FOLIO course listing is required.'); + } + if ((string)$courseOffering->folio_course_id !== (string)$folioCourseId) { + throw new BadRequestHttpException('The selected offering does not match the FOLIO course listing.'); + } + // Ensure the required "title" field is present $title = $body['title'] ?? null; if (!$title) { @@ -1950,14 +1978,9 @@ public function actionCreateResource() } } - $courseOfferingId = CourseOfferings::findOne([ - 'course_id' => $courseId, - 'term_id' => $folioData["courseListingObject"]['termId'] - ]); - // Link the resource to the course with visibility settings $courseResource = new CourseResources(); - $courseResource->offering_id = $courseOfferingId->offering_id; + $courseResource->offering_id = $courseOffering->offering_id; $courseResource->resource_id = $resource->resource_id; // Set visibility fields on the course_resources relationship @@ -1977,7 +2000,7 @@ public function actionCreateResource() return [ 'resource' => $resource, - 'course_offering' => $courseOfferingId->offering_id, + 'course_offering' => $courseOffering->offering_id, 'links' => ResourceLinks::findAll(['resource_id' => $resource->resource_id]), 'metadata' => ResourceMetadata::findAll(['resource_id' => $resource->resource_id]), 'success' => true, diff --git a/apps/api/tests/_support/Helper/Functional.php b/apps/api/tests/_support/Helper/Functional.php new file mode 100644 index 0000000..3094af0 --- /dev/null +++ b/apps/api/tests/_support/Helper/Functional.php @@ -0,0 +1,44 @@ +urlManager->createUrl([$route]); + $this->getModule('Yii2')->_request( + 'POST', + $url, + [], + [], + ['CONTENT_TYPE' => 'application/json'], + json_encode($payload) + ); + } + + /** + * Decode the last response body as JSON. + * + * @return array|null + */ + public function grabJsonResponse() + { + return json_decode($this->getModule('Yii2')->_getResponseContent(), true); + } +} diff --git a/apps/api/tests/functional.suite.yml b/apps/api/tests/functional.suite.yml index 374c6df..2ec650f 100644 --- a/apps/api/tests/functional.suite.yml +++ b/apps/api/tests/functional.suite.yml @@ -11,3 +11,4 @@ modules: enabled: - Filesystem - Yii2 + - \Helper\Functional diff --git a/apps/api/tests/functional/CreateResourceOfferingCest.php b/apps/api/tests/functional/CreateResourceOfferingCest.php new file mode 100644 index 0000000..cff5023 --- /dev/null +++ b/apps/api/tests/functional/CreateResourceOfferingCest.php @@ -0,0 +1,216 @@ +db = \Yii::$app->db; + + $issuedAt = time(); + $this->token = \Firebase\JWT\JWT::encode([ + 'iat' => $issuedAt, + 'exp' => $issuedAt + 3600, + 'username' => 'sandbox.staff', + 'role' => 'admin', + 'id' => 0, + ], getenv('JWT_SECRET_KEY'), 'HS256'); + + $suffix = bin2hex(random_bytes(6)); + $this->termId = $this->uuid(); + $this->sectionOneFolioCourseId = $this->uuid(); + $this->sectionThreeFolioCourseId = $this->uuid(); + + $this->db->createCommand()->insert('courses', [ + 'permanent_course_uuid' => $this->uuid(), + 'course_name' => 'Offering regression ' . $suffix, + 'course_number' => 'TEST-' . $suffix, + ])->execute(); + $this->courseId = (int)$this->db->getLastInsertID(); + + // Section 01 is inserted first so a course+term lookup returns it. + $this->sectionOneOfferingId = $this->insertOffering($this->sectionOneFolioCourseId, '01'); + $this->sectionThreeOfferingId = $this->insertOffering($this->sectionThreeFolioCourseId, '03'); + } + + public function _after(FunctionalTester $I) + { + if ($this->resourceNames) { + $resourceIds = (new \yii\db\Query()) + ->select('resource_id') + ->from('resources') + ->where(['name' => $this->resourceNames]) + ->column($this->db); + + if ($resourceIds) { + foreach (['resource_metadata', 'resource_links', 'course_resources'] as $table) { + $this->db->createCommand()->delete($table, ['resource_id' => $resourceIds])->execute(); + } + $this->db->createCommand()->delete('resources', ['resource_id' => $resourceIds])->execute(); + } + $this->resourceNames = []; + } + + if ($this->courseId) { + $this->db->createCommand()->delete('course_offerings', ['course_id' => $this->courseId])->execute(); + $this->db->createCommand()->delete('courses', ['course_id' => $this->courseId])->execute(); + } + } + + // ── Tests ───────────────────────────────────────────────────────────────── + + /** + * Staff working in section 03 must get a resource linked to section 03, + * even though section 01 shares the same course and term. + */ + public function resourceCreatedForSectionThreeIsLinkedToSectionThree(FunctionalTester $I) + { + $name = $this->resourceName('selected'); + + $I->haveHttpHeader('Authorization', 'Bearer ' . $this->token); + $I->sendJsonPost('course/create-resource', $this->payload( + $this->sectionThreeOfferingId, + $this->sectionThreeFolioCourseId, + $name + )); + $I->seeResponseCodeIs(200); + + $body = $I->grabJsonResponse(); + \PHPUnit\Framework\Assert::assertSame( + $this->sectionThreeOfferingId, + (int)(isset($body['course_offering']) ? $body['course_offering'] : 0), + 'Response should report the explicitly selected offering.' + ); + + $storedOfferingId = $this->db->createCommand( + 'SELECT cr.offering_id FROM course_resources cr ' + . 'JOIN resources r ON r.resource_id = cr.resource_id WHERE r.name = :name', + [':name' => $name] + )->queryScalar(); + \PHPUnit\Framework\Assert::assertSame( + $this->sectionThreeOfferingId, + (int)$storedOfferingId, + 'Stored course_resources row should point at the selected offering.' + ); + } + + /** + * An offering that does not match the FOLIO listing in the request is a + * client error, not a silent link to whichever offering was found first. + */ + public function offeringThatDoesNotMatchFolioListingIsRejected(FunctionalTester $I) + { + $name = $this->resourceName('mismatch'); + + $I->haveHttpHeader('Authorization', 'Bearer ' . $this->token); + $I->sendJsonPost('course/create-resource', $this->payload( + $this->sectionOneOfferingId, + $this->sectionThreeFolioCourseId, + $name + )); + $I->seeResponseCodeIs(400); + $this->assertNoResourceNamed($name); + } + + /** + * Without an explicit offering the request cannot be resolved safely. + */ + public function missingOfferingIdIsRejected(FunctionalTester $I) + { + $name = $this->resourceName('missing'); + $payload = $this->payload($this->sectionThreeOfferingId, $this->sectionThreeFolioCourseId, $name); + unset($payload['offering_id']); + + $I->haveHttpHeader('Authorization', 'Bearer ' . $this->token); + $I->sendJsonPost('course/create-resource', $payload); + $I->seeResponseCodeIs(400); + $this->assertNoResourceNamed($name); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private function payload($offeringId, $folioCourseId, $title) + { + return [ + 'courseId' => $this->courseId, + 'offering_id' => $offeringId, + 'title' => $title, + 'link' => 'https://example.test/resource', + 'folioData' => [ + 'courseListingId' => $folioCourseId, + 'courseListingObject' => ['termId' => $this->termId], + ], + ]; + } + + private function insertOffering($folioCourseId, $sectionName) + { + $this->db->createCommand()->insert('course_offerings', [ + 'offering_uuid' => $this->uuid(), + 'course_id' => $this->courseId, + 'folio_course_id' => $folioCourseId, + 'term_id' => $this->termId, + 'term_name' => 'Regression term', + 'section_name' => $sectionName, + ])->execute(); + + return (int)$this->db->getLastInsertID(); + } + + private function resourceName($label) + { + $name = 'offering-regression-' . $label . '-' . bin2hex(random_bytes(4)); + $this->resourceNames[] = $name; + + return $name; + } + + private function assertNoResourceNamed($name) + { + $count = (int)$this->db->createCommand( + 'SELECT COUNT(*) FROM resources WHERE name = :name', + [':name' => $name] + )->queryScalar(); + \PHPUnit\Framework\Assert::assertSame(0, $count, 'Rejected request must not create a resource.'); + } + + private function uuid() + { + $bytes = random_bytes(16); + $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); + $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); + } +}