From f8d6f441ba6169d07b42de84dfd2f4d383544b2f Mon Sep 17 00:00:00 2001 From: gremat <50012463+gremat@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:13:21 +0100 Subject: [PATCH 1/4] feat: generate server stub from API schema --- .gitignore | 1 + .openapi-generator-ignore | 25 ++ Dockerfile | 15 + README.md | 33 ++ api/openapi.yaml | 624 ++++++++++++++++++++++++++ go.mod | 4 + go/api.go | 70 +++ go/api_info.go | 220 +++++++++ go/api_info_service.go | 81 ++++ go/api_results.go | 103 +++++ go/api_results_service.go | 45 ++ go/api_scan.go | 161 +++++++ go/api_scan_service.go | 63 +++ go/error.go | 74 +++ go/helpers.go | 356 +++++++++++++++ go/impl.go | 17 + go/logger.go | 33 ++ go/model_async_result.go | 39 ++ go/model_error.go | 37 ++ go/model_file_object.go | 41 ++ go/model_sample_id.go | 37 ++ go/model_sample_id_obj.go | 37 ++ go/model_thor_finding.go | 25 ++ go/model_thor_report.go | 26 ++ go/model_thunderstorm_info.go | 65 +++ go/model_thunderstorm_status.go | 49 ++ go/model_thunderstorm_version_info.go | 37 ++ go/model_timestamp_map.go | 25 ++ go/routers.go | 52 +++ main.go | 35 ++ openapi-genconfig.yaml | 11 + openapitools.json | 7 + 32 files changed, 2448 insertions(+) create mode 100644 .gitignore create mode 100644 .openapi-generator-ignore create mode 100644 Dockerfile create mode 100644 api/openapi.yaml create mode 100644 go.mod create mode 100644 go/api.go create mode 100644 go/api_info.go create mode 100644 go/api_info_service.go create mode 100644 go/api_results.go create mode 100644 go/api_results_service.go create mode 100644 go/api_scan.go create mode 100644 go/api_scan_service.go create mode 100644 go/error.go create mode 100644 go/helpers.go create mode 100644 go/impl.go create mode 100644 go/logger.go create mode 100644 go/model_async_result.go create mode 100644 go/model_error.go create mode 100644 go/model_file_object.go create mode 100644 go/model_sample_id.go create mode 100644 go/model_sample_id_obj.go create mode 100644 go/model_thor_finding.go create mode 100644 go/model_thor_report.go create mode 100644 go/model_thunderstorm_info.go create mode 100644 go/model_thunderstorm_status.go create mode 100644 go/model_thunderstorm_version_info.go create mode 100644 go/model_timestamp_map.go create mode 100644 go/routers.go create mode 100644 main.go create mode 100644 openapi-genconfig.yaml create mode 100644 openapitools.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..798c20d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.openapi-generator/ diff --git a/.openapi-generator-ignore b/.openapi-generator-ignore new file mode 100644 index 0000000..4c0d2e1 --- /dev/null +++ b/.openapi-generator-ignore @@ -0,0 +1,25 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md + +/README.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..82286ee --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.19 AS build +WORKDIR /go/src +COPY go ./go +COPY main.go . +COPY go.sum . +COPY go.mod . + +ENV CGO_ENABLED=0 + +RUN go build -o thunderstormmock . + +FROM scratch AS runtime +COPY --from=build /go/src/thunderstormmock ./ +EXPOSE 8080/tcp +ENTRYPOINT ["./thunderstormmock"] diff --git a/README.md b/README.md index 68c36d5..06c90fd 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,36 @@ # Thunderstorm Mock Server The server implements a mock version of the Thunderstorm API that can be used for testing purposes. All requests do nothing but log to the console. + +## Overview + +This server was generated by the [openapi-generator](https://openapi-generator.tech) project by using the +[Thunderstorm OpenAPI specification](https://github.com/NextronSystems/thunderstorm-openapi) and the +configuration found in `./openapi-genconfig.yaml`. + +- API version: 1.0.0 +- Build date: 2026-01-26T17:16:00.310597514+01:00[Europe/Berlin] +- Generator version: 7.19.0 +- Command used to generate this server: + ```bash + GO_POST_PROCESS_FILE="gofmt -w" openapi-generator-cli generate --generator-name go-server --config ./openapi-genconfig.yaml + ``` + +### Running the server +To run the server, follow these simple steps: + +``` +go run main.go +``` + +The server will be available on `http://localhost:8080`. + +To run the server in a docker container +``` +docker build --network=host -t thunderstormmock . +``` + +Once image is built use +``` +docker run --rm -it thunderstormmock +``` diff --git a/api/openapi.yaml b/api/openapi.yaml new file mode 100644 index 0000000..d73d5dd --- /dev/null +++ b/api/openapi.yaml @@ -0,0 +1,624 @@ +openapi: 3.0.4 +info: + description: This API allows you to send files to THOR to scan them and provides + information about the running THOR instance. + title: THOR Thunderstorm API + version: 1.0.0 +servers: +- description: Local THOR instance + url: http://localhost:8080/api/v1 +tags: +- description: Endpoints to scan files with THOR + name: scan +- description: Endpoints to retrieve results of asynchronous scans + name: results +- description: Endpoints to retrieve information about the running THOR instance + name: info +paths: + /check: + post: + description: Check a file with THOR + operationId: check + parameters: + - description: Specify source for the THOR log + explode: true + in: query + name: source + required: false + schema: + type: string + style: form + requestBody: + $ref: "#/components/requestBodies/FileUpload" + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ThorReport" + description: Returns a list of findings + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Invalid parameters given + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Unauthorized + summary: Check a file with THOR + tags: + - scan + /checkAsync: + post: + description: Check a file with THOR asynchronously + operationId: checkAsync + parameters: + - description: Specify source for the THOR log + explode: true + in: query + name: source + required: false + schema: + type: string + style: form + requestBody: + $ref: "#/components/requestBodies/FileUpload" + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/SampleIdObj" + description: Returns a map containing the sample ID + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Invalid parameters given + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Unauthorized + summary: Check a file with THOR asynchronously + tags: + - scan + /getAsyncResults: + get: + description: Retrieve the results of an asynchronous file check. + operationId: getAsyncResults + parameters: + - description: Sample ID + explode: true + in: query + name: id + required: true + schema: + $ref: "#/components/schemas/SampleId" + style: form + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/AsyncResult" + description: "Returns a JSON with the current status and, if applicable,\ + \ the results" + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Invalid parameters given + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Unauthorized + summary: Retrieve the results of an asynchronous file check + tags: + - results + /queueHistory: + get: + description: Retrieve a history of how many asynchronous requests were queued + operationId: queueHistory + parameters: + - description: Aggregate this many minutes per value (default 1). + explode: true + in: query + name: aggregate + required: false + schema: + format: int64 + type: integer + style: form + - description: Give a history for the last this many minutes (default infinite). + explode: true + in: query + name: limit + required: false + schema: + format: int64 + type: integer + style: form + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/TimestampMap" + description: A JSON Map with each time mapped to the queue length (estimated) + from the last time mentioned to that time. + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Invalid parameters given + summary: Retrieve a history of how many asynchronous requests were queued + tags: + - info + /sampleHistory: + get: + description: Retrieve a history of how many samples were scanned. + operationId: sampleHistory + parameters: + - description: Aggregate this many minutes per value (default 1). + explode: true + in: query + name: aggregate + required: false + schema: + format: int64 + type: integer + style: form + - description: Give a history for the last this many minutes (default infinite). + explode: true + in: query + name: limit + required: false + schema: + format: int64 + type: integer + style: form + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/TimestampMap" + description: Returns a JSON Map with each time mapped to the samples scanned + from the last time mentioned to that time + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Invalid parameters given + summary: Retrieve a history of how many samples were scanned + tags: + - info + /info: + get: + description: Receive static information about the running THOR instance. + operationId: info + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ThunderstormInfo" + description: "Map with values to running version, rate limitation, ..." + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Unauthorized + summary: Receive static information about the running THOR instance + tags: + - info + /status: + get: + description: Receive live information about the running THOR instance. + operationId: status + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ThunderstormStatus" + description: "Map with values to scan times, scanned samples, wait times,\ + \ ..." + summary: Receive live information about the running THOR instance + tags: + - info +components: + parameters: + SampleSource: + description: Specify source for the THOR log + explode: true + in: query + name: source + required: false + schema: + type: string + style: form + AggregationPeriod: + description: Aggregate this many minutes per value (default 1). + explode: true + in: query + name: aggregate + required: false + schema: + format: int64 + type: integer + style: form + HistoryLimit: + description: Give a history for the last this many minutes (default infinite). + explode: true + in: query + name: limit + required: false + schema: + format: int64 + type: integer + style: form + requestBodies: + FileUpload: + content: + multipart/form-data: + schema: + $ref: "#/components/schemas/FileObject" + description: Multipart form data containing a file + required: true + responses: + QueueHistory: + content: + application/json: + schema: + $ref: "#/components/schemas/TimestampMap" + description: A JSON Map with each time mapped to the queue length (estimated) + from the last time mentioned to that time. + SampleHistory: + content: + application/json: + schema: + $ref: "#/components/schemas/TimestampMap" + description: Returns a JSON Map with each time mapped to the samples scanned + from the last time mentioned to that time + BadRequest: + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Invalid parameters given + InternalServerError: + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Unauthorized + schemas: + FileObject: + description: Wrapped file + properties: + file: + description: File to be checked + format: binary + type: string + required: + - file + type: object + ThorReport: + description: THOR Report containing findings + example: + - type: THOR finding + meta: + time: 2026-01-16T13:35:34.11133172+01:00 + level: Alert + module: HTTPServer + scan_id: S-qkQv5yHIUHk-2 + hostname: 127.0.0.1 + message: Malicious file found + subject: + type: file + path: somefile.exe + exists: "yes" + extension: .exe + magic_header: EXE + hashes: + md5: 7168892693d7716220d98883fffd848c + sha1: 42acd9b554e1c843a9f8139022b560de0bf48682 + sha256: 660464c473c47784d8820d3e268c0d1327ac22ce0e607dc35e858628c53f0687 + first_bytes: + hex: 4d5a90000300000004000000ffff0000b8000000 + ascii: MZ + size: 1352192 + permissions: null + content: + type: sparse data + elements: + - offset: 856110 + data: '>`ncrypt.dll' + length: 1352192 + score: 94 + reasons: + - type: reason + summary: some YARA rule + signature: + score: 85 + reference: + - Internal Research + origin: internal + kind: YARA Rule + date: 2023-05-12 + tags: + - EXE + - HKTL + rule_name: Some_Rule_Name + description: Detects Something + matched: + - data: '%*s**CREDENTIAL**' + offset: 918480 + field: /content + - data: "%*s Persist : %08x - %u - %s" + offset: 919056 + field: /content + - data: '%*s**DOMAINKEY**' + offset: 950688 + field: /content + - type: reason + summary: Another rule + signature: + score: 80 + reference: + - Some reference + origin: internal + kind: YARA Rule + date: 2016-02-05 + tags: + - T1059_001 + rule_name: Another_Rule_Name + description: Detects Something other + matched: + - data: kuhl_m_lsadump_getUsersAndSamKey ; kull_m_registry_RegOpenKeyEx + SAM Accounts (0x%08x) + offset: 1100540 + field: /content + - data: kuhl_m_lsadump_getComputerAndSyskey ; kuhl_m_lsadump_getSyskey KO + offset: 1099708 + field: /content + reason_count: 28 + context: null + log_version: v3.0.0 + items: + $ref: "#/components/schemas/ThorFinding" + type: array + ThorFinding: + additionalProperties: true + description: THOR Finding + type: object + SampleIdObj: + description: Object containing the Sample ID returned for an asynchronous scan + request + example: + id: 12345 + properties: + id: + description: Sample ID returned for an asynchronous scan request + example: 12345 + format: int64 + type: integer + required: + - id + type: object + SampleId: + description: Sample ID returned for an asynchronous scan request + example: 12345 + format: int64 + type: integer + AsyncResult: + description: Result object for asynchronous scan operations + example: + result: + - type: THOR finding + meta: + time: 2026-01-19T16:08:27.809483955+01:00 + level: Warning + module: HTTPServer + scan_id: S-qiVIo7fm2Yk-4 + hostname: 127.0.0.1 + message: Suspicious file found + subject: + type: file + path: 6700758b14fe8ac1bbcbbf3d652613d8 + exists: "yes" + extension: "" + magic_header: UNKNOWN + hashes: + md5: ffc74d5afc22d1f1c785f98154cc7bae + sha1: 9603a4806528578686dc5f02b805c64414b3c91b + sha256: da1d2378fbacf84d09d18a94def83cd3bfc06e89e7429ba3de57cd96a0e04a87 + first_bytes: + hex: 2f2a0ae8aeade8aeb0200a5b726577726974655f + ascii: "/* [rewrite_" + size: 5369 + permissions: null + content: + type: sparse data + elements: + - offset: 273 + data: var _0x + length: 5369 + score: 79 + reasons: + - type: reason + summary: YARA rule OBFUSC + signature: + score: 65 + origin: internal + kind: YARA Rule + date: 2025-03-29 + tags: + - OBFUS + rule_name: OBFUSC + description: Detects obfuscated JavaScript code + matched: + - data: "']=!![];}" + offset: 3865 + field: /content + reason_count: 1 + context: null + log_version: v3.0.0 + status: Sample analysis complete + properties: + status: + description: Current status of the scan + example: Currently being scanned + type: string + result: + $ref: "#/components/schemas/ThorReport" + required: + - status + type: object + TimestampMap: + additionalProperties: + format: int64 + type: integer + description: Map of timestamps to integer values + example: + "2026-01-21 06:45": 0 + "2026-01-21 07:00": 9165 + "2026-01-21 07:15": 49023 + "2026-01-21 07:30": 61685 + "2026-01-21 07:45": 54932 + type: object + ThunderstormInfo: + description: "Information about the Thunderstorm service instance, including\ + \ version details, configuration, and license information." + example: + version_info: + thor: 10.8.0 + build: 2026-01-07 04:02:31 + signatures: 2026/01/12-160550 + sigma: r2025-12-01-18-g6d581764e + arguments: + - --config + - /opt/nextron/thunderstorm/config/custom-thor.yml + license_expiration: 2026-07-08T00:00:00Z + license_owner: admin + scan_speed_limitation: 0 + threads: 16 + properties: + version_info: + $ref: "#/components/schemas/ThunderstormVersionInfo" + arguments: + description: Command-line arguments used to start the underlying THOR instance. + example: + - --config + - /opt/nextron/thunderstorm/config/custom-thor.yml + items: + type: string + type: array + license_expiration: + description: Expiration date and time of the license in ISO 8601 format. + example: 2026-07-08T00:00:00Z + format: date-time + type: string + license_owner: + description: Name of the license owner. + example: admin + type: string + scan_speed_limitation: + description: Scan speed limitation in bytes per second. A value of 0 indicates + no limitation. + example: 0 + format: int64 + type: integer + threads: + description: Number of threads used for scanning. + example: 16 + format: int64 + type: integer + required: + - arguments + - license_expiration + - license_owner + - scan_speed_limitation + - threads + - version_info + type: object + ThunderstormVersionInfo: + description: Version information for various components of the Thunderstorm + service. + properties: + thor: + description: Version of the THOR scanner engine. + example: 10.8.0 + type: string + build: + description: Build timestamp of the THOR scanner. + example: 2026-01-07 04:02:31 + type: string + signatures: + description: Version/timestamp of the signature database. + example: 2026/01/12-160550 + type: string + sigma: + description: Version identifier of the Sigma rules. + example: r2025-12-01-18-g6d581764e + type: string + type: object + ThunderstormStatus: + description: Live information about the running THOR instance + example: + scanned_samples: 18686305 + queued_async_requests: 0 + avg_scan_time_milliseconds: 21 + avg_wait_time_milliseconds: 0 + properties: + scanned_samples: + description: Total number of samples that have been scanned + example: 18686305 + format: int64 + type: integer + queued_async_requests: + description: Number of asynchronous requests currently in queue + example: 0 + format: int64 + type: integer + avg_scan_time_milliseconds: + description: Average time in milliseconds to scan a sample + example: 21 + format: int64 + type: integer + avg_wait_time_milliseconds: + description: Average wait time in milliseconds before processing + example: 0 + format: int64 + type: integer + required: + - avg_scan_time_milliseconds + - avg_wait_time_milliseconds + - queued_async_requests + - scanned_samples + type: object + Error: + description: Error with message + example: + message: message + properties: + message: + description: Error message describing the problem + type: string + required: + - message + type: object diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..90d88d4 --- /dev/null +++ b/go.mod @@ -0,0 +1,4 @@ +module github.com/NextronSystems/thunderstorm-mock + +go 1.18 + diff --git a/go/api.go b/go/api.go new file mode 100644 index 0000000..485ecd4 --- /dev/null +++ b/go/api.go @@ -0,0 +1,70 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +import ( + "context" + "net/http" + "os" +) + +// InfoAPIRouter defines the required methods for binding the api requests to a responses for the InfoAPI +// The InfoAPIRouter implementation should parse necessary information from the http request, +// pass the data to a InfoAPIServicer to perform the required actions, then write the service results to the http response. +type InfoAPIRouter interface { + QueueHistory(http.ResponseWriter, *http.Request) + SampleHistory(http.ResponseWriter, *http.Request) + Info(http.ResponseWriter, *http.Request) + Status(http.ResponseWriter, *http.Request) +} + +// ResultsAPIRouter defines the required methods for binding the api requests to a responses for the ResultsAPI +// The ResultsAPIRouter implementation should parse necessary information from the http request, +// pass the data to a ResultsAPIServicer to perform the required actions, then write the service results to the http response. +type ResultsAPIRouter interface { + GetAsyncResults(http.ResponseWriter, *http.Request) +} + +// ScanAPIRouter defines the required methods for binding the api requests to a responses for the ScanAPI +// The ScanAPIRouter implementation should parse necessary information from the http request, +// pass the data to a ScanAPIServicer to perform the required actions, then write the service results to the http response. +type ScanAPIRouter interface { + Check(http.ResponseWriter, *http.Request) + CheckAsync(http.ResponseWriter, *http.Request) +} + +// InfoAPIServicer defines the api actions for the InfoAPI service +// This interface intended to stay up to date with the openapi yaml used to generate it, +// while the service implementation can be ignored with the .openapi-generator-ignore file +// and updated with the logic required for the API. +type InfoAPIServicer interface { + QueueHistory(context.Context, int64, int64) (ImplResponse, error) + SampleHistory(context.Context, int64, int64) (ImplResponse, error) + Info(context.Context) (ImplResponse, error) + Status(context.Context) (ImplResponse, error) +} + +// ResultsAPIServicer defines the api actions for the ResultsAPI service +// This interface intended to stay up to date with the openapi yaml used to generate it, +// while the service implementation can be ignored with the .openapi-generator-ignore file +// and updated with the logic required for the API. +type ResultsAPIServicer interface { + GetAsyncResults(context.Context, int64) (ImplResponse, error) +} + +// ScanAPIServicer defines the api actions for the ScanAPI service +// This interface intended to stay up to date with the openapi yaml used to generate it, +// while the service implementation can be ignored with the .openapi-generator-ignore file +// and updated with the logic required for the API. +type ScanAPIServicer interface { + Check(context.Context, *os.File, string) (ImplResponse, error) + CheckAsync(context.Context, *os.File, string) (ImplResponse, error) +} diff --git a/go/api_info.go b/go/api_info.go new file mode 100644 index 0000000..e4688fc --- /dev/null +++ b/go/api_info.go @@ -0,0 +1,220 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +import ( + "net/http" + "strings" +) + +// InfoAPIController binds http requests to an api service and writes the service results to the http response +type InfoAPIController struct { + service InfoAPIServicer + errorHandler ErrorHandler +} + +// InfoAPIOption for how the controller is set up. +type InfoAPIOption func(*InfoAPIController) + +// WithInfoAPIErrorHandler inject ErrorHandler into controller +func WithInfoAPIErrorHandler(h ErrorHandler) InfoAPIOption { + return func(c *InfoAPIController) { + c.errorHandler = h + } +} + +// NewInfoAPIController creates a default api controller +func NewInfoAPIController(s InfoAPIServicer, opts ...InfoAPIOption) *InfoAPIController { + controller := &InfoAPIController{ + service: s, + errorHandler: DefaultErrorHandler, + } + + for _, opt := range opts { + opt(controller) + } + + return controller +} + +// Routes returns all the api routes for the InfoAPIController +func (c *InfoAPIController) Routes() Routes { + return Routes{ + "QueueHistory": Route{ + "QueueHistory", + strings.ToUpper("Get"), + "/api/v1/queueHistory", + c.QueueHistory, + }, + "SampleHistory": Route{ + "SampleHistory", + strings.ToUpper("Get"), + "/api/v1/sampleHistory", + c.SampleHistory, + }, + "Info": Route{ + "Info", + strings.ToUpper("Get"), + "/api/v1/info", + c.Info, + }, + "Status": Route{ + "Status", + strings.ToUpper("Get"), + "/api/v1/status", + c.Status, + }, + } +} + +// OrderedRoutes returns all the api routes in a deterministic order for the InfoAPIController +func (c *InfoAPIController) OrderedRoutes() []Route { + return []Route{ + Route{ + "QueueHistory", + strings.ToUpper("Get"), + "/api/v1/queueHistory", + c.QueueHistory, + }, + Route{ + "SampleHistory", + strings.ToUpper("Get"), + "/api/v1/sampleHistory", + c.SampleHistory, + }, + Route{ + "Info", + strings.ToUpper("Get"), + "/api/v1/info", + c.Info, + }, + Route{ + "Status", + strings.ToUpper("Get"), + "/api/v1/status", + c.Status, + }, + } +} + +// QueueHistory - Retrieve a history of how many asynchronous requests were queued +func (c *InfoAPIController) QueueHistory(w http.ResponseWriter, r *http.Request) { + query, err := parseQuery(r.URL.RawQuery) + if err != nil { + c.errorHandler(w, r, &ParsingError{Err: err}, nil) + return + } + var aggregateParam int64 + if query.Has("aggregate") { + param, err := parseNumericParameter[int64]( + query.Get("aggregate"), + WithParse[int64](parseInt64), + ) + if err != nil { + c.errorHandler(w, r, &ParsingError{Param: "aggregate", Err: err}, nil) + return + } + + aggregateParam = param + } else { + } + var limitParam int64 + if query.Has("limit") { + param, err := parseNumericParameter[int64]( + query.Get("limit"), + WithParse[int64](parseInt64), + ) + if err != nil { + c.errorHandler(w, r, &ParsingError{Param: "limit", Err: err}, nil) + return + } + + limitParam = param + } else { + } + result, err := c.service.QueueHistory(r.Context(), aggregateParam, limitParam) + // If an error occurred, encode the error with the status code + if err != nil { + c.errorHandler(w, r, err, &result) + return + } + // If no error, encode the body and the result code + _ = EncodeJSONResponse(result.Body, &result.Code, w) +} + +// SampleHistory - Retrieve a history of how many samples were scanned +func (c *InfoAPIController) SampleHistory(w http.ResponseWriter, r *http.Request) { + query, err := parseQuery(r.URL.RawQuery) + if err != nil { + c.errorHandler(w, r, &ParsingError{Err: err}, nil) + return + } + var aggregateParam int64 + if query.Has("aggregate") { + param, err := parseNumericParameter[int64]( + query.Get("aggregate"), + WithParse[int64](parseInt64), + ) + if err != nil { + c.errorHandler(w, r, &ParsingError{Param: "aggregate", Err: err}, nil) + return + } + + aggregateParam = param + } else { + } + var limitParam int64 + if query.Has("limit") { + param, err := parseNumericParameter[int64]( + query.Get("limit"), + WithParse[int64](parseInt64), + ) + if err != nil { + c.errorHandler(w, r, &ParsingError{Param: "limit", Err: err}, nil) + return + } + + limitParam = param + } else { + } + result, err := c.service.SampleHistory(r.Context(), aggregateParam, limitParam) + // If an error occurred, encode the error with the status code + if err != nil { + c.errorHandler(w, r, err, &result) + return + } + // If no error, encode the body and the result code + _ = EncodeJSONResponse(result.Body, &result.Code, w) +} + +// Info - Receive static information about the running THOR instance +func (c *InfoAPIController) Info(w http.ResponseWriter, r *http.Request) { + result, err := c.service.Info(r.Context()) + // If an error occurred, encode the error with the status code + if err != nil { + c.errorHandler(w, r, err, &result) + return + } + // If no error, encode the body and the result code + _ = EncodeJSONResponse(result.Body, &result.Code, w) +} + +// Status - Receive live information about the running THOR instance +func (c *InfoAPIController) Status(w http.ResponseWriter, r *http.Request) { + result, err := c.service.Status(r.Context()) + // If an error occurred, encode the error with the status code + if err != nil { + c.errorHandler(w, r, err, &result) + return + } + // If no error, encode the body and the result code + _ = EncodeJSONResponse(result.Body, &result.Code, w) +} diff --git a/go/api_info_service.go b/go/api_info_service.go new file mode 100644 index 0000000..de65a44 --- /dev/null +++ b/go/api_info_service.go @@ -0,0 +1,81 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +import ( + "context" + "errors" + "net/http" +) + +// InfoAPIService is a service that implements the logic for the InfoAPIServicer +// This service should implement the business logic for every endpoint for the InfoAPI API. +// Include any external packages or services that will be required by this service. +type InfoAPIService struct { +} + +// NewInfoAPIService creates a default api service +func NewInfoAPIService() *InfoAPIService { + return &InfoAPIService{} +} + +// QueueHistory - Retrieve a history of how many asynchronous requests were queued +func (s *InfoAPIService) QueueHistory(ctx context.Context, aggregate int64, limit int64) (ImplResponse, error) { + // TODO - update QueueHistory with the required logic for this service method. + // Add api_info_service.go to the .openapi-generator-ignore to avoid overwriting this service implementation when updating open api generation. + + // TODO: Uncomment the next line to return response Response(200, TimestampMap{}) or use other options such as http.Ok ... + // return Response(200, TimestampMap{}), nil + + // TODO: Uncomment the next line to return response Response(400, Error{}) or use other options such as http.Ok ... + // return Response(400, Error{}), nil + + return Response(http.StatusNotImplemented, nil), errors.New("QueueHistory method not implemented") +} + +// SampleHistory - Retrieve a history of how many samples were scanned +func (s *InfoAPIService) SampleHistory(ctx context.Context, aggregate int64, limit int64) (ImplResponse, error) { + // TODO - update SampleHistory with the required logic for this service method. + // Add api_info_service.go to the .openapi-generator-ignore to avoid overwriting this service implementation when updating open api generation. + + // TODO: Uncomment the next line to return response Response(200, TimestampMap{}) or use other options such as http.Ok ... + // return Response(200, TimestampMap{}), nil + + // TODO: Uncomment the next line to return response Response(400, Error{}) or use other options such as http.Ok ... + // return Response(400, Error{}), nil + + return Response(http.StatusNotImplemented, nil), errors.New("SampleHistory method not implemented") +} + +// Info - Receive static information about the running THOR instance +func (s *InfoAPIService) Info(ctx context.Context) (ImplResponse, error) { + // TODO - update Info with the required logic for this service method. + // Add api_info_service.go to the .openapi-generator-ignore to avoid overwriting this service implementation when updating open api generation. + + // TODO: Uncomment the next line to return response Response(200, ThunderstormInfo{}) or use other options such as http.Ok ... + // return Response(200, ThunderstormInfo{}), nil + + // TODO: Uncomment the next line to return response Response(500, Error{}) or use other options such as http.Ok ... + // return Response(500, Error{}), nil + + return Response(http.StatusNotImplemented, nil), errors.New("Info method not implemented") +} + +// Status - Receive live information about the running THOR instance +func (s *InfoAPIService) Status(ctx context.Context) (ImplResponse, error) { + // TODO - update Status with the required logic for this service method. + // Add api_info_service.go to the .openapi-generator-ignore to avoid overwriting this service implementation when updating open api generation. + + // TODO: Uncomment the next line to return response Response(200, ThunderstormStatus{}) or use other options such as http.Ok ... + // return Response(200, ThunderstormStatus{}), nil + + return Response(http.StatusNotImplemented, nil), errors.New("Status method not implemented") +} diff --git a/go/api_results.go b/go/api_results.go new file mode 100644 index 0000000..7b12e9d --- /dev/null +++ b/go/api_results.go @@ -0,0 +1,103 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +import ( + "net/http" + "strings" +) + +// ResultsAPIController binds http requests to an api service and writes the service results to the http response +type ResultsAPIController struct { + service ResultsAPIServicer + errorHandler ErrorHandler +} + +// ResultsAPIOption for how the controller is set up. +type ResultsAPIOption func(*ResultsAPIController) + +// WithResultsAPIErrorHandler inject ErrorHandler into controller +func WithResultsAPIErrorHandler(h ErrorHandler) ResultsAPIOption { + return func(c *ResultsAPIController) { + c.errorHandler = h + } +} + +// NewResultsAPIController creates a default api controller +func NewResultsAPIController(s ResultsAPIServicer, opts ...ResultsAPIOption) *ResultsAPIController { + controller := &ResultsAPIController{ + service: s, + errorHandler: DefaultErrorHandler, + } + + for _, opt := range opts { + opt(controller) + } + + return controller +} + +// Routes returns all the api routes for the ResultsAPIController +func (c *ResultsAPIController) Routes() Routes { + return Routes{ + "GetAsyncResults": Route{ + "GetAsyncResults", + strings.ToUpper("Get"), + "/api/v1/getAsyncResults", + c.GetAsyncResults, + }, + } +} + +// OrderedRoutes returns all the api routes in a deterministic order for the ResultsAPIController +func (c *ResultsAPIController) OrderedRoutes() []Route { + return []Route{ + Route{ + "GetAsyncResults", + strings.ToUpper("Get"), + "/api/v1/getAsyncResults", + c.GetAsyncResults, + }, + } +} + +// GetAsyncResults - Retrieve the results of an asynchronous file check +func (c *ResultsAPIController) GetAsyncResults(w http.ResponseWriter, r *http.Request) { + query, err := parseQuery(r.URL.RawQuery) + if err != nil { + c.errorHandler(w, r, &ParsingError{Err: err}, nil) + return + } + var idParam int64 + if query.Has("id") { + param, err := parseNumericParameter[int64]( + query.Get("id"), + WithParse[int64](parseInt64), + ) + if err != nil { + c.errorHandler(w, r, &ParsingError{Param: "id", Err: err}, nil) + return + } + + idParam = param + } else { + c.errorHandler(w, r, &RequiredError{Field: "id"}, nil) + return + } + result, err := c.service.GetAsyncResults(r.Context(), idParam) + // If an error occurred, encode the error with the status code + if err != nil { + c.errorHandler(w, r, err, &result) + return + } + // If no error, encode the body and the result code + _ = EncodeJSONResponse(result.Body, &result.Code, w) +} diff --git a/go/api_results_service.go b/go/api_results_service.go new file mode 100644 index 0000000..ac40664 --- /dev/null +++ b/go/api_results_service.go @@ -0,0 +1,45 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +import ( + "context" + "errors" + "net/http" +) + +// ResultsAPIService is a service that implements the logic for the ResultsAPIServicer +// This service should implement the business logic for every endpoint for the ResultsAPI API. +// Include any external packages or services that will be required by this service. +type ResultsAPIService struct { +} + +// NewResultsAPIService creates a default api service +func NewResultsAPIService() *ResultsAPIService { + return &ResultsAPIService{} +} + +// GetAsyncResults - Retrieve the results of an asynchronous file check +func (s *ResultsAPIService) GetAsyncResults(ctx context.Context, id int64) (ImplResponse, error) { + // TODO - update GetAsyncResults with the required logic for this service method. + // Add api_results_service.go to the .openapi-generator-ignore to avoid overwriting this service implementation when updating open api generation. + + // TODO: Uncomment the next line to return response Response(200, AsyncResult{}) or use other options such as http.Ok ... + // return Response(200, AsyncResult{}), nil + + // TODO: Uncomment the next line to return response Response(400, Error{}) or use other options such as http.Ok ... + // return Response(400, Error{}), nil + + // TODO: Uncomment the next line to return response Response(500, Error{}) or use other options such as http.Ok ... + // return Response(500, Error{}), nil + + return Response(http.StatusNotImplemented, nil), errors.New("GetAsyncResults method not implemented") +} diff --git a/go/api_scan.go b/go/api_scan.go new file mode 100644 index 0000000..2738427 --- /dev/null +++ b/go/api_scan.go @@ -0,0 +1,161 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +import ( + "net/http" + "os" + "strings" +) + +// ScanAPIController binds http requests to an api service and writes the service results to the http response +type ScanAPIController struct { + service ScanAPIServicer + errorHandler ErrorHandler +} + +// ScanAPIOption for how the controller is set up. +type ScanAPIOption func(*ScanAPIController) + +// WithScanAPIErrorHandler inject ErrorHandler into controller +func WithScanAPIErrorHandler(h ErrorHandler) ScanAPIOption { + return func(c *ScanAPIController) { + c.errorHandler = h + } +} + +// NewScanAPIController creates a default api controller +func NewScanAPIController(s ScanAPIServicer, opts ...ScanAPIOption) *ScanAPIController { + controller := &ScanAPIController{ + service: s, + errorHandler: DefaultErrorHandler, + } + + for _, opt := range opts { + opt(controller) + } + + return controller +} + +// Routes returns all the api routes for the ScanAPIController +func (c *ScanAPIController) Routes() Routes { + return Routes{ + "Check": Route{ + "Check", + strings.ToUpper("Post"), + "/api/v1/check", + c.Check, + }, + "CheckAsync": Route{ + "CheckAsync", + strings.ToUpper("Post"), + "/api/v1/checkAsync", + c.CheckAsync, + }, + } +} + +// OrderedRoutes returns all the api routes in a deterministic order for the ScanAPIController +func (c *ScanAPIController) OrderedRoutes() []Route { + return []Route{ + Route{ + "Check", + strings.ToUpper("Post"), + "/api/v1/check", + c.Check, + }, + Route{ + "CheckAsync", + strings.ToUpper("Post"), + "/api/v1/checkAsync", + c.CheckAsync, + }, + } +} + +// Check - Check a file with THOR +func (c *ScanAPIController) Check(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(32 << 20); err != nil { + c.errorHandler(w, r, &ParsingError{Err: err}, nil) + return + } + query, err := parseQuery(r.URL.RawQuery) + if err != nil { + c.errorHandler(w, r, &ParsingError{Err: err}, nil) + return + } + var fileParam *os.File + { + param, err := ReadFormFileToTempFile(r, "file") + if err != nil { + c.errorHandler(w, r, &ParsingError{Param: "file", Err: err}, nil) + return + } + + fileParam = param + } + + var sourceParam string + if query.Has("source") { + param := query.Get("source") + + sourceParam = param + } else { + } + result, err := c.service.Check(r.Context(), fileParam, sourceParam) + // If an error occurred, encode the error with the status code + if err != nil { + c.errorHandler(w, r, err, &result) + return + } + // If no error, encode the body and the result code + _ = EncodeJSONResponse(result.Body, &result.Code, w) +} + +// CheckAsync - Check a file with THOR asynchronously +func (c *ScanAPIController) CheckAsync(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(32 << 20); err != nil { + c.errorHandler(w, r, &ParsingError{Err: err}, nil) + return + } + query, err := parseQuery(r.URL.RawQuery) + if err != nil { + c.errorHandler(w, r, &ParsingError{Err: err}, nil) + return + } + var fileParam *os.File + { + param, err := ReadFormFileToTempFile(r, "file") + if err != nil { + c.errorHandler(w, r, &ParsingError{Param: "file", Err: err}, nil) + return + } + + fileParam = param + } + + var sourceParam string + if query.Has("source") { + param := query.Get("source") + + sourceParam = param + } else { + } + result, err := c.service.CheckAsync(r.Context(), fileParam, sourceParam) + // If an error occurred, encode the error with the status code + if err != nil { + c.errorHandler(w, r, err, &result) + return + } + // If no error, encode the body and the result code + _ = EncodeJSONResponse(result.Body, &result.Code, w) +} diff --git a/go/api_scan_service.go b/go/api_scan_service.go new file mode 100644 index 0000000..b2cf677 --- /dev/null +++ b/go/api_scan_service.go @@ -0,0 +1,63 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +import ( + "context" + "errors" + "net/http" + "os" +) + +// ScanAPIService is a service that implements the logic for the ScanAPIServicer +// This service should implement the business logic for every endpoint for the ScanAPI API. +// Include any external packages or services that will be required by this service. +type ScanAPIService struct { +} + +// NewScanAPIService creates a default api service +func NewScanAPIService() *ScanAPIService { + return &ScanAPIService{} +} + +// Check - Check a file with THOR +func (s *ScanAPIService) Check(ctx context.Context, file *os.File, source string) (ImplResponse, error) { + // TODO - update Check with the required logic for this service method. + // Add api_scan_service.go to the .openapi-generator-ignore to avoid overwriting this service implementation when updating open api generation. + + // TODO: Uncomment the next line to return response Response(200, ThorReport{}) or use other options such as http.Ok ... + // return Response(200, ThorReport{}), nil + + // TODO: Uncomment the next line to return response Response(400, Error{}) or use other options such as http.Ok ... + // return Response(400, Error{}), nil + + // TODO: Uncomment the next line to return response Response(500, Error{}) or use other options such as http.Ok ... + // return Response(500, Error{}), nil + + return Response(http.StatusNotImplemented, nil), errors.New("Check method not implemented") +} + +// CheckAsync - Check a file with THOR asynchronously +func (s *ScanAPIService) CheckAsync(ctx context.Context, file *os.File, source string) (ImplResponse, error) { + // TODO - update CheckAsync with the required logic for this service method. + // Add api_scan_service.go to the .openapi-generator-ignore to avoid overwriting this service implementation when updating open api generation. + + // TODO: Uncomment the next line to return response Response(200, SampleIdObj{}) or use other options such as http.Ok ... + // return Response(200, SampleIdObj{}), nil + + // TODO: Uncomment the next line to return response Response(400, Error{}) or use other options such as http.Ok ... + // return Response(400, Error{}), nil + + // TODO: Uncomment the next line to return response Response(500, Error{}) or use other options such as http.Ok ... + // return Response(500, Error{}), nil + + return Response(http.StatusNotImplemented, nil), errors.New("CheckAsync method not implemented") +} diff --git a/go/error.go b/go/error.go new file mode 100644 index 0000000..13dcbaf --- /dev/null +++ b/go/error.go @@ -0,0 +1,74 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +import ( + "errors" + "fmt" + "net/http" +) + +var ( + // ErrTypeAssertionError is thrown when type an interface does not match the asserted type + ErrTypeAssertionError = errors.New("unable to assert type") +) + +// ParsingError indicates that an error has occurred when parsing request parameters +type ParsingError struct { + Param string + Err error +} + +func (e *ParsingError) Unwrap() error { + return e.Err +} + +func (e *ParsingError) Error() string { + if e.Param == "" { + return e.Err.Error() + } + + return e.Param + ": " + e.Err.Error() +} + +// RequiredError indicates that an error has occurred when parsing request parameters +type RequiredError struct { + Field string +} + +func (e *RequiredError) Error() string { + return fmt.Sprintf("required field '%s' is zero value.", e.Field) +} + +// ErrorHandler defines the required method for handling error. You may implement it and inject this into a controller if +// you would like errors to be handled differently from the DefaultErrorHandler +type ErrorHandler func(w http.ResponseWriter, r *http.Request, err error, result *ImplResponse) + +// DefaultErrorHandler defines the default logic on how to handle errors from the controller. Any errors from parsing +// request params will return a StatusBadRequest. Otherwise, the error code originating from the servicer will be used. +func DefaultErrorHandler(w http.ResponseWriter, _ *http.Request, err error, result *ImplResponse) { + var parsingErr *ParsingError + if ok := errors.As(err, &parsingErr); ok { + // Handle parsing errors + _ = EncodeJSONResponse(err.Error(), func(i int) *int { return &i }(http.StatusBadRequest), w) + return + } + + var requiredErr *RequiredError + if ok := errors.As(err, &requiredErr); ok { + // Handle missing required errors + _ = EncodeJSONResponse(err.Error(), func(i int) *int { return &i }(http.StatusUnprocessableEntity), w) + return + } + + // Handle all other errors + _ = EncodeJSONResponse(err.Error(), &result.Code, w) +} diff --git a/go/helpers.go b/go/helpers.go new file mode 100644 index 0000000..bac70d9 --- /dev/null +++ b/go/helpers.go @@ -0,0 +1,356 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +import ( + "encoding/json" + "errors" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "reflect" + "strconv" + "strings" + "time" +) + +const errMsgRequiredMissing = "required parameter is missing" +const errMsgMinValueConstraint = "provided parameter is not respecting minimum value constraint" +const errMsgMaxValueConstraint = "provided parameter is not respecting maximum value constraint" + +// Response return a ImplResponse struct filled +func Response(code int, body interface{}) ImplResponse { + return ImplResponse{ + Code: code, + Body: body, + } +} + +// IsZeroValue checks if the val is the zero-ed value. +func IsZeroValue(val interface{}) bool { + return val == nil || reflect.DeepEqual(val, reflect.Zero(reflect.TypeOf(val)).Interface()) +} + +// AssertRecurseInterfaceRequired recursively checks each struct in a slice against the callback. +// This method traverse nested slices in a preorder fashion. +func AssertRecurseInterfaceRequired[T any](obj interface{}, callback func(T) error) error { + return AssertRecurseValueRequired(reflect.ValueOf(obj), callback) +} + +// AssertRecurseValueRequired checks each struct in the nested slice against the callback. +// This method traverse nested slices in a preorder fashion. ErrTypeAssertionError is thrown if +// the underlying struct does not match type T. +func AssertRecurseValueRequired[T any](value reflect.Value, callback func(T) error) error { + switch value.Kind() { + // If it is a struct we check using callback + case reflect.Struct: + obj, ok := value.Interface().(T) + if !ok { + return ErrTypeAssertionError + } + + if err := callback(obj); err != nil { + return err + } + + // If it is a slice we continue recursion + case reflect.Slice: + for i := 0; i < value.Len(); i++ { + if err := AssertRecurseValueRequired(value.Index(i), callback); err != nil { + return err + } + } + } + return nil +} + +// EncodeJSONResponse uses the json encoder to write an interface to the http response with an optional status code +func EncodeJSONResponse(i interface{}, status *int, w http.ResponseWriter) error { + wHeader := w.Header() + + f, ok := i.(*os.File) + if ok { + data, err := io.ReadAll(f) + if err != nil { + return err + } + wHeader.Set("Content-Type", http.DetectContentType(data)) + wHeader.Set("Content-Disposition", "attachment; filename="+f.Name()) + if status != nil { + w.WriteHeader(*status) + } else { + w.WriteHeader(http.StatusOK) + } + _, err = w.Write(data) + return err + } + wHeader.Set("Content-Type", "application/json; charset=UTF-8") + + if status != nil { + w.WriteHeader(*status) + } else { + w.WriteHeader(http.StatusOK) + } + + if i != nil { + return json.NewEncoder(w).Encode(i) + } + + return nil +} + +// ReadFormFileToTempFile reads file data from a request form and writes it to a temporary file +func ReadFormFileToTempFile(r *http.Request, key string) (*os.File, error) { + _, fileHeader, err := r.FormFile(key) + if err != nil { + return nil, err + } + + return readFileHeaderToTempFile(fileHeader) +} + +// ReadFormFilesToTempFiles reads files array data from a request form and writes it to a temporary files +func ReadFormFilesToTempFiles(r *http.Request, key string) ([]*os.File, error) { + if err := r.ParseMultipartForm(32 << 20); err != nil { + return nil, err + } + + files := make([]*os.File, 0, len(r.MultipartForm.File[key])) + + for _, fileHeader := range r.MultipartForm.File[key] { + file, err := readFileHeaderToTempFile(fileHeader) + if err != nil { + return nil, err + } + + files = append(files, file) + } + + return files, nil +} + +// readFileHeaderToTempFile reads multipart.FileHeader and writes it to a temporary file +func readFileHeaderToTempFile(fileHeader *multipart.FileHeader) (*os.File, error) { + formFile, err := fileHeader.Open() + if err != nil { + return nil, err + } + + defer formFile.Close() + + // Use .* as suffix, because the asterisk is a placeholder for the random value, + // and the period allows consumers of this file to remove the suffix to obtain the original file name + file, err := os.CreateTemp("", fileHeader.Filename+".*") + if err != nil { + return nil, err + } + + defer file.Close() + + _, err = io.Copy(file, formFile) + if err != nil { + return nil, err + } + + return file, nil +} + +func parseTimes(param string) ([]time.Time, error) { + splits := strings.Split(param, ",") + times := make([]time.Time, 0, len(splits)) + for _, v := range splits { + t, err := parseTime(v) + if err != nil { + return nil, err + } + times = append(times, t) + } + return times, nil +} + +// parseTime will parses a string parameter into a time.Time using the RFC3339 format +func parseTime(param string) (time.Time, error) { + if param == "" { + return time.Time{}, nil + } + return time.Parse(time.RFC3339, param) +} + +type Number interface { + ~int32 | ~int64 | ~float32 | ~float64 +} + +type ParseString[T Number | string | bool] func(v string) (T, error) + +// parseFloat64 parses a string parameter to an float64. +func parseFloat64(param string) (float64, error) { + if param == "" { + return 0, nil + } + + return strconv.ParseFloat(param, 64) +} + +// parseFloat32 parses a string parameter to an float32. +func parseFloat32(param string) (float32, error) { + if param == "" { + return 0, nil + } + + v, err := strconv.ParseFloat(param, 32) + return float32(v), err +} + +// parseInt64 parses a string parameter to an int64. +func parseInt64(param string) (int64, error) { + if param == "" { + return 0, nil + } + + return strconv.ParseInt(param, 10, 64) +} + +// parseInt32 parses a string parameter to an int32. +func parseInt32(param string) (int32, error) { + if param == "" { + return 0, nil + } + + val, err := strconv.ParseInt(param, 10, 32) + return int32(val), err +} + +// parseBool parses a string parameter to an bool. +func parseBool(param string) (bool, error) { + if param == "" { + return false, nil + } + + return strconv.ParseBool(param) +} + +type Operation[T Number | string | bool] func(actual string) (T, bool, error) + +func WithRequire[T Number | string | bool](parse ParseString[T]) Operation[T] { + var empty T + return func(actual string) (T, bool, error) { + if actual == "" { + return empty, false, errors.New(errMsgRequiredMissing) + } + + v, err := parse(actual) + return v, false, err + } +} + +func WithDefaultOrParse[T Number | string | bool](def T, parse ParseString[T]) Operation[T] { + return func(actual string) (T, bool, error) { + if actual == "" { + return def, true, nil + } + + v, err := parse(actual) + return v, false, err + } +} + +func WithParse[T Number | string | bool](parse ParseString[T]) Operation[T] { + return func(actual string) (T, bool, error) { + v, err := parse(actual) + return v, false, err + } +} + +type Constraint[T Number | string | bool] func(actual T) error + +func WithMinimum[T Number](expected T) Constraint[T] { + return func(actual T) error { + if actual < expected { + return errors.New(errMsgMinValueConstraint) + } + + return nil + } +} + +func WithMaximum[T Number](expected T) Constraint[T] { + return func(actual T) error { + if actual > expected { + return errors.New(errMsgMaxValueConstraint) + } + + return nil + } +} + +// parseNumericParameter parses a numeric parameter to its respective type. +func parseNumericParameter[T Number](param string, fn Operation[T], checks ...Constraint[T]) (T, error) { + v, ok, err := fn(param) + if err != nil { + return 0, err + } + + if !ok { + for _, check := range checks { + if err := check(v); err != nil { + return 0, err + } + } + } + + return v, nil +} + +// parseBoolParameter parses a string parameter to a bool +func parseBoolParameter(param string, fn Operation[bool]) (bool, error) { + v, _, err := fn(param) + return v, err +} + +// parseNumericArrayParameter parses a string parameter containing array of values to its respective type. +func parseNumericArrayParameter[T Number](param, delim string, required bool, fn Operation[T], checks ...Constraint[T]) ([]T, error) { + if param == "" { + if required { + return nil, errors.New(errMsgRequiredMissing) + } + + return nil, nil + } + + str := strings.Split(param, delim) + values := make([]T, len(str)) + + for i, s := range str { + v, ok, err := fn(s) + if err != nil { + return nil, err + } + + if !ok { + for _, check := range checks { + if err := check(v); err != nil { + return nil, err + } + } + } + + values[i] = v + } + + return values, nil +} + +// parseQuery parses query parameters and returns an error if any malformed value pairs are encountered. +func parseQuery(rawQuery string) (url.Values, error) { + return url.ParseQuery(rawQuery) +} diff --git a/go/impl.go b/go/impl.go new file mode 100644 index 0000000..58925ec --- /dev/null +++ b/go/impl.go @@ -0,0 +1,17 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +// ImplResponse defines an implementation response with error code and the associated body +type ImplResponse struct { + Code int + Body interface{} +} diff --git a/go/logger.go b/go/logger.go new file mode 100644 index 0000000..458238a --- /dev/null +++ b/go/logger.go @@ -0,0 +1,33 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +import ( + "log" + "net/http" + "time" +) + +func Logger(inner http.Handler, name string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + inner.ServeHTTP(w, r) + + log.Printf( + "%s %s %s %s", + r.Method, + r.RequestURI, + name, + time.Since(start), + ) + }) +} diff --git a/go/model_async_result.go b/go/model_async_result.go new file mode 100644 index 0000000..d083531 --- /dev/null +++ b/go/model_async_result.go @@ -0,0 +1,39 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +// AsyncResult - Result object for asynchronous scan operations +type AsyncResult struct { + + // Current status of the scan + Status string `json:"status"` + + Result ThorReport `json:"result,omitempty"` +} + +// AssertAsyncResultRequired checks if the required fields are not zero-ed +func AssertAsyncResultRequired(obj AsyncResult) error { + elements := map[string]interface{}{ + "status": obj.Status, + } + for name, el := range elements { + if isZero := IsZeroValue(el); isZero { + return &RequiredError{Field: name} + } + } + + return nil +} + +// AssertAsyncResultConstraints checks if the values respects the defined constraints +func AssertAsyncResultConstraints(obj AsyncResult) error { + return nil +} diff --git a/go/model_error.go b/go/model_error.go new file mode 100644 index 0000000..86eef39 --- /dev/null +++ b/go/model_error.go @@ -0,0 +1,37 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +// Error - Error with message +type Error struct { + + // Error message describing the problem + Message string `json:"message"` +} + +// AssertErrorRequired checks if the required fields are not zero-ed +func AssertErrorRequired(obj Error) error { + elements := map[string]interface{}{ + "message": obj.Message, + } + for name, el := range elements { + if isZero := IsZeroValue(el); isZero { + return &RequiredError{Field: name} + } + } + + return nil +} + +// AssertErrorConstraints checks if the values respects the defined constraints +func AssertErrorConstraints(obj Error) error { + return nil +} diff --git a/go/model_file_object.go b/go/model_file_object.go new file mode 100644 index 0000000..e352a50 --- /dev/null +++ b/go/model_file_object.go @@ -0,0 +1,41 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +import ( + "os" +) + +// FileObject - Wrapped file +type FileObject struct { + + // File to be checked + File *os.File `json:"file"` +} + +// AssertFileObjectRequired checks if the required fields are not zero-ed +func AssertFileObjectRequired(obj FileObject) error { + elements := map[string]interface{}{ + "file": obj.File, + } + for name, el := range elements { + if isZero := IsZeroValue(el); isZero { + return &RequiredError{Field: name} + } + } + + return nil +} + +// AssertFileObjectConstraints checks if the values respects the defined constraints +func AssertFileObjectConstraints(obj FileObject) error { + return nil +} diff --git a/go/model_sample_id.go b/go/model_sample_id.go new file mode 100644 index 0000000..e36eeaa --- /dev/null +++ b/go/model_sample_id.go @@ -0,0 +1,37 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +// SampleId - Sample ID returned for an asynchronous scan request +type SampleId struct { + + // Sample ID + Id int64 `json:"id"` +} + +// AssertSampleIdRequired checks if the required fields are not zero-ed +func AssertSampleIdRequired(obj SampleId) error { + elements := map[string]interface{}{ + "id": obj.Id, + } + for name, el := range elements { + if isZero := IsZeroValue(el); isZero { + return &RequiredError{Field: name} + } + } + + return nil +} + +// AssertSampleIdConstraints checks if the values respects the defined constraints +func AssertSampleIdConstraints(obj SampleId) error { + return nil +} diff --git a/go/model_sample_id_obj.go b/go/model_sample_id_obj.go new file mode 100644 index 0000000..ad2437f --- /dev/null +++ b/go/model_sample_id_obj.go @@ -0,0 +1,37 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +// SampleIdObj - Object containing the Sample ID returned for an asynchronous scan request +type SampleIdObj struct { + + // Sample ID returned for an asynchronous scan request + Id int64 `json:"id"` +} + +// AssertSampleIdObjRequired checks if the required fields are not zero-ed +func AssertSampleIdObjRequired(obj SampleIdObj) error { + elements := map[string]interface{}{ + "id": obj.Id, + } + for name, el := range elements { + if isZero := IsZeroValue(el); isZero { + return &RequiredError{Field: name} + } + } + + return nil +} + +// AssertSampleIdObjConstraints checks if the values respects the defined constraints +func AssertSampleIdObjConstraints(obj SampleIdObj) error { + return nil +} diff --git a/go/model_thor_finding.go b/go/model_thor_finding.go new file mode 100644 index 0000000..04a2970 --- /dev/null +++ b/go/model_thor_finding.go @@ -0,0 +1,25 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +// ThorFinding - THOR Finding +type ThorFinding struct { +} + +// AssertThorFindingRequired checks if the required fields are not zero-ed +func AssertThorFindingRequired(obj ThorFinding) error { + return nil +} + +// AssertThorFindingConstraints checks if the values respects the defined constraints +func AssertThorFindingConstraints(obj ThorFinding) error { + return nil +} diff --git a/go/model_thor_report.go b/go/model_thor_report.go new file mode 100644 index 0000000..fd1e475 --- /dev/null +++ b/go/model_thor_report.go @@ -0,0 +1,26 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +// ThorReport - THOR Report containing findings +type ThorReport struct { + Items []ThorFinding +} + +// AssertThorReportRequired checks if the required fields are not zero-ed +func AssertThorReportRequired(obj ThorReport) error { + return nil +} + +// AssertThorReportConstraints checks if the values respects the defined constraints +func AssertThorReportConstraints(obj ThorReport) error { + return nil +} diff --git a/go/model_thunderstorm_info.go b/go/model_thunderstorm_info.go new file mode 100644 index 0000000..ece3da7 --- /dev/null +++ b/go/model_thunderstorm_info.go @@ -0,0 +1,65 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +import ( + "time" +) + +// ThunderstormInfo - Information about the Thunderstorm service instance, including version details, configuration, and license information. +type ThunderstormInfo struct { + VersionInfo ThunderstormVersionInfo `json:"version_info"` + + // Command-line arguments used to start the underlying THOR instance. + Arguments []string `json:"arguments"` + + // Expiration date and time of the license in ISO 8601 format. + LicenseExpiration time.Time `json:"license_expiration"` + + // Name of the license owner. + LicenseOwner string `json:"license_owner"` + + // Scan speed limitation in bytes per second. A value of 0 indicates no limitation. + ScanSpeedLimitation int64 `json:"scan_speed_limitation"` + + // Number of threads used for scanning. + Threads int64 `json:"threads"` +} + +// AssertThunderstormInfoRequired checks if the required fields are not zero-ed +func AssertThunderstormInfoRequired(obj ThunderstormInfo) error { + elements := map[string]interface{}{ + "version_info": obj.VersionInfo, + "arguments": obj.Arguments, + "license_expiration": obj.LicenseExpiration, + "license_owner": obj.LicenseOwner, + "scan_speed_limitation": obj.ScanSpeedLimitation, + "threads": obj.Threads, + } + for name, el := range elements { + if isZero := IsZeroValue(el); isZero { + return &RequiredError{Field: name} + } + } + + if err := AssertThunderstormVersionInfoRequired(obj.VersionInfo); err != nil { + return err + } + return nil +} + +// AssertThunderstormInfoConstraints checks if the values respects the defined constraints +func AssertThunderstormInfoConstraints(obj ThunderstormInfo) error { + if err := AssertThunderstormVersionInfoConstraints(obj.VersionInfo); err != nil { + return err + } + return nil +} diff --git a/go/model_thunderstorm_status.go b/go/model_thunderstorm_status.go new file mode 100644 index 0000000..e1e67ea --- /dev/null +++ b/go/model_thunderstorm_status.go @@ -0,0 +1,49 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +// ThunderstormStatus - Live information about the running THOR instance +type ThunderstormStatus struct { + + // Total number of samples that have been scanned + ScannedSamples int64 `json:"scanned_samples"` + + // Number of asynchronous requests currently in queue + QueuedAsyncRequests int64 `json:"queued_async_requests"` + + // Average time in milliseconds to scan a sample + AvgScanTimeMilliseconds int64 `json:"avg_scan_time_milliseconds"` + + // Average wait time in milliseconds before processing + AvgWaitTimeMilliseconds int64 `json:"avg_wait_time_milliseconds"` +} + +// AssertThunderstormStatusRequired checks if the required fields are not zero-ed +func AssertThunderstormStatusRequired(obj ThunderstormStatus) error { + elements := map[string]interface{}{ + "scanned_samples": obj.ScannedSamples, + "queued_async_requests": obj.QueuedAsyncRequests, + "avg_scan_time_milliseconds": obj.AvgScanTimeMilliseconds, + "avg_wait_time_milliseconds": obj.AvgWaitTimeMilliseconds, + } + for name, el := range elements { + if isZero := IsZeroValue(el); isZero { + return &RequiredError{Field: name} + } + } + + return nil +} + +// AssertThunderstormStatusConstraints checks if the values respects the defined constraints +func AssertThunderstormStatusConstraints(obj ThunderstormStatus) error { + return nil +} diff --git a/go/model_thunderstorm_version_info.go b/go/model_thunderstorm_version_info.go new file mode 100644 index 0000000..2ad64d6 --- /dev/null +++ b/go/model_thunderstorm_version_info.go @@ -0,0 +1,37 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +// ThunderstormVersionInfo - Version information for various components of the Thunderstorm service. +type ThunderstormVersionInfo struct { + + // Version of the THOR scanner engine. + Thor string `json:"thor,omitempty"` + + // Build timestamp of the THOR scanner. + Build string `json:"build,omitempty"` + + // Version/timestamp of the signature database. + Signatures string `json:"signatures,omitempty"` + + // Version identifier of the Sigma rules. + Sigma string `json:"sigma,omitempty"` +} + +// AssertThunderstormVersionInfoRequired checks if the required fields are not zero-ed +func AssertThunderstormVersionInfoRequired(obj ThunderstormVersionInfo) error { + return nil +} + +// AssertThunderstormVersionInfoConstraints checks if the values respects the defined constraints +func AssertThunderstormVersionInfoConstraints(obj ThunderstormVersionInfo) error { + return nil +} diff --git a/go/model_timestamp_map.go b/go/model_timestamp_map.go new file mode 100644 index 0000000..8e0a08d --- /dev/null +++ b/go/model_timestamp_map.go @@ -0,0 +1,25 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +// TimestampMap - Map of timestamps to integer values +type TimestampMap struct { +} + +// AssertTimestampMapRequired checks if the required fields are not zero-ed +func AssertTimestampMapRequired(obj TimestampMap) error { + return nil +} + +// AssertTimestampMapConstraints checks if the values respects the defined constraints +func AssertTimestampMapConstraints(obj TimestampMap) error { + return nil +} diff --git a/go/routers.go b/go/routers.go new file mode 100644 index 0000000..11fec58 --- /dev/null +++ b/go/routers.go @@ -0,0 +1,52 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package thunderstormmock + +import ( + "github.com/gorilla/mux" + "net/http" +) + +// A Route defines the parameters for an api endpoint +type Route struct { + Name string + Method string + Pattern string + HandlerFunc http.HandlerFunc +} + +// Routes is a map of defined api endpoints +type Routes map[string]Route + +// Router defines the required methods for retrieving api routes +type Router interface { + Routes() Routes + OrderedRoutes() []Route +} + +// NewRouter creates a new router for any number of api routers +func NewRouter(routers ...Router) *mux.Router { + router := mux.NewRouter().StrictSlash(true) + for _, api := range routers { + for _, route := range api.OrderedRoutes() { + var handler http.Handler = route.HandlerFunc + handler = Logger(handler, route.Name) + + router. + Methods(route.Method). + Path(route.Pattern). + Name(route.Name). + Handler(handler) + } + } + + return router +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..6ff38ef --- /dev/null +++ b/main.go @@ -0,0 +1,35 @@ +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +/* + * THOR Thunderstorm API + * + * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * + * API version: 1.0.0 + */ + +package main + +import ( + "log" + "net/http" + + thunderstormmock "github.com/NextronSystems/thunderstorm-mock/go" +) + +func main() { + log.Printf("Server started") + + InfoAPIService := thunderstormmock.NewInfoAPIService() + InfoAPIController := thunderstormmock.NewInfoAPIController(InfoAPIService) + + ResultsAPIService := thunderstormmock.NewResultsAPIService() + ResultsAPIController := thunderstormmock.NewResultsAPIController(ResultsAPIService) + + ScanAPIService := thunderstormmock.NewScanAPIService() + ScanAPIController := thunderstormmock.NewScanAPIController(ScanAPIService) + + router := thunderstormmock.NewRouter(InfoAPIController, ResultsAPIController, ScanAPIController) + + log.Fatal(http.ListenAndServe(":8080", router)) +} diff --git a/openapi-genconfig.yaml b/openapi-genconfig.yaml new file mode 100644 index 0000000..c128469 --- /dev/null +++ b/openapi-genconfig.yaml @@ -0,0 +1,11 @@ +generatorName: go-server +inputSpec: https://github.com/NextronSystems/thunderstorm-openapi/releases/download/v1.0.0/openapi.yaml +enablePostProcessFile: true +generateAliasAsModel: true +gitUserId: NextronSystems +gitRepoId: thunderstorm-mock +globalProperties: + skipFormModel: false +additionalProperties: + packageName: thunderstormmock + packageVersion: 1.0.0 diff --git a/openapitools.json b/openapitools.json new file mode 100644 index 0000000..dae2553 --- /dev/null +++ b/openapitools.json @@ -0,0 +1,7 @@ +{ + "$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json", + "spaces": 2, + "generator-cli": { + "version": "7.19.0" + } +} From 4d09f7d8f39221b78fbafad5e91e21d150433a75 Mon Sep 17 00:00:00 2001 From: gremat <50012463+gremat@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:15:21 +0100 Subject: [PATCH 2/4] feat: implement echoing mock server --- .openapi-generator-ignore | 14 +++ go.mod | 8 +- go.sum | 6 + go/api_info_service.go | 43 +------ go/api_results_service.go | 29 +++-- go/api_scan_service.go | 53 ++++----- go/imported.go | 25 +++++ go/logger.go | 71 ++++++++++-- go/model_thor_finding.go | 6 +- go/model_thor_report.go | 4 +- go/model_timestamp_map.go | 3 +- go/state.go | 229 ++++++++++++++++++++++++++++++++++++++ main.go | 161 ++++++++++++++++++++++++++- 13 files changed, 551 insertions(+), 101 deletions(-) create mode 100644 go.sum create mode 100644 go/imported.go create mode 100644 go/state.go diff --git a/.openapi-generator-ignore b/.openapi-generator-ignore index 4c0d2e1..68b25f0 100644 --- a/.openapi-generator-ignore +++ b/.openapi-generator-ignore @@ -22,4 +22,18 @@ # Then explicitly reverse the ignore rule for a single file: #!docs/README.md +# Files expected to be customized after first generation and then fixed: +# (i.e., you probably want to keep these ignores permanently, or only disable +# temporarily on massive API changes or huge version bumps of the generator.) /README.md +go/*_service.go + +# Files that normally shouldn't be changed, but we need changes there: +# (i.e., you might want to disable these ignores temporarily to check for +# updates in the generation code when regenerating.) +go/model_thor_finding.go +go/model_thor_report.go +go/model_timestamp_map.go +go/logger.go +main.go +go.mod diff --git a/go.mod b/go.mod index 90d88d4..760c932 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,10 @@ module github.com/NextronSystems/thunderstorm-mock -go 1.18 +go 1.24.0 +require ( + github.com/go-faker/faker/v4 v4.7.0 + github.com/gorilla/mux v1.8.1 +) + +require golang.org/x/text v0.29.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b1b7164 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/go-faker/faker/v4 v4.7.0 h1:VboC02cXHl/NuQh5lM2W8b87yp4iFXIu59x4w0RZi4E= +github.com/go-faker/faker/v4 v4.7.0/go.mod h1:u1dIRP5neLB6kTzgyVjdBOV5R1uP7BdxkcWk7tiKQXk= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= diff --git a/go/api_info_service.go b/go/api_info_service.go index de65a44..cc11d59 100644 --- a/go/api_info_service.go +++ b/go/api_info_service.go @@ -12,8 +12,6 @@ package thunderstormmock import ( "context" - "errors" - "net/http" ) // InfoAPIService is a service that implements the logic for the InfoAPIServicer @@ -29,53 +27,20 @@ func NewInfoAPIService() *InfoAPIService { // QueueHistory - Retrieve a history of how many asynchronous requests were queued func (s *InfoAPIService) QueueHistory(ctx context.Context, aggregate int64, limit int64) (ImplResponse, error) { - // TODO - update QueueHistory with the required logic for this service method. - // Add api_info_service.go to the .openapi-generator-ignore to avoid overwriting this service implementation when updating open api generation. - - // TODO: Uncomment the next line to return response Response(200, TimestampMap{}) or use other options such as http.Ok ... - // return Response(200, TimestampMap{}), nil - - // TODO: Uncomment the next line to return response Response(400, Error{}) or use other options such as http.Ok ... - // return Response(400, Error{}), nil - - return Response(http.StatusNotImplemented, nil), errors.New("QueueHistory method not implemented") + return Response(200, History(queueHistory, aggregate, limit)), nil } // SampleHistory - Retrieve a history of how many samples were scanned func (s *InfoAPIService) SampleHistory(ctx context.Context, aggregate int64, limit int64) (ImplResponse, error) { - // TODO - update SampleHistory with the required logic for this service method. - // Add api_info_service.go to the .openapi-generator-ignore to avoid overwriting this service implementation when updating open api generation. - - // TODO: Uncomment the next line to return response Response(200, TimestampMap{}) or use other options such as http.Ok ... - // return Response(200, TimestampMap{}), nil - - // TODO: Uncomment the next line to return response Response(400, Error{}) or use other options such as http.Ok ... - // return Response(400, Error{}), nil - - return Response(http.StatusNotImplemented, nil), errors.New("SampleHistory method not implemented") + return Response(200, History(sampleHistory, aggregate, limit)), nil } // Info - Receive static information about the running THOR instance func (s *InfoAPIService) Info(ctx context.Context) (ImplResponse, error) { - // TODO - update Info with the required logic for this service method. - // Add api_info_service.go to the .openapi-generator-ignore to avoid overwriting this service implementation when updating open api generation. - - // TODO: Uncomment the next line to return response Response(200, ThunderstormInfo{}) or use other options such as http.Ok ... - // return Response(200, ThunderstormInfo{}), nil - - // TODO: Uncomment the next line to return response Response(500, Error{}) or use other options such as http.Ok ... - // return Response(500, Error{}), nil - - return Response(http.StatusNotImplemented, nil), errors.New("Info method not implemented") + return Response(200, MockInfo()), nil } // Status - Receive live information about the running THOR instance func (s *InfoAPIService) Status(ctx context.Context) (ImplResponse, error) { - // TODO - update Status with the required logic for this service method. - // Add api_info_service.go to the .openapi-generator-ignore to avoid overwriting this service implementation when updating open api generation. - - // TODO: Uncomment the next line to return response Response(200, ThunderstormStatus{}) or use other options such as http.Ok ... - // return Response(200, ThunderstormStatus{}), nil - - return Response(http.StatusNotImplemented, nil), errors.New("Status method not implemented") + return Response(200, MockStatus()), nil } diff --git a/go/api_results_service.go b/go/api_results_service.go index ac40664..c44b6bc 100644 --- a/go/api_results_service.go +++ b/go/api_results_service.go @@ -12,8 +12,6 @@ package thunderstormmock import ( "context" - "errors" - "net/http" ) // ResultsAPIService is a service that implements the logic for the ResultsAPIServicer @@ -29,17 +27,18 @@ func NewResultsAPIService() *ResultsAPIService { // GetAsyncResults - Retrieve the results of an asynchronous file check func (s *ResultsAPIService) GetAsyncResults(ctx context.Context, id int64) (ImplResponse, error) { - // TODO - update GetAsyncResults with the required logic for this service method. - // Add api_results_service.go to the .openapi-generator-ignore to avoid overwriting this service implementation when updating open api generation. - - // TODO: Uncomment the next line to return response Response(200, AsyncResult{}) or use other options such as http.Ok ... - // return Response(200, AsyncResult{}), nil - - // TODO: Uncomment the next line to return response Response(400, Error{}) or use other options such as http.Ok ... - // return Response(400, Error{}), nil - - // TODO: Uncomment the next line to return response Response(500, Error{}) or use other options such as http.Ok ... - // return Response(500, Error{}), nil - - return Response(http.StatusNotImplemented, nil), errors.New("GetAsyncResults method not implemented") + switch id { + // Handle some special triggers for testing purposes + case 0: + return Response(200, ScanRequest{ID: 0}.ToResult()), nil + case -400: + return Response(400, Error{Message: "Invalid parameters given"}), nil + case -500: + return Response(500, Error{Message: "Internal server error"}), nil + } + + if req, ok := LoadScanRequest(id); ok { + return Response(200, req.ToResult()), nil + } + return Response(400, Error{Message: "Invalid sample ID"}), nil } diff --git a/go/api_scan_service.go b/go/api_scan_service.go index b2cf677..0c03908 100644 --- a/go/api_scan_service.go +++ b/go/api_scan_service.go @@ -12,8 +12,7 @@ package thunderstormmock import ( "context" - "errors" - "net/http" + "fmt" "os" ) @@ -30,34 +29,38 @@ func NewScanAPIService() *ScanAPIService { // Check - Check a file with THOR func (s *ScanAPIService) Check(ctx context.Context, file *os.File, source string) (ImplResponse, error) { - // TODO - update Check with the required logic for this service method. - // Add api_scan_service.go to the .openapi-generator-ignore to avoid overwriting this service implementation when updating open api generation. + if resp, found := getSpecialTriggerResponse(source); found { + return resp, nil + } - // TODO: Uncomment the next line to return response Response(200, ThorReport{}) or use other options such as http.Ok ... - // return Response(200, ThorReport{}), nil - - // TODO: Uncomment the next line to return response Response(400, Error{}) or use other options such as http.Ok ... - // return Response(400, Error{}), nil - - // TODO: Uncomment the next line to return response Response(500, Error{}) or use other options such as http.Ok ... - // return Response(500, Error{}), nil - - return Response(http.StatusNotImplemented, nil), errors.New("Check method not implemented") + req, err := StoreScanRequest(true, file, source) + if err != nil { + return Response(500, Error{Message: fmt.Sprintf("Internal server error: %v", err)}), nil + } + return Response(200, req.ToThorReport()), nil } // CheckAsync - Check a file with THOR asynchronously func (s *ScanAPIService) CheckAsync(ctx context.Context, file *os.File, source string) (ImplResponse, error) { - // TODO - update CheckAsync with the required logic for this service method. - // Add api_scan_service.go to the .openapi-generator-ignore to avoid overwriting this service implementation when updating open api generation. + if resp, found := getSpecialTriggerResponse(source); found { + return resp, nil + } - // TODO: Uncomment the next line to return response Response(200, SampleIdObj{}) or use other options such as http.Ok ... - // return Response(200, SampleIdObj{}), nil - - // TODO: Uncomment the next line to return response Response(400, Error{}) or use other options such as http.Ok ... - // return Response(400, Error{}), nil - - // TODO: Uncomment the next line to return response Response(500, Error{}) or use other options such as http.Ok ... - // return Response(500, Error{}), nil + req, err := StoreScanRequest(false, file, source) + if err != nil { + return Response(500, Error{Message: fmt.Sprintf("Internal server error: %v", err)}), nil + } + return Response(200, SampleId{Id: int64(req.ID)}), nil +} - return Response(http.StatusNotImplemented, nil), errors.New("CheckAsync method not implemented") +// getSpecialTriggerResponse checks source for special testing-related triggers +// and returns a corresponding response and true if found, else returns false. +func getSpecialTriggerResponse(source string) (ImplResponse, bool) { + switch source { + case "error 400": + return Response(400, Error{Message: "Invalid parameters given"}), true + case "error 500": + return Response(500, Error{Message: "Internal server error"}), true + } + return ImplResponse{}, false } diff --git a/go/imported.go b/go/imported.go new file mode 100644 index 0000000..de819e6 --- /dev/null +++ b/go/imported.go @@ -0,0 +1,25 @@ +package thunderstormmock + +type status int + +const ( + waiting status = iota + inProgress + finished + crashed +) + +func (s status) String() string { + switch s { + case waiting: + return "Waiting for execution" + case inProgress: + return "Currently being scanned" + case finished: + return "Sample analysis complete" + case crashed: + return "Sample analysis failed" + default: + return "invalid" + } +} diff --git a/go/logger.go b/go/logger.go index 458238a..88c8f97 100644 --- a/go/logger.go +++ b/go/logger.go @@ -11,23 +11,76 @@ package thunderstormmock import ( - "log" + "encoding/json" + "fmt" + "io" "net/http" + "os" + "sync" "time" ) +// logOutput is the destination for request logs +var ( + logOutput io.Writer = os.Stdout + logOutputMu sync.RWMutex +) + +// SetLogOutput sets the output destination for request logs. +// This is safe to call concurrently. +func SetLogOutput(w io.Writer) { + logOutputMu.Lock() + defer logOutputMu.Unlock() + logOutput = w +} + +// getLogOutput returns the current log output writer. +func getLogOutput() io.Writer { + logOutputMu.RLock() + defer logOutputMu.RUnlock() + return logOutput +} + +type JSONLoggingWriter struct { + http.ResponseWriter + JSONResponse []byte +} + +func (w *JSONLoggingWriter) Write(b []byte) (int, error) { + w.JSONResponse = append(w.JSONResponse, b...) + return w.ResponseWriter.Write(b) +} + func Logger(inner http.Handler, name string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() - inner.ServeHTTP(w, r) + jw := &JSONLoggingWriter{ResponseWriter: w} + + inner.ServeHTTP(jw, r) + + info := struct { + Time time.Time `json:"time"` + Method string `json:"method"` + URI string `json:"uri"` + Handler string `json:"handler"` + Duration time.Duration `json:"duration"` + Response string `json:"response"` + }{ + Time: start, + Method: r.Method, + URI: r.RequestURI, + Handler: name, + Duration: time.Since(start), + Response: string(jw.JSONResponse), + } - log.Printf( - "%s %s %s %s", - r.Method, - r.RequestURI, - name, - time.Since(start), - ) + var msg string + if jsonBytes, err := json.Marshal(info); err != nil { + msg = "{\"error\": \"failed to marshal log info\"}" + } else { + msg = string(jsonBytes) + } + fmt.Fprintln(getLogOutput(), msg) }) } diff --git a/go/model_thor_finding.go b/go/model_thor_finding.go index 04a2970..5362420 100644 --- a/go/model_thor_finding.go +++ b/go/model_thor_finding.go @@ -10,8 +10,12 @@ package thunderstormmock -// ThorFinding - THOR Finding +// ThorFinding - Mocked THOR Finding type ThorFinding struct { + Type string `json:"type,omitempty"` + ID int64 `json:"id,omitempty"` + Hash string `json:"hash,omitempty"` + Source string `json:"source,omitempty"` } // AssertThorFindingRequired checks if the required fields are not zero-ed diff --git a/go/model_thor_report.go b/go/model_thor_report.go index fd1e475..88f8ef6 100644 --- a/go/model_thor_report.go +++ b/go/model_thor_report.go @@ -11,9 +11,7 @@ package thunderstormmock // ThorReport - THOR Report containing findings -type ThorReport struct { - Items []ThorFinding -} +type ThorReport []ThorFinding // AssertThorReportRequired checks if the required fields are not zero-ed func AssertThorReportRequired(obj ThorReport) error { diff --git a/go/model_timestamp_map.go b/go/model_timestamp_map.go index 8e0a08d..53c48c9 100644 --- a/go/model_timestamp_map.go +++ b/go/model_timestamp_map.go @@ -11,8 +11,7 @@ package thunderstormmock // TimestampMap - Map of timestamps to integer values -type TimestampMap struct { -} +type TimestampMap map[string]uint64 // AssertTimestampMapRequired checks if the required fields are not zero-ed func AssertTimestampMapRequired(obj TimestampMap) error { diff --git a/go/state.go b/go/state.go new file mode 100644 index 0000000..c603f14 --- /dev/null +++ b/go/state.go @@ -0,0 +1,229 @@ +package thunderstormmock + +import ( + "crypto/sha256" + "fmt" + "io" + "math/rand" + "os" + "sync" + "time" + + "github.com/go-faker/faker/v4" +) + +type ScanID int64 + +var currentID ScanID + +func init() { + currentID = ScanID(rand.Int63n(1024) + 42) +} + +var idMutex sync.Mutex + +func NextScanID() ScanID { + idMutex.Lock() + defer idMutex.Unlock() + currentID++ + return currentID +} + +var scanRequests sync.Map + +type ScanRequest struct { + ID ScanID + Synchronous bool + FileHash string + Source string + SubmissionTime time.Time +} + +func (s ScanRequest) ToThorFinding() ThorFinding { + return ThorFinding{ + Type: "THOR Finding", + ID: int64(s.ID), + Hash: s.FileHash, + Source: s.Source, + } +} + +func (s ScanRequest) ToThorReport() ThorReport { + return ThorReport{s.ToThorFinding()} +} + +const ( + asyncWaitingTime = 2 * time.Second + asyncProgressTime = 5 * time.Second +) + +// ToResult converts the ScanRequest to a result. For convenience of use and to gather logic in one place, it works with synchronous requests, too, and simply (mis)uses an AsyncResult as a return type. If the scan ID is 0, it returns a failure status. +func (s ScanRequest) ToResult() AsyncResult { + if s.ID == 0 { + return AsyncResult{ + Status: crashed.String(), + } + } + + if s.Synchronous { + return AsyncResult{ + Status: finished.String(), + Result: s.ToThorReport(), + } + } + + age := time.Since(s.SubmissionTime) + switch { + case age < asyncWaitingTime: + return AsyncResult{ + Status: waiting.String(), + } + case age < asyncProgressTime: + return AsyncResult{ + Status: inProgress.String(), + } + default: + return AsyncResult{ + Status: finished.String(), + Result: s.ToThorReport(), + } + } +} + +func StoreScanRequest(synchronous bool, file *os.File, source string) (ScanRequest, error) { + submissionTime := time.Now() + + h := sha256.New() + f, err := os.Open(file.Name()) + if err != nil { + return ScanRequest{}, err + } + defer func() { _ = f.Close() }() + if _, err := io.Copy(h, f); err != nil { + return ScanRequest{}, err + } + fileHash := fmt.Sprintf("%x", h.Sum(nil)) + + scanID := NextScanID() + + scanRequest := ScanRequest{ + ID: scanID, + Synchronous: synchronous, + FileHash: fileHash, + Source: source, + SubmissionTime: submissionTime, + } + + scanRequests.Store(scanID, scanRequest) + return scanRequest, nil +} + +func LoadScanRequest(id int64) (ScanRequest, bool) { + if req, found := scanRequests.Load(ScanID(id)); found { + return req.(ScanRequest), true + } + return ScanRequest{}, false +} + +const historyTimeLayout = "2006-01-02 15:04" + +type historyType string + +const ( + sampleHistory historyType = "samples" + queueHistory historyType = "queue" +) + +func History(ht historyType, aggregateMinutes, limitMinutes int64) TimestampMap { + if aggregateMinutes < 1 { + aggregateMinutes = 1 + } + if limitMinutes <= 0 { + // The default maximum is some very long time ago. The value is not limited + // by the minimal time.Time value but by the time.Duration limit (int64 in + // nanoseconds, i.e., about 290 years) where limitMinutes is used in. + limitMinutes = 1 << 27 + } + + timesByTime := map[string]uint64{} + bucketDuration := time.Minute * time.Duration(aggregateMinutes) + now := time.Now() + minObservedTime := now + scanRequests.Range(func(key, value any) bool { + req := value.(ScanRequest) + bucketStart := req.SubmissionTime.Truncate(bucketDuration) + if bucketStart.Before(minObservedTime) { + minObservedTime = bucketStart + } + if ht == queueHistory { + if req.SubmissionTime.Add(asyncWaitingTime).Before(bucketStart.Add(bucketDuration)) { + return true + } + } + bucketString := bucketStart.Format(historyTimeLayout) + timesByTime[bucketString]++ + return true + }) + + history := map[string]uint64{} + oldestTime := minObservedTime + if limitTime := now.Truncate(time.Minute).Add(time.Minute * time.Duration(-1*limitMinutes+1)).Truncate(bucketDuration); limitTime.After(oldestTime) { + oldestTime = limitTime + } + for t := oldestTime; t.Before(now); t = t.Add(bucketDuration) { + bucketStr := t.Format(historyTimeLayout) + history[bucketStr] = timesByTime[bucketStr] + } + return history +} + +func MockInfo() ThunderstormInfo { + var ti ThunderstormInfo + _ = faker.FakeData(&ti) + return ti +} + +func MockStatus() ThunderstormStatus { + var scannedSamples int64 + var queuedRequests int64 + var totalScanTime time.Duration + var totalWaitTime time.Duration + + scanRequests.Range(func(key, value any) bool { + req := value.(ScanRequest) + age := time.Since(req.SubmissionTime) + + if req.Synchronous { + scannedSamples++ + totalScanTime += time.Duration(42+rand.Intn(500)) * time.Millisecond + } else { + result := req.ToResult() + switch result.Status { + case waiting.String(): + queuedRequests++ + totalWaitTime += age + case finished.String(): + scannedSamples++ + totalScanTime += time.Duration(42+rand.Intn(500)) * time.Millisecond + } + } + return true + }) + + var avgScanTimeMs int64 + if scannedSamples > 0 { + avgScanTimeMs = totalScanTime.Milliseconds() / scannedSamples + } + + var avgWaitTimeMs int64 + if queuedRequests > 0 { + avgWaitTimeMs = totalWaitTime.Milliseconds() / queuedRequests + } + + return ThunderstormStatus{ + ScannedSamples: scannedSamples, + QueuedAsyncRequests: queuedRequests, + AvgScanTimeMilliseconds: avgScanTimeMs, + AvgWaitTimeMilliseconds: avgWaitTimeMs, + } +} diff --git a/main.go b/main.go index 6ff38ef..8c90ae9 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,7 @@ -// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. - /* - * THOR Thunderstorm API + * THOR Thunderstorm Mock Server * - * This API allows you to send files to THOR to scan them and provides information about the running THOR instance. + * A mock implementation of the THOR Thunderstorm API for testing purposes. * * API version: 1.0.0 */ @@ -11,15 +9,151 @@ package main import ( + "flag" + "fmt" + "io" "log" "net/http" + "os" + "strings" thunderstormmock "github.com/NextronSystems/thunderstorm-mock/go" ) +// Version is the current version of the mock server. +// This should match the latest release tag (e.g., "v1.0.0"). +// During build, this can be overridden via -ldflags. +var Version = "v0.0.0-dev" + +const ( + envPrefix = "THUNDERSTORM_MOCK_" + + defaultPort = "8080" + defaultAddress = "" + defaultOutput = "-" +) + +// Config holds the server configuration +type Config struct { + Port string + Address string + Output string + Version bool + Help bool +} + +// getEnv returns the environment variable value or the default +func getEnv(key, defaultValue string) string { + if value := os.Getenv(envPrefix + key); value != "" { + return value + } + return defaultValue +} + +// parseFlags parses command-line flags with environment variable defaults +func parseFlags() *Config { + cfg := &Config{} + + // Define flags with environment variable defaults + flag.StringVar(&cfg.Port, "port", getEnv("PORT", defaultPort), + "Port to listen on (env: THUNDERSTORM_MOCK_PORT)") + flag.StringVar(&cfg.Port, "p", getEnv("PORT", defaultPort), + "Port to listen on (shorthand)") + + flag.StringVar(&cfg.Address, "address", getEnv("ADDRESS", defaultAddress), + "Network address to bind to (env: THUNDERSTORM_MOCK_ADDRESS)") + flag.StringVar(&cfg.Address, "a", getEnv("ADDRESS", defaultAddress), + "Network address to bind to (shorthand)") + + flag.StringVar(&cfg.Output, "output", getEnv("OUTPUT", defaultOutput), + "Output destination: '-' for stdout, or a file path (env: THUNDERSTORM_MOCK_OUTPUT)") + flag.StringVar(&cfg.Output, "o", getEnv("OUTPUT", defaultOutput), + "Output destination (shorthand)") + + flag.BoolVar(&cfg.Version, "version", false, "Print version and exit") + flag.BoolVar(&cfg.Version, "v", false, "Print version and exit (shorthand)") + + // Custom usage message + flag.Usage = func() { + fmt.Fprintf(os.Stderr, `THOR Thunderstorm Mock Server %s + +A mock implementation of the THOR Thunderstorm API for testing collector clients. + +Usage: + thunderstorm-mock [flags] + +Flags: + -p, --port string Port to listen on (default "%s") + Environment: THUNDERSTORM_MOCK_PORT + -a, --address string Network address to bind to (default "" = all interfaces) + Environment: THUNDERSTORM_MOCK_ADDRESS + -o, --output string Output destination for request logs: + '-' for stdout (default), or a file path + Environment: THUNDERSTORM_MOCK_OUTPUT + -v, --version Print version and exit + -h, --help Show this help message + +Examples: + # Start on default port 8080 + thunderstorm-mock + + # Start on port 9090 + thunderstorm-mock --port 9090 + + # Bind to localhost only and log to file + thunderstorm-mock -a 127.0.0.1 -p 8080 -o /var/log/thunderstorm-mock.log + + # Using environment variables + THUNDERSTORM_MOCK_PORT=9090 THUNDERSTORM_MOCK_OUTPUT=/tmp/mock.log thunderstorm-mock + +`, Version, defaultPort) + } + + flag.Parse() + + return cfg +} + +// setupOutput configures the output writer based on the output flag +// Returns the writer, a cleanup function to close any resources, and an error if setup fails. +func setupOutput(output string) (io.Writer, func(), error) { + if output == "-" { + return os.Stdout, func() {}, nil + } + + file, err := os.OpenFile(output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return nil, nil, fmt.Errorf("failed to open output file %q: %w", output, err) + } + + cleanup := func() { + _ = file.Close() + } + + return file, cleanup, nil +} + func main() { - log.Printf("Server started") + cfg := parseFlags() + + // Handle --version + if cfg.Version { + fmt.Println(Version) + os.Exit(0) + } + // Setup output + output, cleanup, err := setupOutput(cfg.Output) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + defer cleanup() + + // Configure the logger to use the specified output + thunderstormmock.SetLogOutput(output) + + // Create API services and controllers InfoAPIService := thunderstormmock.NewInfoAPIService() InfoAPIController := thunderstormmock.NewInfoAPIController(InfoAPIService) @@ -31,5 +165,20 @@ func main() { router := thunderstormmock.NewRouter(InfoAPIController, ResultsAPIController, ScanAPIController) - log.Fatal(http.ListenAndServe(":8080", router)) + // Build the listen address + listenAddr := cfg.Address + if !strings.Contains(listenAddr, ":") { + listenAddr = listenAddr + ":" + cfg.Port + } + + // Log startup info to stderr (always visible) + fmt.Fprintf(os.Stderr, "Thunderstorm Mock Server %s starting on %s\n", Version, listenAddr) + if cfg.Output != "-" { + fmt.Fprintf(os.Stderr, "Logging requests to: %s\n", cfg.Output) + } + + // Start the server + if err := http.ListenAndServe(listenAddr, router); err != nil { + log.Fatal(err) + } } From a581ab29adffe4bd80885a5d90f66bd8f5e967c5 Mon Sep 17 00:00:00 2001 From: gremat <50012463+gremat@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:00:51 +0100 Subject: [PATCH 3/4] test: add unittests and integration tests --- go/api_test.go | 1324 ++++++++++++++++++++++++++++++++++++++++++++++++ main_test.go | 795 +++++++++++++++++++++++++++++ 2 files changed, 2119 insertions(+) create mode 100644 go/api_test.go create mode 100644 main_test.go diff --git a/go/api_test.go b/go/api_test.go new file mode 100644 index 0000000..a2fde5a --- /dev/null +++ b/go/api_test.go @@ -0,0 +1,1324 @@ +/* + * THOR Thunderstorm API - Integration Tests + * + * This file contains integration tests for the Thunderstorm mock server. + * Tests verify that all API endpoints work correctly and that responses + * are properly logged to stdout as expected for testing collector clients. + * + * API version: 1.0.0 + * + * Author: Claude Opus 4.5 + * + * Note: Feel free to abandon these tests if they become too brittle or difficult to maintain, especially on API changes. Main parts should be working anyway assuming the generator and the generated code are working correctly. + */ + +package thunderstormmock + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +// testRouter creates a router with all API controllers for testing +func testRouter() *httptest.Server { + InfoAPIService := NewInfoAPIService() + InfoAPIController := NewInfoAPIController(InfoAPIService) + + ResultsAPIService := NewResultsAPIService() + ResultsAPIController := NewResultsAPIController(ResultsAPIService) + + ScanAPIService := NewScanAPIService() + ScanAPIController := NewScanAPIController(ScanAPIService) + + router := NewRouter(InfoAPIController, ResultsAPIController, ScanAPIController) + return httptest.NewServer(router) +} + +// createTestFile creates a temporary file with the given content for upload tests +func createTestFile(t *testing.T, content string) string { + t.Helper() + f, err := os.CreateTemp("", "thunderstorm-test-*.txt") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + if _, err := f.WriteString(content); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + t.Fatalf("failed to write to temp file: %v", err) + } + _ = f.Close() + return f.Name() +} + +// createMultipartFormFile creates a multipart form body with a file upload +func createMultipartFormFile(t *testing.T, fieldName, filePath string) (*bytes.Buffer, string) { + t.Helper() + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + file, err := os.Open(filePath) + if err != nil { + t.Fatalf("failed to open file: %v", err) + } + defer func() { _ = file.Close() }() + + part, err := writer.CreateFormFile(fieldName, filePath) + if err != nil { + t.Fatalf("failed to create form file: %v", err) + } + + if _, err := io.Copy(part, file); err != nil { + t.Fatalf("failed to copy file content: %v", err) + } + + if err := writer.Close(); err != nil { + t.Fatalf("failed to close writer: %v", err) + } + + return body, writer.FormDataContentType() +} + +// LogEntry represents the structure of log entries written to stdout +type LogEntry struct { + Time time.Time `json:"time"` + Method string `json:"method"` + URI string `json:"uri"` + Handler string `json:"handler"` + Duration int64 `json:"duration"` + Response string `json:"response"` +} + +// ==================== Info API Tests ==================== + +func TestInfoEndpoint(t *testing.T) { + server := testRouter() + defer server.Close() + + resp, err := http.Get(server.URL + "/api/v1/info") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + var info ThunderstormInfo + if err := json.Unmarshal(body, &info); err != nil { + t.Errorf("failed to parse response JSON: %v", err) + } + + // Verify required fields are present + if info.LicenseOwner == "" { + t.Error("expected non-empty license_owner") + } + if info.Threads == 0 { + t.Error("expected non-zero threads") + } +} + +func TestStatusEndpoint(t *testing.T) { + server := testRouter() + defer server.Close() + + resp, err := http.Get(server.URL + "/api/v1/status") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + var status ThunderstormStatus + if err := json.Unmarshal(body, &status); err != nil { + t.Errorf("failed to parse response JSON: %v", err) + } + + // Status should have required fields (even if zero values are valid) + if status.ScannedSamples < 0 { + t.Error("scanned_samples should not be negative") + } + if status.QueuedAsyncRequests < 0 { + t.Error("queued_async_requests should not be negative") + } + + // Verify response contains required JSON fields + if !strings.Contains(string(body), `"scanned_samples"`) { + t.Error("expected response to contain scanned_samples") + } +} + +func TestQueueHistoryEndpoint(t *testing.T) { + server := testRouter() + defer server.Close() + + resp, err := http.Get(server.URL + "/api/v1/queueHistory") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + var history TimestampMap + if err := json.Unmarshal(body, &history); err != nil { + t.Errorf("failed to parse response JSON: %v", err) + } +} + +func TestQueueHistoryWithParams(t *testing.T) { + server := testRouter() + defer server.Close() + + resp, err := http.Get(server.URL + "/api/v1/queueHistory?aggregate=5&limit=60") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + var history TimestampMap + if err := json.Unmarshal(body, &history); err != nil { + t.Errorf("failed to parse response JSON: %v", err) + } +} + +func TestSampleHistoryEndpoint(t *testing.T) { + server := testRouter() + defer server.Close() + + resp, err := http.Get(server.URL + "/api/v1/sampleHistory") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + var history TimestampMap + if err := json.Unmarshal(body, &history); err != nil { + t.Errorf("failed to parse response JSON: %v", err) + } +} + +func TestSampleHistoryWithParams(t *testing.T) { + server := testRouter() + defer server.Close() + + resp, err := http.Get(server.URL + "/api/v1/sampleHistory?aggregate=10&limit=120") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + var history TimestampMap + if err := json.Unmarshal(body, &history); err != nil { + t.Errorf("failed to parse response JSON: %v", err) + } +} + +// ==================== Scan API Tests ==================== + +func TestCheckEndpoint(t *testing.T) { + server := testRouter() + defer server.Close() + + // Create a test file + testFile := createTestFile(t, "test malicious content for scanning") + defer func() { _ = os.Remove(testFile) }() + + body, contentType := createMultipartFormFile(t, "file", testFile) + + req, err := http.NewRequest("POST", server.URL+"/api/v1/check", body) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + var report ThorReport + if err := json.Unmarshal(respBody, &report); err != nil { + t.Errorf("failed to parse response JSON: %v", err) + } + + // Should return at least one finding + if len(report) == 0 { + t.Error("expected at least one finding in report") + } + + // Verify finding has expected fields + if len(report) > 0 { + finding := report[0] + if finding.Type != "THOR Finding" { + t.Errorf("expected finding type 'THOR Finding', got '%s'", finding.Type) + } + if finding.Hash == "" { + t.Error("expected non-empty hash in finding") + } + } + + // Verify response contains expected JSON structure + if !strings.Contains(string(respBody), `"THOR Finding"`) { + t.Error("expected response to contain THOR Finding") + } +} + +func TestCheckEndpointWithSource(t *testing.T) { + server := testRouter() + defer server.Close() + + testFile := createTestFile(t, "test content with custom source") + defer func() { _ = os.Remove(testFile) }() + + body, contentType := createMultipartFormFile(t, "file", testFile) + + req, err := http.NewRequest("POST", server.URL+"/api/v1/check?source=custom-source", body) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + var report ThorReport + if err := json.Unmarshal(respBody, &report); err != nil { + t.Errorf("failed to parse response JSON: %v", err) + } + + // Verify source is in the response + if len(report) > 0 && report[0].Source != "custom-source" { + t.Errorf("expected source 'custom-source', got '%s'", report[0].Source) + } +} + +func TestCheckAsyncEndpoint(t *testing.T) { + server := testRouter() + defer server.Close() + + testFile := createTestFile(t, "test async content for scanning") + defer func() { _ = os.Remove(testFile) }() + + body, contentType := createMultipartFormFile(t, "file", testFile) + + req, err := http.NewRequest("POST", server.URL+"/api/v1/checkAsync", body) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + var sampleId SampleId + if err := json.Unmarshal(respBody, &sampleId); err != nil { + t.Errorf("failed to parse response JSON: %v", err) + } + + // Should return a valid ID + if sampleId.Id <= 0 { + t.Errorf("expected positive sample ID, got %d", sampleId.Id) + } + + // Verify response contains id field + if !strings.Contains(string(respBody), `"id"`) { + t.Error("expected response to contain id field") + } +} + +// ==================== Results API Tests ==================== + +func TestGetAsyncResultsWaiting(t *testing.T) { + server := testRouter() + defer server.Close() + + // First submit an async scan + testFile := createTestFile(t, "test content for async waiting state") + defer func() { _ = os.Remove(testFile) }() + + body, contentType := createMultipartFormFile(t, "file", testFile) + + req, err := http.NewRequest("POST", server.URL+"/api/v1/checkAsync", body) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + + var sampleId SampleId + respBody, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err := json.Unmarshal(respBody, &sampleId); err != nil { + t.Fatalf("failed to parse sample ID: %v", err) + } + + // Immediately check results (should be waiting) + resultResp, err := http.Get(fmt.Sprintf("%s/api/v1/getAsyncResults?id=%d", server.URL, sampleId.Id)) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resultResp.Body.Close() }() + + if resultResp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resultResp.StatusCode) + } + + resultBody, err := io.ReadAll(resultResp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + var result AsyncResult + if err := json.Unmarshal(resultBody, &result); err != nil { + t.Errorf("failed to parse response JSON: %v", err) + } + + // Should be in waiting state immediately after submission + if result.Status != waiting.String() { + t.Errorf("expected status '%s', got '%s'", waiting.String(), result.Status) + } +} + +func TestGetAsyncResultsComplete(t *testing.T) { + server := testRouter() + defer server.Close() + + // First submit an async scan + testFile := createTestFile(t, "test content for async complete state") + defer func() { _ = os.Remove(testFile) }() + + body, contentType := createMultipartFormFile(t, "file", testFile) + + req, err := http.NewRequest("POST", server.URL+"/api/v1/checkAsync", body) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + + var sampleId SampleId + respBody, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err := json.Unmarshal(respBody, &sampleId); err != nil { + t.Fatalf("failed to parse sample ID: %v", err) + } + + // Wait for the scan to complete (asyncProgressTime = 5 seconds) + time.Sleep(6 * time.Second) + + // Check results (should be complete) + resultResp, err := http.Get(fmt.Sprintf("%s/api/v1/getAsyncResults?id=%d", server.URL, sampleId.Id)) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resultResp.Body.Close() }() + + if resultResp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resultResp.StatusCode) + } + + resultBody, err := io.ReadAll(resultResp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + var result AsyncResult + if err := json.Unmarshal(resultBody, &result); err != nil { + t.Errorf("failed to parse response JSON: %v", err) + } + + // Should be completed with results + if result.Status != finished.String() { + t.Errorf("expected status '%s', got '%s'", finished.String(), result.Status) + } + + // Should have results + if len(result.Result) == 0 { + t.Error("expected results in completed scan") + } + + // Verify response contains the complete status + if !strings.Contains(string(resultBody), `"Sample analysis complete"`) { + t.Error("expected response to contain 'Sample analysis complete'") + } +} + +func TestGetAsyncResultsInvalidId(t *testing.T) { + server := testRouter() + defer server.Close() + + // Request with non-existent ID + resp, err := http.Get(server.URL + "/api/v1/getAsyncResults?id=999999999") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + var errResp Error + if err := json.Unmarshal(body, &errResp); err != nil { + t.Errorf("failed to parse response JSON: %v", err) + } + + if errResp.Message == "" { + t.Error("expected non-empty error message") + } +} + +func TestGetAsyncResultsCrashedTrigger(t *testing.T) { + server := testRouter() + defer server.Close() + + // ID=0 is a special trigger for crashed status + resp, err := http.Get(server.URL + "/api/v1/getAsyncResults?id=0") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + var result AsyncResult + if err := json.Unmarshal(body, &result); err != nil { + t.Errorf("failed to parse response JSON: %v", err) + } + + if result.Status != crashed.String() { + t.Errorf("expected status '%s', got '%s'", crashed.String(), result.Status) + } +} + +// ==================== Error Trigger Tests ==================== + +func TestCheckEndpointError400(t *testing.T) { + server := testRouter() + defer server.Close() + + testFile := createTestFile(t, "test content for error 400") + defer func() { _ = os.Remove(testFile) }() + + body, contentType := createMultipartFormFile(t, "file", testFile) + + // source="error 400" is a special trigger + req, err := http.NewRequest("POST", server.URL+"/api/v1/check?source=error%20400", body) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", resp.StatusCode) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + var errResp Error + if err := json.Unmarshal(respBody, &errResp); err != nil { + t.Errorf("failed to parse response JSON: %v", err) + } + + if errResp.Message != "Invalid parameters given" { + t.Errorf("expected 'Invalid parameters given', got '%s'", errResp.Message) + } + + // Verify error message is in response + if !strings.Contains(string(respBody), `"Invalid parameters given"`) { + t.Error("expected response to contain error message") + } +} + +func TestCheckEndpointError500(t *testing.T) { + server := testRouter() + defer server.Close() + + testFile := createTestFile(t, "test content for error 500") + defer func() { _ = os.Remove(testFile) }() + + body, contentType := createMultipartFormFile(t, "file", testFile) + + // source="error 500" is a special trigger + req, err := http.NewRequest("POST", server.URL+"/api/v1/check?source=error%20500", body) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusInternalServerError { + t.Errorf("expected status 500, got %d", resp.StatusCode) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + var errResp Error + if err := json.Unmarshal(respBody, &errResp); err != nil { + t.Errorf("failed to parse response JSON: %v", err) + } + + if errResp.Message != "Internal server error" { + t.Errorf("expected 'Internal server error', got '%s'", errResp.Message) + } +} + +func TestCheckAsyncEndpointError400(t *testing.T) { + server := testRouter() + defer server.Close() + + testFile := createTestFile(t, "test content for async error 400") + defer func() { _ = os.Remove(testFile) }() + + body, contentType := createMultipartFormFile(t, "file", testFile) + + req, err := http.NewRequest("POST", server.URL+"/api/v1/checkAsync?source=error%20400", body) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", resp.StatusCode) + } +} + +func TestCheckAsyncEndpointError500(t *testing.T) { + server := testRouter() + defer server.Close() + + testFile := createTestFile(t, "test content for async error 500") + defer func() { _ = os.Remove(testFile) }() + + body, contentType := createMultipartFormFile(t, "file", testFile) + + req, err := http.NewRequest("POST", server.URL+"/api/v1/checkAsync?source=error%20500", body) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusInternalServerError { + t.Errorf("expected status 500, got %d", resp.StatusCode) + } +} + +func TestGetAsyncResultsError400Trigger(t *testing.T) { + server := testRouter() + defer server.Close() + + // ID=-400 is a special trigger for 400 error + resp, err := http.Get(server.URL + "/api/v1/getAsyncResults?id=-400") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", resp.StatusCode) + } +} + +func TestGetAsyncResultsError500Trigger(t *testing.T) { + server := testRouter() + defer server.Close() + + // ID=-500 is a special trigger for 500 error + resp, err := http.Get(server.URL + "/api/v1/getAsyncResults?id=-500") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusInternalServerError { + t.Errorf("expected status 500, got %d", resp.StatusCode) + } +} + +// ==================== Integration/Workflow Tests ==================== + +func TestFullAsyncWorkflow(t *testing.T) { + server := testRouter() + defer server.Close() + + // 1. Submit a file for async scanning + testFile := createTestFile(t, "full workflow test content") + defer func() { _ = os.Remove(testFile) }() + + body, contentType := createMultipartFormFile(t, "file", testFile) + + req, err := http.NewRequest("POST", server.URL+"/api/v1/checkAsync", body) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + + var sampleId SampleId + respBody, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err := json.Unmarshal(respBody, &sampleId); err != nil { + t.Fatalf("failed to parse sample ID: %v", err) + } + + t.Logf("Received sample ID: %d", sampleId.Id) + + // 2. Check status - should be waiting initially + resultResp, err := http.Get(fmt.Sprintf("%s/api/v1/getAsyncResults?id=%d", server.URL, sampleId.Id)) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + + var result AsyncResult + resultBody, _ := io.ReadAll(resultResp.Body) + _ = resultResp.Body.Close() + _ = json.Unmarshal(resultBody, &result) + + if result.Status != waiting.String() { + t.Logf("Initial status: %s (expected %s)", result.Status, waiting.String()) + } + + // 3. Wait and check again - should progress through states + time.Sleep(3 * time.Second) + + resultResp, err = http.Get(fmt.Sprintf("%s/api/v1/getAsyncResults?id=%d", server.URL, sampleId.Id)) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + + resultBody, _ = io.ReadAll(resultResp.Body) + _ = resultResp.Body.Close() + _ = json.Unmarshal(resultBody, &result) + + if result.Status != inProgress.String() { + t.Logf("Progress status: %s (expected %s)", result.Status, inProgress.String()) + } + + // 4. Wait for completion + time.Sleep(3 * time.Second) + + resultResp, err = http.Get(fmt.Sprintf("%s/api/v1/getAsyncResults?id=%d", server.URL, sampleId.Id)) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + + resultBody, _ = io.ReadAll(resultResp.Body) + _ = resultResp.Body.Close() + _ = json.Unmarshal(resultBody, &result) + + if result.Status != finished.String() { + t.Errorf("expected final status '%s', got '%s'", finished.String(), result.Status) + } + + if len(result.Result) == 0 { + t.Error("expected findings in completed scan") + } + + // 5. Check status endpoint reflects the scan + statusResp, err := http.Get(server.URL + "/api/v1/status") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = statusResp.Body.Close() }() + + statusBody, _ := io.ReadAll(statusResp.Body) + var status ThunderstormStatus + _ = json.Unmarshal(statusBody, &status) + + if status.ScannedSamples < 1 { + t.Error("expected at least 1 scanned sample in status") + } +} + +// ==================== Stdout Logging Format Test ==================== + +// TestLoggerWritesJSON verifies the Logger middleware correctly writes JSON to stdout. +// This test uses httptest.ResponseRecorder to directly verify the logging behavior +// without relying on stdout capture which has race conditions with httptest.Server. +func TestLoggerWritesJSON(t *testing.T) { + // Create a simple handler + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{"test": "response"}) + }) + + // Wrap with Logger + loggedHandler := Logger(handler, "TestHandler") + + // Create a request and recorder + req := httptest.NewRequest("GET", "/test/endpoint", nil) + rr := httptest.NewRecorder() + + // This will print to stdout - we verify the handler works correctly + loggedHandler.ServeHTTP(rr, req) + + // Verify the response was captured properly + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + } + + // Verify response body + body := rr.Body.String() + if !strings.Contains(body, `"test"`) { + t.Error("expected response to contain test field") + } +} + +// TestJSONLoggingWriter verifies the JSONLoggingWriter captures response correctly +func TestJSONLoggingWriter(t *testing.T) { + rr := httptest.NewRecorder() + jw := &JSONLoggingWriter{ResponseWriter: rr} + + // Write some content + testContent := `{"key":"value"}` + n, err := jw.Write([]byte(testContent)) + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if n != len(testContent) { + t.Errorf("expected %d bytes written, got %d", len(testContent), n) + } + if string(jw.JSONResponse) != testContent { + t.Errorf("expected captured content '%s', got '%s'", testContent, string(jw.JSONResponse)) + } +} + +// ==================== Missing File Tests ==================== + +func TestCheckEndpointMissingFile(t *testing.T) { + server := testRouter() + defer server.Close() + + // Send POST without a file + req, err := http.NewRequest("POST", server.URL+"/api/v1/check", nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + // Should return an error (400 or 422 for missing required field) + if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnprocessableEntity { + t.Errorf("expected status 400 or 422, got %d", resp.StatusCode) + } +} + +// ==================== Content Type Tests ==================== + +func TestResponseContentType(t *testing.T) { + server := testRouter() + defer server.Close() + + endpoints := []string{ + "/api/v1/info", + "/api/v1/status", + "/api/v1/queueHistory", + "/api/v1/sampleHistory", + } + + for _, endpoint := range endpoints { + t.Run(endpoint, func(t *testing.T) { + resp, err := http.Get(server.URL + endpoint) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + contentType := resp.Header.Get("Content-Type") + if !strings.Contains(contentType, "application/json") { + t.Errorf("expected Content-Type to contain 'application/json', got '%s'", contentType) + } + }) + } +} + +// ==================== Concurrent Request Tests ==================== + +func TestConcurrentScans(t *testing.T) { + server := testRouter() + defer server.Close() + + numRequests := 10 + done := make(chan bool, numRequests) + + for i := 0; i < numRequests; i++ { + go func(n int) { + testFile := createTestFile(t, fmt.Sprintf("concurrent test content %d", n)) + defer func() { _ = os.Remove(testFile) }() + + body, contentType := createMultipartFormFile(t, "file", testFile) + + req, err := http.NewRequest("POST", server.URL+"/api/v1/check", body) + if err != nil { + t.Errorf("request %d: failed to create request: %v", n, err) + done <- false + return + } + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Errorf("request %d: failed to make request: %v", n, err) + done <- false + return + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Errorf("request %d: expected status 200, got %d", n, resp.StatusCode) + done <- false + return + } + + done <- true + }(i) + } + + // Wait for all requests + successCount := 0 + for i := 0; i < numRequests; i++ { + if <-done { + successCount++ + } + } + + if successCount != numRequests { + t.Errorf("expected %d successful requests, got %d", numRequests, successCount) + } +} + +// ==================== Async State Progression Test ==================== + +func TestAsyncStateProgression(t *testing.T) { + server := testRouter() + defer server.Close() + + // Submit file + testFile := createTestFile(t, "state progression test content") + defer func() { _ = os.Remove(testFile) }() + + body, contentType := createMultipartFormFile(t, "file", testFile) + + req, err := http.NewRequest("POST", server.URL+"/api/v1/checkAsync", body) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + + var sampleId SampleId + respBody, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + _ = json.Unmarshal(respBody, &sampleId) + + // Test state progression + states := []struct { + sleepBefore time.Duration + expected status + }{ + {0, waiting}, + {2500 * time.Millisecond, inProgress}, + {3 * time.Second, finished}, + } + + for i, s := range states { + time.Sleep(s.sleepBefore) + + resultResp, err := http.Get(fmt.Sprintf("%s/api/v1/getAsyncResults?id=%d", server.URL, sampleId.Id)) + if err != nil { + t.Fatalf("step %d: failed to make request: %v", i, err) + } + + resultBody, _ := io.ReadAll(resultResp.Body) + _ = resultResp.Body.Close() + + var result AsyncResult + _ = json.Unmarshal(resultBody, &result) + + if result.Status != s.expected.String() { + t.Errorf("step %d: expected status '%s', got '%s'", i, s.expected.String(), result.Status) + } + } +} + +// ==================== Multiple Async Requests Test ==================== + +func TestMultipleAsyncRequests(t *testing.T) { + server := testRouter() + defer server.Close() + + numRequests := 5 + sampleIds := make([]int64, numRequests) + + // Submit multiple async requests + for i := 0; i < numRequests; i++ { + testFile := createTestFile(t, fmt.Sprintf("multi async test content %d", i)) + defer func() { _ = os.Remove(testFile) }() + + body, contentType := createMultipartFormFile(t, "file", testFile) + + req, err := http.NewRequest("POST", server.URL+"/api/v1/checkAsync", body) + if err != nil { + t.Fatalf("failed to create request %d: %v", i, err) + } + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request %d: %v", i, err) + } + + var sampleId SampleId + respBody, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + _ = json.Unmarshal(respBody, &sampleId) + + sampleIds[i] = sampleId.Id + } + + // Verify all IDs are unique + idSet := make(map[int64]bool) + for _, id := range sampleIds { + if idSet[id] { + t.Errorf("duplicate sample ID: %d", id) + } + idSet[id] = true + } + + // Wait for completion and verify all results + time.Sleep(6 * time.Second) + + for i, id := range sampleIds { + resultResp, err := http.Get(fmt.Sprintf("%s/api/v1/getAsyncResults?id=%d", server.URL, id)) + if err != nil { + t.Fatalf("failed to get results for request %d: %v", i, err) + } + + resultBody, _ := io.ReadAll(resultResp.Body) + _ = resultResp.Body.Close() + + var result AsyncResult + _ = json.Unmarshal(resultBody, &result) + + if result.Status != finished.String() { + t.Errorf("request %d (ID %d): expected status '%s', got '%s'", i, id, finished.String(), result.Status) + } + + if len(result.Result) == 0 { + t.Errorf("request %d (ID %d): expected findings in result", i, id) + } + } +} + +// ==================== Response Body Verification Tests ==================== + +func TestInfoResponseContainsRequiredFields(t *testing.T) { + server := testRouter() + defer server.Close() + + resp, err := http.Get(server.URL + "/api/v1/info") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) + + requiredFields := []string{ + "version_info", + "arguments", + "license_expiration", + "license_owner", + "scan_speed_limitation", + "threads", + } + + for _, field := range requiredFields { + if !strings.Contains(bodyStr, fmt.Sprintf(`"%s"`, field)) { + t.Errorf("expected response to contain field '%s'", field) + } + } +} + +func TestStatusResponseContainsRequiredFields(t *testing.T) { + server := testRouter() + defer server.Close() + + resp, err := http.Get(server.URL + "/api/v1/status") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) + + requiredFields := []string{ + "scanned_samples", + "queued_async_requests", + "avg_scan_time_milliseconds", + "avg_wait_time_milliseconds", + } + + for _, field := range requiredFields { + if !strings.Contains(bodyStr, fmt.Sprintf(`"%s"`, field)) { + t.Errorf("expected response to contain field '%s'", field) + } + } +} + +func TestCheckResponseContainsRequiredFields(t *testing.T) { + server := testRouter() + defer server.Close() + + testFile := createTestFile(t, "check response test content") + defer func() { _ = os.Remove(testFile) }() + + body, contentType := createMultipartFormFile(t, "file", testFile) + + req, err := http.NewRequest("POST", server.URL+"/api/v1/check", body) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, _ := io.ReadAll(resp.Body) + bodyStr := string(respBody) + + // ThorFinding should have these fields + requiredFields := []string{ + "type", + "id", + "hash", + } + + for _, field := range requiredFields { + if !strings.Contains(bodyStr, fmt.Sprintf(`"%s"`, field)) { + t.Errorf("expected response to contain field '%s'", field) + } + } +} + +func TestAsyncResultResponseContainsRequiredFields(t *testing.T) { + server := testRouter() + defer server.Close() + + testFile := createTestFile(t, "async result response test content") + defer func() { _ = os.Remove(testFile) }() + + body, contentType := createMultipartFormFile(t, "file", testFile) + + req, err := http.NewRequest("POST", server.URL+"/api/v1/checkAsync", body) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + + var sampleId SampleId + respBody, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + _ = json.Unmarshal(respBody, &sampleId) + + // Get the async result + resultResp, err := http.Get(fmt.Sprintf("%s/api/v1/getAsyncResults?id=%d", server.URL, sampleId.Id)) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resultResp.Body.Close() }() + + resultBody, _ := io.ReadAll(resultResp.Body) + bodyStr := string(resultBody) + + // AsyncResult must have status field + if !strings.Contains(bodyStr, `"status"`) { + t.Error("expected response to contain field 'status'") + } +} + +// ==================== Error Response Tests ==================== + +func TestErrorResponseFormat(t *testing.T) { + server := testRouter() + defer server.Close() + + // Test invalid async results ID + resp, err := http.Get(server.URL + "/api/v1/getAsyncResults?id=999999999") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) + + // Error response must have message field + if !strings.Contains(bodyStr, `"message"`) { + t.Error("expected error response to contain field 'message'") + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..c33d5b3 --- /dev/null +++ b/main_test.go @@ -0,0 +1,795 @@ +//go:build integration + +/* + * THOR Thunderstorm Mock Server - Integration Tests + * + * These tests treat the mock server as a black box: + * 1. Build and start the server as a subprocess + * 2. Send HTTP requests to the server + * 3. Verify responses match the OpenAPI specification + * 4. Verify stdout logging works correctly (for testing collector clients) + * + * Run with: go test -tags=integration -v + * + * Author: Claude Opus 4.5 + */ + +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "os/exec" + "strings" + "sync" + "syscall" + "testing" + "time" +) + +const ( + serverAddr = "http://localhost:8080" + apiBase = serverAddr + "/api/v1" + startupDelay = 2 * time.Second + shutdownDelay = 500 * time.Millisecond +) + +// serverProcess holds the running server process and its stdout +type serverProcess struct { + cmd *exec.Cmd + cancel context.CancelFunc + stdoutMu sync.Mutex + stdoutBuf bytes.Buffer + wg sync.WaitGroup +} + +// startServer builds and starts the mock server, returning stdout for log verification +func startServer(t *testing.T) *serverProcess { + t.Helper() + + // Build the server + buildCmd := exec.Command("go", "build", "-o", "thunderstorm-mock-test", ".") + buildCmd.Stderr = os.Stderr + if err := buildCmd.Run(); err != nil { + t.Fatalf("failed to build server: %v", err) + } + + // Create context for the server process + ctx, cancel := context.WithCancel(context.Background()) + + // Start the server + cmd := exec.CommandContext(ctx, "./thunderstorm-mock-test") + + // Capture stdout for log verification + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + cancel() + t.Fatalf("failed to create stdout pipe: %v", err) + } + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + cancel() + t.Fatalf("failed to start server: %v", err) + } + + sp := &serverProcess{ + cmd: cmd, + cancel: cancel, + } + + // Read stdout in background goroutine + sp.wg.Add(1) + go func() { + defer sp.wg.Done() + scanner := bufio.NewScanner(stdoutPipe) + for scanner.Scan() { + line := scanner.Text() + sp.stdoutMu.Lock() + sp.stdoutBuf.WriteString(line) + sp.stdoutBuf.WriteString("\n") + sp.stdoutMu.Unlock() + } + }() + + // Wait for server to be ready + if !waitForServer(t, serverAddr, startupDelay) { + sp.stop(t) + t.Fatal("server failed to start in time") + } + + return sp +} + +// stop gracefully shuts down the server +func (sp *serverProcess) stop(t *testing.T) { + t.Helper() + + // Send interrupt signal for graceful shutdown + if sp.cmd.Process != nil { + _ = sp.cmd.Process.Signal(syscall.SIGTERM) + } + + // Wait briefly then force cancel + time.Sleep(shutdownDelay) + sp.cancel() + _ = sp.cmd.Wait() + sp.wg.Wait() + + // Clean up binary + _ = os.Remove("./thunderstorm-mock-test") +} + +// getStdout returns collected stdout output +func (sp *serverProcess) getStdout() string { + // Give a moment for logs to flush + time.Sleep(200 * time.Millisecond) + sp.stdoutMu.Lock() + defer sp.stdoutMu.Unlock() + return sp.stdoutBuf.String() +} + +// waitForServer polls the server until it responds or timeout +func waitForServer(t *testing.T, addr string, timeout time.Duration) bool { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + resp, err := http.Get(addr + "/api/v1/status") + if err == nil { + _ = resp.Body.Close() + return true + } + time.Sleep(100 * time.Millisecond) + } + return false +} + +// createTestFile creates a temporary file for upload +func createTestFile(t *testing.T, content string) string { + t.Helper() + f, err := os.CreateTemp("", "thunderstorm-integration-*.txt") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + if _, err := f.WriteString(content); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + t.Fatalf("failed to write temp file: %v", err) + } + _ = f.Close() + return f.Name() +} + +// uploadFile creates a multipart request body with a file +func uploadFile(t *testing.T, filePath string) (*bytes.Buffer, string) { + t.Helper() + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + file, err := os.Open(filePath) + if err != nil { + t.Fatalf("failed to open file: %v", err) + } + defer func() { _ = file.Close() }() + + part, err := writer.CreateFormFile("file", filePath) + if err != nil { + t.Fatalf("failed to create form file: %v", err) + } + + if _, err := io.Copy(part, file); err != nil { + t.Fatalf("failed to copy file: %v", err) + } + + _ = writer.Close() + return body, writer.FormDataContentType() +} + +// LogEntry represents the JSON log structure written to stdout +type LogEntry struct { + Time string `json:"time"` + Method string `json:"method"` + URI string `json:"uri"` + Handler string `json:"handler"` + Duration int64 `json:"duration"` + Response string `json:"response"` +} + +// parseLogEntries extracts log entries from stdout +func parseLogEntries(stdout string) []LogEntry { + var entries []LogEntry + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var entry LogEntry + if err := json.Unmarshal([]byte(line), &entry); err == nil { + entries = append(entries, entry) + } + } + return entries +} + +// ==================== Integration Tests ==================== + +func TestServerStartsAndResponds(t *testing.T) { + server := startServer(t) + defer server.stop(t) + + // Simple health check - server should respond + resp, err := http.Get(apiBase + "/status") + if err != nil { + t.Fatalf("server not responding: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } +} + +func TestInfoEndpointReturnsValidJSON(t *testing.T) { + server := startServer(t) + defer server.stop(t) + + resp, err := http.Get(apiBase + "/info") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + body, _ := io.ReadAll(resp.Body) + + // Verify it's valid JSON with expected structure + var info map[string]interface{} + if err := json.Unmarshal(body, &info); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + + // Check required fields per OpenAPI spec + requiredFields := []string{"version_info", "arguments", "license_expiration", "license_owner", "threads"} + for _, field := range requiredFields { + if _, ok := info[field]; !ok { + t.Errorf("missing required field: %s", field) + } + } + + // Verify stdout logging + stdout := server.getStdout() + if !strings.Contains(stdout, `"handler":"Info"`) { + t.Error("expected Info handler to be logged to stdout") + } + if !strings.Contains(stdout, `"method":"GET"`) { + t.Error("expected GET method to be logged") + } +} + +func TestStatusEndpointReturnsValidJSON(t *testing.T) { + server := startServer(t) + defer server.stop(t) + + resp, err := http.Get(apiBase + "/status") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + body, _ := io.ReadAll(resp.Body) + + var status map[string]interface{} + if err := json.Unmarshal(body, &status); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + + // Check required fields per OpenAPI spec + requiredFields := []string{"scanned_samples", "queued_async_requests", "avg_scan_time_milliseconds", "avg_wait_time_milliseconds"} + for _, field := range requiredFields { + if _, ok := status[field]; !ok { + t.Errorf("missing required field: %s", field) + } + } +} + +func TestSynchronousScanWorkflow(t *testing.T) { + server := startServer(t) + defer server.stop(t) + + // Create and upload a test file + testFile := createTestFile(t, "test malware content for sync scan") + defer func() { _ = os.Remove(testFile) }() + + body, contentType := uploadFile(t, testFile) + + req, err := http.NewRequest("POST", apiBase+"/check", body) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + respBody, _ := io.ReadAll(resp.Body) + + // Response should be an array (ThorReport) + var report []map[string]interface{} + if err := json.Unmarshal(respBody, &report); err != nil { + t.Fatalf("response is not valid JSON array: %v", err) + } + + if len(report) == 0 { + t.Error("expected at least one finding in report") + } + + // Verify finding structure + if len(report) > 0 { + finding := report[0] + if finding["type"] != "THOR Finding" { + t.Errorf("expected type 'THOR Finding', got '%v'", finding["type"]) + } + if finding["hash"] == nil || finding["hash"] == "" { + t.Error("expected non-empty hash in finding") + } + } + + // Verify stdout logging contains response + stdout := server.getStdout() + if !strings.Contains(stdout, `"handler":"Check"`) { + t.Error("expected Check handler to be logged") + } + // Note: The response is embedded as a JSON string, so quotes are escaped + if !strings.Contains(stdout, `\"THOR Finding\"`) { + t.Error("expected finding to be logged in response") + } +} + +func TestAsynchronousScanWorkflow(t *testing.T) { + server := startServer(t) + defer server.stop(t) + + // Step 1: Submit file for async scan + testFile := createTestFile(t, "test malware content for async scan") + defer func() { _ = os.Remove(testFile) }() + + body, contentType := uploadFile(t, testFile) + + req, err := http.NewRequest("POST", apiBase+"/checkAsync", body) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + + respBody, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + + var sampleId map[string]interface{} + if err := json.Unmarshal(respBody, &sampleId); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + + id, ok := sampleId["id"].(float64) + if !ok || id <= 0 { + t.Fatalf("expected positive sample ID, got %v", sampleId["id"]) + } + + t.Logf("Received sample ID: %.0f", id) + + // Step 2: Check status immediately (should be waiting) + resultResp, err := http.Get(fmt.Sprintf("%s/getAsyncResults?id=%.0f", apiBase, id)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + resultBody, _ := io.ReadAll(resultResp.Body) + _ = resultResp.Body.Close() + + var result map[string]interface{} + _ = json.Unmarshal(resultBody, &result) + + if result["status"] != "Waiting for execution" { + t.Logf("Initial status: %v (expected 'Waiting for execution')", result["status"]) + } + + // Step 3: Wait for scan to complete and check again + time.Sleep(6 * time.Second) + + resultResp, err = http.Get(fmt.Sprintf("%s/getAsyncResults?id=%.0f", apiBase, id)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + resultBody, _ = io.ReadAll(resultResp.Body) + _ = resultResp.Body.Close() + + _ = json.Unmarshal(resultBody, &result) + + if result["status"] != "Sample analysis complete" { + t.Errorf("expected 'Sample analysis complete', got '%v'", result["status"]) + } + + // Verify result contains findings + if result["result"] == nil { + t.Error("expected result field in completed scan") + } + + // Verify stdout logging + stdout := server.getStdout() + if !strings.Contains(stdout, `"handler":"CheckAsync"`) { + t.Error("expected CheckAsync handler to be logged") + } + if !strings.Contains(stdout, `"handler":"GetAsyncResults"`) { + t.Error("expected GetAsyncResults handler to be logged") + } +} + +func TestStdoutLoggingFormat(t *testing.T) { + server := startServer(t) + defer server.stop(t) + + // Make a request + resp, err := http.Get(apiBase + "/info") + if err != nil { + t.Fatalf("request failed: %v", err) + } + _ = resp.Body.Close() + + // Give time for log to be written + stdout := server.getStdout() + entries := parseLogEntries(stdout) + + if len(entries) == 0 { + t.Fatal("no log entries found in stdout") + } + + // Find the info request log entry + var infoEntry *LogEntry + for i := range entries { + if entries[i].Handler == "Info" { + infoEntry = &entries[i] + break + } + } + + if infoEntry == nil { + t.Fatal("Info handler log entry not found") + } + + // Verify log entry structure + if infoEntry.Method != "GET" { + t.Errorf("expected method 'GET', got '%s'", infoEntry.Method) + } + if infoEntry.URI != "/api/v1/info" { + t.Errorf("expected URI '/api/v1/info', got '%s'", infoEntry.URI) + } + if infoEntry.Time == "" { + t.Error("expected non-empty time field") + } + if infoEntry.Response == "" { + t.Error("expected non-empty response field") + } + + // Verify response in log is valid JSON + var loggedResponse map[string]interface{} + if err := json.Unmarshal([]byte(infoEntry.Response), &loggedResponse); err != nil { + t.Errorf("logged response is not valid JSON: %v", err) + } +} + +func TestErrorTriggers(t *testing.T) { + server := startServer(t) + defer server.stop(t) + + testFile := createTestFile(t, "test content") + defer func() { _ = os.Remove(testFile) }() + + tests := []struct { + name string + endpoint string + source string + expectedStatus int + expectedMsg string + }{ + { + name: "Check 400 error", + endpoint: "/check", + source: "error 400", + expectedStatus: http.StatusBadRequest, + expectedMsg: "Invalid parameters given", + }, + { + name: "Check 500 error", + endpoint: "/check", + source: "error 500", + expectedStatus: http.StatusInternalServerError, + expectedMsg: "Internal server error", + }, + { + name: "CheckAsync 400 error", + endpoint: "/checkAsync", + source: "error 400", + expectedStatus: http.StatusBadRequest, + expectedMsg: "Invalid parameters given", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + body, contentType := uploadFile(t, testFile) + + // Properly URL-encode the source parameter + endpoint := fmt.Sprintf("%s%s?source=%s", apiBase, tc.endpoint, url.QueryEscape(tc.source)) + req, _ := http.NewRequest("POST", endpoint, body) + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != tc.expectedStatus { + t.Errorf("expected status %d, got %d", tc.expectedStatus, resp.StatusCode) + } + + respBody, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(respBody), tc.expectedMsg) { + t.Errorf("expected message '%s' in response, got: %s", tc.expectedMsg, string(respBody)) + } + }) + } +} + +func TestGetAsyncResultsErrorTriggers(t *testing.T) { + server := startServer(t) + defer server.stop(t) + + tests := []struct { + name string + id string + expectedStatus int + }{ + {"Crashed trigger (id=0)", "0", http.StatusOK}, + {"Bad request trigger (id=-400)", "-400", http.StatusBadRequest}, + {"Server error trigger (id=-500)", "-500", http.StatusInternalServerError}, + {"Invalid ID", "999999999", http.StatusBadRequest}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resp, err := http.Get(fmt.Sprintf("%s/getAsyncResults?id=%s", apiBase, tc.id)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != tc.expectedStatus { + t.Errorf("expected status %d, got %d", tc.expectedStatus, resp.StatusCode) + } + }) + } + + // Special check for crashed status message + resp, _ := http.Get(apiBase + "/getAsyncResults?id=0") + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + + if !strings.Contains(string(body), "Sample analysis failed") { + t.Error("expected 'Sample analysis failed' status for id=0") + } +} + +func TestHistoryEndpoints(t *testing.T) { + server := startServer(t) + defer server.stop(t) + + endpoints := []struct { + name string + path string + }{ + {"QueueHistory", "/queueHistory"}, + {"QueueHistory with params", "/queueHistory?aggregate=5&limit=60"}, + {"SampleHistory", "/sampleHistory"}, + {"SampleHistory with params", "/sampleHistory?aggregate=10&limit=120"}, + } + + for _, ep := range endpoints { + t.Run(ep.name, func(t *testing.T) { + resp, err := http.Get(apiBase + ep.path) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + body, _ := io.ReadAll(resp.Body) + + // Should be valid JSON object (TimestampMap) + var history map[string]interface{} + if err := json.Unmarshal(body, &history); err != nil { + t.Errorf("response is not valid JSON: %v", err) + } + }) + } +} + +func TestStatusReflectsScans(t *testing.T) { + server := startServer(t) + defer server.stop(t) + + // Get initial status + resp, _ := http.Get(apiBase + "/status") + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + + var initialStatus map[string]interface{} + _ = json.Unmarshal(body, &initialStatus) + initialScans := initialStatus["scanned_samples"].(float64) + + // Perform a sync scan + testFile := createTestFile(t, "content to increase scan count") + defer func() { _ = os.Remove(testFile) }() + + uploadBody, contentType := uploadFile(t, testFile) + req, _ := http.NewRequest("POST", apiBase+"/check", uploadBody) + req.Header.Set("Content-Type", contentType) + scanResp, _ := http.DefaultClient.Do(req) + _ = scanResp.Body.Close() + + // Get updated status + resp, _ = http.Get(apiBase + "/status") + body, _ = io.ReadAll(resp.Body) + _ = resp.Body.Close() + + var newStatus map[string]interface{} + _ = json.Unmarshal(body, &newStatus) + newScans := newStatus["scanned_samples"].(float64) + + if newScans <= initialScans { + t.Errorf("expected scanned_samples to increase, was %.0f, now %.0f", initialScans, newScans) + } +} + +func TestResponseContentType(t *testing.T) { + server := startServer(t) + defer server.stop(t) + + endpoints := []string{"/info", "/status", "/queueHistory", "/sampleHistory"} + + for _, ep := range endpoints { + t.Run(ep, func(t *testing.T) { + resp, err := http.Get(apiBase + ep) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + ct := resp.Header.Get("Content-Type") + if !strings.Contains(ct, "application/json") { + t.Errorf("expected Content-Type application/json, got %s", ct) + } + }) + } +} + +func TestScanWithCustomSource(t *testing.T) { + server := startServer(t) + defer server.stop(t) + + testFile := createTestFile(t, "test content with source") + defer func() { _ = os.Remove(testFile) }() + + body, contentType := uploadFile(t, testFile) + + // Use a custom source parameter + endpoint := apiBase + "/check?source=" + url.QueryEscape("my-custom-source") + req, _ := http.NewRequest("POST", endpoint, body) + req.Header.Set("Content-Type", contentType) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, _ := io.ReadAll(resp.Body) + + var report []map[string]interface{} + _ = json.Unmarshal(respBody, &report) + + if len(report) > 0 && report[0]["source"] != "my-custom-source" { + t.Errorf("expected source 'my-custom-source', got '%v'", report[0]["source"]) + } +} + +func TestAsyncStateProgression(t *testing.T) { + server := startServer(t) + defer server.stop(t) + + testFile := createTestFile(t, "async state progression test") + defer func() { _ = os.Remove(testFile) }() + + body, contentType := uploadFile(t, testFile) + req, _ := http.NewRequest("POST", apiBase+"/checkAsync", body) + req.Header.Set("Content-Type", contentType) + + resp, _ := http.DefaultClient.Do(req) + respBody, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + + var sampleId map[string]interface{} + _ = json.Unmarshal(respBody, &sampleId) + id := sampleId["id"].(float64) + + // State 1: Waiting (immediate) + resultResp, _ := http.Get(fmt.Sprintf("%s/getAsyncResults?id=%.0f", apiBase, id)) + resultBody, _ := io.ReadAll(resultResp.Body) + _ = resultResp.Body.Close() + + var result map[string]interface{} + _ = json.Unmarshal(resultBody, &result) + + if result["status"] != "Waiting for execution" { + t.Errorf("expected 'Waiting for execution', got '%v'", result["status"]) + } + + // State 2: In Progress (after ~2 seconds) + time.Sleep(2500 * time.Millisecond) + + resultResp, _ = http.Get(fmt.Sprintf("%s/getAsyncResults?id=%.0f", apiBase, id)) + resultBody, _ = io.ReadAll(resultResp.Body) + _ = resultResp.Body.Close() + + _ = json.Unmarshal(resultBody, &result) + + if result["status"] != "Currently being scanned" { + t.Errorf("expected 'Currently being scanned', got '%v'", result["status"]) + } + + // State 3: Complete (after ~5 seconds total) + time.Sleep(3 * time.Second) + + resultResp, _ = http.Get(fmt.Sprintf("%s/getAsyncResults?id=%.0f", apiBase, id)) + resultBody, _ = io.ReadAll(resultResp.Body) + _ = resultResp.Body.Close() + + _ = json.Unmarshal(resultBody, &result) + + if result["status"] != "Sample analysis complete" { + t.Errorf("expected 'Sample analysis complete', got '%v'", result["status"]) + } + + // Should have results when complete + if result["result"] == nil { + t.Error("expected result field when status is complete") + } +} From e1a7dffd412e6a4e35d06f5f00c58b98d4edeafb Mon Sep 17 00:00:00 2001 From: gremat <50012463+gremat@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:01:16 +0100 Subject: [PATCH 4/4] chore: add pipelines --- .github/workflows/ci.yml | 199 +++++++++++++++++++++++++++++++++++++++ README.md | 82 ++++++++++++---- 2 files changed, 262 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c8939e1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,199 @@ +name: CI + +on: + push: + branches: [main, master] + tags: + - 'v*.*.*' + pull_request: + branches: [main, master] + +jobs: + format: + name: Check Formatting + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Check gofmt + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "The following files are not formatted correctly:" + echo "$unformatted" + echo "" + echo "Run 'gofmt -w .' to fix formatting" + exit 1 + fi + echo "All files are properly formatted" + + lint: + name: Lint + runs-on: ubuntu-latest + needs: format + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + + test: + name: Test + runs-on: ubuntu-latest + needs: lint + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Download dependencies + run: go mod download + + - name: Run unit tests + run: go test -v -count=1 ./go/... + + - name: Run integration tests + run: go test -tags=integration -v -count=1 + + build: + name: Build + runs-on: ubuntu-latest + needs: test + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Needed for git describe + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Download dependencies + run: go mod download + + - name: Get version from git + id: version + run: | + VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "v0.0.0-dev") + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Building version: $VERSION" + + - name: Build binary with version + run: | + go build -ldflags "-X main.Version=${{ steps.version.outputs.version }}" -o thunderstorm-mock . + + - name: Verify version in binary + run: | + ./thunderstorm-mock --version + BINARY_VERSION=$(./thunderstorm-mock --version) + echo "Binary reports version: $BINARY_VERSION" + if [ "$BINARY_VERSION" != "${{ steps.version.outputs.version }}" ]; then + echo "ERROR: Binary version mismatch!" + exit 1 + fi + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: thunderstorm-mock + path: thunderstorm-mock + retention-days: 7 + + release: + name: Release + runs-on: ubuntu-latest + needs: build + # Quick minimal test, proper one follows + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Verify tag version + id: tag + run: | + TAG="${GITHUB_REF#refs/tags/}" + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "" + echo "ERROR: Tag pattern mismatch!" + echo "Tag $TAG does not match the required format v[0-9]+.[0-9]+.[0-9]+" + echo "" + exit 1 + fi + echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "Release tag: $TAG" + + - name: Validate version constant matches tag + run: | + TAG="${{ steps.tag.outputs.tag }}" + # Extract the Version constant from main.go + CODE_VERSION=$(grep -E '^var Version = ".*"$' main.go | sed 's/.*"\(.*\)".*/\1/') + echo "Tag version: $TAG" + echo "Code version constant: $CODE_VERSION" + if [ "$CODE_VERSION" != "$TAG" ]; then + echo "" + echo "ERROR: Version mismatch!" + echo "The Version constant in main.go ($CODE_VERSION) does not match the git tag ($TAG)." + echo "" + echo "Please update the Version constant in main.go to match the release tag:" + echo " var Version = \"$TAG\"" + echo "" + exit 1 + fi + echo "Version constant matches tag - OK" + + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: thunderstorm-mock + + - name: Make binary executable + run: chmod +x thunderstorm-mock + + - name: Verify binary version matches tag + run: | + BINARY_VERSION=$(./thunderstorm-mock --version) + TAG="${{ steps.tag.outputs.tag }}" + echo "Binary version: $BINARY_VERSION" + echo "Tag version: $TAG" + if [ "$BINARY_VERSION" != "$TAG" ]; then + echo "ERROR: Binary version does not match tag!" + exit 1 + fi + echo "Binary version matches tag - OK" + + - name: Rename binary with version + run: | + TAG="${{ steps.tag.outputs.tag }}" + mv thunderstorm-mock "thunderstorm-mock-${TAG}" + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: thunderstorm-mock-${{ steps.tag.outputs.tag }} diff --git a/README.md b/README.md index 06c90fd..f5368c1 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,80 @@ # Thunderstorm Mock Server -The server implements a mock version of the Thunderstorm API that can be used for testing purposes. All requests do nothing but log to the console. +The server implements a mock version of the Thunderstorm API that can be used for testing purposes. All requests do nothing but logging. -## Overview +## Download -This server was generated by the [openapi-generator](https://openapi-generator.tech) project by using the -[Thunderstorm OpenAPI specification](https://github.com/NextronSystems/thunderstorm-openapi) and the -configuration found in `./openapi-genconfig.yaml`. +Download the latest release from the [Releases page](https://github.com/NextronSystems/thunderstorm-mock/releases). -- API version: 1.0.0 -- Build date: 2026-01-26T17:16:00.310597514+01:00[Europe/Berlin] -- Generator version: 7.19.0 -- Command used to generate this server: - ```bash - GO_POST_PROCESS_FILE="gofmt -w" openapi-generator-cli generate --generator-name go-server --config ./openapi-genconfig.yaml - ``` +## Usage + +```bash +./thunderstorm-mock [options] +``` + +### Options -### Running the server -To run the server, follow these simple steps: +| Flag | Environment Variable | Default | Description | +|------|---------------------|---------|-------------| +| `-p, --port` | `THUNDERSTORM_MOCK_PORT` | `8080` | Port to listen on | +| `-a, --address` | `THUNDERSTORM_MOCK_ADDRESS` | (all interfaces) | Address to bind to | +| `-o, --output` | `THUNDERSTORM_MOCK_OUTPUT` | (stdout) | Log output file path | +| `-v, --version` | | | Print version and exit | +| `-h, --help` | | | Print help and exit | +CLI flags take precedence over environment variables. + +### Examples + +```bash +# Start server on default port 8080 +./thunderstorm-mock + +# Start server on port 9000 +./thunderstorm-mock --port 9000 + +# Bind to localhost only and log to file +./thunderstorm-mock --address 127.0.0.1 --output server.log + +# Using environment variables +THUNDERSTORM_MOCK_PORT=9000 ./thunderstorm-mock ``` + +The server will be available at `http://localhost:8080` (or your configured port). + +--- + +## Development + +### Running from Source + +```bash go run main.go ``` -The server will be available on `http://localhost:8080`. +### Running with Docker -To run the server in a docker container -``` +Build the image: +```bash docker build --network=host -t thunderstormmock . ``` -Once image is built use -``` +Run the container: +```bash docker run --rm -it thunderstormmock ``` + +### Code Generation + +This server was generated by the [openapi-generator](https://openapi-generator.tech) project using the +[Thunderstorm OpenAPI specification](https://github.com/NextronSystems/thunderstorm-openapi) and the +configuration found in `./openapi-genconfig.yaml`. + +- API version: 1.0.0 +- Build date: 2026-01-26T17:16:00.310597514+01:00[Europe/Berlin] +- Generator version: 7.19.0 + +Command used to generate this server: +```bash +GO_POST_PROCESS_FILE="gofmt -w" openapi-generator-cli generate --generator-name go-server --config ./openapi-genconfig.yaml +```