diff --git a/bases/rsptx/book_server_api/routers/rslogging.py b/bases/rsptx/book_server_api/routers/rslogging.py index 3c89db83e..17629eff2 100644 --- a/bases/rsptx/book_server_api/routers/rslogging.py +++ b/bases/rsptx/book_server_api/routers/rslogging.py @@ -665,13 +665,16 @@ async def get_source_code( if db_result: # Found data, unpack desired fields file_contents = db_result.main_code + is_binary = db_result.is_binary if db_result.filename: filename = db_result.filename else: file_contents = None + is_binary = None response_bundle = { "filename": filename, "file_contents": file_contents, + "is_binary": is_binary, } return make_json_response(detail=response_bundle) diff --git a/bases/rsptx/interactives/runestone/activecode/js/livecode.js b/bases/rsptx/interactives/runestone/activecode/js/livecode.js index d61fa6652..9872e33b2 100644 --- a/bases/rsptx/interactives/runestone/activecode/js/livecode.js +++ b/bases/rsptx/interactives/runestone/activecode/js/livecode.js @@ -277,7 +277,7 @@ export default class LiveCode extends ActiveCode { var files = []; for (let f of allFilesRaw) { // Need to determine content and filename for each datafile - let fileName, content; + let fileName, content, isBinary = false; // Check on page to see if we have the datafile. // Datafiles are looked up via data-filename attribute while additional_files are looked up via id let fileElement; @@ -310,6 +310,9 @@ export default class LiveCode extends ActiveCode { // If the file came from an item with a data-filename attribute, use that as the filename // otherwise this must be an RST item with filename as the id fileName = fileElement.dataset.filename || f.filename; + isBinary = + fileElement.dataset.isbinary === "true" || + fileElement.dataset.isBinary === "true"; } else { // check to see if file is in db let result = null; @@ -356,10 +359,19 @@ export default class LiveCode extends ActiveCode { // favor student code if it exists content = studentCode || result.file_contents; fileName = result.filename; + // binary files (e.g. .jar) are stored base64; the is_binary + // flag tells us to hand the content to the server verbatim + isBinary = result.is_binary === true; } } - if (fileName) { + if (fileName && isBinary) { + files.push({ + name: fileName, + content: content, + isBinary: true, + }); + } else if (fileName) { let fileExtension = fileName.substring( fileName.lastIndexOf(".") + 1, ); @@ -425,15 +437,82 @@ export default class LiveCode extends ActiveCode { paramobj.compileargs = [sourcefilename]; } } + // "compile-also" marks additional files as part of the build. What + // that means is language-specific and depends on whether the file is + // text or binary. The list carries filenames (PreTeXt emits + // @filename), so match them against the collected files to learn each + // one's type. These files are also always part of the file_list, so + // they are delivered to the working directory regardless. + let buildFileNames = []; if (this.compileAlso) { - // a comma separated list of files that also need to be compiled - // they also all should be part of additional_files - // we will stick them onto the end of the compilerargs - // so that jobe builds them into the string sent to the compiler - // e.g. g++ [other_compile_args] [compileAlso] sourcefile -o executable + buildFileNames = this.compileAlso + .split(",") + .map((n) => n.trim()) + .filter((n) => n !== ""); + } + let classpathNames = []; + let linkFileNames = []; + for (let name of buildFileNames) { + let f = files.find((file) => file.name === name); + let extension = name.substring(name.lastIndexOf(".") + 1); + if (f && f.isBinary) { + if ( + (this.language === "java" || + this.language === "kotlin") && + ["jar", "zip"].indexOf(extension) > -1 + ) { + // A compiled archive (.jar/.zip) must be on the classpath + // for the compiler and runtime to see the classes it holds. + classpathNames.push(name); + } else if ( + (this.language === "c" || this.language === "cpp") && + ["o", "a"].indexOf(extension) > -1 + ) { + // Binary link inputs (.o/.a) must come AFTER the source + // file on the command line, so they belong in linkargs, + // not compileargs. (.so is unsupported: the server cannot + // arrange for the runtime linker to find it.) + linkFileNames.push(name); + } + // Other languages or file types: nothing to wire; the file is + // in the working directory for the program to read. + } else { + // Text source: compile it together with the main program. + paramobj.compileargs = paramobj.compileargs || []; + if (paramobj.compileargs.indexOf(name) === -1) { + paramobj.compileargs.push(name); + } + } + } + if (classpathNames.length > 0) { + let classpath = + "." + classpathNames.map((n) => ":" + n).join(""); + // Jobe replaces interpreterargs wholesale rather than merging, + // and the client cannot discover Jobe's defaults, so reproduce + // the -X flags Jobe would otherwise supply (mirrors java_task.php). + // The -cp must precede the main class, so it rides in + // interpreterargs rather than runargs. + let interpreterArgs = ["-Xrs", "-Xss8m", "-Xmx200m"]; + if (paramobj.interpreterargs) { + interpreterArgs = paramobj.interpreterargs; + } + interpreterArgs = interpreterArgs.concat(["-cp", classpath]); + paramobj.interpreterargs = interpreterArgs; + // The default compileargs for Java/Kotlin is empty, so appending + // is safe. paramobj.compileargs = paramobj.compileargs || []; - let compileList = this.compileAlso.split(","); - paramobj.compileargs = paramobj.compileargs.concat(compileList); + paramobj.compileargs = paramobj.compileargs.concat([ + "-cp", + classpath, + ]); + } + if (linkFileNames.length > 0) { + paramobj.linkargs = paramobj.linkargs || []; + for (let name of linkFileNames) { + if (paramobj.linkargs.indexOf(name) === -1) { + paramobj.linkargs.push(name); + } + } } let runspec = { language_id: this.language, @@ -798,7 +877,9 @@ export default class LiveCode extends ActiveCode { // File types being uploaded that come in already in base64 format var extensions = ["jar", "zip", "png", "jpg", "jpeg"]; var contentsb64; - if (extensions.indexOf(extension) === -1) { + if (file.isBinary) { + contentsb64 = contents; + } else if (extensions.indexOf(extension) === -1) { contentsb64 = base64encode(contents); } else { contentsb64 = contents; diff --git a/bases/rsptx/interactives/runestone/activecode/test/livecode_binary.test.js b/bases/rsptx/interactives/runestone/activecode/test/livecode_binary.test.js new file mode 100644 index 000000000..b479cc8f2 --- /dev/null +++ b/bases/rsptx/interactives/runestone/activecode/test/livecode_binary.test.js @@ -0,0 +1,322 @@ +// Tests for LiveCode's handling of binary files (e.g. a compiled .jar). +// +// runSetup() assembles the list of files to push to Jobe before submitting the +// run. Binary files are carried as base64 and must be passed to the server +// verbatim -- never base64-encoded again, and never fed to +// parseJavaClasses(). These tests drive runSetup directly with the network +// and CodeMirror machinery stubbed out. +import { describe, it, expect, beforeEach } from "vitest"; +import LiveCode from "../js/livecode.js"; + +// Build a bare LiveCode instance (no DOM component, no CodeMirror), wiring in +// the small set of collaborators runSetup touches, and capturing the file +// objects checkFile is fed. +function makeRunner({ + additionalFiles = "helper-jar", + compileAlso = undefined, + pageFiles = [], +} = {}) { + const lc = Object.create(LiveCode.prototype); + lc.additional_files = additionalFiles; + lc.datafiles = undefined; + lc.language = "python3"; + lc.editor = { getValue: () => "print('hi')" }; + lc.autorun = true; + lc.historyScrubber = null; + lc.trimLockedCode = (c) => c; + lc.output = { innerHTML: "" }; + lc.sourcefile = undefined; + lc.suffix = undefined; + lc.prefix = undefined; + lc.includes = undefined; + lc.compileAlso = compileAlso; + lc.jsonHeaders = {}; + lc.div2id = {}; + lc.submitted = []; + lc.checkFile = async (file, resolve) => { + lc.submitted.push(file); + resolve(); + }; + // Place the referenced files in the (jsdom) DOM. + document.body.innerHTML = pageFiles.join("\n"); + return lc; +} + +beforeEach(() => { + document.body.innerHTML = ""; +}); + +// The DB fallback path in runSetup builds `new Request(relativeUrl)` and then +// `fetch(request)`. Browser fetch resolves relative URLs against the page +// URL; Node's undici does not, so install a Request that carries the URL and a +// fetch that responds from it. +function stubSourceCodeFetcher(baseCourse, derivedCourse, responsesByUrl) { + global.Request = class { + constructor(url, opts) { + this.url = String(url); + this.method = (opts && opts.method) || "GET"; + this.headers = (opts && opts.headers) || {}; + } + }; + global.fetch = async (input) => { + const url = typeof input === "string" ? input : input.url; + const response = responsesByUrl[url]; + if (!response) { + throw new Error(`unexpected fetch: ${url}`); + } + const detail = typeof response === "function" ? response() : response; + return { ok: true, json: () => Promise.resolve(detail) }; + }; + + const latest = (acid) => + `/ns/assessment/get_latest_code?acid=${encodeURIComponent(acid)}`; + const source = (acid) => + `/ns/logger/get_source_code?course_id=test_course&acid=${encodeURIComponent(acid)}`; + return { latest, source }; +} + +function resetFetchers() { + delete globalThis.Request; + delete globalThis.fetch; +} + +describe("binary files in runSetup", () => { + it("flags a data-isbinary page element and passes its base64 verbatim", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ + additionalFiles: "PTXSB_2_helper-jar", + pageFiles: [ + `
${base64}
`, + ], + }); + await lc.runSetup(); + expect(lc.submitted).toHaveLength(1); + expect(lc.submitted[0]).toEqual({ + name: "helper.jar", + content: base64, + isBinary: true, + }); + }); + + it("does not split a binary .jar with parseJavaClasses", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ + additionalFiles: "helper-jar", + pageFiles: [ + `
${base64}
`, + ], + }); + lc.parseJavaClasses = (text) => { + throw new Error("parseJavaClasses must not run on binary data"); + }; + await lc.runSetup(); + expect(lc.submitted).toHaveLength(1); + expect(lc.submitted[0].isBinary).toBe(true); + }); + + it("flags a DB-sourced file whose is_binary marks it binary", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ additionalFiles: "cross-page-jar", pageFiles: [] }); + const latestUrl = `/ns/assessment/get_latest_code?acid=cross-page-jar`; + const sourceUrl = `/ns/logger/get_source_code?course_id=test_course&acid=cross-page-jar`; + stubSourceCodeFetcher("test_course", "test_course", { + [latestUrl]: () => ({ + detail: { code: null }, + }), + [sourceUrl]: { + detail: { + filename: "helper.jar", + file_contents: base64, + // True here means binary; text files come back false. + is_binary: true, + }, + }, + }); + await lc.runSetup(); + expect(lc.submitted).toHaveLength(1); + expect(lc.submitted[0]).toEqual({ + name: "helper.jar", + content: base64, + isBinary: true, + }); + resetFetchers(); + }); + + it("treats a fetched file with is_binary false as plain text", async () => { + const lc = makeRunner({ additionalFiles: "cross-text-file", pageFiles: [] }); + const latest = `/ns/assessment/get_latest_code?acid=cross-text-file`; + const source = `/ns/logger/get_source_code?course_id=test_course&acid=cross-text-file`; + stubSourceCodeFetcher("test_course", "test_course", { + [latest]: () => ({ + detail: { code: null }, + }), + [source]: { + detail: { + filename: "helper.txt", + file_contents: "plain text", + // A false is_binary means text, so the client must not + // hand the contents to the server as base64. + is_binary: false, + }, + }, + }); + await lc.runSetup(); + expect(lc.submitted).toHaveLength(1); + expect(lc.submitted[0]).toEqual({ + name: "helper.txt", + content: "plain text", + }); + resetFetchers(); + }); + + it("adds a binary jar to the Java compile and runtime classpath", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ + additionalFiles: "PTXSB_2_helper-jar", + compileAlso: "helper.jar", + pageFiles: [ + `
${base64}
`, + ], + }); + lc.language = "java"; + await lc.runSetup(); + const runspec = JSON.parse(lc.json_runspec).run_spec; + expect(runspec.parameters.compileargs).toEqual([ + "-cp", + ".:helper.jar", + ]); + // Jobe replaces interpreterargs wholesale, so we must reproduce its + // Java defaults (the -X flags from java_task.php) and append the -cp. + expect(runspec.parameters.interpreterargs).toEqual([ + "-Xrs", + "-Xss8m", + "-Xmx200m", + "-cp", + ".:helper.jar", + ]); + }); + + it("puts a binary jar on the classpath for Kotlin too", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ + additionalFiles: "PTXSB_2_helper-jar", + compileAlso: "helper.jar", + pageFiles: [ + `
${base64}
`, + ], + }); + lc.language = "kotlin"; + await lc.runSetup(); + const runspec = JSON.parse(lc.json_runspec).run_spec; + expect(runspec.parameters.compileargs).toEqual([ + "-cp", + ".:helper.jar", + ]); + expect(runspec.parameters.interpreterargs).toEqual([ + "-Xrs", + "-Xss8m", + "-Xmx200m", + "-cp", + ".:helper.jar", + ]); + }); + + it("does not add a binary jar to the classpath unless it is compile-also", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ + additionalFiles: "PTXSB_2_helper-jar", + pageFiles: [ + `
${base64}
`, + ], + }); + lc.language = "java"; + await lc.runSetup(); + const runspec = JSON.parse(lc.json_runspec).run_spec; + expect(runspec.parameters.compileargs).toBeUndefined(); + expect(runspec.parameters.interpreterargs).toBeUndefined(); + }); + + it("keeps author-supplied interpreterargs when adding the jar classpath", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ + additionalFiles: "PTXSB_2_helper-jar", + compileAlso: "helper.jar", + pageFiles: [ + `
${base64}
`, + ], + }); + lc.language = "java"; + lc.interpreterargs = '["-Xmx512m"]'; + await lc.runSetup(); + const runspec = JSON.parse(lc.json_runspec).run_spec; + expect(runspec.parameters.interpreterargs).toEqual([ + "-Xmx512m", + "-cp", + ".:helper.jar", + ]); + }); + + it("does not touch the classpath for a non-archive binary file", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ + additionalFiles: "PTXSB_2_pic-datafile", + compileAlso: "pic.png", + pageFiles: [ + `
${base64}
`, + ], + }); + lc.language = "java"; + await lc.runSetup(); + const runspec = JSON.parse(lc.json_runspec).run_spec; + expect(runspec.parameters.compileargs).toBeUndefined(); + expect(runspec.parameters.interpreterargs).toBeUndefined(); + }); + + it("puts a binary object file on the C++ linkargs", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ + additionalFiles: "PTXSB_2_helper-o", + compileAlso: "helper.o", + pageFiles: [ + `
${base64}
`, + ], + }); + lc.language = "cpp"; + await lc.runSetup(); + const runspec = JSON.parse(lc.json_runspec).run_spec; + expect(runspec.parameters.linkargs).toEqual(["helper.o"]); + expect(runspec.parameters.compileargs).toBeUndefined(); + }); + + it("puts a text source on the C++ compileargs, not linkargs", async () => { + const lc = makeRunner({ + additionalFiles: "PTXSB_2_addcpp", + compileAlso: "add.cpp", + pageFiles: [ + `
int add(int a, int b) { return a + b; }
`, + ], + }); + lc.language = "cpp"; + await lc.runSetup(); + const runspec = JSON.parse(lc.json_runspec).run_spec; + expect(runspec.parameters.compileargs).toEqual(["add.cpp"]); + expect(runspec.parameters.linkargs).toBeUndefined(); + }); + + it("does not wire binary compile-also files for Python", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ + additionalFiles: "PTXSB_2_pkg-whl", + compileAlso: "pkg.whl", + pageFiles: [ + `
${base64}
`, + ], + }); + lc.language = "python3"; + await lc.runSetup(); + const runspec = JSON.parse(lc.json_runspec).run_spec; + expect(runspec.parameters.compileargs).toBeUndefined(); + expect(runspec.parameters.linkargs).toBeUndefined(); + expect(runspec.parameters.interpreterargs).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/bases/rsptx/interactives/runestone/datafile/js/datafile.js b/bases/rsptx/interactives/runestone/datafile/js/datafile.js index 5df06e28a..71fba79b4 100644 --- a/bases/rsptx/interactives/runestone/datafile/js/datafile.js +++ b/bases/rsptx/interactives/runestone/datafile/js/datafile.js @@ -23,6 +23,7 @@ class DataFile extends RunestoneBase { this.divid = orig.id; this.dataEdit = this.parseBooleanAttribute(orig, "data-edit"); this.isImage = this.parseBooleanAttribute(orig, "data-isimage"); + this.isBinary = this.parseBooleanAttribute(orig, "data-isbinary"); this.fileName = orig.dataset.filename || null; this.displayClass = "block"; // Users can specify the non-edit component to be hidden--default is not hidden if (this.parseBooleanAttribute(orig, "data-hidden")) { @@ -60,6 +61,7 @@ class DataFile extends RunestoneBase { this.containerDiv.id = this.divid; this.containerDiv.style.display = this.displayClass; this.containerDiv.innerHTML = this.origElem.innerHTML; + this.copyDataAttributes(); this.origElem.replaceWith(this.containerDiv); } createTextArea() { @@ -69,8 +71,16 @@ class DataFile extends RunestoneBase { if (this.numberOfCols) this.containerDiv.cols = this.numberOfCols; this.containerDiv.innerHTML = this.origElem.innerHTML; this.containerDiv.classList.add("datafiletextfield"); + this.copyDataAttributes(); this.origElem.replaceWith(this.containerDiv); } + copyDataAttributes() { + for (const attr of this.origElem.attributes) { + if (attr.name.startsWith("data-")) { + this.containerDiv.setAttribute(attr.name, attr.value); + } + } + } } /*================================= diff --git a/bases/rsptx/interactives/runestone/datafile/test/datafile_binary.test.js b/bases/rsptx/interactives/runestone/datafile/test/datafile_binary.test.js new file mode 100644 index 000000000..f38dcf6d2 --- /dev/null +++ b/bases/rsptx/interactives/runestone/datafile/test/datafile_binary.test.js @@ -0,0 +1,44 @@ +// Characterization tests for the DataFile component's handling of binary +// files. A binary file (e.g. a compiled .jar) is registered as a hidden +//
 carrying its base64 representation plus a data-isbinary attribute.
+// The component replaces that 
 with a container that must keep those
+// attributes so LiveCode can still find them.
+import { describe, it, expect, beforeEach } from "vitest";
+// datafile.js has no exports; it registers a component_factory on window.
+import "../js/datafile.js";
+
+function makeFixture(attrs = "", content = "") {
+    document.body.innerHTML = `
+      
+
${content}
+
`; + return document.querySelector("[data-component=datafile]"); +} + +beforeEach(() => { + document.body.innerHTML = ""; +}); + +describe("DataFile", () => { + it("preserves data-isbinary on the rendered container", () => { + const orig = makeFixture( + 'data-filename="helper.jar" data-isbinary="true" ' + + 'data-mime-type="application/java-archive" data-edit="false" data-hidden=""', + "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==", + ); + const df = window.component_factory.datafile({ orig }); + expect(df.containerDiv.id).toBe("binary_1"); + expect(df.containerDiv.dataset.isbinary).toBe("true"); + expect(df.containerDiv.dataset.filename).toBe("helper.jar"); + expect(df.containerDiv.textContent).toBe( + "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==", + ); + }); + + it("does not mark an ordinary text file as binary", () => { + const orig = makeFixture('data-filename="helper.txt" data-edit="false"', "hello"); + const df = window.component_factory.datafile({ orig }); + expect(df.isBinary).toBe(false); + expect(df.containerDiv.dataset.isbinary).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/components/rsptx/build_tools/core.py b/components/rsptx/build_tools/core.py index a0820b3d3..34ae47982 100644 --- a/components/rsptx/build_tools/core.py +++ b/components/rsptx/build_tools/core.py @@ -1266,12 +1266,14 @@ def _handle_datafile(el, course_name): filename = el.attrib.get("data-filename", el.attrib["id"]) id = el.attrib["id"] + is_binary = el.attrib.get("data-isbinary") == "true" update_source_code_sync( acid=id, course_id=course_name, main_code=file_contents, filename=filename, + is_binary=is_binary, ) diff --git a/components/rsptx/db/crud/rsfiles.py b/components/rsptx/db/crud/rsfiles.py index 56c13b896..f22a8db74 100644 --- a/components/rsptx/db/crud/rsfiles.py +++ b/components/rsptx/db/crud/rsfiles.py @@ -12,7 +12,12 @@ # We need a synchronous version of this function for use in manifest_data_to_db # if/when process_manifest moves to being async we could remove this def update_source_code_sync( - acid: str, filename: str, course_id: str, main_code: str, owner: str = None + acid: str, + filename: str, + course_id: str, + main_code: str, + owner: str = None, + is_binary: bool = False, ): """ Update the source code for a given acid or filename @@ -31,6 +36,7 @@ def update_source_code_sync( source_code_obj.filename = filename if owner is not None: source_code_obj.owner = owner + source_code_obj.is_binary = is_binary session.add(source_code_obj) else: new_entry = SourceCode( @@ -39,13 +45,19 @@ def update_source_code_sync( course_id=course_id, main_code=main_code, owner=owner, + is_binary=is_binary, ) session.add(new_entry) session.commit() async def update_source_code( - acid: str, filename: str, course_id: str, main_code: str, owner: str = None + acid: str, + filename: str, + course_id: str, + main_code: str, + owner: str = None, + is_binary: bool = False, ): """ Update the source code for a given acid or filename @@ -64,6 +76,7 @@ async def update_source_code( source_code_obj.filename = filename if owner is not None: source_code_obj.owner = owner + source_code_obj.is_binary = is_binary session.add(source_code_obj) else: new_entry = SourceCode( @@ -72,6 +85,7 @@ async def update_source_code( course_id=course_id, main_code=main_code, owner=owner, + is_binary=is_binary, ) session.add(new_entry) await session.commit() diff --git a/components/rsptx/db/models.py b/components/rsptx/db/models.py index ff3e52903..8e3e316bb 100644 --- a/components/rsptx/db/models.py +++ b/components/rsptx/db/models.py @@ -427,6 +427,10 @@ class SourceCode(Base, IdMixin): # Filename to use when saving contents to Jobe or trying to include # this file in a program. It is OK to reuse the same filename for different filename = Column(String(512)) + # True when the file contents are a base64 binary payload (e.g. a + # compiled .jar or .zip), which must be handed to a server (Jobe) verbatim + # rather than treated as source. Text files leave it False. + is_binary = Column(Web2PyBoolean, nullable=False) # Owner of the datafile (username of the instructor who created it) # Used to enforce uniqueness: filename + owner + course_id should be unique owner = Column(String(512), index=True) diff --git a/migrations/versions/e5f6a7b8c9d0_add_is_binary_to_source_code.py b/migrations/versions/e5f6a7b8c9d0_add_is_binary_to_source_code.py new file mode 100644 index 000000000..6cdf22dbc --- /dev/null +++ b/migrations/versions/e5f6a7b8c9d0_add_is_binary_to_source_code.py @@ -0,0 +1,40 @@ +"""add is_binary to source_code + +Revision ID: e5f6a7b8c9d0 +Revises: c4e8a1f7b2d9 +Create Date: 2026-08-08 12:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +from rsptx.db.models import Web2PyBoolean + + +# revision identifiers, used by Alembic. +revision: str = "e5f6a7b8c9d0" +down_revision: Union[str, None] = "c4e8a1f7b2d9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add is_binary column to source_code table. True marks a base64 binary + # payload (e.g. a compiled .jar or .zip); text files keep it False. This + # lets a program on one page recognize a binary file stored for another. + op.add_column( + "source_code", + sa.Column( + "is_binary", + Web2PyBoolean(length=1), + nullable=False, + server_default=sa.text("'F'"), + ), + ) + + +def downgrade() -> None: + op.drop_column("source_code", "is_binary") diff --git a/test/bases/rsptx/book_server_api/test_get_source_code.py b/test/bases/rsptx/book_server_api/test_get_source_code.py new file mode 100644 index 000000000..707b0aefa --- /dev/null +++ b/test/bases/rsptx/book_server_api/test_get_source_code.py @@ -0,0 +1,71 @@ +""" +Functional tests for GET /logger/get_source_code returning is_binary. + +Binary files (e.g. a compiled .jar) are stored base64 in source_code with +is_binary set; text files leave it False. The endpoint must surface that so +the client knows to hand the contents to a server (Jobe) verbatim. +""" + +import pytest + +from rsptx.db.crud import update_source_code + +pytestmark = pytest.mark.asyncio(loop_scope="session") + +COURSE = "test_course_1" + + +async def test_get_source_code_returns_is_binary_for_binary( + auth_book_client, init_test_db +): + """A binary source_code row comes back with is_binary true.""" + await update_source_code( + acid="endpoint_binary_jar", + filename="helper.jar", + course_id=COURSE, + main_code="UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==", + is_binary=True, + ) + resp = await auth_book_client.get( + "/logger/get_source_code", + params={"course_id": COURSE, "acid": "endpoint_binary_jar"}, + ) + assert resp.status_code == 200 + detail = resp.json()["detail"] + assert detail["filename"] == "helper.jar" + assert detail["file_contents"] == "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==" + assert detail["is_binary"] is True + + +async def test_get_source_code_returns_false_is_binary_for_text( + auth_book_client, init_test_db +): + """A plain text row comes back with is_binary false.""" + await update_source_code( + acid="endpoint_text_file", + filename="notes.txt", + course_id=COURSE, + main_code="hello world", + ) + resp = await auth_book_client.get( + "/logger/get_source_code", + params={"course_id": COURSE, "acid": "endpoint_text_file"}, + ) + assert resp.status_code == 200 + detail = resp.json()["detail"] + assert detail["filename"] == "notes.txt" + assert detail["is_binary"] is False + + +async def test_get_source_code_missing_row_returns_null_is_binary( + auth_book_client, init_test_db +): + """A missing row yields nulls, including is_binary.""" + resp = await auth_book_client.get( + "/logger/get_source_code", + params={"course_id": COURSE, "acid": "endpoint_does_not_exist"}, + ) + assert resp.status_code == 200 + detail = resp.json()["detail"] + assert detail["file_contents"] is None + assert detail["is_binary"] is None diff --git a/test/components/rsptx/db/test_rsfiles.py b/test/components/rsptx/db/test_rsfiles.py new file mode 100644 index 000000000..5497b4843 --- /dev/null +++ b/test/components/rsptx/db/test_rsfiles.py @@ -0,0 +1,72 @@ +"""Tests for the is_binary round-trip through the source_code crud layer. + +Binary files (e.g. a compiled .jar) are stored base64 in ``main_code`` with +``is_binary`` set; text files leave it False. This covers the crud functions +that store and fetch those rows. +""" + +import pytest + +from rsptx.db.crud import fetch_source_code, update_source_code + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +async def test_update_source_code_stores_is_binary(init_test_db): + """A binary file's is_binary flag is stored and returned.""" + await update_source_code( + acid="test_binary_jar", + filename="helper.jar", + course_id="test_course_1", + main_code="UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==", + is_binary=True, + ) + row = await fetch_source_code( + base_course="test_course_1", + course_name="test_course_1", + acid="test_binary_jar", + ) + assert row is not None + assert row.is_binary is True + assert row.filename == "helper.jar" + + +async def test_update_source_code_defaults_text_to_false(init_test_db): + """A plain text file keeps is_binary False.""" + await update_source_code( + acid="test_text_file", + filename="notes.txt", + course_id="test_course_1", + main_code="hello world", + ) + row = await fetch_source_code( + base_course="test_course_1", + course_name="test_course_1", + acid="test_text_file", + ) + assert row is not None + assert row.is_binary is False + + +async def test_update_source_code_changes_is_binary_on_existing_row(init_test_db): + """Updating an existing row changes its is_binary flag.""" + await update_source_code( + acid="test_replace_jar", + filename="helper.jar", + course_id="test_course_1", + main_code="first", + ) + await update_source_code( + acid="test_replace_jar", + filename="helper.jar", + course_id="test_course_1", + main_code="second", + is_binary=True, + ) + row = await fetch_source_code( + base_course="test_course_1", + course_name="test_course_1", + acid="test_replace_jar", + ) + assert row.is_binary is True + assert row.main_code == "second" diff --git a/test/migrations/test_is_binary_to_source_code.py b/test/migrations/test_is_binary_to_source_code.py new file mode 100644 index 000000000..439292a29 --- /dev/null +++ b/test/migrations/test_is_binary_to_source_code.py @@ -0,0 +1,103 @@ +"""Round trip for the source_code is_binary migration (e5f6a7b8c9d0). + +Runs the real ``upgrade()``/``downgrade()`` bodies against the test database +inside a transaction that is always rolled back, with a stand-in for alembic's +``op``. Postgres DDL is transactional, so the added column disappears with the +rollback too. +""" + +import importlib.util +import os +import sys +from pathlib import Path + +import pytest +import sqlalchemy as sa + +MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "versions" + / "e5f6a7b8c9d0_add_is_binary_to_source_code.py" +) + + +@pytest.fixture +def conn(): + """A connection in a transaction that is never committed.""" + url = os.environ["TEST_DBURL"] + engine = sa.create_engine(url, future=True) + connection = engine.connect() + try: + yield connection + finally: + connection.rollback() + connection.close() + engine.dispose() + + +@pytest.fixture +def migration(conn): + """The migration module, with ``op`` bound to the test connection.""" + spec = importlib.util.spec_from_file_location("is_binary_mig", MIGRATION) + mod = importlib.util.module_from_spec(spec) + sys.modules["is_binary_mig"] = mod + spec.loader.exec_module(mod) + + class FakeOp: + @staticmethod + def get_bind(): + return conn + + @staticmethod + def execute(stmt): + return conn.execute(sa.text(stmt) if isinstance(stmt, str) else stmt) + + @staticmethod + def add_column(table, column): + stmt = ( + f"ALTER TABLE {table} ADD COLUMN " + f"{column.name} {str(column.type)}" + ) + conn.execute(sa.text(stmt)) + + @staticmethod + def drop_column(table, column): + name = column if isinstance(column, str) else column.name + conn.execute(sa.text(f"ALTER TABLE {table} DROP COLUMN {name}")) + + mod.op = FakeOp + return mod + + +def _columns(conn): + return { + r.column_name + for r in conn.execute( + sa.text( + "SELECT column_name FROM information_schema.columns " + "WHERE table_name = 'source_code'" + ) + ) + } + + +def _drop_is_binary(conn): + """Restore the 'before migration' schema if a prior test run left it.""" + if "is_binary" in _columns(conn): + conn.execute(sa.text("ALTER TABLE source_code DROP COLUMN is_binary")) + + +def test_upgrade_adds_is_binary(conn, migration): + _drop_is_binary(conn) + assert "is_binary" not in _columns(conn) + migration.upgrade() + assert "is_binary" in _columns(conn) + + +def test_downgrade_drops_is_binary(conn, migration): + _drop_is_binary(conn) + migration.upgrade() + assert "is_binary" in _columns(conn) + migration.downgrade() + assert "is_binary" not in _columns(conn)