diff --git a/.circleci/config.yml b/.circleci/config.yml index f0b82f0..0bf8523 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -21,9 +21,7 @@ jobs: MYSQL_DB_USER: codecast MYSQL_DB_PASSWORD: codecast MYSQL_DB_DATABASE: task_platform - TEST_MODE: 1 - TEST_MODE_PLATFORM_NAME: codecast-test - TEST_MODE_USER_ID: 1 + TEST_MODE: 0 GRADER_QUEUE_DEBUG_PASSWORD: test CODECAST_DEBUGGERS_URL: ws://127.0.0.1:9003 steps: diff --git a/features/get_submission.feature b/features/get_submission.feature index fb5e124..7fccfd7 100644 --- a/features/get_submission.feature +++ b/features/get_submission.feature @@ -19,15 +19,29 @@ Feature: Get submission | 5000 | 1000 | 4000 | Evaluation | 0 | 1 | s1-t1 | 16 | 20 | 2147483647 | | 5001 | 1000 | 4000 | Evaluation | 1 | 1 | s1-t2 | 10 | 15 | 2147483647 | | 5002 | 1000 | 4001 | Evaluation | 2 | 1 | s2-t1 | 15 | 10 | 2147483647 | + And the database has the following table "tm_platforms": + | ID | name | public_key | api_url | + | 1 | codecast-test | -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt8dBg+ojFTrgFeDxoGqqBSQkW/BDSl/H+qzpIpZTCj4mw7zyrIeV7zaaPuA/8g8WVPDjliuVxLwOnX6p8bT0ZEgsyo4/nql2VEI1cLBqSowQ3VoICqeRYHqgv+8g/B4mFxvRRpNNWiM9aE80KtjXBesi7GjULjg6Jnpqfn1UAGrx4AlnbuabH50/xQoQMWLHSpSVhnpEV5XrUPvzHGbkW51/HRRMEF9Fj5SSPs8vQPbA5ZO8H7NgHwN+8fyNuyVtm9DwY9QZVp2mYlbLlV/+y8xrd5TKf/aGyMjVr3du5YwfosrlrnTAJ+DgoxuZRw77DKaiATxSpEiQRH/C208mOwIDAQAB -----END PUBLIC KEY----- | https://mockapi.com | + And "taskToken" is a token signed by the platform with the following payload: + """ + { + "bSubmissionPossible": true, + "date": "10-04-2024", + "idUser": "1", + "idUserAnswer": "1", + "itemUrl": "https://codecast.france-ioi.org/next/task?taskId=1000", + "nbHintsGiven": "0" + } + """ Scenario: Get non-evaluated submission by id Given the database has the following table "tm_submissions": - | ID | idUser | idPlatform | idTask | sDate | idSourceCode | bManualCorrection | bSuccess | nbTestsTotal | nbTestsPassed | iScore | bCompilError | bEvaluated | bConfirmed | sMode | iChecksum | iVersion | - | 6000 | 1 | 1 | 1000 | 2023-04-03 | 7001 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | Submitted | 0 | 2147483647 | + | ID | idUser | idPlatform | idTask | sDate | idSourceCode | bManualCorrection | bSuccess | nbTestsTotal | nbTestsPassed | iScore | bCompilError | bEvaluated | bConfirmed | sMode | idUserAnswer | iChecksum | iVersion | + | 6000 | 1 | 1 | 1000 | 2023-04-03 | 7001 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | Submitted | 1 | 0 | 2147483647 | And the database has the following table "tm_source_codes": | ID | idUser | idPlatform | idTask | sDate | sParams | sName | sSource | bEditable | bSubmission | sType | bActive | iRank | iVersion | | 7001 | 1 | 1 | 1000 | 2023-04-03 | {"sLangProg":"python"} | 485380303499640413 | print("ici") | 0 | 1 | User | 0 | 0 | 2147483647 | - When I send a GET request to "/submissions/6000" + When I send a GET request to "/submissions/6000?token={{taskToken}}&platform=codecast-test" Then the response status code should be 200 And the response body should be the following JSON: """ @@ -39,6 +53,7 @@ Feature: Get submission "score": 0, "compilationError": false, "compilationMessage": null, + "date": "2023-04-03T00:00:00.000Z", "errorMessage": null, "evaluated": false, "confirmed": false, @@ -63,8 +78,8 @@ Feature: Get submission Scenario: Get evaluated submission by id Given the database has the following table "tm_submissions": - | ID | idUser | idPlatform | idTask | sDate | idSourceCode | bManualCorrection | bSuccess | nbTestsTotal | nbTestsPassed | iScore | bCompilError | bEvaluated | sMetadata | bConfirmed | sMode | iChecksum | iVersion | - | 6000 | 1 | 1 | 1000 | 2023-04-03 | 7001 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | {"errorline": 5} | 0 | Submitted | 0 | 2147483647 | + | ID | idUser | idPlatform | idTask | sDate | idSourceCode | bManualCorrection | bSuccess | nbTestsTotal | nbTestsPassed | iScore | bCompilError | bEvaluated | sMetadata | bConfirmed | sMode | idUserAnswer | iChecksum | iVersion | + | 6000 | 1 | 1 | 1000 | 2023-04-03 | 7001 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | {"errorline": 5} | 0 | Submitted | 1 | 0 | 2147483647 | And the database has the following table "tm_submissions_subtasks": | ID | bSuccess | iScore | idSubtask | idSubmission | iVersion | | 7000 | 0 | 50 | 4000 | 6000 | 2147483647 | @@ -77,7 +92,7 @@ Feature: Get submission And the database has the following table "tm_source_codes": | ID | idUser | idPlatform | idTask | sDate | sParams | sName | sSource | bEditable | bSubmission | sType | bActive | iRank | iVersion | | 7001 | 1 | 1 | 1000 | 2023-04-03 | {"sLangProg":"python"} | 485380303499640413 | print("ici") | 0 | 1 | User | 0 | 0 | 2147483647 | - When I send a GET request to "/submissions/6000" + When I send a GET request to "/submissions/6000?token={{taskToken}}&platform=codecast-test" Then the response status code should be 200 And the response body, after decoding "scoreToken", should be the following JSON: """ @@ -91,12 +106,13 @@ Feature: Get submission "date": "{{currentDateTokenFormat}}", "idItem": "1000", "idUser": "1", - "idUserAnswer": null, + "idUserAnswer": "1", "sAnswer": "{\"idSubmission\":\"6000\",\"langProg\":\"python\",\"sourceCode\":\"print(\\\"ici\\\")\"}", "score": "0" }, "compilationError": false, "compilationMessage": null, + "date": "2023-04-03T00:00:00.000Z", "errorMessage": null, "evaluated": true, "confirmed": false, @@ -189,8 +205,8 @@ Feature: Get submission Scenario: Get evaluated submission by id with tests Given the database has the following table "tm_submissions": - | ID | idUser | idPlatform | idTask | sDate | idSourceCode | bManualCorrection | bSuccess | nbTestsTotal | nbTestsPassed | iScore | bCompilError | bEvaluated | sMetadata | bConfirmed | sMode | iChecksum | iVersion | - | 6000 | 1 | 1 | 1000 | 2023-04-03 | 7001 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | {"errorline": 5} | 0 | Submitted | 0 | 2147483647 | + | ID | idUser | idPlatform | idTask | sDate | idSourceCode | bManualCorrection | bSuccess | nbTestsTotal | nbTestsPassed | iScore | bCompilError | bEvaluated | sMetadata | bConfirmed | sMode | idUserAnswer | iChecksum | iVersion | + | 6000 | 1 | 1 | 1000 | 2023-04-03 | 7001 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | {"errorline": 5} | 0 | Submitted | 1 | 0 | 2147483647 | And the database has the following table "tm_submissions_subtasks": | ID | bSuccess | iScore | idSubtask | idSubmission | iVersion | | 7000 | 0 | 50 | 4000 | 6000 | 2147483647 | @@ -203,7 +219,7 @@ Feature: Get submission And the database has the following table "tm_source_codes": | ID | idUser | idPlatform | idTask | sDate | sParams | sName | sSource | bEditable | bSubmission | sType | bActive | iRank | iVersion | | 7001 | 1 | 1 | 1000 | 2023-04-03 | {"sLangProg":"python"} | 485380303499640413 | print("ici") | 0 | 1 | User | 0 | 0 | 2147483647 | - When I send a GET request to "/submissions/6000?withTests" + When I send a GET request to "/submissions/6000?token={{taskToken}}&platform=codecast-test&withTests" Then the response status code should be 200 And the response body, after decoding "scoreToken", should be the following JSON: """ @@ -217,12 +233,13 @@ Feature: Get submission "date": "{{currentDateTokenFormat}}", "idItem": "1000", "idUser": "1", - "idUserAnswer": null, + "idUserAnswer": "1", "sAnswer": "{\"idSubmission\":\"6000\",\"langProg\":\"python\",\"sourceCode\":\"print(\\\"ici\\\")\"}", "score": "0" }, "compilationError": false, "compilationMessage": null, + "date": "2023-04-03T00:00:00.000Z", "errorMessage": null, "evaluated": true, "confirmed": false, @@ -360,8 +377,8 @@ Feature: Get submission Scenario: Get evaluating submission by id using longPolling Given the database has the following table "tm_submissions": - | ID | idUser | idPlatform | idTask | sDate | idSourceCode | bManualCorrection | bSuccess | nbTestsTotal | nbTestsPassed | iScore | bCompilError | bEvaluated | bConfirmed | sMode | iChecksum | iVersion | - | 6000 | 1 | 1 | 1000 | 2023-04-03 | 7001 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | Submitted | 0 | 2147483647 | + | ID | idUser | idPlatform | idTask | sDate | idSourceCode | bManualCorrection | bSuccess | nbTestsTotal | nbTestsPassed | iScore | bCompilError | bEvaluated | bConfirmed | sMode | idUserAnswer | iChecksum | iVersion | + | 6000 | 1 | 1 | 1000 | 2023-04-03 | 7001 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | Submitted | 1 | 0 | 2147483647 | And the database has the following table "tm_submissions_subtasks": | ID | bSuccess | iScore | idSubtask | idSubmission | iVersion | | 7000 | 0 | 50 | 4000 | 6000 | 2147483647 | @@ -374,7 +391,7 @@ Feature: Get submission And the database has the following table "tm_source_codes": | ID | idUser | idPlatform | idTask | sDate | sParams | sName | sSource | bEditable | bSubmission | sType | bActive | iRank | iVersion | | 7001 | 1 | 1 | 1000 | 2023-04-03 | {"sLangProg":"python"} | 485380303499640413 | print("ici") | 0 | 1 | User | 0 | 0 | 2147483647 | - When I asynchronously send a GET request to "/submissions/6000?longPolling" + When I asynchronously send a GET request to "/submissions/6000?token={{taskToken}}&platform=codecast-test&longPolling" And I wait 10ms Then the server must not have returned a response When I fire the event "evaluation-6000" to the longPolling handler @@ -390,6 +407,7 @@ Feature: Get submission "score": 0, "compilationError": false, "compilationMessage": null, + "date": "2023-04-03T00:00:00.000Z", "errorMessage": null, "evaluated": false, "confirmed": false, @@ -413,5 +431,116 @@ Feature: Get submission """ Scenario: Get unknown submission - When I send a GET request to "/submissions/999999" + When I send a GET request to "/submissions/999999?token={{taskToken}}&platform=codecast-test" Then the response status code should be 404 + + Scenario: Get submission without token + Given the database has the following table "tm_submissions": + | ID | idUser | idPlatform | idTask | sDate | idSourceCode | bManualCorrection | bSuccess | nbTestsTotal | nbTestsPassed | iScore | bCompilError | bEvaluated | sMetadata | bConfirmed | sMode | iChecksum | iVersion | + | 6000 | 1 | 1 | 1000 | 2023-04-03 | 7001 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | {"errorline": 5} | 0 | Submitted | 0 | 2147483647 | + When I send a GET request to "/submissions/6000" + Then the response status code should be 400 + And the response body should be the following JSON: + """ + { + "error": "Incorrect input arguments.", + "message": "Error: Missing token or platform parameters" + } + """ + + Scenario: Get submission with token from another user + Given the database has the following table "tm_submissions": + | ID | idUser | idPlatform | idTask | sDate | idSourceCode | bManualCorrection | bSuccess | nbTestsTotal | nbTestsPassed | iScore | bCompilError | bEvaluated | sMetadata | bConfirmed | sMode | iChecksum | iVersion | + | 6000 | 1 | 1 | 1000 | 2023-04-03 | 7001 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | {"errorline": 5} | 0 | Submitted | 0 | 2147483647 | + And "fakeTaskToken" is a token signed by the platform with the following payload: + """ + { + "bSubmissionPossible": true, + "date": "10-04-2024", + "idUser": "999999", + "itemUrl": "https://codecast.france-ioi.org/next/task?taskId=1000", + "nbHintsGiven": "0" + } + """ + When I send a GET request to "/submissions/6000?token={{fakeTaskToken}}&platform=codecast-test" + Then the response status code should be 401 + And the response body should be the following JSON: + """ + { + "error": "Access denied.", + "message": "Error: User id mismatch between submission data and provided user id from the token: 999999" + } + """ + + Scenario: Get submission with token from another task + Given the database has the following table "tm_submissions": + | ID | idUser | idPlatform | idTask | sDate | idSourceCode | bManualCorrection | bSuccess | nbTestsTotal | nbTestsPassed | iScore | bCompilError | bEvaluated | sMetadata | bConfirmed | sMode | iChecksum | iVersion | + | 6000 | 1 | 1 | 1000 | 2023-04-03 | 7001 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | {"errorline": 5} | 0 | Submitted | 0 | 2147483647 | + And "fakeTaskToken" is a token signed by the platform with the following payload: + """ + { + "bSubmissionPossible": true, + "date": "10-04-2024", + "idUser": "1", + "itemUrl": "https://codecast.france-ioi.org/next/task?taskId=999999", + "nbHintsGiven": "0" + } + """ + When I send a GET request to "/submissions/6000?token={{fakeTaskToken}}&platform=codecast-test" + Then the response status code should be 401 + And the response body should be the following JSON: + """ + { + "error": "Access denied.", + "message": "Error: Task id mismatch between submission data and provided task id from the token: 999999" + } + """ + + Scenario: Get submission with token from another platform + Given the database has the following table "tm_submissions": + | ID | idUser | idPlatform | idTask | sDate | idSourceCode | bManualCorrection | bSuccess | nbTestsTotal | nbTestsPassed | iScore | bCompilError | bEvaluated | sMetadata | bConfirmed | sMode | iChecksum | iVersion | + | 6000 | 1 | 999999 | 1000 | 2023-04-03 | 7001 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | {"errorline": 5} | 0 | Submitted | 0 | 2147483647 | + And "fakeTaskToken" is a token signed by the platform with the following payload: + """ + { + "bSubmissionPossible": true, + "date": "10-04-2024", + "idUser": "1", + "itemUrl": "https://codecast.france-ioi.org/next/task?taskId=1000", + "nbHintsGiven": "0" + } + """ + When I send a GET request to "/submissions/6000?token={{fakeTaskToken}}&platform=codecast-test" + Then the response status code should be 401 + And the response body should be the following JSON: + """ + { + "error": "Access denied.", + "message": "Error: Platform id mismatch between submission data and provided platform from the token: codecast-test" + } + """ + + Scenario: Get submission with token from another idUserAnswer + Given the database has the following table "tm_submissions": + | ID | idUser | idPlatform | idTask | sDate | idSourceCode | bManualCorrection | bSuccess | nbTestsTotal | nbTestsPassed | iScore | bCompilError | bEvaluated | sMetadata | bConfirmed | sMode | idUserAnswer | iChecksum | iVersion | + | 6000 | 1 | 1 | 1000 | 2023-04-03 | 7001 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | {"errorline": 5} | 0 | Submitted | 1 | 0 | 2147483647 | + And "fakeTaskToken" is a token signed by the platform with the following payload: + """ + { + "bSubmissionPossible": true, + "date": "10-04-2024", + "idUser": "1", + "itemUrl": "https://codecast.france-ioi.org/next/task?taskId=1000", + "idUserAnswer": "999999", + "nbHintsGiven": "0" + } + """ + When I send a GET request to "/submissions/6000?token={{fakeTaskToken}}&platform=codecast-test" + Then the response status code should be 401 + And the response body should be the following JSON: + """ + { + "error": "Access denied.", + "message": "Error: User answer id mismatch between submission data and provided idUserAnswer from the token: 999999" + } + """ \ No newline at end of file diff --git a/features/get_task.feature b/features/get_task.feature index 6a58701..23bf5ab 100644 --- a/features/get_task.feature +++ b/features/get_task.feature @@ -23,8 +23,8 @@ Feature: Get task | 5001 | 1000 | 4000 | Evaluation | 1 | 1 | s1-t2 | 10 | 15 | 2147483647 | | 5002 | 1000 | 4001 | Evaluation | 2 | 1 | s2-t1 | 15 | 10 | 2147483647 | And the database has the following table "tm_platforms": - | ID | name | public_key | - | 1 | codecast-test | | + | ID | name | public_key | api_url | + | 1 | codecast-test | -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt8dBg+ojFTrgFeDxoGqqBSQkW/BDSl/H+qzpIpZTCj4mw7zyrIeV7zaaPuA/8g8WVPDjliuVxLwOnX6p8bT0ZEgsyo4/nql2VEI1cLBqSowQ3VoICqeRYHqgv+8g/B4mFxvRRpNNWiM9aE80KtjXBesi7GjULjg6Jnpqfn1UAGrx4AlnbuabH50/xQoQMWLHSpSVhnpEV5XrUPvzHGbkW51/HRRMEF9Fj5SSPs8vQPbA5ZO8H7NgHwN+8fyNuyVtm9DwY9QZVp2mYlbLlV/+y8xrd5TKf/aGyMjVr3du5YwfosrlrnTAJ+DgoxuZRw77DKaiATxSpEiQRH/C208mOwIDAQAB -----END PUBLIC KEY----- | https://mockapi.com | Scenario: Get task by id When I send a GET request to "/tasks/1000" Then the response status code should be 200 @@ -35,10 +35,11 @@ Feature: Get task """ { "bAccessSolutions": true, - "itemUrl": "https://codecast.france-ioi.org/next/task?taskId=1000" + "itemUrl": "https://codecast.france-ioi.org/next/task?taskId=1000", + "idUser": "1" } """ - When I send a GET request to "/tasks/1000?token={{taskToken}}" + When I send a GET request to "/tasks/1000?token={{taskToken}}&platform=codecast-test" Then the response status code should be 200 And the response body content at property path "strings.0" should be the following JSON: """ diff --git a/features/post_submission.feature b/features/post_submission.feature index df13b59..d7767d5 100644 --- a/features/post_submission.feature +++ b/features/post_submission.feature @@ -24,13 +24,32 @@ Feature: Post submission | 1 | codecast-test | -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt8dBg+ojFTrgFeDxoGqqBSQkW/BDSl/H+qzpIpZTCj4mw7zyrIeV7zaaPuA/8g8WVPDjliuVxLwOnX6p8bT0ZEgsyo4/nql2VEI1cLBqSowQ3VoICqeRYHqgv+8g/B4mFxvRRpNNWiM9aE80KtjXBesi7GjULjg6Jnpqfn1UAGrx4AlnbuabH50/xQoQMWLHSpSVhnpEV5XrUPvzHGbkW51/HRRMEF9Fj5SSPs8vQPbA5ZO8H7NgHwN+8fyNuyVtm9DwY9QZVp2mYlbLlV/+y8xrd5TKf/aGyMjVr3du5YwfosrlrnTAJ+DgoxuZRw77DKaiATxSpEiQRH/C208mOwIDAQAB -----END PUBLIC KEY----- | https://mockapi.com | And I seed the ID generator to 100 And I mock the graderqueue + And "taskToken" is a token signed by the platform with the following payload: + """ + { + "bSubmissionPossible": true, + "date": "10-04-2024", + "idUser": "1", + "itemUrl": "https://codecast.france-ioi.org/next/task?taskId=1000" + } + """ + And "answerToken" is a token signed by the platform with the following payload: + """ + { + "itemUrl": "https://codecast.france-ioi.org/next/task?taskId=1000", + "idUser": "1", + "randomSeed": "6", + "sHintsRequested": "[]", + "sAnswer": "print('ici')" + } + """ Scenario: Post submission When I send a POST request to "/submissions" with the following payload: """ { - "token": null, - "answerToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpdGVtVXJsIjoiaHR0cDovL2x2aC5tZTo4MDAxL25leHQvdGFzaz90YXNrSUQ9bnVsbCZ2ZXJzaW9uPXVuZGVmaW5lZCIsInJhbmRvbVNlZWQiOiI2Iiwic0hpbnRzUmVxdWVzdGVkIjoiW10iLCJzQW5zd2VyIjoiXCJcXFwiYWFhXFxcIlwiIiwiaWF0IjoxNjgyMzQxMTQxfQ.vNA9EgZkGboNS7aGzFJRo60JdrQX-APIOHnf313ESzA", + "token": "{{taskToken}}", + "answerToken": "{{answerToken}}", "answer": { "language": "python", "fileName": "Code 5", @@ -38,7 +57,7 @@ Feature: Post submission }, "userTests": [], "sLocale": "fr", - "platform": null, + "platform": "codecast-test", "taskId": "1000", "taskParams": { "minScore": 0, @@ -73,7 +92,7 @@ Feature: Post submission "tags": "", "jobname": "101", "jobdata": "{\"taskPath\":\"$ROOT_PATH/FranceIOI/Contests/2018/Algorea_finale/plateau\",\"extraParams\":{\"solutionFilename\":\"101.py\",\"solutionContent\":\"print('ici')\",\"solutionLanguage\":\"python3\",\"solutionDependencies\":\"@defaultDependencies-python3\",\"solutionFilterTests\":\"@defaultFilterTests-python3\",\"solutionId\":\"sol0-101.py\",\"solutionExecId\":\"exec0-101.py\",\"defaultSolutionCompParams\":{\"memoryLimitKb\":131072,\"timeLimitMs\":10000,\"stdoutTruncateKb\":-1,\"stderrTruncateKb\":-1,\"useCache\":true,\"getFiles\":[]},\"defaultSolutionExecParams\":{\"memoryLimitKb\":64000,\"timeLimitMs\":200,\"stdoutTruncateKb\":-1,\"stderrTruncateKb\":-1,\"useCache\":true,\"getFiles\":[]}},\"options\":{\"locale\":\"fr\"}}", - "jobusertaskid": "1000-103-1", + "jobusertaskid": "1000-1-1", "debugPassword": "test" } """ @@ -82,8 +101,8 @@ Feature: Post submission When I send a POST request to "/submissions" with the following payload: """ { - "token": null, - "answerToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpdGVtVXJsIjoiaHR0cDovL2x2aC5tZTo4MDAxL25leHQvdGFzaz90YXNrSUQ9bnVsbCZ2ZXJzaW9uPXVuZGVmaW5lZCIsInJhbmRvbVNlZWQiOiI2Iiwic0hpbnRzUmVxdWVzdGVkIjoiW10iLCJzQW5zd2VyIjoiXCJcXFwiYWFhXFxcIlwiIiwiaWF0IjoxNjgyMzQxMTQxfQ.vNA9EgZkGboNS7aGzFJRo60JdrQX-APIOHnf313ESzA", + "token": "{{taskToken}}", + "answerToken": "{{answerToken}}", "answer": { "language": "python", "fileName": "Code 5", @@ -98,7 +117,7 @@ Feature: Post submission } ], "sLocale": "fr", - "platform": null, + "platform": "codecast-test", "taskId": "1000", "taskParams": { "minScore": 0, @@ -140,17 +159,26 @@ Feature: Post submission "tags": "", "jobname": "101", "jobdata": "{\"taskPath\":\"$ROOT_PATH/FranceIOI/Contests/2018/Algorea_finale/plateau\",\"extraParams\":{\"solutionFilename\":\"101.py\",\"solutionContent\":\"print('ici')\",\"solutionLanguage\":\"python3\",\"solutionDependencies\":\"@defaultDependencies-python3\",\"solutionFilterTests\":[\"id-*.in\"],\"solutionId\":\"sol0-101.py\",\"solutionExecId\":\"exec0-101.py\",\"defaultSolutionCompParams\":{\"memoryLimitKb\":131072,\"timeLimitMs\":10000,\"stdoutTruncateKb\":-1,\"stderrTruncateKb\":-1,\"useCache\":true,\"getFiles\":[]},\"defaultSolutionExecParams\":{\"memoryLimitKb\":64000,\"timeLimitMs\":200,\"stdoutTruncateKb\":-1,\"stderrTruncateKb\":-1,\"useCache\":true,\"getFiles\":[]}},\"extraTests\":[{\"name\":\"id-10.in\",\"content\":\"test\"},{\"name\":\"id-10.out\",\"content\":\"ici\"}],\"executions\":[{\"id\":\"testExecution\",\"idSolution\":\"@solutionId\",\"filterTests\":[\"id-*.in\"],\"runExecution\":\"@defaultSolutionExecParams\"}],\"options\":{\"locale\":\"fr\"}}", - "jobusertaskid": "1000-103-1", + "jobusertaskid": "1000-1-1", "debugPassword": "test" } """ Scenario: Post submission on unknown task + Given "unknownTaskToken" is a token signed by the platform with the following payload: + """ + { + "bSubmissionPossible": true, + "date": "10-04-2024", + "idUser": "1", + "itemUrl": "https://codecast.france-ioi.org/next/task?taskId=1001" + } + """ When I send a POST request to "/submissions" with the following payload: """ { - "token": null, - "answerToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpdGVtVXJsIjoiaHR0cDovL2x2aC5tZTo4MDAxL25leHQvdGFzaz90YXNrSUQ9bnVsbCZ2ZXJzaW9uPXVuZGVmaW5lZCIsInJhbmRvbVNlZWQiOiI2Iiwic0hpbnRzUmVxdWVzdGVkIjoiW10iLCJzQW5zd2VyIjoiXCJcXFwiYWFhXFxcIlwiIiwiaWF0IjoxNjgyMzQxMTQxfQ.vNA9EgZkGboNS7aGzFJRo60JdrQX-APIOHnf313ESzA", + "token": "{{unknownTaskToken}}", + "answerToken": "{{answerToken}}", "answer": { "language": "python", "fileName": "Code 5", @@ -158,8 +186,7 @@ Feature: Post submission }, "userTests": [], "sLocale": "fr", - "platform": null, - "taskId": "1001", + "platform": "codecast-test", "taskParams": { "minScore": 0, "maxScore": 100, @@ -187,8 +214,7 @@ Feature: Post submission "date": "10-04-2024", "idUser": "1", "itemUrl": "https://codecast.france-ioi.org/next/task?taskId=1000", - "nbHintsGiven": "0", - "platformName": "codecast-test" + "nbHintsGiven": "0" } """ And I setup a mock API answering any POST request to "/answers" with the following payload: @@ -196,7 +222,7 @@ Feature: Post submission { "success": true, "data": { - "answer_token": "fake_answer_token" + "answer_token": "{{answerToken}}" } } """ @@ -236,7 +262,7 @@ Feature: Post submission "tags": "", "jobname": "101", "jobdata": "{\"taskPath\":\"$ROOT_PATH/FranceIOI/Contests/2018/Algorea_finale/plateau\",\"extraParams\":{\"solutionFilename\":\"101.py\",\"solutionContent\":\"print('test')\",\"solutionLanguage\":\"python3\",\"solutionDependencies\":\"@defaultDependencies-python3\",\"solutionFilterTests\":\"@defaultFilterTests-python3\",\"solutionId\":\"sol0-101.py\",\"solutionExecId\":\"exec0-101.py\",\"defaultSolutionCompParams\":{\"memoryLimitKb\":131072,\"timeLimitMs\":10000,\"stdoutTruncateKb\":-1,\"stderrTruncateKb\":-1,\"useCache\":true,\"getFiles\":[]},\"defaultSolutionExecParams\":{\"memoryLimitKb\":64000,\"timeLimitMs\":200,\"stdoutTruncateKb\":-1,\"stderrTruncateKb\":-1,\"useCache\":true,\"getFiles\":[]}},\"options\":{\"locale\":\"fr\"}}", - "jobusertaskid": "1000-103-1", + "jobusertaskid": "1000-1-1", "debugPassword": "test" } """ diff --git a/features/steps/server_steps.ts b/features/steps/server_steps.ts index c30782b..e2e9297 100644 --- a/features/steps/server_steps.ts +++ b/features/steps/server_steps.ts @@ -29,7 +29,7 @@ When(/^I send a GET request to "([^"]*)"$/, async function (this: ServerStepsCon When(/^I asynchronously send a GET request to "([^"]*)"$/, function (this: ServerStepsContext, url: string) { this.responsePromise = testServer.inject({ method: 'GET', - url, + url: injectVariables(this, url), }) .then((response: ServerInjectResponse): ServerInjectResponse => { this.response = response; @@ -41,7 +41,7 @@ When(/^I asynchronously send a GET request to "([^"]*)"$/, function (this: Serve When(/^I send a POST request to "([^"]*)" with the following payload:$/, async function (this: ServerStepsContext, url: string, payload: string) { this.response = await testServer.inject({ method: 'POST', - url, + url: injectVariables(this, url), payload: injectVariables(this, payload), }); }); @@ -193,7 +193,7 @@ Then(/^the "([^"]*)" WS server should have received the following JSON:$/, funct Given(/^I setup a mock API answering any POST request to "([^"]*)" with the following payload:$/, function (this: ServerStepsContext, endpoint: string, mockPayload: string) { nock('https://mockapi.com') .post(endpoint) - .reply(200, JSON.parse(mockPayload) as Record); + .reply(200, JSON.parse(injectVariables(this, mockPayload)) as Record); }); Then(/^the mock API should have received the expected request$/, function () { diff --git a/features/support/hooks.ts b/features/support/hooks.ts index 1209bed..c9e3cca 100644 --- a/features/support/hooks.ts +++ b/features/support/hooks.ts @@ -37,6 +37,7 @@ function randomIdGenerator(): string { } BeforeAll(async function () { + process.env.TZ = 'UTC'; Db.init(); setRandomIdGenerator(randomIdGenerator); testServer = await init(); @@ -55,7 +56,7 @@ AfterAll(async function () { }); async function cleanDatabase(): Promise { - if (!appConfig.testMode.enabled) { + if ('test' !== appConfig.nodeEnv) { throw new Error('Database cannot be cleaned while not in test environment.'); } diff --git a/src/config.ts b/src/config.ts index 9da1a73..8eb7618 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,7 @@ dotenv.config({path: path.resolve(__dirname, '../.env')}); interface Config { nodeEnv: string, + development: boolean, port: number|null, mysqlDatabase: { host: string, @@ -48,6 +49,7 @@ function stringifyIfExists(string: string|undefined): string|undefined { const appConfig: Config = { nodeEnv, + development: 'test' === nodeEnv || 'development' === nodeEnv, port: process.env['PORT'] ? Number(process.env['PORT']) : null, mysqlDatabase: { host: String(process.env.MYSQL_DB_HOST), diff --git a/src/crypto/jwes_decoder.ts b/src/crypto/jwes_decoder.ts index 6916eb4..d34baee 100644 --- a/src/crypto/jwes_decoder.ts +++ b/src/crypto/jwes_decoder.ts @@ -1,6 +1,7 @@ import {type JWTPayload, KeyLike} from 'jose/dist/types/types'; import * as jose from 'jose'; import moment from 'moment/moment'; +import {InvalidInputError} from '../error_handler'; /** * JWE key is our private key used for decryption @@ -43,18 +44,24 @@ export class JwesDecoder { throw new Error('JWS key must be fulfilled to do decryption'); } - const {payload: validatedContent} = await jose.compactVerify(payload, this.jwsKey); + let validatedContent; + try { + const verification = await jose.compactVerify(payload, this.jwsKey); + validatedContent = verification.payload; + } catch (e) { + throw new InvalidInputError('Token cannot be decrypted, please check your SSL keys'); + } const result = new TextDecoder().decode(validatedContent); let params: {date?: string, type?: string} = {}; try { params = JSON.parse(result) as {date?: string, type?: string}; } catch (e) { - throw new Error('Token cannot be decrypted, please check your SSL keys'); + throw new InvalidInputError('Token cannot be decrypted, please check your SSL keys'); } if (!params['date']) { - throw new Error(`Invalid Task token, unable to decrypt: ${result}`); + throw new InvalidInputError(`Invalid Task token, unable to decrypt: ${result}`); } const yesterdayDate = moment().subtract(1, 'day').format('DD-MM-YYYY'); @@ -62,7 +69,7 @@ export class JwesDecoder { const tomorrowDate = moment().add(1, 'day').format('DD-MM-YYYY'); if ((!params['type'] || params['type'] !== 'long') && params['date'] !== yesterdayDate && params['date'] !== todayDate && params['date'] !== tomorrowDate) { - throw new Error(`API token expired: ${params['date']}`); + throw new InvalidInputError(`API token expired: ${params['date']}`); } return params; diff --git a/src/db.ts b/src/db.ts index c3a163b..413c13a 100644 --- a/src/db.ts +++ b/src/db.ts @@ -29,9 +29,8 @@ export function init(): void { namedPlaceholders: true, supportBigNumbers: true, bigNumberStrings: true, + timezone: 'test' === appConfig.nodeEnv ? '+00:00' : undefined, }); - - // TODO: set timezone? } catch (error) { throw new DatabaseError('Failed to initialized pool', undefined, error); } diff --git a/src/error_handler.ts b/src/error_handler.ts index 2696a3e..256bcdb 100644 --- a/src/error_handler.ts +++ b/src/error_handler.ts @@ -10,6 +10,9 @@ export class NotFoundError extends Error { export class InvalidInputError extends Error { } +export class AccessDeniedError extends Error { +} + export class PlatformInteractionError extends Error { } @@ -25,6 +28,12 @@ export class ErrorHandler { .code(400); } + if (e instanceof AccessDeniedError) { + return h + .response({error: 'Access denied.', message: String(e)}) + .code(401); + } + if (e instanceof DatabaseError) { if (e.query) { // eslint-disable-next-line @@ -35,13 +44,13 @@ export class ErrorHandler { } return h - .response({error: 'A database error has occurred.', ...(appConfig.testMode.enabled ? {details: String(e), query: e.query, databaseError: e.error} : {})}) + .response({error: 'A database error has occurred.', ...(appConfig.development ? {details: String(e), query: e.query, databaseError: e.error} : {})}) .code(500); } if (e instanceof NotFoundError) { return h - .response({error: 'Not found', ...(appConfig.testMode.enabled ? {details: String(e)} : {})}) + .response({error: 'Not found', ...(appConfig.development ? {details: String(e)} : {})}) .code(404); } @@ -60,7 +69,7 @@ export class ErrorHandler { console.error({url, error}); return h - .response({error: 'Error during external API call', ...(appConfig.testMode.enabled ? {url, details: parsedError} : {})}) + .response({error: 'Error during external API call', ...(appConfig.development ? {url, details: parsedError} : {})}) .code(500); } @@ -68,7 +77,7 @@ export class ErrorHandler { console.error(e); return h - .response({error: 'An internal server error occurred', ...(appConfig.testMode.enabled ? {details: String(e)} : {})}) + .response({error: 'An internal server error occurred', ...(appConfig.development ? {details: String(e)} : {})}) .code(500); } } diff --git a/src/server.ts b/src/server.ts index ba87a45..e0ae3f9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,8 @@ import { getSubmission, offlineSubmissionDataDecoder, OfflineSubmissionParameters, submissionDataDecoder, SubmissionParameters, + submissionQueryDecoder, + SubmissionQueryParameters, } from './submissions'; import ReturnValue = Lifecycle.ReturnValue; import {ErrorHandler, isResponseBoom, NotFoundError} from './error_handler'; @@ -106,12 +108,9 @@ export async function init(): Promise { path: '/submissions/{submissionId}', options: { handler: async (request, h) => { - const submissionQueryParameters = { - longPolling: 'longPolling' in request.query, - withTests: 'withTests' in request.query, - }; + const submissionQueryParameters: SubmissionQueryParameters = decode(submissionQueryDecoder)(request.query); - let submissionData = await getSubmission(String(request.params.submissionId), submissionQueryParameters.withTests); + let submissionData = await getSubmission(String(request.params.submissionId), submissionQueryParameters); if (null === submissionData) { throw new NotFoundError(`Submission not found with this id: ${String(request.params.submissionId)}`); } @@ -122,7 +121,7 @@ export async function init(): Promise { const longPollingResult = await longPollingHandler.waitForEvent('evaluation-' + submissionData.id, 10 * 1000); if ('event' === longPollingResult) { // Re-fetch submission - submissionData = await getSubmission(String(request.params.submissionId), submissionQueryParameters.withTests); + submissionData = await getSubmission(String(request.params.submissionId), submissionQueryParameters); if (null === submissionData) { throw new NotFoundError(`Submission not found with this id: ${String(request.params.submissionId)}`); } diff --git a/src/submissions.ts b/src/submissions.ts index 1ba3a97..63ff783 100644 --- a/src/submissions.ts +++ b/src/submissions.ts @@ -3,7 +3,7 @@ import {SourceCode, Submission, SubmissionSubtask, SubmissionTest, TaskTest,} fr import {getRandomId} from './util'; import * as D from 'io-ts/Decoder'; import {pipe} from 'fp-ts/function'; -import {InvalidInputError} from './error_handler'; +import {AccessDeniedError, InvalidInputError} from './error_handler'; import {sendSubmissionToTaskGrader} from './grader_interface'; import {findTaskById, normalizeTaskTest} from './tasks'; import {ProgramExecutionResultMetadata} from './grader_webhook'; @@ -20,7 +20,6 @@ import {PlatformTaskTokenPayload} from './tokenization'; export const submissionDataDecoder = pipe( D.struct({ - taskId: D.string, answer: D.struct({ sourceCode: D.string, fileName: D.nullable(D.string), @@ -38,6 +37,7 @@ export const submissionDataDecoder = pipe( output: D.string, clientId: D.nullable(D.string), })), + taskId: D.string, // Optional, needed only for test, otherwise it's extracted from the token taskParams: D.struct({ returnUrl: D.string, }), @@ -46,6 +46,21 @@ export const submissionDataDecoder = pipe( ); export type SubmissionParameters = D.TypeOf; +const booleanFlag = pipe( + D.string, + D.map(v => '1' === v || 'true' === v || '' === v), +); + +export const submissionQueryDecoder = pipe( + D.partial({ + token: D.nullable(D.string), + platform: D.nullable(D.string), + longPolling: booleanFlag, + withTests: booleanFlag, + }), +); +export type SubmissionQueryParameters = D.TypeOf; + export const offlineSubmissionDataDecoder = pipe( D.struct({ token: D.string, @@ -84,6 +99,7 @@ export interface SubmissionNormalized { manualCorrection: boolean, manualScoreDiffComment: string|null, mode: SubmissionMode, + date: string, } export interface SubmissionSubtaskNormalized { @@ -159,7 +175,7 @@ export async function createOfflineSubmission(submissionData: OfflineSubmissionP // - or the test mode is enabled export async function createSubmission(submissionData: SubmissionParameters): Promise { if (!appConfig.testMode.enabled && (!submissionData.token || !submissionData.platform)) { - throw new InvalidInputError('Missing token or platform POST variable'); + throw new InvalidInputError('Missing token or platform parameters'); } const taskTokenData = await extractPlatformTaskTokenData(submissionData.token, submissionData.platform, submissionData.taskId); @@ -284,6 +300,7 @@ function normalizeSubmission(submission: Submission): SubmissionNormalized { manualCorrection: !!submission.bManualCorrection, manualScoreDiffComment: submission.sManualScoreDiffComment, mode: submission.sMode, + date: submission.sDate, }; } @@ -331,12 +348,14 @@ export function normalizeSourceCode(sourceCode: SourceCode): SourceCodeNormalize }; } -export async function getSubmission(submissionId: string, withTaskTests: boolean = false): Promise { +export async function getSubmission(submissionId: string, submissionQueryParameters: SubmissionQueryParameters): Promise { const submission = await findSubmissionById(submissionId); if (null === submission) { return null; } + await checkAuthorizedToGetSubmission(submissionQueryParameters, submission); + const sourceCode = await findSourceCodeById(submission.idSourceCode); if (!submission.bEvaluated) { @@ -348,7 +367,7 @@ export async function getSubmission(submissionId: string, withTaskTests: boolean const submissionSubtasks = await Db.execute('SELECT * FROM tm_submissions_subtasks WHERE idSubmission = ?', [submissionId]); const submissionTestResults = await Db.execute('SELECT * FROM tm_submissions_tests WHERE idSubmission = ?', [submissionId]); - const submissionTests = await Db.execute(`SELECT * FROM tm_tasks_tests WHERE (idSubmission = ? AND sGroupType = "User")${withTaskTests ? " OR (idTask = ?)" : ''}`, [submissionId, ...(withTaskTests ? [submission.idTask] : [])]); + const submissionTests = await Db.execute(`SELECT * FROM tm_tasks_tests WHERE (idSubmission = ? AND sGroupType = "User")${submissionQueryParameters.withTests ? " OR (idTask = ?)" : ''}`, [submissionId, ...(submissionQueryParameters.withTests ? [submission.idTask] : [])]); return { ...normalizeSubmission(submission), @@ -358,3 +377,25 @@ export async function getSubmission(submissionId: string, withTaskTests: boolean scoreToken: await generateScoreToken(submission, submission.iScore), }; } + +async function checkAuthorizedToGetSubmission(submissionQueryParameters: SubmissionQueryParameters, submission: Submission) { + if (!appConfig.testMode.enabled && (!submissionQueryParameters.token || !submissionQueryParameters.platform)) { + throw new InvalidInputError('Missing token or platform parameters'); + } + + const taskTokenData = await extractPlatformTaskTokenData(submissionQueryParameters.token, submissionQueryParameters.platform, submission.idTask); + + // Check task token data match submission data + if (submission.idTask !== taskTokenData.taskId) { + throw new AccessDeniedError(`Task id mismatch between submission data and provided task id from the token: ${taskTokenData.taskId}`); + } + if (submission.idUser !== taskTokenData.payload.idUser) { + throw new AccessDeniedError(`User id mismatch between submission data and provided user id from the token: ${taskTokenData.payload.idUser}`); + } + if (submission.idPlatform !== taskTokenData.platform.ID) { + throw new AccessDeniedError(`Platform id mismatch between submission data and provided platform from the token: ${taskTokenData.platform.name}`); + } + if (taskTokenData.payload.idUserAnswer && taskTokenData.payload.idUserAnswer !== submission.idUserAnswer) { + throw new AccessDeniedError(`User answer id mismatch between submission data and provided idUserAnswer from the token: ${taskTokenData.payload.idUserAnswer}`); + } +} diff --git a/src/tokenization.ts b/src/tokenization.ts index da216f2..65b3b53 100644 --- a/src/tokenization.ts +++ b/src/tokenization.ts @@ -22,6 +22,7 @@ export interface PlatformTaskTokenPayload extends PlatformGenericTokenPayload { bSubmissionPossible?: boolean, bAllowGrading?: boolean, bAccessSolutions: boolean, + idUserAnswer?: string, } export interface PlatformAnswerTokenPayload extends PlatformGenericTokenPayload {