Compare commits

..

4 Commits

9 changed files with 948 additions and 890 deletions

View File

@@ -19,7 +19,6 @@ import kotlin.script.experimental.jvmhost.BasicJvmScriptingHost
private val scriptingHost = BasicJvmScriptingHost() private val scriptingHost = BasicJvmScriptingHost()
private val evalLock = Any() private val evalLock = Any()
private val metadataCache = ConcurrentHashMap<String, Pair<String, ScriptMetadata>>() // key -> stamp, metadata
private val compiledScriptCache = ConcurrentHashMap<String, Pair<String, CompiledScript>>() // key -> stamp, compiled script private val compiledScriptCache = ConcurrentHashMap<String, Pair<String, CompiledScript>>() // key -> stamp, compiled script
private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver()) private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver())
@@ -27,8 +26,6 @@ private val argsDeclarationRegexes = listOf(
Regex("""^\s*val\s+args\s*:\s*Array<String>\s*=\s*emptyArray\(\)\s*$"""), Regex("""^\s*val\s+args\s*:\s*Array<String>\s*=\s*emptyArray\(\)\s*$"""),
Regex("""^\s*lateinit\s+var\s+args\s*:\s*Array<String>\s*$"""), Regex("""^\s*lateinit\s+var\s+args\s*:\s*Array<String>\s*$"""),
) )
private const val DEFAULT_SCRIPT_TIMEOUT_MS = 10_000L
private val metadataParamNameRegex = Regex("[A-Za-z0-9._-]+")
private val evalExecutor = Executors.newCachedThreadPool { r -> private val evalExecutor = Executors.newCachedThreadPool { r ->
Thread(r, "script-eval-worker").apply { isDaemon = true } Thread(r, "script-eval-worker").apply { isDaemon = true }
} }
@@ -85,8 +82,6 @@ private fun compilationConfiguration(explicitCp: List<File>?): ScriptCompilation
} }
} }
private fun scriptStamp(file: File): String = "${file.length()}-${file.lastModified()}"
private fun injectArgsBridgeDeclaration(scriptContent: String): String { private fun injectArgsBridgeDeclaration(scriptContent: String): String {
val lines = scriptContent.lines() val lines = scriptContent.lines()
val injected = "val args: Array<String> = hostArgs" val injected = "val args: Array<String> = hostArgs"
@@ -120,151 +115,6 @@ private fun applyDefaultArgs(metadata: ScriptMetadata, requestArgs: List<String>
return merged return merged
} }
private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
var description: String? = null
var timeoutMs = DEFAULT_SCRIPT_TIMEOUT_MS
val params = mutableListOf<ScriptParamDefinition>()
scriptContent.lines().forEach { raw ->
val line = raw.trim()
if (!line.startsWith("//")) return@forEach
val comment = line.removePrefix("//").trim()
if (comment.startsWith("@desc:", ignoreCase = true)) {
description = comment.substringAfter(":").trim().takeIf { it.isNotBlank() }
return@forEach
}
if (comment.startsWith("@timeout:", ignoreCase = true)) {
val raw = comment.substringAfter(":").trim()
parseTimeoutMs(raw)?.let { timeoutMs = it }
return@forEach
}
if (comment.startsWith("@param:", ignoreCase = true)) {
val payload = comment.substringAfter(":").trim()
if (payload.isBlank()) return@forEach
val parts = payload.split("|").map { it.trim() }.filter { it.isNotBlank() }
if (parts.isEmpty()) return@forEach
val name = parts.first()
var required = false
var defaultValue: String? = null
var desc: String? = null
parts.drop(1).forEach { part ->
when {
part.equals("required", ignoreCase = true) -> required = true
part.startsWith("required=", ignoreCase = true) ->
required = part.substringAfter("=").trim().equals("true", ignoreCase = true)
part.startsWith("default=", ignoreCase = true) ->
defaultValue = part.substringAfter("=").trim().ifBlank { null }
part.startsWith("desc=", ignoreCase = true) ->
desc = part.substringAfter("=").trim().ifBlank { null }
}
}
params += ScriptParamDefinition(
name = name,
required = required,
defaultValue = defaultValue,
description = desc
)
}
}
return ScriptMetadata(description = description, params = params, timeoutMs = timeoutMs)
}
fun validateScriptMetadata(scriptContent: String): List<String> {
val errors = mutableListOf<String>()
val seenParams = mutableSetOf<String>()
scriptContent.lines().forEachIndexed { idx, raw ->
val lineNo = idx + 1
val line = raw.trim()
if (!line.startsWith("//")) return@forEachIndexed
val comment = line.removePrefix("//").trim()
if (comment.startsWith("@timeout:", ignoreCase = true)) {
val rawTimeout = comment.substringAfter(":").trim()
if (parseTimeoutMs(rawTimeout) == null) {
errors += "line $lineNo: invalid @timeout '$rawTimeout'. expected format: '@timeout: 10s' or '500ms' or '1m'."
}
return@forEachIndexed
}
if (comment.startsWith("@param:", ignoreCase = true)) {
val payload = comment.substringAfter(":").trim()
if (payload.isBlank()) {
errors += "line $lineNo: empty @param. expected format: '@param: name | required=true|false | default=value | desc=text'."
return@forEachIndexed
}
val parts = payload.split("|").map { it.trim() }.filter { it.isNotBlank() }
if (parts.isEmpty()) {
errors += "line $lineNo: invalid @param. expected format: '@param: name | required=true|false | default=value | desc=text'."
return@forEachIndexed
}
val name = parts.first()
if (!metadataParamNameRegex.matches(name)) {
errors += "line $lineNo: invalid param name '$name'. allowed pattern: [A-Za-z0-9._-]+."
}
if (!seenParams.add(name)) {
errors += "line $lineNo: duplicate @param name '$name'. param names must be unique."
}
var hasRequiredOption = false
parts.drop(1).forEach { part ->
when {
part.equals("required", ignoreCase = true) -> {
hasRequiredOption = true
}
part.startsWith("required=", ignoreCase = true) -> {
hasRequiredOption = true
val v = part.substringAfter("=").trim()
if (!v.equals("true", ignoreCase = true) && !v.equals("false", ignoreCase = true)) {
errors += "line $lineNo: invalid required value '$v'. expected true/false."
}
}
part.startsWith("default=", ignoreCase = true) -> Unit
part.startsWith("desc=", ignoreCase = true) -> Unit
else -> {
errors += "line $lineNo: unsupported @param option '$part'. supported: required=, default=, desc=."
}
}
}
if (!hasRequiredOption) {
errors += "line $lineNo: missing required option. expected '@param: name | required=true|false | default=value | desc=text'."
}
}
}
return errors
}
private fun parseTimeoutMs(raw: String): Long? {
if (raw.isBlank()) return null
val v = raw.trim().lowercase()
return when {
v.endsWith("ms") -> v.removeSuffix("ms").trim().toLongOrNull()
v.endsWith("s") -> v.removeSuffix("s").trim().toLongOrNull()?.times(1000)
v.endsWith("m") -> v.removeSuffix("m").trim().toLongOrNull()?.times(60_000)
else -> v.toLongOrNull()?.times(1000)
}?.takeIf { it > 0 }
}
private fun metadataForFile(scriptFile: File, scriptContent: String): ScriptMetadata {
val key = scriptFile.canonicalPath
val stamp = scriptStamp(scriptFile)
val cached = metadataCache[key]
if (cached != null && cached.first == stamp) return cached.second
val parsed = parseMetadataFromComments(scriptContent)
metadataCache[key] = stamp to parsed
return parsed
}
private fun compileSource(source: SourceCode): ResultWithDiagnostics<CompiledScript> { private fun compileSource(source: SourceCode): ResultWithDiagnostics<CompiledScript> {
val explicitCp = explicitClasspathFromEnv() val explicitCp = explicitClasspathFromEnv()
return runBlocking { return runBlocking {
@@ -304,23 +154,11 @@ data class ScriptExecutionResult(
val timedOut: Boolean = false, val timedOut: Boolean = false,
) )
fun cachedMetadata(scriptFile: File): ScriptMetadata? {
val key = scriptFile.canonicalPath
val cached = metadataCache[key] ?: return null
val currentStamp = scriptStamp(scriptFile)
return if (cached.first == currentStamp) cached.second else null
}
fun removeCachedMetadata(scriptFile: File) { fun removeCachedMetadata(scriptFile: File) {
metadataCache.remove(scriptFile.canonicalPath) clearMetadataCache(scriptFile)
compiledScriptCache.remove(scriptFile.canonicalPath) compiledScriptCache.remove(scriptFile.canonicalPath)
} }
fun loadMetadataFromComments(scriptFile: File): ScriptMetadata {
val content = scriptFile.readText()
return metadataForFile(scriptFile, content)
}
fun evalAndCapture(scriptFile: File, requestContext: ScriptRequestContext = ScriptRequestContext()): ScriptExecutionResult { fun evalAndCapture(scriptFile: File, requestContext: ScriptRequestContext = ScriptRequestContext()): ScriptExecutionResult {
return evalAndCapture(scriptFile, requestContext, enforceRequiredParams = true) return evalAndCapture(scriptFile, requestContext, enforceRequiredParams = true)
} }

View File

@@ -0,0 +1,171 @@
package work.slhaf.hub
import java.io.File
import java.util.concurrent.ConcurrentHashMap
private val metadataCache = ConcurrentHashMap<String, Pair<String, ScriptMetadata>>() // key -> stamp, metadata
private const val DEFAULT_SCRIPT_TIMEOUT_MS = 10_000L
private val metadataParamNameRegex = Regex("[A-Za-z0-9._-]+")
internal fun scriptStamp(file: File): String = "${file.length()}-${file.lastModified()}"
private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
var description: String? = null
var timeoutMs = DEFAULT_SCRIPT_TIMEOUT_MS
val params = mutableListOf<ScriptParamDefinition>()
scriptContent.lines().forEach { raw ->
val line = raw.trim()
if (!line.startsWith("//")) return@forEach
val comment = line.removePrefix("//").trim()
if (comment.startsWith("@desc:", ignoreCase = true)) {
description = comment.substringAfter(":").trim().takeIf { it.isNotBlank() }
return@forEach
}
if (comment.startsWith("@timeout:", ignoreCase = true)) {
val raw = comment.substringAfter(":").trim()
parseTimeoutMs(raw)?.let { timeoutMs = it }
return@forEach
}
if (comment.startsWith("@param:", ignoreCase = true)) {
val payload = comment.substringAfter(":").trim()
if (payload.isBlank()) return@forEach
val parts = payload.split("|").map { it.trim() }.filter { it.isNotBlank() }
if (parts.isEmpty()) return@forEach
val name = parts.first()
var required = false
var defaultValue: String? = null
var desc: String? = null
parts.drop(1).forEach { part ->
when {
part.equals("required", ignoreCase = true) -> required = true
part.startsWith("required=", ignoreCase = true) ->
required = part.substringAfter("=").trim().equals("true", ignoreCase = true)
part.startsWith("default=", ignoreCase = true) ->
defaultValue = part.substringAfter("=").trim().ifBlank { null }
part.startsWith("desc=", ignoreCase = true) ->
desc = part.substringAfter("=").trim().ifBlank { null }
}
}
params += ScriptParamDefinition(
name = name,
required = required,
defaultValue = defaultValue,
description = desc
)
}
}
return ScriptMetadata(description = description, params = params, timeoutMs = timeoutMs)
}
fun validateScriptMetadata(scriptContent: String): List<String> {
val errors = mutableListOf<String>()
val seenParams = mutableSetOf<String>()
scriptContent.lines().forEachIndexed { idx, raw ->
val lineNo = idx + 1
val line = raw.trim()
if (!line.startsWith("//")) return@forEachIndexed
val comment = line.removePrefix("//").trim()
if (comment.startsWith("@timeout:", ignoreCase = true)) {
val rawTimeout = comment.substringAfter(":").trim()
if (parseTimeoutMs(rawTimeout) == null) {
errors += "line $lineNo: invalid @timeout '$rawTimeout'. expected format: '@timeout: 10s' or '500ms' or '1m'."
}
return@forEachIndexed
}
if (comment.startsWith("@param:", ignoreCase = true)) {
val payload = comment.substringAfter(":").trim()
if (payload.isBlank()) {
errors += "line $lineNo: empty @param. expected format: '@param: name | required=true|false | default=value | desc=text'."
return@forEachIndexed
}
val parts = payload.split("|").map { it.trim() }.filter { it.isNotBlank() }
if (parts.isEmpty()) {
errors += "line $lineNo: invalid @param. expected format: '@param: name | required=true|false | default=value | desc=text'."
return@forEachIndexed
}
val name = parts.first()
if (!metadataParamNameRegex.matches(name)) {
errors += "line $lineNo: invalid param name '$name'. allowed pattern: [A-Za-z0-9._-]+."
}
if (!seenParams.add(name)) {
errors += "line $lineNo: duplicate @param name '$name'. param names must be unique."
}
var hasRequiredOption = false
parts.drop(1).forEach { part ->
when {
part.equals("required", ignoreCase = true) -> {
hasRequiredOption = true
}
part.startsWith("required=", ignoreCase = true) -> {
hasRequiredOption = true
val v = part.substringAfter("=").trim()
if (!v.equals("true", ignoreCase = true) && !v.equals("false", ignoreCase = true)) {
errors += "line $lineNo: invalid required value '$v'. expected true/false."
}
}
part.startsWith("default=", ignoreCase = true) -> Unit
part.startsWith("desc=", ignoreCase = true) -> Unit
else -> {
errors += "line $lineNo: unsupported @param option '$part'. supported: required=, default=, desc=."
}
}
}
if (!hasRequiredOption) {
errors += "line $lineNo: missing required option. expected '@param: name | required=true|false | default=value | desc=text'."
}
}
}
return errors
}
private fun parseTimeoutMs(raw: String): Long? {
if (raw.isBlank()) return null
val v = raw.trim().lowercase()
return when {
v.endsWith("ms") -> v.removeSuffix("ms").trim().toLongOrNull()
v.endsWith("s") -> v.removeSuffix("s").trim().toLongOrNull()?.times(1000)
v.endsWith("m") -> v.removeSuffix("m").trim().toLongOrNull()?.times(60_000)
else -> v.toLongOrNull()?.times(1000)
}?.takeIf { it > 0 }
}
internal fun metadataForFile(scriptFile: File, scriptContent: String): ScriptMetadata {
val key = scriptFile.canonicalPath
val stamp = scriptStamp(scriptFile)
val cached = metadataCache[key]
if (cached != null && cached.first == stamp) return cached.second
val parsed = parseMetadataFromComments(scriptContent)
metadataCache[key] = stamp to parsed
return parsed
}
fun cachedMetadata(scriptFile: File): ScriptMetadata? {
val key = scriptFile.canonicalPath
val cached = metadataCache[key] ?: return null
val currentStamp = scriptStamp(scriptFile)
return if (cached.first == currentStamp) cached.second else null
}
internal fun clearMetadataCache(scriptFile: File) {
metadataCache.remove(scriptFile.canonicalPath)
}
fun loadMetadataFromComments(scriptFile: File): ScriptMetadata {
val content = scriptFile.readText()
return metadataForFile(scriptFile, content)
}

View File

@@ -1,394 +1,14 @@
package work.slhaf.hub package work.slhaf.hub
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call
import io.ktor.server.engine.embeddedServer import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty import io.ktor.server.netty.Netty
import io.ktor.server.request.httpMethod
import io.ktor.server.request.path
import io.ktor.server.request.receiveText
import io.ktor.server.response.respondText
import io.ktor.server.routing.Routing
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.routing
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.slf4j.LoggerFactory
import java.io.File import java.io.File
private const val DEFAULT_PORT = 8080 private const val DEFAULT_PORT = 8080
private const val DEFAULT_SCRIPTS_DIR = "scripts" private const val DEFAULT_SCRIPTS_DIR = "scripts"
private const val DEFAULT_HOST = "0.0.0.0" private const val DEFAULT_HOST = "0.0.0.0"
private val DEFAULT_MAX_RUN_CONCURRENCY = Runtime.getRuntime().availableProcessors().coerceAtLeast(1) private val DEFAULT_MAX_RUN_CONCURRENCY = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
private val requestLogger = LoggerFactory.getLogger("work.slhaf.hub.RequestAudit")
private suspend inline fun withRequestAudit(
call: ApplicationCall,
endpoint: String,
authProvider: () -> AuthContext? = { null },
crossinline block: suspend () -> Unit,
) {
val startNs = System.nanoTime()
var thrown: Throwable? = null
try {
block()
} catch (t: Throwable) {
thrown = t
throw t
} finally {
val durationMs = (System.nanoTime() - startNs) / 1_000_000
val auth = authProvider()
val tokenType = auth?.type?.name?.lowercase() ?: "none"
val subToken = auth?.subTokenName ?: "-"
val script = call.parameters["script"] ?: "-"
val status = call.response.status()?.value ?: if (thrown == null) 200 else 500
if (thrown == null) {
requestLogger.info(
"endpoint={} method={} path={} status={} durationMs={} tokenType={} subToken={} script={}",
endpoint,
call.request.httpMethod.value,
call.request.path(),
status,
durationMs,
tokenType,
subToken,
script,
)
} else {
requestLogger.warn(
"endpoint={} method={} path={} status={} durationMs={} tokenType={} subToken={} script={} error={}",
endpoint,
call.request.httpMethod.value,
call.request.path(),
status,
durationMs,
tokenType,
subToken,
script,
"${thrown::class.simpleName}: ${thrown.message}",
)
}
}
}
private suspend fun handleSubTokenCreate(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) {
val name = call.parameters["name"]
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
if (!name.matches(Regex("[A-Za-z0-9._-]+"))) {
return call.respondText("invalid subtoken name", status = HttpStatusCode.BadRequest)
}
val scriptsRaw = call.receiveText()
val scripts =
try {
parseScriptNameSet(scriptsRaw)
} catch (t: Throwable) {
return call.respondText(t.message ?: "invalid script names", status = HttpStatusCode.BadRequest)
}
val created =
try {
security.subTokens.create(name, scripts)
} catch (t: Throwable) {
return call.respondText(t.message ?: "failed to create subtoken", status = HttpStatusCode.Conflict)
}
call.respondText(subTokenItemJson(created, includeToken = true), contentType = ContentType.Application.Json, status = HttpStatusCode.Created)
}
private suspend fun handleSubTokenUpdate(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) {
val name = call.parameters["name"]
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
val scriptsRaw = call.receiveText()
val scripts =
try {
parseScriptNameSet(scriptsRaw)
} catch (t: Throwable) {
return call.respondText(t.message ?: "invalid script names", status = HttpStatusCode.BadRequest)
}
val updated =
try {
security.subTokens.update(name, scripts)
} catch (t: Throwable) {
return call.respondText(t.message ?: "failed to update subtoken", status = HttpStatusCode.NotFound)
}
call.respondText(subTokenItemJson(updated, includeToken = true), contentType = ContentType.Application.Json)
}
private suspend fun handleSubTokenGet(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) {
val name = call.parameters["name"]
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
val item = security.subTokens.get(name)
?: return call.respondText("subtoken not found: $name", status = HttpStatusCode.NotFound)
call.respondText(subTokenItemJson(item, includeToken = true), contentType = ContentType.Application.Json)
}
private suspend fun handleSubTokenDelete(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) {
val name = call.parameters["name"]
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
val deleted = security.subTokens.delete(name)
if (!deleted) return call.respondText("subtoken not found: $name", status = HttpStatusCode.NotFound)
call.respondText("deleted subtoken: $name")
}
private suspend fun handleTypeForAuth(call: ApplicationCall, auth: AuthContext) {
call.respondText(tokenTypeJson(auth), contentType = ContentType.Application.Json)
}
private suspend fun handleScriptsForAuth(call: ApplicationCall, scriptsDir: File, auth: AuthContext) {
val allow = visibleScriptsFor(auth)
call.respondText(renderScriptList(scriptsDir, allow), ContentType.Text.Plain)
}
private suspend fun handleMetaForAuth(call: ApplicationCall, scriptsDir: File, auth: AuthContext) {
val name = call.parameters["script"]
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
if (!requireScriptAccess(call, auth, name)) return
val script = resolveScriptFile(scriptsDir, name)
?: return call.respondText("invalid script name", status = HttpStatusCode.BadRequest)
if (!script.exists()) {
return call.respondText("script not found: ${script.name}", status = HttpStatusCode.NotFound)
}
val (metadata, source) = loadMetadata(script)
call.respondText(
metadataJson(name, metadata, source),
contentType = ContentType.Application.Json,
)
}
private suspend fun handleRunForAuth(
call: ApplicationCall,
scriptsDir: File,
auth: AuthContext,
runConcurrencyLimiter: Semaphore,
consumeBody: Boolean,
) {
val name = call.parameters["script"]
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
if (!requireScriptAccess(call, auth, name)) return
runConcurrencyLimiter.withPermit {
handleRunRequest(call, scriptsDir, consumeBody = consumeBody)
}
}
private fun Routing.registerHeaderAuthenticatedRoutes(
scriptsDir: File,
security: HostSecurity,
runConcurrencyLimiter: Semaphore,
) {
get("/type") {
var authForLog: AuthContext? = null
withRequestAudit(call, "type", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleTypeForAuth(call, auth)
}
}
get("/scripts") {
var authForLog: AuthContext? = null
withRequestAudit(call, "scripts.list", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleScriptsForAuth(call, scriptsDir, auth)
}
}
get("/meta/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "meta.get", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleMetaForAuth(call, scriptsDir, auth)
}
}
get("/run/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "run.get", { authForLog }) {
authForLog = requireAuth(call, security) ?: return@withRequestAudit
handleRunForAuth(call, scriptsDir, authForLog, runConcurrencyLimiter, consumeBody = false)
}
}
post("/run/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "run.post", { authForLog }) {
authForLog = requireAuth(call, security) ?: return@withRequestAudit
handleRunForAuth(call, scriptsDir, authForLog, runConcurrencyLimiter, consumeBody = true)
}
}
}
private fun Routing.registerSubTokenPathRoutes(
scriptsDir: File,
security: HostSecurity,
runConcurrencyLimiter: Semaphore,
) {
get("/u/{subAuth}/type") {
var authForLog: AuthContext? = null
withRequestAudit(call, "u.type", { authForLog }) {
val auth = requireSubTokenPathAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleTypeForAuth(call, auth)
}
}
get("/u/{subAuth}/scripts") {
var authForLog: AuthContext? = null
withRequestAudit(call, "u.scripts.list", { authForLog }) {
val auth = requireSubTokenPathAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleScriptsForAuth(call, scriptsDir, auth)
}
}
get("/u/{subAuth}/meta/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "u.meta.get", { authForLog }) {
val auth = requireSubTokenPathAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleMetaForAuth(call, scriptsDir, auth)
}
}
get("/u/{subAuth}/run/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "u.run.get", { authForLog }) {
val auth = requireSubTokenPathAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = false)
}
}
post("/u/{subAuth}/run/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "u.run.post", { authForLog }) {
val auth = requireSubTokenPathAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = true)
}
}
}
fun Application.webModule(scriptsDir: File, security: HostSecurity, runConcurrencyLimiter: Semaphore) {
routing {
get("/health") {
withRequestAudit(call, "health") {
call.respondText("OK")
}
}
registerHeaderAuthenticatedRoutes(scriptsDir, security, runConcurrencyLimiter)
registerSubTokenPathRoutes(scriptsDir, security, runConcurrencyLimiter)
get("/scripts/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "scripts.get", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleGetScriptContent(call, scriptsDir)
}
}
post("/scripts/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "scripts.create", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleCreateScript(call, scriptsDir)
}
}
put("/scripts/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "scripts.update", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleUpdateScript(call, scriptsDir)
}
}
delete("/scripts/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "scripts.delete", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleDeleteScript(call, scriptsDir)
}
}
get("/subtokens") {
var authForLog: AuthContext? = null
withRequestAudit(call, "subtokens.list", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
call.respondText(subTokenListJson(security.subTokens.list()), contentType = ContentType.Application.Json)
}
}
get("/subtokens/{name}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "subtokens.get", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleSubTokenGet(call, security)
}
}
post("/subtokens/{name}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "subtokens.create", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleSubTokenCreate(call, security)
}
}
put("/subtokens/{name}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "subtokens.update", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleSubTokenUpdate(call, security)
}
}
delete("/subtokens/{name}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "subtokens.delete", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleSubTokenDelete(call, security)
}
}
}
}
private fun usage() { private fun usage() {
println( println(

View File

@@ -0,0 +1,398 @@
package work.slhaf.hub
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call
import io.ktor.server.request.httpMethod
import io.ktor.server.request.path
import io.ktor.server.request.receiveText
import io.ktor.server.response.respondText
import io.ktor.server.routing.Routing
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.routing
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.slf4j.LoggerFactory
import java.io.File
private val requestLogger = LoggerFactory.getLogger("work.slhaf.hub.RequestAudit")
private val subTokenPathRegex = Regex("^/u/([^/]+)/")
private fun sanitizeRequestPath(path: String): String {
val match = subTokenPathRegex.find(path) ?: return path
val credential = match.groupValues[1]
val at = credential.indexOf('@')
if (at <= 0 || at == credential.lastIndex) return path
val nameOnly = credential.substring(0, at)
return path.replaceFirst("/u/$credential/", "/u/$nameOnly@***/")
}
private suspend inline fun withRequestAudit(
call: ApplicationCall,
endpoint: String,
authProvider: () -> AuthContext? = { null },
crossinline block: suspend () -> Unit,
) {
val startNs = System.nanoTime()
var thrown: Throwable? = null
try {
block()
} catch (t: Throwable) {
thrown = t
throw t
} finally {
val durationMs = (System.nanoTime() - startNs) / 1_000_000
val auth = authProvider()
val tokenType = auth?.type?.name?.lowercase() ?: "none"
val subToken = auth?.subTokenName ?: "-"
val script = call.parameters["script"] ?: "-"
val sanitizedPath = sanitizeRequestPath(call.request.path())
val status = call.response.status()?.value ?: if (thrown == null) 200 else 500
if (thrown == null) {
requestLogger.info(
"endpoint={} method={} path={} status={} durationMs={} tokenType={} subToken={} script={}",
endpoint,
call.request.httpMethod.value,
sanitizedPath,
status,
durationMs,
tokenType,
subToken,
script,
)
} else {
requestLogger.warn(
"endpoint={} method={} path={} status={} durationMs={} tokenType={} subToken={} script={} error={}",
endpoint,
call.request.httpMethod.value,
sanitizedPath,
status,
durationMs,
tokenType,
subToken,
script,
"${thrown::class.simpleName}: ${thrown.message}",
)
}
}
}
private suspend fun handleSubTokenCreate(call: ApplicationCall, security: HostSecurity) {
val name = call.parameters["name"]
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
if (!name.matches(Regex("[A-Za-z0-9._-]+"))) {
return call.respondText("invalid subtoken name", status = HttpStatusCode.BadRequest)
}
val scriptsRaw = call.receiveText()
val scripts =
try {
parseScriptNameSet(scriptsRaw)
} catch (t: Throwable) {
return call.respondText(t.message ?: "invalid script names", status = HttpStatusCode.BadRequest)
}
val created =
try {
security.subTokens.create(name, scripts)
} catch (t: Throwable) {
return call.respondText(t.message ?: "failed to create subtoken", status = HttpStatusCode.Conflict)
}
call.respondText(subTokenItemJson(created, includeToken = true), contentType = ContentType.Application.Json, status = HttpStatusCode.Created)
}
private suspend fun handleSubTokenUpdate(call: ApplicationCall, security: HostSecurity) {
val name = call.parameters["name"]
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
val scriptsRaw = call.receiveText()
val scripts =
try {
parseScriptNameSet(scriptsRaw)
} catch (t: Throwable) {
return call.respondText(t.message ?: "invalid script names", status = HttpStatusCode.BadRequest)
}
val updated =
try {
security.subTokens.update(name, scripts)
} catch (t: Throwable) {
return call.respondText(t.message ?: "failed to update subtoken", status = HttpStatusCode.NotFound)
}
call.respondText(subTokenItemJson(updated, includeToken = true), contentType = ContentType.Application.Json)
}
private suspend fun handleSubTokenGet(call: ApplicationCall, security: HostSecurity) {
val name = call.parameters["name"]
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
val item = security.subTokens.get(name)
?: return call.respondText("subtoken not found: $name", status = HttpStatusCode.NotFound)
call.respondText(subTokenItemJson(item, includeToken = true), contentType = ContentType.Application.Json)
}
private suspend fun handleSubTokenDelete(call: ApplicationCall, security: HostSecurity) {
val name = call.parameters["name"]
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
val deleted = security.subTokens.delete(name)
if (!deleted) return call.respondText("subtoken not found: $name", status = HttpStatusCode.NotFound)
call.respondText("deleted subtoken: $name")
}
private suspend fun handleTypeForAuth(call: ApplicationCall, auth: AuthContext) {
call.respondText(tokenTypeJson(auth), contentType = ContentType.Application.Json)
}
private suspend fun handleScriptsForAuth(call: ApplicationCall, scriptsDir: File, auth: AuthContext) {
val allow = visibleScriptsFor(auth)
call.respondText(renderScriptList(scriptsDir, allow), ContentType.Text.Plain)
}
private suspend fun handleMetaForAuth(call: ApplicationCall, scriptsDir: File, auth: AuthContext) {
val name = call.parameters["script"]
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
if (!requireScriptAccess(call, auth, name)) return
val script = resolveScriptFile(scriptsDir, name)
?: return call.respondText("invalid script name", status = HttpStatusCode.BadRequest)
if (!script.exists()) {
return call.respondText("script not found: ${script.name}", status = HttpStatusCode.NotFound)
}
val (metadata, source) = loadMetadata(script)
call.respondText(
metadataJson(name, metadata, source),
contentType = ContentType.Application.Json,
)
}
private suspend fun handleRunForAuth(
call: ApplicationCall,
scriptsDir: File,
auth: AuthContext,
runConcurrencyLimiter: Semaphore,
consumeBody: Boolean,
) {
val name = call.parameters["script"]
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
if (!requireScriptAccess(call, auth, name)) return
runConcurrencyLimiter.withPermit {
handleRunRequest(call, scriptsDir, consumeBody = consumeBody)
}
}
private fun Routing.registerHeaderAuthenticatedRoutes(
scriptsDir: File,
security: HostSecurity,
runConcurrencyLimiter: Semaphore,
) {
get("/type") {
var authForLog: AuthContext? = null
withRequestAudit(call, "type", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleTypeForAuth(call, auth)
}
}
get("/scripts") {
var authForLog: AuthContext? = null
withRequestAudit(call, "scripts.list", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleScriptsForAuth(call, scriptsDir, auth)
}
}
get("/meta/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "meta.get", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleMetaForAuth(call, scriptsDir, auth)
}
}
get("/run/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "run.get", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = false)
}
}
post("/run/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "run.post", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = true)
}
}
}
private fun Routing.registerSubTokenPathRoutes(
scriptsDir: File,
security: HostSecurity,
runConcurrencyLimiter: Semaphore,
) {
get("/u/{subAuth}/type") {
var authForLog: AuthContext? = null
withRequestAudit(call, "u.type", { authForLog }) {
val auth = requireSubTokenPathAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleTypeForAuth(call, auth)
}
}
get("/u/{subAuth}/scripts") {
var authForLog: AuthContext? = null
withRequestAudit(call, "u.scripts.list", { authForLog }) {
val auth = requireSubTokenPathAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleScriptsForAuth(call, scriptsDir, auth)
}
}
get("/u/{subAuth}/meta/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "u.meta.get", { authForLog }) {
val auth = requireSubTokenPathAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleMetaForAuth(call, scriptsDir, auth)
}
}
get("/u/{subAuth}/run/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "u.run.get", { authForLog }) {
val auth = requireSubTokenPathAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = false)
}
}
post("/u/{subAuth}/run/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "u.run.post", { authForLog }) {
val auth = requireSubTokenPathAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = true)
}
}
}
fun Application.webModule(scriptsDir: File, security: HostSecurity, runConcurrencyLimiter: Semaphore) {
routing {
get("/health") {
withRequestAudit(call, "health") {
call.respondText("OK")
}
}
registerHeaderAuthenticatedRoutes(scriptsDir, security, runConcurrencyLimiter)
registerSubTokenPathRoutes(scriptsDir, security, runConcurrencyLimiter)
get("/scripts/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "scripts.get", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleGetScriptContent(call, scriptsDir)
}
}
post("/scripts/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "scripts.create", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleCreateScript(call, scriptsDir)
}
}
put("/scripts/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "scripts.update", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleUpdateScript(call, scriptsDir)
}
}
delete("/scripts/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "scripts.delete", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleDeleteScript(call, scriptsDir)
}
}
get("/subtokens") {
var authForLog: AuthContext? = null
withRequestAudit(call, "subtokens.list", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
call.respondText(subTokenListJson(security.subTokens.list()), contentType = ContentType.Application.Json)
}
}
get("/subtokens/{name}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "subtokens.get", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleSubTokenGet(call, security)
}
}
post("/subtokens/{name}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "subtokens.create", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleSubTokenCreate(call, security)
}
}
put("/subtokens/{name}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "subtokens.update", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleSubTokenUpdate(call, security)
}
}
delete("/subtokens/{name}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "subtokens.delete", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleSubTokenDelete(call, security)
}
}
}
}

View File

@@ -0,0 +1,106 @@
package work.slhaf.hub
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class WebAuthAndScriptApiTest : WebHostTestSupport() {
@Test
fun healthAndUnauthorized() = withApp { _ ->
val health = client.get("/health")
assertEquals(HttpStatusCode.OK, health.status)
assertEquals("OK", health.bodyAsText())
val scripts = client.get("/scripts")
assertEquals(HttpStatusCode.Unauthorized, scripts.status)
assertTrue(scripts.bodyAsText().contains("unauthorized"))
}
@Test
fun scriptCrudMetaRunAndValidation() = withApp { scriptsDir ->
val create = client.post("/scripts/demo") {
bearerRoot()
setBody(
"""
// @desc: demo api
// @timeout: 10s
// @param: name | required=false | default=world | desc=Name to greet
val args: Array<String> = emptyArray()
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
println("hi " + (kv["name"] ?: "world"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val list = client.get("/scripts") { bearerRoot() }
assertEquals(HttpStatusCode.OK, list.status)
assertTrue(list.bodyAsText().contains("demo"))
val source = client.get("/scripts/demo") { bearerRoot() }
assertEquals(HttpStatusCode.OK, source.status)
assertTrue(source.bodyAsText().contains("@desc: demo api"))
val meta = client.get("/meta/demo") { bearerRoot() }
assertEquals(HttpStatusCode.OK, meta.status)
val metaText = meta.bodyAsText()
assertTrue(metaText.contains("\"script\":\"demo\""))
assertTrue(metaText.contains("\"timeoutMs\":10000"))
val run = client.get("/run/demo?name=Alice") { bearerRoot() }
assertEquals(HttpStatusCode.OK, run.status)
assertTrue(run.bodyAsText().contains("hi Alice"))
val invalidUpdate = client.put("/scripts/demo") {
bearerRoot()
setBody(
"""
// @desc: bad metadata
// @param: user name | required=maybe | xxx=1
val args: Array<String> = emptyArray()
println("bad")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.BadRequest, invalidUpdate.status)
val invalidText = invalidUpdate.bodyAsText()
assertTrue(invalidText.contains("metadata validation failed"))
assertTrue(invalidText.contains("examples:"))
assertTrue(invalidText.contains("@param:"))
val remove = client.delete("/scripts/demo") { bearerRoot() }
assertEquals(HttpStatusCode.OK, remove.status)
assertFalse((scriptsDir.resolve("demo.hub.kts")).toFile().exists())
}
@Test
fun metadataRequiresExplicitRequiredField() = withApp { _ ->
val create = client.post("/scripts/badmeta") {
bearerRoot()
setBody(
"""
// @desc: bad metadata
// @param: name | default=world | desc=missing required
val args: Array<String> = emptyArray()
println("ok")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.BadRequest, create.status)
val body = create.bodyAsText()
assertTrue(body.contains("metadata validation failed"))
assertTrue(body.contains("missing required option"))
}
}

View File

@@ -1,347 +0,0 @@
package work.slhaf.hub
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.testApplication
import kotlinx.coroutines.sync.Semaphore
import kotlin.io.path.createTempDirectory
import kotlin.io.path.writeText
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class WebHostApiTest {
private val tempDirs = mutableListOf<java.nio.file.Path>()
@AfterTest
fun cleanup() {
tempDirs.forEach { path ->
runCatching { path.toFile().deleteRecursively() }
}
tempDirs.clear()
}
@Test
fun healthAndUnauthorized() = withApp { _ ->
val health = client.get("/health")
assertEquals(HttpStatusCode.OK, health.status)
assertEquals("OK", health.bodyAsText())
val scripts = client.get("/scripts")
assertEquals(HttpStatusCode.Unauthorized, scripts.status)
assertTrue(scripts.bodyAsText().contains("unauthorized"))
}
@Test
fun scriptCrudMetaRunAndValidation() = withApp { scriptsDir ->
val create = client.post("/scripts/demo") {
bearerRoot()
setBody(
"""
// @desc: demo api
// @timeout: 10s
// @param: name | required=false | default=world | desc=Name to greet
val args: Array<String> = emptyArray()
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
println("hi " + (kv["name"] ?: "world"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val list = client.get("/scripts") { bearerRoot() }
assertEquals(HttpStatusCode.OK, list.status)
assertTrue(list.bodyAsText().contains("demo"))
val source = client.get("/scripts/demo") { bearerRoot() }
assertEquals(HttpStatusCode.OK, source.status)
assertTrue(source.bodyAsText().contains("@desc: demo api"))
val meta = client.get("/meta/demo") { bearerRoot() }
assertEquals(HttpStatusCode.OK, meta.status)
val metaText = meta.bodyAsText()
assertTrue(metaText.contains("\"script\":\"demo\""))
assertTrue(metaText.contains("\"timeoutMs\":10000"))
val run = client.get("/run/demo?name=Alice") { bearerRoot() }
assertEquals(HttpStatusCode.OK, run.status)
assertTrue(run.bodyAsText().contains("hi Alice"))
val invalidUpdate = client.put("/scripts/demo") {
bearerRoot()
setBody(
"""
// @desc: bad metadata
// @param: user name | required=maybe | xxx=1
val args: Array<String> = emptyArray()
println("bad")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.BadRequest, invalidUpdate.status)
val invalidText = invalidUpdate.bodyAsText()
assertTrue(invalidText.contains("metadata validation failed"))
assertTrue(invalidText.contains("examples:"))
assertTrue(invalidText.contains("@param:"))
val remove = client.delete("/scripts/demo") { bearerRoot() }
assertEquals(HttpStatusCode.OK, remove.status)
assertFalse((scriptsDir.resolve("demo.hub.kts")).toFile().exists())
}
@Test
fun subTokenAccessControlAndFiltering() = withApp { scriptsDir ->
scriptsDir.resolve("allowed.hub.kts").writeText(
"""
// @desc: allowed script
val args: Array<String> = emptyArray()
println("allowed")
""".trimIndent()
)
scriptsDir.resolve("blocked.hub.kts").writeText(
"""
// @desc: blocked script
val args: Array<String> = emptyArray()
println("blocked")
""".trimIndent()
)
val createSub = client.post("/subtokens/demo-sub") {
bearerRoot()
setBody("allowed")
}
assertEquals(HttpStatusCode.Created, createSub.status)
val token = extractJsonField(createSub.bodyAsText(), "token")
assertNotNull(token)
val type = client.get("/type") { bearer(token) }
assertEquals(HttpStatusCode.OK, type.status)
val typeText = type.bodyAsText()
assertTrue(typeText.contains("\"tokenType\":\"sub\""))
assertTrue(typeText.contains("\"subTokenName\":\"demo-sub\""))
val scripts = client.get("/scripts") { bearer(token) }
assertEquals(HttpStatusCode.OK, scripts.status)
val scriptList = scripts.bodyAsText()
assertTrue(scriptList.contains("allowed"))
assertFalse(scriptList.contains("blocked"))
val metaAllowed = client.get("/meta/allowed") { bearer(token) }
assertEquals(HttpStatusCode.OK, metaAllowed.status)
val metaBlocked = client.get("/meta/blocked") { bearer(token) }
assertEquals(HttpStatusCode.Forbidden, metaBlocked.status)
val runAllowed = client.get("/run/allowed") { bearer(token) }
assertEquals(HttpStatusCode.OK, runAllowed.status)
val runBlocked = client.get("/run/blocked") { bearer(token) }
assertEquals(HttpStatusCode.Forbidden, runBlocked.status)
val createScript = client.post("/scripts/not-allowed") {
bearer(token)
setBody("val args: Array<String> = emptyArray()\nprintln(\"x\")")
}
assertEquals(HttpStatusCode.Forbidden, createScript.status)
val listSubTokens = client.get("/subtokens") { bearer(token) }
assertEquals(HttpStatusCode.Forbidden, listSubTokens.status)
val typeByPath = client.get("/u/demo-sub@$token/type")
assertEquals(HttpStatusCode.OK, typeByPath.status)
assertTrue(typeByPath.bodyAsText().contains("\"tokenType\":\"sub\""))
val scriptsByPath = client.get("/u/demo-sub@$token/scripts")
assertEquals(HttpStatusCode.OK, scriptsByPath.status)
assertTrue(scriptsByPath.bodyAsText().contains("allowed"))
assertFalse(scriptsByPath.bodyAsText().contains("blocked"))
val metaByPathAllowed = client.get("/u/demo-sub@$token/meta/allowed")
assertEquals(HttpStatusCode.OK, metaByPathAllowed.status)
val metaByPathBlocked = client.get("/u/demo-sub@$token/meta/blocked")
assertEquals(HttpStatusCode.Forbidden, metaByPathBlocked.status)
val runByPathAllowed = client.get("/u/demo-sub@$token/run/allowed")
assertEquals(HttpStatusCode.OK, runByPathAllowed.status)
val runByPathBlocked = client.get("/u/demo-sub@$token/run/blocked")
assertEquals(HttpStatusCode.Forbidden, runByPathBlocked.status)
val invalidPathAuth = client.get("/u/demo-sub@invalid-token/scripts")
assertEquals(HttpStatusCode.Unauthorized, invalidPathAuth.status)
}
@Test
fun runTimeoutReturnsRequestTimeout() = withApp { _ ->
val create = client.post("/scripts/slow") {
bearerRoot()
setBody(
"""
// @desc: slow script
// @timeout: 1ms
val args: Array<String> = emptyArray()
Thread.sleep(100)
println("done")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val run = client.get("/run/slow") { bearerRoot() }
assertEquals(HttpStatusCode.RequestTimeout, run.status)
assertTrue(run.bodyAsText().contains("timed out"))
}
@Test
fun metadataRequiresExplicitRequiredField() = withApp { _ ->
val create = client.post("/scripts/badmeta") {
bearerRoot()
setBody(
"""
// @desc: bad metadata
// @param: name | default=world | desc=missing required
val args: Array<String> = emptyArray()
println("ok")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.BadRequest, create.status)
val body = create.bodyAsText()
assertTrue(body.contains("metadata validation failed"))
assertTrue(body.contains("missing required option"))
}
@Test
fun runErrorResponseIncludesParamsAndRequiredCheck() = withApp { _ ->
val create = client.post("/scripts/runner") {
bearerRoot()
setBody(
"""
// @desc: run test
// @param: must | required=true | default=world | desc=Must be provided explicitly
// @param: boom | required=false | default=false | desc=Trigger runtime failure
val args: Array<String> = emptyArray()
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
if ((kv["boom"] ?: "false").equals("true", ignoreCase = true)) {
error("boom")
}
println("must=" + (kv["must"] ?: "none"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val missingRequired = client.get("/run/runner") { bearerRoot() }
assertEquals(HttpStatusCode.BadRequest, missingRequired.status)
val missingBody = missingRequired.bodyAsText()
assertTrue(missingBody.contains("missing required params: must"))
assertTrue(missingBody.contains("params:"))
assertTrue(missingBody.contains("must (required=true"))
val runtimeError = client.get("/run/runner?must=ok&boom=true") { bearerRoot() }
assertEquals(HttpStatusCode.InternalServerError, runtimeError.status)
val runtimeErrorBody = runtimeError.bodyAsText()
assertTrue(runtimeErrorBody.contains("script execution failed"))
assertTrue(runtimeErrorBody.contains("params:"))
assertTrue(runtimeErrorBody.contains("boom (required=false"))
}
@Test
fun defaultParamValueIsInjectedIntoHostArgs() = withApp { _ ->
val create = client.post("/scripts/defaults") {
bearerRoot()
setBody(
"""
// @desc: default args test
// @param: name | required=false | default=world | desc=Name fallback
val args: Array<String> = emptyArray()
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
println("name=" + (kv["name"] ?: "missing"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val runWithoutArg = client.get("/run/defaults") { bearerRoot() }
assertEquals(HttpStatusCode.OK, runWithoutArg.status)
assertTrue(runWithoutArg.bodyAsText().contains("name=world"))
val runWithArg = client.get("/run/defaults?name=alice") { bearerRoot() }
assertEquals(HttpStatusCode.OK, runWithArg.status)
assertTrue(runWithArg.bodyAsText().contains("name=alice"))
}
@Test
fun lateinitArgsDeclarationIsSupported() = withApp { _ ->
val create = client.post("/scripts/lateinit-args") {
bearerRoot()
setBody(
"""
// @desc: lateinit args
// @param: name | required=false | default=world | desc=Name fallback
lateinit var args: Array<String>
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
println("name=" + (kv["name"] ?: "missing"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val run = client.get("/run/lateinit-args") { bearerRoot() }
assertEquals(HttpStatusCode.OK, run.status)
assertTrue(run.bodyAsText().contains("name=world"))
}
private fun withApp(testBlock: suspend io.ktor.server.testing.ApplicationTestBuilder.(java.nio.file.Path) -> Unit) {
val scriptsDir = createTempDirectory("webhost-api-test-")
tempDirs.add(scriptsDir)
testApplication {
val security = createHostSecurity(scriptsDir.toFile(), ROOT_TOKEN)
application {
webModule(scriptsDir.toFile(), security, Semaphore(4))
}
testBlock(scriptsDir)
}
}
private fun io.ktor.client.request.HttpRequestBuilder.bearer(token: String) {
headers.append(HttpHeaders.Authorization, "Bearer $token")
}
private fun io.ktor.client.request.HttpRequestBuilder.bearerRoot() {
bearer(ROOT_TOKEN)
}
private fun extractJsonField(json: String, field: String): String? {
val regex = Regex("\"" + Regex.escape(field) + "\":\"([^\"]*)\"")
return regex.find(json)?.groupValues?.getOrNull(1)
}
companion object {
private const val ROOT_TOKEN = "root-test-token"
}
}

View File

@@ -0,0 +1,51 @@
package work.slhaf.hub
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.http.HttpHeaders
import io.ktor.server.testing.ApplicationTestBuilder
import io.ktor.server.testing.testApplication
import kotlinx.coroutines.sync.Semaphore
import kotlin.io.path.createTempDirectory
import kotlin.test.AfterTest
abstract class WebHostTestSupport {
private val tempDirs = mutableListOf<java.nio.file.Path>()
@AfterTest
fun cleanup() {
tempDirs.forEach { path ->
runCatching { path.toFile().deleteRecursively() }
}
tempDirs.clear()
}
protected fun withApp(testBlock: suspend ApplicationTestBuilder.(java.nio.file.Path) -> Unit) {
val scriptsDir = createTempDirectory("webhost-api-test-")
tempDirs.add(scriptsDir)
testApplication {
val security = createHostSecurity(scriptsDir.toFile(), ROOT_TOKEN)
application {
webModule(scriptsDir.toFile(), security, Semaphore(4))
}
testBlock(scriptsDir)
}
}
protected fun HttpRequestBuilder.bearer(token: String) {
headers.append(HttpHeaders.Authorization, "Bearer $token")
}
protected fun HttpRequestBuilder.bearerRoot() {
bearer(ROOT_TOKEN)
}
protected fun extractJsonField(json: String, field: String): String? {
val regex = Regex("\\\"" + Regex.escape(field) + "\\\":\\\"([^\\\"]*)\\\"")
return regex.find(json)?.groupValues?.getOrNull(1)
}
companion object {
private const val ROOT_TOKEN = "root-test-token"
}
}

View File

@@ -0,0 +1,123 @@
package work.slhaf.hub
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class WebRunApiTest : WebHostTestSupport() {
@Test
fun runTimeoutReturnsRequestTimeout() = withApp { _ ->
val create = client.post("/scripts/slow") {
bearerRoot()
setBody(
"""
// @desc: slow script
// @timeout: 1ms
val args: Array<String> = emptyArray()
Thread.sleep(100)
println("done")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val run = client.get("/run/slow") { bearerRoot() }
assertEquals(HttpStatusCode.RequestTimeout, run.status)
assertTrue(run.bodyAsText().contains("timed out"))
}
@Test
fun runErrorResponseIncludesParamsAndRequiredCheck() = withApp { _ ->
val create = client.post("/scripts/runner") {
bearerRoot()
setBody(
"""
// @desc: run test
// @param: must | required=true | default=world | desc=Must be provided explicitly
// @param: boom | required=false | default=false | desc=Trigger runtime failure
val args: Array<String> = emptyArray()
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
if ((kv["boom"] ?: "false").equals("true", ignoreCase = true)) {
error("boom")
}
println("must=" + (kv["must"] ?: "none"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val missingRequired = client.get("/run/runner") { bearerRoot() }
assertEquals(HttpStatusCode.BadRequest, missingRequired.status)
val missingBody = missingRequired.bodyAsText()
assertTrue(missingBody.contains("missing required params: must"))
assertTrue(missingBody.contains("params:"))
assertTrue(missingBody.contains("must (required=true"))
val runtimeError = client.get("/run/runner?must=ok&boom=true") { bearerRoot() }
assertEquals(HttpStatusCode.InternalServerError, runtimeError.status)
val runtimeErrorBody = runtimeError.bodyAsText()
assertTrue(runtimeErrorBody.contains("script execution failed"))
assertTrue(runtimeErrorBody.contains("params:"))
assertTrue(runtimeErrorBody.contains("boom (required=false"))
}
@Test
fun defaultParamValueIsInjectedIntoHostArgs() = withApp { _ ->
val create = client.post("/scripts/defaults") {
bearerRoot()
setBody(
"""
// @desc: default args test
// @param: name | required=false | default=world | desc=Name fallback
val args: Array<String> = emptyArray()
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
println("name=" + (kv["name"] ?: "missing"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val runWithoutArg = client.get("/run/defaults") { bearerRoot() }
assertEquals(HttpStatusCode.OK, runWithoutArg.status)
assertTrue(runWithoutArg.bodyAsText().contains("name=world"))
val runWithArg = client.get("/run/defaults?name=alice") { bearerRoot() }
assertEquals(HttpStatusCode.OK, runWithArg.status)
assertTrue(runWithArg.bodyAsText().contains("name=alice"))
}
@Test
fun lateinitArgsDeclarationIsSupported() = withApp { _ ->
val create = client.post("/scripts/lateinit-args") {
bearerRoot()
setBody(
"""
// @desc: lateinit args
// @param: name | required=false | default=world | desc=Name fallback
lateinit var args: Array<String>
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
println("name=" + (kv["name"] ?: "missing"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val run = client.get("/run/lateinit-args") { bearerRoot() }
assertEquals(HttpStatusCode.OK, run.status)
assertTrue(run.bodyAsText().contains("name=world"))
}
}

View File

@@ -0,0 +1,98 @@
package work.slhaf.hub
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import kotlin.io.path.writeText
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class WebSubTokenApiTest : WebHostTestSupport() {
@Test
fun subTokenAccessControlAndFiltering() = withApp { scriptsDir ->
scriptsDir.resolve("allowed.hub.kts").writeText(
"""
// @desc: allowed script
val args: Array<String> = emptyArray()
println("allowed")
""".trimIndent()
)
scriptsDir.resolve("blocked.hub.kts").writeText(
"""
// @desc: blocked script
val args: Array<String> = emptyArray()
println("blocked")
""".trimIndent()
)
val createSub = client.post("/subtokens/demo-sub") {
bearerRoot()
setBody("allowed")
}
assertEquals(HttpStatusCode.Created, createSub.status)
val token = extractJsonField(createSub.bodyAsText(), "token")
assertNotNull(token)
val type = client.get("/type") { bearer(token) }
assertEquals(HttpStatusCode.OK, type.status)
val typeText = type.bodyAsText()
assertTrue(typeText.contains("\"tokenType\":\"sub\""))
assertTrue(typeText.contains("\"subTokenName\":\"demo-sub\""))
val scripts = client.get("/scripts") { bearer(token) }
assertEquals(HttpStatusCode.OK, scripts.status)
val scriptList = scripts.bodyAsText()
assertTrue(scriptList.contains("allowed"))
assertFalse(scriptList.contains("blocked"))
val metaAllowed = client.get("/meta/allowed") { bearer(token) }
assertEquals(HttpStatusCode.OK, metaAllowed.status)
val metaBlocked = client.get("/meta/blocked") { bearer(token) }
assertEquals(HttpStatusCode.Forbidden, metaBlocked.status)
val runAllowed = client.get("/run/allowed") { bearer(token) }
assertEquals(HttpStatusCode.OK, runAllowed.status)
val runBlocked = client.get("/run/blocked") { bearer(token) }
assertEquals(HttpStatusCode.Forbidden, runBlocked.status)
val createScript = client.post("/scripts/not-allowed") {
bearer(token)
setBody("val args: Array<String> = emptyArray()\nprintln(\"x\")")
}
assertEquals(HttpStatusCode.Forbidden, createScript.status)
val listSubTokens = client.get("/subtokens") { bearer(token) }
assertEquals(HttpStatusCode.Forbidden, listSubTokens.status)
val typeByPath = client.get("/u/demo-sub@$token/type")
assertEquals(HttpStatusCode.OK, typeByPath.status)
assertTrue(typeByPath.bodyAsText().contains("\"tokenType\":\"sub\""))
val scriptsByPath = client.get("/u/demo-sub@$token/scripts")
assertEquals(HttpStatusCode.OK, scriptsByPath.status)
assertTrue(scriptsByPath.bodyAsText().contains("allowed"))
assertFalse(scriptsByPath.bodyAsText().contains("blocked"))
val metaByPathAllowed = client.get("/u/demo-sub@$token/meta/allowed")
assertEquals(HttpStatusCode.OK, metaByPathAllowed.status)
val metaByPathBlocked = client.get("/u/demo-sub@$token/meta/blocked")
assertEquals(HttpStatusCode.Forbidden, metaByPathBlocked.status)
val runByPathAllowed = client.get("/u/demo-sub@$token/run/allowed")
assertEquals(HttpStatusCode.OK, runByPathAllowed.status)
val runByPathBlocked = client.get("/u/demo-sub@$token/run/blocked")
assertEquals(HttpStatusCode.Forbidden, runByPathBlocked.status)
val invalidPathAuth = client.get("/u/demo-sub@invalid-token/scripts")
assertEquals(HttpStatusCode.Unauthorized, invalidPathAuth.status)
}
}