mirror of
https://github.com/sasjs/server.git
synced 2026-07-24 13:42:14 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1007e7e75 | ||
|
|
45bff7b0d1 | ||
|
|
58c66c2ac4 | ||
|
|
9c3a1086b6 | ||
|
|
05768890a2 | ||
|
|
386185d1a0 | ||
|
|
681f0123ad | ||
|
|
daa5274fb0 | ||
|
|
50fa4320cd | ||
|
|
40b9fa8735 | ||
|
|
fd31fe94ea | ||
|
|
dec5191491 | ||
|
|
4858245372 | ||
|
|
2fe733d02f | ||
|
|
63086a8a0b | ||
|
|
0af6b63ffa | ||
|
|
67fce475a3 | ||
|
|
f84af4ac06 | ||
|
|
4f75fd290f | ||
|
|
8b2a6155a9 | ||
|
|
a56c0b0340 | ||
|
|
02fe79d4d7 | ||
|
|
00a107babd | ||
|
|
4f782025db | ||
|
|
8b5abcd661 | ||
|
|
48e8cb7b2d | ||
|
|
225f381bdf | ||
|
|
3f49186e3b | ||
|
|
ab96653564 | ||
|
|
471c28eaa2 | ||
|
|
584ffe9e0e | ||
|
|
e51b20421a | ||
|
|
631e95604b | ||
|
|
198cd79354 | ||
|
|
379ea604bc | ||
|
|
9ffa403bcb | ||
|
|
6d123c3e23 | ||
|
|
dda1aadc67 | ||
|
|
d47cf15cdb | ||
|
|
d0c7968d66 | ||
|
|
a5c99971cc | ||
|
|
c422e7f02e | ||
|
|
02a993611c | ||
|
|
aca2fff4ac | ||
|
|
af1a386b13 | ||
|
|
f5018ce1df | ||
|
|
3529232f1f | ||
|
|
f4768bffd3 | ||
|
|
c261745f1d | ||
|
|
d6e527ecf2 | ||
|
|
bc2cff1d0d | ||
|
|
66aa9b5891 | ||
|
|
ca17e7c192 | ||
|
|
73df102422 | ||
|
|
48a9a4dd0e | ||
|
|
4f6f735f5b | ||
|
|
6b6546c7ad | ||
|
|
f94ddc0352 | ||
|
|
03670cf0d6 | ||
|
|
ea2ec97c1c | ||
|
|
832f1156e8 | ||
|
|
5cda9cd5d8 | ||
|
|
5d576aff91 | ||
|
|
a044176054 | ||
|
|
deee34f5fd | ||
|
|
b0723f1444 | ||
|
|
e9519cb3c6 | ||
|
|
66232aefd2 | ||
|
|
bf35791655 | ||
|
|
2dc11630e4 | ||
|
|
1473925896 | ||
|
|
b472f1bd61 | ||
|
|
c43afabe28 | ||
|
|
1531e9cd9c | ||
|
|
8cdf605006 | ||
|
|
3f815e9beb | ||
|
|
6c88eeabd2 | ||
|
|
093fe90589 |
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
run: npm run lint-web
|
||||
|
||||
build-api:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
CI: true
|
||||
|
||||
build-web:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
|
||||
Vendored
+1
-3
@@ -1,5 +1,3 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"autoexec"
|
||||
]
|
||||
"cSpell.words": ["autoexec", "initialising"]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,86 @@
|
||||
## [0.39.7](https://github.com/sasjs/server/compare/v0.39.6...v0.39.7) (2026-07-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **jsonwebtoken:** bumped version to avoid vulnerability ([1473925](https://github.com/sasjs/server/commit/1473925896db0e4472c9ef5ba64955527a1be2de))
|
||||
|
||||
## [0.39.6](https://github.com/sasjs/server/compare/v0.39.5...v0.39.6) (2026-07-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** isolate drive.spec.ts's files folder from the real drive ([dec5191](https://github.com/sasjs/server/commit/dec51914911c10b66b43ebe95f77b0af63e2d03e))
|
||||
* **api:** stop overwriting a failed JS/PY/R session back to completed ([4858245](https://github.com/sasjs/server/commit/4858245372fa19929b6e59c28148f5d004239c9d))
|
||||
|
||||
## [0.39.5](https://github.com/sasjs/server/compare/v0.39.4...v0.39.5) (2026-07-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** harden mock SAS executable and timeouts for CI ([67fce47](https://github.com/sasjs/server/commit/67fce475a3c53b3c20919902edb45dce809dffb9))
|
||||
* **api:** return prompt error response instead of hanging when a SAS session fails ([f84af4a](https://github.com/sasjs/server/commit/f84af4ac0639625fee4508e764f0eed07d7b4e8a))
|
||||
* fix code.spec.ts failing on a fresh checkout (CI) ([0af6b63](https://github.com/sasjs/server/commit/0af6b63ffa770bfa2b77505d74778be517f8d631))
|
||||
|
||||
## [0.39.4](https://github.com/sasjs/server/compare/v0.39.3...v0.39.4) (2025-12-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** bump dependencies and resolve audit issues ([4f78202](https://github.com/sasjs/server/commit/4f782025dbcdfcbae6ca1fabb42ce1bc385e0162))
|
||||
|
||||
## [0.39.3](https://github.com/sasjs/server/compare/v0.39.2...v0.39.3) (2025-11-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* (deps) bump @sasjs/core to 4.59.7 ([ab96653](https://github.com/sasjs/server/commit/ab966535642d08d4e8e984007b98c8fdffbe30f7))
|
||||
* (deps) rerun npm i to sync ([225f381](https://github.com/sasjs/server/commit/225f381bdf8ad5aa2af8d75648df1dd5175e12e0))
|
||||
|
||||
## [0.39.2](https://github.com/sasjs/server/compare/v0.39.1...v0.39.2) (2025-09-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* addressing test fail ([e51b204](https://github.com/sasjs/server/commit/e51b20421adc1598ea267c79b1fb4dbc085f97b9))
|
||||
* packages missmatch ([379ea60](https://github.com/sasjs/server/commit/379ea604bcb5686b5299fae6a32f759c45b275ea))
|
||||
* type libs ([6d123c3](https://github.com/sasjs/server/commit/6d123c3e23628c1d703eaa13142c77f0da970a55))
|
||||
* typescript errors ([631e956](https://github.com/sasjs/server/commit/631e95604b64b1a96f2abade659348618f3b00b2))
|
||||
* typescript errors ([198cd79](https://github.com/sasjs/server/commit/198cd79354254511c21ac1acfbf7b6bcfdab2af7))
|
||||
|
||||
## [0.39.1](https://github.com/sasjs/server/compare/v0.39.0...v0.39.1) (2025-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* extra bit of sleep for file recognition ([f4768bf](https://github.com/sasjs/server/commit/f4768bffd3dbb2fe243966572ba74002024d96e1)), closes [#381](https://github.com/sasjs/server/issues/381)
|
||||
|
||||
# [0.39.0](https://github.com/sasjs/server/compare/v0.38.0...v0.39.0) (2024-10-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** fixed condition in processProgram ([48a9a4d](https://github.com/sasjs/server/commit/48a9a4dd0e31f84209635382be4ec4bb2c3a9c0c))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** added session state endpoint ([6b6546c](https://github.com/sasjs/server/commit/6b6546c7ad0833347f8dc4cdba6ad19132f7aaef))
|
||||
|
||||
# [0.38.0](https://github.com/sasjs/server/compare/v0.37.0...v0.38.0) (2024-10-30)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** enabled query params in stp/trigger endpoint ([5cda9cd](https://github.com/sasjs/server/commit/5cda9cd5d8623b7ea2ecd989d7808f47ec866672))
|
||||
|
||||
# [0.37.0](https://github.com/sasjs/server/compare/v0.36.0...v0.37.0) (2024-10-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **stp:** added trigger endpoint ([b0723f1](https://github.com/sasjs/server/commit/b0723f14448d60ffce4f2175cf8a73fc4d4dd0ee))
|
||||
|
||||
# [0.36.0](https://github.com/sasjs/server/compare/v0.35.4...v0.36.0) (2024-10-29)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Code execution & session diagrams
|
||||
|
||||
Mermaid diagrams describing how `@sasjs/server` executes submitted code
|
||||
(SAS/JS/PY/R) against pooled "sessions". Written for fast context-loading by
|
||||
an AI coding agent: each diagram is self-contained, node/edge labels carry
|
||||
the actual file:line references, and prose is kept to the minimum needed to
|
||||
disambiguate the diagram.
|
||||
|
||||
| File | Covers |
|
||||
|---|---|
|
||||
| [session-lifecycle.md](session-lifecycle.md) | `SessionState` state machine; how it differs between the SAS runtime and the JS/PY/R runtimes |
|
||||
| [sas-execution-handshake.md](sas-execution-handshake.md) | The SYSIN/AUTOEXEC file-swap mechanism a SAS session uses to turn one long-lived `sas` process into a single-use execution slot |
|
||||
| [request-execution-flow.md](request-execution-flow.md) | End-to-end flowchart from HTTP request to response, covering session-pool acquisition and both runtime branches (SAS vs JS/PY/R) |
|
||||
|
||||
## Core concept
|
||||
|
||||
A "session" is not a request-scoped object. It is a pooled, reusable
|
||||
execution slot with a filesystem folder (`session.path`) and a state
|
||||
(`SessionState`). For the SAS runtime specifically, a session also owns one
|
||||
real OS process, spawned at session-*creation* time and consumed by exactly
|
||||
one code submission (see `sas-execution-handshake.md`). For JS/PY/R, a
|
||||
session is just an ID + folder; the actual interpreter process is spawned
|
||||
fresh per request inside `processProgram`.
|
||||
|
||||
## Key source files
|
||||
|
||||
- `api/src/controllers/internal/Session.ts` — session pool + lifecycle
|
||||
(`SessionController`, `SASSessionController`), `SessionState` transitions.
|
||||
- `api/src/controllers/internal/processProgram.ts` — writes the submitted
|
||||
code into the session and drives it to completion/failure, per runtime.
|
||||
- `api/src/controllers/internal/Execution.ts` — `ExecutionController`,
|
||||
the entry point controllers call; acquires a session, calls
|
||||
`processProgram`, reads back `log.log`/`webout.txt`/headers, builds the
|
||||
HTTP response (or a `SessionExecutionError` on failure).
|
||||
- `api/src/controllers/internal/create{SAS,JS,Python,R}Program.ts` —
|
||||
per-runtime code templating (wraps the user's submitted code with
|
||||
boilerplate: variable injection, `_webout` redirection, etc).
|
||||
- `api/src/types/Session.ts` — `SessionState` enum and `Session` interface.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Request execution flow (end-to-end)
|
||||
|
||||
Full path from an inbound HTTP request to the HTTP response, covering
|
||||
session-pool acquisition and the two structurally different runtime
|
||||
branches: SAS (reuses a parked long-lived process) vs JS/PY/R (spawns a
|
||||
fresh interpreter process per request).
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["HTTP request<br/>POST /SASjsApi/code/execute<br/>or /SASjsApi/stp/execute"] --> B["Controller<br/>code.ts / stp.ts<br/>try/catch wrapper"]
|
||||
B --> C["ExecutionController.executeProgram<br/>or .executeFile<br/>Execution.ts:58-91"]
|
||||
C --> D{"session already provided?<br/>(e.g. file-upload flow)"}
|
||||
D -- no --> E["getSessionController(runTime).getSession()<br/>Session.ts:59-69"]
|
||||
D -- yes --> F["use provided session"]
|
||||
E --> G{"pending session<br/>available in pool?"}
|
||||
G -- yes --> H["reuse it"]
|
||||
G -- no --> I["createSession()<br/>SAS: spawns a real sas process, see<br/>sas-execution-handshake.md<br/>JS/PY/R: cheap - folder + id only"]
|
||||
H --> J["pre-warm: if pool has fewer<br/>than 3 pending, fire off more<br/>createSession() calls (not awaited)<br/>Session.ts:66"]
|
||||
I --> J
|
||||
F --> K
|
||||
J --> K["session.state = running<br/>Execution.ts:96"]
|
||||
K --> L["write webout.txt (empty),<br/>reqHeaders.txt<br/>Execution.ts:103-107"]
|
||||
L --> M["processProgram(...)<br/>processProgram.ts:16-27"]
|
||||
|
||||
M --> N{"runTime?"}
|
||||
|
||||
N -- SAS --> O["createSASProgram():<br/>wrap user code with _webout<br/>filename, macro vars, autoexec"]
|
||||
O --> P["write code.sas via .bkp + rename<br/>processProgram.ts:48-49"]
|
||||
P --> Q["poll: while session.state !== completed<br/>processProgram.ts:52-58"]
|
||||
Q -- "state becomes completed" --> R["resolve"]
|
||||
Q -- "state becomes failed" --> S["throw Error(session.failureReason)"]
|
||||
|
||||
N -- "JS / PY / R" --> T["createJSProgram / createPythonProgram /<br/>createRProgram: wrap user code similarly"]
|
||||
T --> U["write code file; spawn interpreter<br/>fresh via execFile; pipe stdout/stderr<br/>into a log.log write stream<br/>processProgram.ts:108-134"]
|
||||
U -- "exit 0" --> R
|
||||
U -- "exit non-zero" --> S
|
||||
|
||||
R --> V["read log.log, webout.txt,<br/>stpsrv_header.txt<br/>Execution.ts:128-131"]
|
||||
V --> W["build httpHeaders + result<br/>return ExecuteReturnRaw<br/>Execution.ts:128-151"]
|
||||
W --> X["Controller: res.send(result)<br/>HTTP 200"]
|
||||
|
||||
S --> Y["catch in Execution.ts:109-126:<br/>read whatever log exists,<br/>throw SessionExecutionError(message, log)"]
|
||||
Y --> Z["Controller catch block<br/>rethrow { code: 400, status: 'failure',<br/>message, error, log }"]
|
||||
Z --> AA["res.status(err.code).send(err)<br/>HTTP 400 with complete log"]
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **Session pool is per-runtime.** SAS sessions and JS/PY/R sessions live in
|
||||
separate controllers/pools (`process.sasSessionController` vs
|
||||
`process.sessionController`, `Session.ts:239-253`) - a pending SAS session
|
||||
is never handed out for a JS request or vice versa.
|
||||
- **The SAS branch and the JS/PY/R branch converge** on the same
|
||||
success/failure signal shape: `session.state` becomes `completed` or
|
||||
`failed` either way, and both paths flow through the same log/webout
|
||||
reading logic in `Execution.ts` afterward. The mechanics of *how* that
|
||||
state gets set differ substantially (see `session-lifecycle.md`).
|
||||
- **`includePrintOutput`** (SAS only) additionally appends `output.lst`
|
||||
content to the result when debug mode is on - omitted above for brevity;
|
||||
see `Execution.ts:135-142`.
|
||||
- **`triggerProgram`/`triggerCode`** (fire-and-forget variants, not shown)
|
||||
call the same `ExecutionController` methods without awaiting them and
|
||||
immediately return `{ sessionId }`; the client polls
|
||||
`GET /SASjsApi/session/{sessionId}/state` separately.
|
||||
@@ -0,0 +1,81 @@
|
||||
# SAS session execution handshake
|
||||
|
||||
How one long-lived `sas` process is turned into a single-use execution slot
|
||||
via a SYSIN file-swap trick. This is the mechanism specific to
|
||||
`RunTimeType.SAS`; JS/PY/R sessions spawn a fresh interpreter process per
|
||||
request instead (see `request-execution-flow.md`).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as HTTP client
|
||||
participant Ctrl as Controller<br/>(code.ts / stp.ts)
|
||||
participant Exec as ExecutionController<br/>(Execution.ts)
|
||||
participant Pool as SASSessionController<br/>(Session.ts)
|
||||
participant FS as Filesystem<br/>(session.path)
|
||||
participant SAS as spawned "sas" process
|
||||
|
||||
Note over Pool,SAS: SESSION CREATION - happens ahead of any request,<br/>pool pre-warms up to 3 sessions (Session.ts:59-69)
|
||||
Pool->>FS: createFile(code.sas, "") - empty dummy SYSIN (Session.ts:117-118)
|
||||
Pool->>FS: readFile(sysInitCompiledPath) - compiled system-init<br/>macros, produced by `npm run compileSysInit` (Session.ts:105)
|
||||
Note over Pool: throws if this file doesn't exist yet -<br/>session creation never reaches execFile below
|
||||
Pool->>FS: createFile(autoexec.sas, autoExecContent) (Session.ts:108-114)
|
||||
Pool->>SAS: execFile(sasLoc, -SYSIN code.sas -AUTOEXEC autoexec.sas -LOG log.log ...) (Session.ts:127-147)
|
||||
activate SAS
|
||||
Note right of SAS: process stays active (this box)<br/>from spawn until it finally exits below
|
||||
SAS->>FS: autoexec step 1: delete code.sas, the dummy SYSIN (Session.ts:257-261)
|
||||
Pool->>FS: waitForSession() polls fileExists(code.sas) (Session.ts:175-192)
|
||||
FS-->>Pool: code.sas no longer exists
|
||||
Pool->>Pool: session.state = pending (Session.ts:190)
|
||||
SAS->>SAS: autoexec step 2: busy-wait for code.sas to reappear,<br/>up to 15 minutes (Session.ts:263-274)
|
||||
Note over SAS: process is now idle, parked mid-autoexec,<br/>waiting on the filesystem.<br/>Counts as "pending" in the pool.
|
||||
|
||||
Note over Client,SAS: REQUEST ARRIVES - reuses the parked process above
|
||||
Client->>Ctrl: POST .../execute { code, runTime: sas }
|
||||
Ctrl->>Exec: executeProgram({ program, runTime: SAS, ... })
|
||||
Exec->>Pool: getSession() returns the pending session (Session.ts:59-69)
|
||||
Exec->>Exec: session.state = running (Execution.ts:96)
|
||||
Exec->>FS: createFile(webout.txt, "") and createFile(reqHeaders.txt, ...) (Execution.ts:103-107)
|
||||
Exec->>Pool: processProgram(program, session, ...) (Execution.ts:110-121)
|
||||
Pool->>FS: createSASProgram() wraps user code with macro vars,<br/>_webout filename, autoexec injection (createSASProgram.ts)
|
||||
Pool->>FS: write code.sas.bkp, then rename to code.sas (processProgram.ts:48-49)
|
||||
Note over FS: write-then-rename, not a direct write,<br/>so SAS never reads a partial file
|
||||
|
||||
FS-->>SAS: code.sas exists again
|
||||
SAS->>SAS: autoexec step 2 loop exits, sleeps 0.01s, autoexec ends (Session.ts:267-271)
|
||||
Note over SAS: -SYSIN has pointed at code.sas from the start,<br/>so SAS now executes ITS CONTENT as the main job
|
||||
SAS->>FS: writes log.log, output.lst, webout.txt while running
|
||||
|
||||
Pool->>Pool: processProgram poll loop:<br/>while session.state !== completed (processProgram.ts:52-58)
|
||||
|
||||
alt program reaches EOF normally
|
||||
SAS->>SAS: exits with code 0
|
||||
SAS-->>Pool: execFilePromise resolves - .then() (Session.ts:148-152)
|
||||
Pool->>Pool: session.state = completed
|
||||
Pool-->>Exec: processProgram() resolves
|
||||
else program aborts (fatal error, license failure, hard STOP)
|
||||
SAS->>SAS: exits with non-zero code
|
||||
SAS-->>Pool: execFilePromise rejects - .catch() (Session.ts:153-163)
|
||||
Pool->>Pool: session.state = failed<br/>session.failureReason = err.toString()
|
||||
Pool-->>Exec: processProgram() throws (processProgram.ts:53-55)
|
||||
end
|
||||
deactivate SAS
|
||||
|
||||
Exec->>FS: read log.log, webout.txt, stpsrv_header.txt (Execution.ts:123, 128-131)
|
||||
Exec-->>Ctrl: { httpHeaders, result }<br/>or throws SessionExecutionError{ message, log } (Execution.ts:109-126)
|
||||
Ctrl-->>Client: 200 + result<br/>or 400 { status, message, error, log }
|
||||
```
|
||||
|
||||
## Why this design
|
||||
|
||||
- SAS has meaningful startup cost (loading the engine, running system init
|
||||
macros). Spawning a fresh `sas` process per request would pay that cost
|
||||
every time. Instead, a process is spawned once and parked, ready to run
|
||||
exactly one job the moment code shows up at a path it's already watching.
|
||||
- The pool (`SessionController.getSession`, `Session.ts:59-69`) keeps up to
|
||||
3 such parked processes ready, so most requests get an instant handoff
|
||||
instead of waiting through the ~15-minute autoexec spin-wait or a cold
|
||||
start.
|
||||
- A session is single-use: once its process consumes the real SYSIN content
|
||||
and exits (success or failure), that process is gone. The session object
|
||||
itself is deleted later by `scheduleSessionDestroy` (`Session.ts:204-236`),
|
||||
not reused for a second job.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Session lifecycle (`SessionState`)
|
||||
|
||||
Enum defined in `api/src/types/Session.ts`. A `Session` is `{ id, state,
|
||||
path, creationTimeStamp, deathTimeStamp, expiresAfterMins?, failureReason? }`.
|
||||
Sessions live in an in-memory array on a per-runtime singleton controller
|
||||
(`process.sasSessionController` for SAS, `process.sessionController` shared
|
||||
by JS/PY/R — see `getSessionController()` in `Session.ts:239-253`).
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> initialising: SAS runtime<br/>SASSessionController.createSession()<br/>Session.ts:77-95
|
||||
[*] --> pending: JS/PY/R runtime, no process yet<br/>SessionController.createSession()<br/>Session.ts:30-57
|
||||
|
||||
initialising --> pending: dummy SYSIN file deleted<br/>by SAS's autoexec<br/>waitForSession(), Session.ts:175-192
|
||||
initialising --> failed: spawned SAS process exits<br/>before handshake completes<br/>Session.ts:153-163, 184-188
|
||||
|
||||
pending --> running: session picked for a request<br/>executeProgram(), Execution.ts:94-96
|
||||
|
||||
running --> completed: process exits 0<br/>SAS: Session.ts:148-152 (original process)<br/>JS/PY/R: processProgram.ts:116-120 (fresh process)
|
||||
running --> failed: process exits non-zero<br/>SAS: Session.ts:153-163<br/>JS/PY/R: processProgram.ts:121-131<br/>failureReason = err.toString()
|
||||
|
||||
completed --> [*]: deleteSession()<br/>scheduleSessionDestroy(), Session.ts:194-236
|
||||
failed --> [*]: deleteSession()<br/>scheduleSessionDestroy()
|
||||
|
||||
note right of initialising
|
||||
SAS only. The real sas
|
||||
executable is already
|
||||
running, spin-waiting
|
||||
inside its AUTOEXEC for
|
||||
real code to arrive.
|
||||
See sas-execution-handshake.md
|
||||
end note
|
||||
|
||||
note right of pending
|
||||
Session sits in the pool,
|
||||
reusable. Pre-warmed up to
|
||||
3 pending sessions per
|
||||
runtime (getSession(),
|
||||
Session.ts:59-69)
|
||||
end note
|
||||
|
||||
note right of running
|
||||
SAS: no new process spawned
|
||||
here, request just feeds
|
||||
code to the already-running
|
||||
process.
|
||||
JS/PY/R: a brand new
|
||||
interpreter process is
|
||||
spawned right now.
|
||||
end note
|
||||
```
|
||||
|
||||
## Consumers of `state`
|
||||
|
||||
| Reader | Location | Watches for |
|
||||
|---|---|---|
|
||||
| `waitForSession` | `Session.ts:175-192` | `failed` (breaks early) or the dummy SYSIN file disappearing (implies session survived init) |
|
||||
| `processProgram` (SAS branch poll loop) | `processProgram.ts:52-58` | `completed` (success exit) or `failed` (throws, carrying `session.failureReason`) |
|
||||
| `scheduleSessionDestroy` | `Session.ts:204-236` | `running` (extends death timer instead of destroying) |
|
||||
|
||||
## Asymmetry between runtimes
|
||||
|
||||
- **SAS**: one OS process per session, spawned once at `createSession()`
|
||||
time and reused for exactly one job (see `sas-execution-handshake.md`).
|
||||
`running`/`completed`/`failed` are all driven by that *same* process's
|
||||
eventual exit.
|
||||
- **JS/PY/R**: a session is cheap (folder + id, no process). The interpreter
|
||||
process is spawned fresh, per request, inside `processProgram.ts:115` and
|
||||
its exit drives `completed`/`failed` directly in the same function - there
|
||||
is no separate poll loop for these runtimes.
|
||||
Generated
+6115
-14453
File diff suppressed because it is too large
Load Diff
+24
-25
@@ -6,11 +6,9 @@
|
||||
"scripts": {
|
||||
"initial": "npm run swagger && npm run compileSysInit && npm run copySASjsCore && npm run downloadMacros",
|
||||
"prestart": "npm run initial",
|
||||
"prebuild": "npm run initial",
|
||||
"start": "NODE_ENV=development nodemon ./src/server.ts",
|
||||
"start:prod": "node ./build/src/server.js",
|
||||
"build": "rimraf build && tsc",
|
||||
"postbuild": "npm run copy:files",
|
||||
"build": "npm run initial && rimraf build && tsc && npm run copy:files",
|
||||
"swagger": "tsoa spec",
|
||||
"prepare": "[ -d .git ] && git config core.hooksPath ./.git-hooks || true",
|
||||
"test": "mkdir -p tmp && mkdir -p ../web/build && jest --silent --coverage",
|
||||
@@ -18,10 +16,10 @@
|
||||
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||
"exe": "npm run build && pkg .",
|
||||
"copy:files": "npm run public:copy && npm run sasjsbuild:copy && npm run sas:copy && npm run web:copy",
|
||||
"public:copy": "cp -r ./public/ ./build/public/",
|
||||
"sasjsbuild:copy": "cp -r ./sasjsbuild/ ./build/sasjsbuild/",
|
||||
"sas:copy": "cp -r ./sas/ ./build/sas/",
|
||||
"web:copy": "rimraf web && mkdir web && cp -r ../web/build/ ./web/build/",
|
||||
"public:copy": "cpr ./public/ ./build/public/",
|
||||
"sasjsbuild:copy": "cpr ./sasjsbuild/ ./build/sasjsbuild/",
|
||||
"sas:copy": "cpr ./sas/ ./build/sas/",
|
||||
"web:copy": "rimraf web && mkdir web && cpr ../web/build/ ./web/build/",
|
||||
"compileSysInit": "ts-node ./scripts/compileSysInit.ts",
|
||||
"copySASjsCore": "ts-node ./scripts/copySASjsCore.ts",
|
||||
"downloadMacros": "ts-node ./scripts/downloadMacros.ts"
|
||||
@@ -48,25 +46,25 @@
|
||||
},
|
||||
"author": "4GL Ltd",
|
||||
"dependencies": {
|
||||
"@sasjs/core": "^4.40.1",
|
||||
"@sasjs/utils": "3.2.0",
|
||||
"@sasjs/core": "^4.59.10",
|
||||
"@sasjs/utils": "^3.5.6",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"connect-mongo": "^4.6.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"connect-mongo": "^5.1.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
"express-session": "^1.17.2",
|
||||
"express": "^4.21.2",
|
||||
"express-session": "^1.18.2",
|
||||
"helmet": "^5.0.2",
|
||||
"joi": "^17.4.2",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"ldapjs": "2.3.3",
|
||||
"mongoose": "^6.0.12",
|
||||
"morgan": "^1.10.0",
|
||||
"mongoose": "^6.13.8",
|
||||
"morgan": "^1.10.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"rate-limiter-flexible": "2.4.1",
|
||||
"rotating-file-stream": "^3.0.4",
|
||||
"swagger-ui-express": "4.3.0",
|
||||
"unzipper": "^0.10.11",
|
||||
"unzipper": "^0.12.3",
|
||||
"url": "^0.10.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -76,32 +74,33 @@
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/express": "^4.17.12",
|
||||
"@types/express-session": "^1.17.4",
|
||||
"@types/jest": "^26.0.24",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/jsonwebtoken": "^8.5.5",
|
||||
"@types/ldapjs": "^2.2.4",
|
||||
"@types/morgan": "^1.9.3",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^15.12.2",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@types/swagger-ui-express": "^4.1.3",
|
||||
"@types/unzipper": "^0.10.5",
|
||||
"adm-zip": "^0.5.9",
|
||||
"axios": "0.27.2",
|
||||
"axios": "1.12.2",
|
||||
"cpr": "^3.0.1",
|
||||
"csrf": "^3.1.0",
|
||||
"dotenv": "^16.0.1",
|
||||
"http-headers-validation": "^0.0.1",
|
||||
"jest": "^27.0.6",
|
||||
"jest": "^29.7.0",
|
||||
"mongodb-memory-server": "8.11.4",
|
||||
"nodejs-file-downloader": "4.10.2",
|
||||
"nodemon": "^2.0.7",
|
||||
"nodemon": "^3.0.0",
|
||||
"pkg": "5.6.0",
|
||||
"prettier": "^2.3.1",
|
||||
"prettier": "^3.0.3",
|
||||
"rimraf": "^3.0.2",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "^27.0.3",
|
||||
"ts-jest": "^29.1.0",
|
||||
"ts-node": "^10.0.0",
|
||||
"tsoa": "3.14.1",
|
||||
"typescript": "^4.3.2"
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"nodemonConfig": {
|
||||
"ignore": [
|
||||
|
||||
+175
-102
@@ -40,8 +40,7 @@ components:
|
||||
clientId:
|
||||
type: string
|
||||
userId:
|
||||
type: number
|
||||
format: double
|
||||
type: string
|
||||
required:
|
||||
- clientId
|
||||
- userId
|
||||
@@ -113,8 +112,8 @@ components:
|
||||
properties:
|
||||
sessionId:
|
||||
type: string
|
||||
description: "The SessionId is the name of the temporary folder used to store the outputs.\nFor SAS, this would be the SASWORK folder. Can be used to poll job status.\nThis session ID should be used to poll job status."
|
||||
example: '{ sessionId: ''20241028074744-54132-1730101664824'' }'
|
||||
description: "`sessionId` is the ID of the session and the name of the temporary folder\nused to store code outputs.<br><br>\nFor SAS, this would be the location of the SASWORK folder.<br><br>\n`sessionId` can be used to poll session state using the\nGET /SASjsApi/session/{sessionId}/state endpoint."
|
||||
example: 20241028074744-54132-1730101664824
|
||||
required:
|
||||
- sessionId
|
||||
type: object
|
||||
@@ -315,9 +314,8 @@ components:
|
||||
additionalProperties: false
|
||||
UserResponse:
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
format: double
|
||||
uid:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
displayName:
|
||||
@@ -325,7 +323,7 @@ components:
|
||||
isAdmin:
|
||||
type: boolean
|
||||
required:
|
||||
- id
|
||||
- uid
|
||||
- username
|
||||
- displayName
|
||||
- isAdmin
|
||||
@@ -333,32 +331,30 @@ components:
|
||||
additionalProperties: false
|
||||
GroupResponse:
|
||||
properties:
|
||||
groupId:
|
||||
type: number
|
||||
format: double
|
||||
uid:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
required:
|
||||
- groupId
|
||||
- uid
|
||||
- name
|
||||
- description
|
||||
type: object
|
||||
additionalProperties: false
|
||||
UserDetailsResponse:
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
format: double
|
||||
displayName:
|
||||
uid:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
isActive:
|
||||
type: boolean
|
||||
displayName:
|
||||
type: string
|
||||
isAdmin:
|
||||
type: boolean
|
||||
isActive:
|
||||
type: boolean
|
||||
autoExec:
|
||||
type: string
|
||||
groups:
|
||||
@@ -366,11 +362,11 @@ components:
|
||||
$ref: '#/components/schemas/GroupResponse'
|
||||
type: array
|
||||
required:
|
||||
- id
|
||||
- displayName
|
||||
- uid
|
||||
- username
|
||||
- isActive
|
||||
- displayName
|
||||
- isAdmin
|
||||
- isActive
|
||||
type: object
|
||||
additionalProperties: false
|
||||
UserPayload:
|
||||
@@ -406,9 +402,8 @@ components:
|
||||
additionalProperties: false
|
||||
GroupDetailsResponse:
|
||||
properties:
|
||||
groupId:
|
||||
type: number
|
||||
format: double
|
||||
uid:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
@@ -420,7 +415,7 @@ components:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
type: array
|
||||
required:
|
||||
- groupId
|
||||
- uid
|
||||
- name
|
||||
- description
|
||||
- isActive
|
||||
@@ -489,9 +484,8 @@ components:
|
||||
additionalProperties: false
|
||||
PermissionDetailsResponse:
|
||||
properties:
|
||||
permissionId:
|
||||
type: number
|
||||
format: double
|
||||
uid:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
type:
|
||||
@@ -503,7 +497,7 @@ components:
|
||||
group:
|
||||
$ref: '#/components/schemas/GroupDetailsResponse'
|
||||
required:
|
||||
- permissionId
|
||||
- uid
|
||||
- path
|
||||
- type
|
||||
- setting
|
||||
@@ -542,10 +536,8 @@ components:
|
||||
description: 'Indicates the type of principal'
|
||||
example: user
|
||||
principalId:
|
||||
type: number
|
||||
format: double
|
||||
type: string
|
||||
description: 'The id of user or group to which a rule is assigned.'
|
||||
example: 123
|
||||
required:
|
||||
- path
|
||||
- type
|
||||
@@ -564,27 +556,47 @@ components:
|
||||
- setting
|
||||
type: object
|
||||
additionalProperties: false
|
||||
SessionResponse:
|
||||
Pick_UserResponse.Exclude_keyofUserResponse.uid__:
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
format: double
|
||||
username:
|
||||
type: string
|
||||
displayName:
|
||||
type: string
|
||||
isAdmin:
|
||||
type: boolean
|
||||
needsToUpdatePassword:
|
||||
type: boolean
|
||||
required:
|
||||
- id
|
||||
- username
|
||||
- displayName
|
||||
- isAdmin
|
||||
- needsToUpdatePassword
|
||||
type: object
|
||||
description: 'From T, pick a set of properties whose keys are in the union K'
|
||||
SessionResponse:
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
displayName:
|
||||
type: string
|
||||
isAdmin:
|
||||
type: boolean
|
||||
id:
|
||||
type: string
|
||||
needsToUpdatePassword:
|
||||
type: boolean
|
||||
required:
|
||||
- username
|
||||
- displayName
|
||||
- isAdmin
|
||||
- id
|
||||
type: object
|
||||
additionalProperties: false
|
||||
SessionState:
|
||||
enum:
|
||||
- initialising
|
||||
- pending
|
||||
- running
|
||||
- completed
|
||||
- failed
|
||||
type: string
|
||||
ExecutePostRequestPayload:
|
||||
properties:
|
||||
_program:
|
||||
@@ -593,6 +605,16 @@ components:
|
||||
example: /Public/somefolder/some.file
|
||||
type: object
|
||||
additionalProperties: false
|
||||
TriggerProgramResponse:
|
||||
properties:
|
||||
sessionId:
|
||||
type: string
|
||||
description: "`sessionId` is the ID of the session and the name of the temporary folder\nused to store program outputs.<br><br>\nFor SAS, this would be the location of the SASWORK folder.<br><br>\n`sessionId` can be used to poll session state using the\nGET /SASjsApi/session/{sessionId}/state endpoint."
|
||||
example: 20241028074744-54132-1730101664824
|
||||
required:
|
||||
- sessionId
|
||||
type: object
|
||||
additionalProperties: false
|
||||
LoginPayload:
|
||||
properties:
|
||||
username:
|
||||
@@ -1260,7 +1282,7 @@ paths:
|
||||
type: array
|
||||
examples:
|
||||
'Example 1':
|
||||
value: [{id: 123, username: johnusername, displayName: John, isAdmin: false}, {id: 456, username: starkusername, displayName: Stark, isAdmin: true}]
|
||||
value: [{uid: userIdString, username: johnusername, displayName: John, isAdmin: false}, {uid: anotherUserIdString, username: starkusername, displayName: Stark, isAdmin: true}]
|
||||
summary: 'Get list of all users (username, displayname). All users can request this.'
|
||||
tags:
|
||||
- User
|
||||
@@ -1279,7 +1301,7 @@ paths:
|
||||
$ref: '#/components/schemas/UserDetailsResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {id: 1234, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
|
||||
value: {uid: userIdString, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
|
||||
summary: 'Create user with the following attributes: UserId, UserName, Password, isAdmin, isActive. Admin only task.'
|
||||
tags:
|
||||
- User
|
||||
@@ -1330,7 +1352,7 @@ paths:
|
||||
$ref: '#/components/schemas/UserDetailsResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {id: 1234, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
|
||||
value: {uid: userIdString, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
|
||||
summary: 'Update user properties - such as displayName. Can be performed either by admins, or the user in question.'
|
||||
tags:
|
||||
- User
|
||||
@@ -1381,7 +1403,7 @@ paths:
|
||||
password:
|
||||
type: string
|
||||
type: object
|
||||
'/SASjsApi/user/{userId}':
|
||||
'/SASjsApi/user/{uid}':
|
||||
get:
|
||||
operationId: GetUser
|
||||
responses:
|
||||
@@ -1400,14 +1422,12 @@ paths:
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
description: 'The user''s identifier'
|
||||
in: path
|
||||
name: userId
|
||||
name: uid
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 1234
|
||||
type: string
|
||||
'/SASjsApi/user/{userId}':
|
||||
patch:
|
||||
operationId: UpdateUser
|
||||
responses:
|
||||
@@ -1419,7 +1439,7 @@ paths:
|
||||
$ref: '#/components/schemas/UserDetailsResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {id: 1234, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
|
||||
value: {uid: userIdString, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
|
||||
summary: 'Update user properties - such as displayName. Can be performed either by admins, or the user in question.'
|
||||
tags:
|
||||
- User
|
||||
@@ -1433,8 +1453,7 @@ paths:
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
type: string
|
||||
example: '1234'
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -1460,8 +1479,7 @@ paths:
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
type: string
|
||||
example: 1234
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -1486,7 +1504,7 @@ paths:
|
||||
type: array
|
||||
examples:
|
||||
'Example 1':
|
||||
value: [{groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users'}]
|
||||
value: [{uid: groupIdString, name: DCGroup, description: 'This group represents Data Controller Users'}]
|
||||
summary: 'Get list of all groups (groupName and groupDescription). All users can request this.'
|
||||
tags:
|
||||
- Group
|
||||
@@ -1505,7 +1523,7 @@ paths:
|
||||
$ref: '#/components/schemas/GroupDetailsResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
|
||||
value: {uid: groupIdString, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
|
||||
summary: 'Create a new group. Admin only.'
|
||||
tags:
|
||||
- Group
|
||||
@@ -1521,7 +1539,7 @@ paths:
|
||||
$ref: '#/components/schemas/GroupPayload'
|
||||
'/SASjsApi/group/by/groupname/{name}':
|
||||
get:
|
||||
operationId: GetGroupByGroupName
|
||||
operationId: GetGroupByName
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
@@ -1543,7 +1561,7 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
'/SASjsApi/group/{groupId}':
|
||||
'/SASjsApi/group/{uid}':
|
||||
get:
|
||||
operationId: GetGroup
|
||||
responses:
|
||||
@@ -1563,12 +1581,11 @@ paths:
|
||||
-
|
||||
description: 'The group''s identifier'
|
||||
in: path
|
||||
name: groupId
|
||||
name: uid
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 1234
|
||||
type: string
|
||||
example: 12ByteString
|
||||
delete:
|
||||
operationId: DeleteGroup
|
||||
responses:
|
||||
@@ -1590,13 +1607,12 @@ paths:
|
||||
-
|
||||
description: 'The group''s identifier'
|
||||
in: path
|
||||
name: groupId
|
||||
name: uid
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 1234
|
||||
'/SASjsApi/group/{groupId}/{userId}':
|
||||
type: string
|
||||
example: 12ByteString
|
||||
'/SASjsApi/group/{groupUid}/{userUid}':
|
||||
post:
|
||||
operationId: AddUserToGroup
|
||||
responses:
|
||||
@@ -1608,7 +1624,7 @@ paths:
|
||||
$ref: '#/components/schemas/GroupDetailsResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
|
||||
value: {uid: groupIdString, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
|
||||
summary: 'Add a user to a group. Admin task only.'
|
||||
tags:
|
||||
- Group
|
||||
@@ -1619,21 +1635,18 @@ paths:
|
||||
-
|
||||
description: 'The group''s identifier'
|
||||
in: path
|
||||
name: groupId
|
||||
name: groupUid
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: '1234'
|
||||
type: string
|
||||
example: 12ByteString
|
||||
-
|
||||
description: 'The user''s identifier'
|
||||
in: path
|
||||
name: userId
|
||||
name: userUid
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: '6789'
|
||||
type: string
|
||||
delete:
|
||||
operationId: RemoveUserFromGroup
|
||||
responses:
|
||||
@@ -1645,8 +1658,8 @@ paths:
|
||||
$ref: '#/components/schemas/GroupDetailsResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
|
||||
summary: 'Remove a user to a group. Admin task only.'
|
||||
value: {uid: groupIdString, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
|
||||
summary: 'Remove a user from a group. Admin task only.'
|
||||
tags:
|
||||
- Group
|
||||
security:
|
||||
@@ -1656,21 +1669,19 @@ paths:
|
||||
-
|
||||
description: 'The group''s identifier'
|
||||
in: path
|
||||
name: groupId
|
||||
name: groupUid
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: '1234'
|
||||
type: string
|
||||
example: 12ByteString
|
||||
-
|
||||
description: 'The user''s identifier'
|
||||
in: path
|
||||
name: userId
|
||||
name: userUid
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: '6789'
|
||||
type: string
|
||||
example: 12ByteString
|
||||
/SASjsApi/info:
|
||||
get:
|
||||
operationId: Info
|
||||
@@ -1721,7 +1732,7 @@ paths:
|
||||
type: array
|
||||
examples:
|
||||
'Example 1':
|
||||
value: [{permissionId: 123, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {id: 1, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}, {permissionId: 124, path: /SASjsApi/code/execute, type: Route, setting: Grant, group: {groupId: 1, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}}]
|
||||
value: [{uid: permissionId1String, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {uid: user1-id, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}, {uid: permissionId2String, path: /SASjsApi/code/execute, type: Route, setting: Grant, group: {uid: group1-id, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}}]
|
||||
description: "Get the list of permission rules applicable the authenticated user.\nIf the user is an admin, all rules are returned."
|
||||
summary: 'Get the list of permission rules. If the user is admin, all rules are returned.'
|
||||
tags:
|
||||
@@ -1741,7 +1752,7 @@ paths:
|
||||
$ref: '#/components/schemas/PermissionDetailsResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {permissionId: 123, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {id: 1, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}
|
||||
value: {uid: permissionIdString, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {uid: userIdString, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}
|
||||
summary: 'Create a new permission. Admin only.'
|
||||
tags:
|
||||
- Permission
|
||||
@@ -1755,7 +1766,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RegisterPermissionPayload'
|
||||
'/SASjsApi/permission/{permissionId}':
|
||||
'/SASjsApi/permission/{uid}':
|
||||
patch:
|
||||
operationId: UpdatePermission
|
||||
responses:
|
||||
@@ -1767,7 +1778,7 @@ paths:
|
||||
$ref: '#/components/schemas/PermissionDetailsResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {permissionId: 123, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {id: 1, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}
|
||||
value: {uid: permissionIdString, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {uid: userIdString, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}
|
||||
summary: 'Update permission setting. Admin only'
|
||||
tags:
|
||||
- Permission
|
||||
@@ -1776,14 +1787,11 @@ paths:
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
description: 'The permission''s identifier'
|
||||
in: path
|
||||
name: permissionId
|
||||
name: uid
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 1234
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -1803,14 +1811,11 @@ paths:
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
description: 'The user''s identifier'
|
||||
in: path
|
||||
name: permissionId
|
||||
name: uid
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 1234
|
||||
type: string
|
||||
/SASjsApi/session:
|
||||
get:
|
||||
operationId: Session
|
||||
@@ -1823,7 +1828,7 @@ paths:
|
||||
$ref: '#/components/schemas/SessionResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {id: 123, username: johnusername, displayName: John, isAdmin: false}
|
||||
value: {id: userIdString, username: johnusername, displayName: John, isAdmin: false, needsToUpdatePassword: false}
|
||||
summary: 'Get session info (username).'
|
||||
tags:
|
||||
- Session
|
||||
@@ -1831,6 +1836,30 @@ paths:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters: []
|
||||
'/SASjsApi/session/{sessionId}/state':
|
||||
get:
|
||||
operationId: SessionState
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SessionState'
|
||||
description: "The polling endpoint is currently implemented for single-server deployments only.<br>\nLoad balanced / grid topologies will be supported in a future release.<br>\nIf your site requires this, please reach out to SASjs Support."
|
||||
summary: 'Get session state (initialising, pending, running, completed, failed).'
|
||||
tags:
|
||||
- Session
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
in: path
|
||||
name: sessionId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
/SASjsApi/stp/execute:
|
||||
get:
|
||||
operationId: ExecuteGetRequest
|
||||
@@ -1901,6 +1930,50 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExecutePostRequestPayload'
|
||||
/SASjsApi/stp/trigger:
|
||||
post:
|
||||
operationId: TriggerProgram
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TriggerProgramResponse'
|
||||
description: 'Trigger Program on the Specified Runtime.'
|
||||
summary: 'Triggers program and returns SessionId immediately - does not wait for program completion.'
|
||||
tags:
|
||||
- STP
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
description: 'Location of code in SASjs Drive.'
|
||||
in: query
|
||||
name: _program
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: /Projects/myApp/some/program
|
||||
-
|
||||
description: 'Optional query param for setting debug mode.'
|
||||
in: query
|
||||
name: _debug
|
||||
required: false
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 131
|
||||
-
|
||||
description: 'Optional query param for setting amount of minutes after the completion of the program when the session must be destroyed.'
|
||||
in: query
|
||||
name: expiresAfterMins
|
||||
required: false
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 15
|
||||
/:
|
||||
get:
|
||||
operationId: Home
|
||||
@@ -1926,7 +1999,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
user: {properties: {needsToUpdatePassword: {type: boolean}, isAdmin: {type: boolean}, displayName: {type: string}, username: {type: string}, id: {type: number, format: double}}, required: [needsToUpdatePassword, isAdmin, displayName, username, id], type: object}
|
||||
user: {properties: {needsToUpdatePassword: {type: boolean}, isAdmin: {type: boolean}, displayName: {type: string}, username: {type: string}, id: {}}, required: [needsToUpdatePassword, isAdmin, displayName, username, id], type: object}
|
||||
loggedIn: {type: boolean}
|
||||
required:
|
||||
- user
|
||||
|
||||
@@ -27,14 +27,14 @@ import User from '../model/User'
|
||||
@Tags('Auth')
|
||||
export class AuthController {
|
||||
static authCodes: { [key: string]: { [key: string]: string } } = {}
|
||||
static saveCode = (userId: number, clientId: string, code: string) => {
|
||||
static saveCode = (userId: string, clientId: string, code: string) => {
|
||||
if (AuthController.authCodes[userId])
|
||||
return (AuthController.authCodes[userId][clientId] = code)
|
||||
|
||||
AuthController.authCodes[userId] = { [clientId]: code }
|
||||
return AuthController.authCodes[userId][clientId]
|
||||
}
|
||||
static deleteCode = (userId: number, clientId: string) =>
|
||||
static deleteCode = (userId: string, clientId: string) =>
|
||||
delete AuthController.authCodes[userId][clientId]
|
||||
|
||||
/**
|
||||
@@ -159,7 +159,7 @@ const updatePassword = async (
|
||||
) => {
|
||||
const { currentPassword, newPassword } = data
|
||||
const userId = req.user?.userId
|
||||
const dbUser = await User.findOne({ id: userId })
|
||||
const dbUser = await User.findOne({ _id: userId })
|
||||
|
||||
if (!dbUser)
|
||||
throw {
|
||||
@@ -234,9 +234,10 @@ const verifyAuthCode = async (
|
||||
jwt.verify(code, process.secrets.AUTH_CODE_SECRET, (err, data) => {
|
||||
if (err) return resolve(undefined)
|
||||
|
||||
const payload = data as InfoJWT
|
||||
const clientInfo: InfoJWT = {
|
||||
clientId: data?.clientId,
|
||||
userId: data?.userId
|
||||
clientId: payload?.clientId,
|
||||
userId: payload?.userId
|
||||
}
|
||||
if (clientInfo.clientId === clientId) {
|
||||
return resolve(clientInfo)
|
||||
|
||||
@@ -42,10 +42,12 @@ interface TriggerCodePayload {
|
||||
|
||||
interface TriggerCodeResponse {
|
||||
/**
|
||||
* The SessionId is the name of the temporary folder used to store the outputs.
|
||||
* For SAS, this would be the SASWORK folder. Can be used to poll job status.
|
||||
* This session ID should be used to poll job status.
|
||||
* @example "{ sessionId: '20241028074744-54132-1730101664824' }"
|
||||
* `sessionId` is the ID of the session and the name of the temporary folder
|
||||
* used to store code outputs.<br><br>
|
||||
* For SAS, this would be the location of the SASWORK folder.<br><br>
|
||||
* `sessionId` can be used to poll session state using the
|
||||
* GET /SASjsApi/session/{sessionId}/state endpoint.
|
||||
* @example "20241028074744-54132-1730101664824"
|
||||
*/
|
||||
sessionId: string
|
||||
}
|
||||
@@ -112,7 +114,8 @@ const executeCode = async (
|
||||
code: 400,
|
||||
status: 'failure',
|
||||
message: 'Job execution failed.',
|
||||
error: typeof err === 'object' ? err.toString() : err
|
||||
error: typeof err === 'object' ? err.toString() : err,
|
||||
log: err?.log
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,7 +123,7 @@ const executeCode = async (
|
||||
const triggerCode = async (
|
||||
req: express.Request,
|
||||
{ code, runTime, expiresAfterMins }: TriggerCodePayload
|
||||
): Promise<{ sessionId: string }> => {
|
||||
): Promise<TriggerCodeResponse> => {
|
||||
const { user } = req
|
||||
const userAutoExec =
|
||||
process.env.MODE === ModeType.Server
|
||||
|
||||
@@ -12,28 +12,29 @@ import {
|
||||
|
||||
import Group, { GroupPayload, PUBLIC_GROUP_NAME } from '../model/Group'
|
||||
import User from '../model/User'
|
||||
import { AuthProviderType } from '../utils'
|
||||
import { UserResponse } from './user'
|
||||
import { GetUserBy, UserResponse } from './user'
|
||||
|
||||
export interface GroupResponse {
|
||||
groupId: number
|
||||
uid: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface GroupDetailsResponse {
|
||||
groupId: number
|
||||
name: string
|
||||
description: string
|
||||
export interface GroupDetailsResponse extends GroupResponse {
|
||||
isActive: boolean
|
||||
users: UserResponse[]
|
||||
}
|
||||
|
||||
interface GetGroupBy {
|
||||
groupId?: number
|
||||
_id?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
enum GroupAction {
|
||||
AddUser = 'addUser',
|
||||
RemoveUser = 'removeUser'
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/group')
|
||||
@Tags('Group')
|
||||
@@ -44,7 +45,7 @@ export class GroupController {
|
||||
*/
|
||||
@Example<GroupResponse[]>([
|
||||
{
|
||||
groupId: 123,
|
||||
uid: 'groupIdString',
|
||||
name: 'DCGroup',
|
||||
description: 'This group represents Data Controller Users'
|
||||
}
|
||||
@@ -59,7 +60,7 @@ export class GroupController {
|
||||
*
|
||||
*/
|
||||
@Example<GroupDetailsResponse>({
|
||||
groupId: 123,
|
||||
uid: 'groupIdString',
|
||||
name: 'DCGroup',
|
||||
description: 'This group represents Data Controller Users',
|
||||
isActive: true,
|
||||
@@ -78,7 +79,7 @@ export class GroupController {
|
||||
* @example dcgroup
|
||||
*/
|
||||
@Get('by/groupname/{name}')
|
||||
public async getGroupByGroupName(
|
||||
public async getGroupByName(
|
||||
@Path() name: string
|
||||
): Promise<GroupDetailsResponse> {
|
||||
return getGroup({ name })
|
||||
@@ -86,68 +87,66 @@ export class GroupController {
|
||||
|
||||
/**
|
||||
* @summary Get list of members of a group (userName). All users can request this.
|
||||
* @param groupId The group's identifier
|
||||
* @example groupId 1234
|
||||
* @param uid The group's identifier
|
||||
* @example uid "12ByteString"
|
||||
*/
|
||||
@Get('{groupId}')
|
||||
public async getGroup(
|
||||
@Path() groupId: number
|
||||
): Promise<GroupDetailsResponse> {
|
||||
return getGroup({ groupId })
|
||||
@Get('{uid}')
|
||||
public async getGroup(@Path() uid: string): Promise<GroupDetailsResponse> {
|
||||
return getGroup({ _id: uid })
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Add a user to a group. Admin task only.
|
||||
* @param groupId The group's identifier
|
||||
* @example groupId "1234"
|
||||
* @param userId The user's identifier
|
||||
* @example userId "6789"
|
||||
* @param groupUid The group's identifier
|
||||
* @example groupUid "12ByteString"
|
||||
* @param userUid The user's identifier
|
||||
* @example userId "12ByteString"
|
||||
*/
|
||||
@Example<GroupDetailsResponse>({
|
||||
groupId: 123,
|
||||
uid: 'groupIdString',
|
||||
name: 'DCGroup',
|
||||
description: 'This group represents Data Controller Users',
|
||||
isActive: true,
|
||||
users: []
|
||||
})
|
||||
@Post('{groupId}/{userId}')
|
||||
@Post('{groupUid}/{userUid}')
|
||||
public async addUserToGroup(
|
||||
@Path() groupId: number,
|
||||
@Path() userId: number
|
||||
@Path() groupUid: string,
|
||||
@Path() userUid: string
|
||||
): Promise<GroupDetailsResponse> {
|
||||
return addUserToGroup(groupId, userId)
|
||||
return addUserToGroup(groupUid, userUid)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Remove a user to a group. Admin task only.
|
||||
* @param groupId The group's identifier
|
||||
* @example groupId "1234"
|
||||
* @param userId The user's identifier
|
||||
* @example userId "6789"
|
||||
* @summary Remove a user from a group. Admin task only.
|
||||
* @param groupUid The group's identifier
|
||||
* @example groupUid "12ByteString"
|
||||
* @param userUid The user's identifier
|
||||
* @example userUid "12ByteString"
|
||||
*/
|
||||
@Example<GroupDetailsResponse>({
|
||||
groupId: 123,
|
||||
uid: 'groupIdString',
|
||||
name: 'DCGroup',
|
||||
description: 'This group represents Data Controller Users',
|
||||
isActive: true,
|
||||
users: []
|
||||
})
|
||||
@Delete('{groupId}/{userId}')
|
||||
@Delete('{groupUid}/{userUid}')
|
||||
public async removeUserFromGroup(
|
||||
@Path() groupId: number,
|
||||
@Path() userId: number
|
||||
@Path() groupUid: string,
|
||||
@Path() userUid: string
|
||||
): Promise<GroupDetailsResponse> {
|
||||
return removeUserFromGroup(groupId, userId)
|
||||
return removeUserFromGroup(groupUid, userUid)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Delete a group. Admin task only.
|
||||
* @param groupId The group's identifier
|
||||
* @example groupId 1234
|
||||
* @param uid The group's identifier
|
||||
* @example uid "12ByteString"
|
||||
*/
|
||||
@Delete('{groupId}')
|
||||
public async deleteGroup(@Path() groupId: number) {
|
||||
const group = await Group.findOne({ groupId })
|
||||
@Delete('{uid}')
|
||||
public async deleteGroup(@Path() uid: string) {
|
||||
const group = await Group.findOne({ _id: uid })
|
||||
if (!group)
|
||||
throw {
|
||||
code: 404,
|
||||
@@ -160,9 +159,7 @@ export class GroupController {
|
||||
}
|
||||
|
||||
const getAllGroups = async (): Promise<GroupResponse[]> =>
|
||||
await Group.find({})
|
||||
.select({ _id: 0, groupId: 1, name: 1, description: 1 })
|
||||
.exec()
|
||||
await Group.find({}).select('uid name description').exec()
|
||||
|
||||
const createGroup = async ({
|
||||
name,
|
||||
@@ -187,7 +184,7 @@ const createGroup = async ({
|
||||
const savedGroup = await group.save()
|
||||
|
||||
return {
|
||||
groupId: savedGroup.groupId,
|
||||
uid: savedGroup.uid,
|
||||
name: savedGroup.name,
|
||||
description: savedGroup.description,
|
||||
isActive: savedGroup.isActive,
|
||||
@@ -198,11 +195,12 @@ const createGroup = async ({
|
||||
const getGroup = async (findBy: GetGroupBy): Promise<GroupDetailsResponse> => {
|
||||
const group = (await Group.findOne(
|
||||
findBy,
|
||||
'groupId name description isActive users -_id'
|
||||
'uid name description isActive users'
|
||||
).populate(
|
||||
'users',
|
||||
'id username displayName isAdmin -_id'
|
||||
'uid username displayName isAdmin'
|
||||
)) as unknown as GroupDetailsResponse
|
||||
|
||||
if (!group)
|
||||
throw {
|
||||
code: 404,
|
||||
@@ -211,7 +209,7 @@ const getGroup = async (findBy: GetGroupBy): Promise<GroupDetailsResponse> => {
|
||||
}
|
||||
|
||||
return {
|
||||
groupId: group.groupId,
|
||||
uid: group.uid,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
isActive: group.isActive,
|
||||
@@ -220,23 +218,23 @@ const getGroup = async (findBy: GetGroupBy): Promise<GroupDetailsResponse> => {
|
||||
}
|
||||
|
||||
const addUserToGroup = async (
|
||||
groupId: number,
|
||||
userId: number
|
||||
groupUid: string,
|
||||
userUid: string
|
||||
): Promise<GroupDetailsResponse> =>
|
||||
updateUsersListInGroup(groupId, userId, 'addUser')
|
||||
updateUsersListInGroup(groupUid, userUid, GroupAction.AddUser)
|
||||
|
||||
const removeUserFromGroup = async (
|
||||
groupId: number,
|
||||
userId: number
|
||||
groupUid: string,
|
||||
userUid: string
|
||||
): Promise<GroupDetailsResponse> =>
|
||||
updateUsersListInGroup(groupId, userId, 'removeUser')
|
||||
updateUsersListInGroup(groupUid, userUid, GroupAction.RemoveUser)
|
||||
|
||||
const updateUsersListInGroup = async (
|
||||
groupId: number,
|
||||
userId: number,
|
||||
action: 'addUser' | 'removeUser'
|
||||
groupUid: string,
|
||||
userUid: string,
|
||||
action: GroupAction
|
||||
): Promise<GroupDetailsResponse> => {
|
||||
const group = await Group.findOne({ groupId })
|
||||
const group = await Group.findOne({ _id: groupUid })
|
||||
if (!group)
|
||||
throw {
|
||||
code: 404,
|
||||
@@ -258,7 +256,7 @@ const updateUsersListInGroup = async (
|
||||
message: `Can't add/remove user to group created by external auth provider.`
|
||||
}
|
||||
|
||||
const user = await User.findOne({ id: userId })
|
||||
const user = await User.findOne({ _id: userUid })
|
||||
if (!user)
|
||||
throw {
|
||||
code: 404,
|
||||
@@ -274,7 +272,7 @@ const updateUsersListInGroup = async (
|
||||
}
|
||||
|
||||
const updatedGroup =
|
||||
action === 'addUser'
|
||||
action === GroupAction.AddUser
|
||||
? await group.addUser(user)
|
||||
: await group.removeUser(user)
|
||||
|
||||
@@ -286,7 +284,7 @@ const updateUsersListInGroup = async (
|
||||
}
|
||||
|
||||
return {
|
||||
groupId: updatedGroup.groupId,
|
||||
uid: updatedGroup.uid,
|
||||
name: updatedGroup.name,
|
||||
description: updatedGroup.description,
|
||||
isActive: updatedGroup.isActive,
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { getSessionController, processProgram } from './'
|
||||
import { readFile, fileExists, createFile, readFileBinary } from '@sasjs/utils'
|
||||
import { PreProgramVars, Session, TreeNode } from '../../types'
|
||||
import { PreProgramVars, Session, TreeNode, SessionState } from '../../types'
|
||||
import {
|
||||
extractHeaders,
|
||||
getFilesFolder,
|
||||
@@ -15,6 +15,24 @@ export interface ExecutionVars {
|
||||
[key: string]: string | number | undefined
|
||||
}
|
||||
|
||||
// Thrown when the session itself fails (e.g. SAS exits abnormally via
|
||||
// %abort;). Carries the complete log - by the time this is thrown, the
|
||||
// session's process has already exited, so the log file it wrote is final,
|
||||
// not a partial/truncated snapshot.
|
||||
export class SessionExecutionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public log?: string
|
||||
) {
|
||||
super(message)
|
||||
|
||||
// required for `instanceof` to work when compiling to ES5, since the
|
||||
// default __extends helper does not preserve the prototype chain for
|
||||
// classes extending built-ins like Error
|
||||
Object.setPrototypeOf(this, SessionExecutionError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExecuteReturnRaw {
|
||||
httpHeaders: HTTPHeaders
|
||||
result: string | Buffer
|
||||
@@ -75,8 +93,7 @@ export class ExecutionController {
|
||||
|
||||
const session =
|
||||
sessionByFileUpload ?? (await sessionController.getSession())
|
||||
session.inUse = true
|
||||
session.consumed = true
|
||||
session.state = SessionState.running
|
||||
|
||||
const logPath = path.join(session.path, 'log.log')
|
||||
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||
@@ -89,6 +106,7 @@ export class ExecutionController {
|
||||
preProgramVariables?.httpHeaders.join('\n') ?? ''
|
||||
)
|
||||
|
||||
try {
|
||||
await processProgram(
|
||||
program,
|
||||
preProgramVariables,
|
||||
@@ -101,6 +119,11 @@ export class ExecutionController {
|
||||
logPath,
|
||||
otherArgs
|
||||
)
|
||||
} catch (err: any) {
|
||||
const log = (await fileExists(logPath)) ? await readFile(logPath) : ''
|
||||
|
||||
throw new SessionExecutionError(err.message, log)
|
||||
}
|
||||
|
||||
const log = (await fileExists(logPath)) ? await readFile(logPath) : ''
|
||||
const headersContent = (await fileExists(headersPath))
|
||||
@@ -121,7 +144,16 @@ export class ExecutionController {
|
||||
: ''
|
||||
|
||||
// it should be deleted by scheduleSessionDestroy
|
||||
session.inUse = false
|
||||
//
|
||||
// Guarded: for JS/PY/R, processProgram sets state to `failed` itself
|
||||
// (without throwing) when the interpreter process exits non-zero - if
|
||||
// we unconditionally set `completed` here we'd silently overwrite that,
|
||||
// and anything downstream inspecting session.state (e.g.
|
||||
// scheduleSessionDestroy's expiresAfterMins branch in Session.ts) would
|
||||
// see a crashed session mis-reported as successful.
|
||||
if ((session.state as SessionState) !== SessionState.failed) {
|
||||
session.state = SessionState.completed
|
||||
}
|
||||
|
||||
const resultParts = []
|
||||
|
||||
@@ -145,7 +177,9 @@ export class ExecutionController {
|
||||
return {
|
||||
httpHeaders,
|
||||
result:
|
||||
isDebugOn(vars) || session.crashed ? resultParts.join(`\n`) : webout
|
||||
isDebugOn(vars) || session.failureReason
|
||||
? resultParts.join(`\n`)
|
||||
: webout
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,8 @@ import { Request, RequestHandler } from 'express'
|
||||
import multer from 'multer'
|
||||
import { uuidv4 } from '@sasjs/utils'
|
||||
import { getSessionController } from '.'
|
||||
import {
|
||||
executeProgramRawValidation,
|
||||
getRunTimeAndFilePath,
|
||||
RunTimeType
|
||||
} from '../../utils'
|
||||
import { executeProgramRawValidation, getRunTimeAndFilePath } from '../../utils'
|
||||
import { SessionState } from '../../types'
|
||||
|
||||
export class FileUploadController {
|
||||
private storage = multer.diskStorage({
|
||||
@@ -56,9 +53,8 @@ export class FileUploadController {
|
||||
}
|
||||
|
||||
const session = await sessionController.getSession()
|
||||
// marking consumed true, so that it's not available
|
||||
// as readySession for any other request
|
||||
session.consumed = true
|
||||
// change session state to 'running', so that it's not available for any other request
|
||||
session.state = SessionState.running
|
||||
|
||||
req.sasjsSession = session
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from 'path'
|
||||
import { Session } from '../../types'
|
||||
import { Session, SessionState } from '../../types'
|
||||
import { promisify } from 'util'
|
||||
import { execFile } from 'child_process'
|
||||
import {
|
||||
@@ -23,7 +23,9 @@ export class SessionController {
|
||||
protected sessions: Session[] = []
|
||||
|
||||
protected getReadySessions = (): Session[] =>
|
||||
this.sessions.filter((sess: Session) => sess.ready && !sess.consumed)
|
||||
this.sessions.filter(
|
||||
(session: Session) => session.state === SessionState.pending
|
||||
)
|
||||
|
||||
protected async createSession(): Promise<Session> {
|
||||
const sessionId = generateUniqueFileName(generateTimestamp())
|
||||
@@ -39,19 +41,18 @@ export class SessionController {
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ready: true,
|
||||
inUse: true,
|
||||
consumed: false,
|
||||
completed: false,
|
||||
state: SessionState.pending,
|
||||
creationTimeStamp,
|
||||
deathTimeStamp,
|
||||
path: sessionFolder
|
||||
}
|
||||
|
||||
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||
|
||||
await createFile(headersPath, 'content-type: text/html; charset=utf-8')
|
||||
|
||||
this.sessions.push(session)
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
@@ -66,6 +67,10 @@ export class SessionController {
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
public getSessionById(id: string) {
|
||||
return this.sessions.find((session) => session.id === id)
|
||||
}
|
||||
}
|
||||
|
||||
export class SASSessionController extends SessionController {
|
||||
@@ -83,10 +88,7 @@ export class SASSessionController extends SessionController {
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ready: false,
|
||||
inUse: false,
|
||||
consumed: false,
|
||||
completed: false,
|
||||
state: SessionState.initialising,
|
||||
creationTimeStamp,
|
||||
deathTimeStamp,
|
||||
path: sessionFolder
|
||||
@@ -144,13 +146,20 @@ ${autoExecContent}`
|
||||
process.sasLoc!.endsWith('sas.exe') ? session.path : ''
|
||||
])
|
||||
.then(() => {
|
||||
session.completed = true
|
||||
session.state = SessionState.completed
|
||||
|
||||
process.logger.info('session completed', session)
|
||||
})
|
||||
.catch((err) => {
|
||||
session.completed = true
|
||||
session.crashed = err.toString()
|
||||
process.logger.error('session crashed', session.id, session.crashed)
|
||||
session.state = SessionState.failed
|
||||
|
||||
session.failureReason = err.toString()
|
||||
|
||||
process.logger.error(
|
||||
'session crashed',
|
||||
session.id,
|
||||
session.failureReason
|
||||
)
|
||||
})
|
||||
|
||||
// we have a triggered session - add to array
|
||||
@@ -167,15 +176,19 @@ ${autoExecContent}`
|
||||
const codeFilePath = path.join(session.path, 'code.sas')
|
||||
|
||||
// TODO: don't wait forever
|
||||
while ((await fileExists(codeFilePath)) && !session.crashed) {}
|
||||
while (
|
||||
(await fileExists(codeFilePath)) &&
|
||||
session.state !== SessionState.failed
|
||||
) {}
|
||||
|
||||
if (session.crashed)
|
||||
if (session.state === SessionState.failed) {
|
||||
process.logger.error(
|
||||
'session crashed! while waiting to be ready',
|
||||
session.crashed
|
||||
session.failureReason
|
||||
)
|
||||
|
||||
session.ready = true
|
||||
} else {
|
||||
session.state = SessionState.pending
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteSession(session: Session) {
|
||||
@@ -191,7 +204,7 @@ ${autoExecContent}`
|
||||
private scheduleSessionDestroy(session: Session) {
|
||||
setTimeout(
|
||||
async () => {
|
||||
if (session.inUse) {
|
||||
if (session.state === SessionState.running) {
|
||||
// adding 10 more minutes
|
||||
const newDeathTimeStamp =
|
||||
parseInt(session.deathTimeStamp) + 10 * 60 * 1000
|
||||
@@ -202,7 +215,7 @@ ${autoExecContent}`
|
||||
const { expiresAfterMins } = session
|
||||
|
||||
// delay session destroy if expiresAfterMins present
|
||||
if (expiresAfterMins && !expiresAfterMins.used) {
|
||||
if (expiresAfterMins && session.state !== SessionState.completed) {
|
||||
// calculate session death time using expiresAfterMins
|
||||
const newDeathTimeStamp =
|
||||
parseInt(session.deathTimeStamp) +
|
||||
@@ -247,9 +260,16 @@ data _null_;
|
||||
rc=filename(fname,getoption('SYSIN') );
|
||||
if rc = 0 and fexist(fname) then rc=fdelete(fname);
|
||||
rc=filename(fname);
|
||||
/* now wait for the real SYSIN */
|
||||
slept=0;
|
||||
do until ( fileexist(getoption('SYSIN')) or slept>(60*15) );
|
||||
/* now wait for the real SYSIN (location of code.sas) */
|
||||
slept=0;fname='';
|
||||
do until (slept>(60*15));
|
||||
rc=filename(fname,getoption('SYSIN'));
|
||||
if rc = 0 and fexist(fname) then do;
|
||||
putlog fname=;
|
||||
rc=filename(fname);
|
||||
rc=sleep(0.01,1); /* wait just a little more */
|
||||
stop;
|
||||
end;
|
||||
slept=slept+sleep(0.01,1);
|
||||
end;
|
||||
stop;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { WriteStream, createWriteStream } from 'fs'
|
||||
import { execFile } from 'child_process'
|
||||
import { once } from 'stream'
|
||||
import { createFile, moveFile } from '@sasjs/utils'
|
||||
import { PreProgramVars, Session } from '../../types'
|
||||
import { PreProgramVars, Session, SessionState } from '../../types'
|
||||
import { RunTimeType } from '../../utils'
|
||||
import {
|
||||
ExecutionVars,
|
||||
@@ -49,7 +49,11 @@ export const processProgram = async (
|
||||
await moveFile(codePath + '.bkp', codePath)
|
||||
|
||||
// we now need to poll the session status
|
||||
while (!session.completed) {
|
||||
while (session.state !== SessionState.completed) {
|
||||
if (session.state === SessionState.failed) {
|
||||
throw new Error(session.failureReason || 'SAS session failed')
|
||||
}
|
||||
|
||||
await delay(50)
|
||||
}
|
||||
} else {
|
||||
@@ -114,13 +118,20 @@ export const processProgram = async (
|
||||
|
||||
await execFilePromise(executablePath, [codePath], writeStream)
|
||||
.then(() => {
|
||||
session.completed = true
|
||||
session.state = SessionState.completed
|
||||
|
||||
process.logger.info('session completed', session)
|
||||
})
|
||||
.catch((err) => {
|
||||
session.completed = true
|
||||
session.crashed = err.toString()
|
||||
process.logger.error('session crashed', session.id, session.crashed)
|
||||
session.state = SessionState.failed
|
||||
|
||||
session.failureReason = err.toString()
|
||||
|
||||
process.logger.error(
|
||||
'session crashed',
|
||||
session.id,
|
||||
session.failureReason
|
||||
)
|
||||
})
|
||||
|
||||
// copy the code file to log and end write stream
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import { createFile, deleteFolder, generateTimestamp } from '@sasjs/utils'
|
||||
import * as ProcessProgramModule from '../processProgram'
|
||||
import { ExecutionController, SessionExecutionError } from '../Execution'
|
||||
import { Session, SessionState, PreProgramVars } from '../../../types'
|
||||
import { RunTimeType } from '../../../utils'
|
||||
|
||||
const preProgramVariables: PreProgramVars = {
|
||||
username: 'testUser',
|
||||
userId: '1',
|
||||
displayName: 'Test User',
|
||||
serverUrl: 'http://localhost:5000',
|
||||
httpHeaders: []
|
||||
}
|
||||
|
||||
describe('ExecutionController.executeProgram', () => {
|
||||
let session: Session
|
||||
|
||||
beforeEach(() => {
|
||||
const sessionId = `test-session-${generateTimestamp()}`
|
||||
session = {
|
||||
id: sessionId,
|
||||
state: SessionState.pending,
|
||||
creationTimeStamp: '0',
|
||||
deathTimeStamp: '0',
|
||||
path: path.join(os.tmpdir(), sessionId)
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks()
|
||||
await deleteFolder(session.path)
|
||||
})
|
||||
|
||||
// Regression coverage: for JS/PY/R, processProgram sets session.state to
|
||||
// `failed` itself (without throwing) on a non-zero interpreter exit.
|
||||
// ExecutionController.executeProgram used to unconditionally overwrite
|
||||
// that back to `completed` right after processProgram returned, silently
|
||||
// losing the failure for anything downstream that inspects session.state.
|
||||
describe('JS/PY/R failure path', () => {
|
||||
it('does not overwrite a failed session state back to completed', async () => {
|
||||
// mirrors processProgram's real JS/PY/R branch on a non-zero exit:
|
||||
// it sets state/failureReason itself and resolves - it does not throw
|
||||
jest
|
||||
.spyOn(ProcessProgramModule, 'processProgram')
|
||||
.mockImplementation(async () => {
|
||||
session.state = SessionState.failed
|
||||
session.failureReason = 'Error: process exited with code 1'
|
||||
})
|
||||
|
||||
const controller = new ExecutionController()
|
||||
|
||||
await controller.executeProgram({
|
||||
program: 'throw new Error("boom")',
|
||||
preProgramVariables,
|
||||
vars: {},
|
||||
session,
|
||||
runTime: RunTimeType.JS
|
||||
})
|
||||
|
||||
expect(session.state).toBe(SessionState.failed)
|
||||
})
|
||||
|
||||
it('still marks a genuinely successful session as completed', async () => {
|
||||
jest
|
||||
.spyOn(ProcessProgramModule, 'processProgram')
|
||||
.mockImplementation(async () => {
|
||||
session.state = SessionState.completed
|
||||
})
|
||||
|
||||
const controller = new ExecutionController()
|
||||
|
||||
await controller.executeProgram({
|
||||
program: 'console.log("hello")',
|
||||
preProgramVariables,
|
||||
vars: {},
|
||||
session,
|
||||
runTime: RunTimeType.JS
|
||||
})
|
||||
|
||||
expect(session.state).toBe(SessionState.completed)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SAS failure path', () => {
|
||||
it('throws a SessionExecutionError carrying the complete log when the session fails', async () => {
|
||||
const logPath = path.join(session.path, 'log.log')
|
||||
const logContent =
|
||||
'NOTE: SAS session\nERROR: SAS session terminated. See log for details.\n'
|
||||
|
||||
await createFile(logPath, logContent)
|
||||
|
||||
jest
|
||||
.spyOn(ProcessProgramModule, 'processProgram')
|
||||
.mockImplementation(async () => {
|
||||
throw new Error('ERROR: SAS session terminated. See log for details.')
|
||||
})
|
||||
|
||||
const controller = new ExecutionController()
|
||||
|
||||
const resultPromise = controller.executeProgram({
|
||||
program: '%abort;',
|
||||
preProgramVariables,
|
||||
vars: {},
|
||||
session,
|
||||
runTime: RunTimeType.SAS
|
||||
})
|
||||
|
||||
await expect(resultPromise).rejects.toBeInstanceOf(SessionExecutionError)
|
||||
await expect(resultPromise).rejects.toMatchObject({
|
||||
log: logContent,
|
||||
message: expect.stringContaining('SAS session terminated')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import { createFile, deleteFolder, generateTimestamp } from '@sasjs/utils'
|
||||
import { processProgram } from '../processProgram'
|
||||
import { Session, SessionState, PreProgramVars } from '../../../types'
|
||||
import {
|
||||
RunTimeType,
|
||||
getSessionsFolder,
|
||||
generateUniqueFileName
|
||||
} from '../../../utils'
|
||||
|
||||
const preProgramVariables: PreProgramVars = {
|
||||
username: 'testUser',
|
||||
userId: '1',
|
||||
displayName: 'Test User',
|
||||
serverUrl: 'http://localhost:5000',
|
||||
httpHeaders: []
|
||||
}
|
||||
|
||||
const makeSession = (): Session => {
|
||||
const sessionId = generateUniqueFileName(generateTimestamp())
|
||||
const sessionFolder = path.join(getSessionsFolder(), sessionId)
|
||||
const creationTimeStamp = sessionId.split('-').pop() as string
|
||||
const deathTimeStamp = (
|
||||
parseInt(creationTimeStamp) +
|
||||
15 * 60 * 1000 -
|
||||
1000
|
||||
).toString()
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
state: SessionState.running,
|
||||
creationTimeStamp,
|
||||
deathTimeStamp,
|
||||
path: sessionFolder
|
||||
}
|
||||
}
|
||||
|
||||
describe('processProgram (SAS runtime)', () => {
|
||||
let session: Session
|
||||
let logPath: string
|
||||
let weboutPath: string
|
||||
let headersPath: string
|
||||
let tokenFile: string
|
||||
|
||||
beforeAll(() => {
|
||||
const root = path.join(
|
||||
os.tmpdir(),
|
||||
`sasjs-processProgram-spec-${generateTimestamp()}`
|
||||
)
|
||||
process.sasjsRoot = root
|
||||
process.driveLoc = path.join(root, 'drive')
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
session = makeSession()
|
||||
logPath = path.join(session.path, 'log.log')
|
||||
weboutPath = path.join(session.path, 'webout.txt')
|
||||
headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||
tokenFile = path.join(session.path, 'reqHeaders.txt')
|
||||
await createFile(weboutPath, '')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await deleteFolder(session.path)
|
||||
})
|
||||
|
||||
it('rejects instead of hanging when the session fails (e.g. %abort;)', async () => {
|
||||
setTimeout(() => {
|
||||
session.state = SessionState.failed
|
||||
session.failureReason =
|
||||
'ERROR: SAS session terminated. See log for details.'
|
||||
}, 100)
|
||||
|
||||
await expect(
|
||||
processProgram(
|
||||
'%abort;',
|
||||
preProgramVariables,
|
||||
{},
|
||||
session,
|
||||
weboutPath,
|
||||
headersPath,
|
||||
tokenFile,
|
||||
RunTimeType.SAS,
|
||||
logPath
|
||||
)
|
||||
).rejects.toThrow(/SAS session terminated/)
|
||||
}, 3000)
|
||||
|
||||
it('resolves without throwing when the session completes normally', async () => {
|
||||
setTimeout(() => {
|
||||
session.state = SessionState.completed
|
||||
}, 100)
|
||||
|
||||
await expect(
|
||||
processProgram(
|
||||
'%put hello world;',
|
||||
preProgramVariables,
|
||||
{},
|
||||
session,
|
||||
weboutPath,
|
||||
headersPath,
|
||||
tokenFile,
|
||||
RunTimeType.SAS,
|
||||
logPath
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
}, 3000)
|
||||
})
|
||||
@@ -56,9 +56,9 @@ interface RegisterPermissionPayload {
|
||||
principalType: PrincipalType
|
||||
/**
|
||||
* The id of user or group to which a rule is assigned.
|
||||
* @example 123
|
||||
* @example 'groupIdString'
|
||||
*/
|
||||
principalId: number
|
||||
principalId: string
|
||||
}
|
||||
|
||||
interface UpdatePermissionPayload {
|
||||
@@ -70,7 +70,7 @@ interface UpdatePermissionPayload {
|
||||
}
|
||||
|
||||
export interface PermissionDetailsResponse {
|
||||
permissionId: number
|
||||
uid: string
|
||||
path: string
|
||||
type: string
|
||||
setting: string
|
||||
@@ -91,24 +91,24 @@ export class PermissionController {
|
||||
*/
|
||||
@Example<PermissionDetailsResponse[]>([
|
||||
{
|
||||
permissionId: 123,
|
||||
uid: 'permissionId1String',
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: 'Route',
|
||||
setting: 'Grant',
|
||||
user: {
|
||||
id: 1,
|
||||
uid: 'user1-id',
|
||||
username: 'johnSnow01',
|
||||
displayName: 'John Snow',
|
||||
isAdmin: false
|
||||
}
|
||||
},
|
||||
{
|
||||
permissionId: 124,
|
||||
uid: 'permissionId2String',
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: 'Route',
|
||||
setting: 'Grant',
|
||||
group: {
|
||||
groupId: 1,
|
||||
uid: 'group1-id',
|
||||
name: 'DCGroup',
|
||||
description: 'This group represents Data Controller Users',
|
||||
isActive: true,
|
||||
@@ -128,12 +128,12 @@ export class PermissionController {
|
||||
*
|
||||
*/
|
||||
@Example<PermissionDetailsResponse>({
|
||||
permissionId: 123,
|
||||
uid: 'permissionIdString',
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: 'Route',
|
||||
setting: 'Grant',
|
||||
user: {
|
||||
id: 1,
|
||||
uid: 'userIdString',
|
||||
username: 'johnSnow01',
|
||||
displayName: 'John Snow',
|
||||
isAdmin: false
|
||||
@@ -149,36 +149,36 @@ export class PermissionController {
|
||||
/**
|
||||
* @summary Update permission setting. Admin only
|
||||
* @param permissionId The permission's identifier
|
||||
* @example permissionId 1234
|
||||
* @example permissionId "permissionIdString"
|
||||
*/
|
||||
@Example<PermissionDetailsResponse>({
|
||||
permissionId: 123,
|
||||
uid: 'permissionIdString',
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: 'Route',
|
||||
setting: 'Grant',
|
||||
user: {
|
||||
id: 1,
|
||||
uid: 'userIdString',
|
||||
username: 'johnSnow01',
|
||||
displayName: 'John Snow',
|
||||
isAdmin: false
|
||||
}
|
||||
})
|
||||
@Patch('{permissionId}')
|
||||
@Patch('{uid}')
|
||||
public async updatePermission(
|
||||
@Path() permissionId: number,
|
||||
@Path() uid: string,
|
||||
@Body() body: UpdatePermissionPayload
|
||||
): Promise<PermissionDetailsResponse> {
|
||||
return updatePermission(permissionId, body)
|
||||
return updatePermission(uid, body)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Delete a permission. Admin only.
|
||||
* @param permissionId The user's identifier
|
||||
* @example permissionId 1234
|
||||
* @example permissionId "permissionIdString"
|
||||
*/
|
||||
@Delete('{permissionId}')
|
||||
public async deletePermission(@Path() permissionId: number) {
|
||||
return deletePermission(permissionId)
|
||||
@Delete('{uid}')
|
||||
public async deletePermission(@Path() uid: string) {
|
||||
return deletePermission(uid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ const getAllPermissions = async (
|
||||
else {
|
||||
const permissions: PermissionDetailsResponse[] = []
|
||||
|
||||
const dbUser = await User.findOne({ id: user?.userId })
|
||||
const dbUser = await User.findOne({ _id: user?.userId })
|
||||
if (!dbUser)
|
||||
throw {
|
||||
code: 404,
|
||||
@@ -227,7 +227,7 @@ const createPermission = async ({
|
||||
|
||||
switch (principalType) {
|
||||
case PrincipalType.user: {
|
||||
const userInDB = await User.findOne({ id: principalId })
|
||||
const userInDB = await User.findOne({ _id: principalId })
|
||||
if (!userInDB)
|
||||
throw {
|
||||
code: 404,
|
||||
@@ -259,7 +259,7 @@ const createPermission = async ({
|
||||
permission.user = userInDB._id
|
||||
|
||||
user = {
|
||||
id: userInDB.id,
|
||||
uid: userInDB.uid,
|
||||
username: userInDB.username,
|
||||
displayName: userInDB.displayName,
|
||||
isAdmin: userInDB.isAdmin
|
||||
@@ -267,7 +267,7 @@ const createPermission = async ({
|
||||
break
|
||||
}
|
||||
case PrincipalType.group: {
|
||||
const groupInDB = await Group.findOne({ groupId: principalId })
|
||||
const groupInDB = await Group.findOne({ _id: principalId })
|
||||
if (!groupInDB)
|
||||
throw {
|
||||
code: 404,
|
||||
@@ -291,13 +291,13 @@ const createPermission = async ({
|
||||
permission.group = groupInDB._id
|
||||
|
||||
group = {
|
||||
groupId: groupInDB.groupId,
|
||||
uid: groupInDB.uid,
|
||||
name: groupInDB.name,
|
||||
description: groupInDB.description,
|
||||
isActive: groupInDB.isActive,
|
||||
users: groupInDB.populate({
|
||||
path: 'users',
|
||||
select: 'id username displayName isAdmin -_id',
|
||||
select: 'uid username displayName isAdmin -_id',
|
||||
options: { limit: 15 }
|
||||
}) as unknown as UserResponse[]
|
||||
}
|
||||
@@ -314,7 +314,7 @@ const createPermission = async ({
|
||||
const savedPermission = await permission.save()
|
||||
|
||||
return {
|
||||
permissionId: savedPermission.permissionId,
|
||||
uid: savedPermission.uid,
|
||||
path: savedPermission.path,
|
||||
type: savedPermission.type,
|
||||
setting: savedPermission.setting,
|
||||
@@ -324,27 +324,21 @@ const createPermission = async ({
|
||||
}
|
||||
|
||||
const updatePermission = async (
|
||||
id: number,
|
||||
uid: string,
|
||||
data: UpdatePermissionPayload
|
||||
): Promise<PermissionDetailsResponse> => {
|
||||
const { setting } = data
|
||||
|
||||
const updatedPermission = (await Permission.findOneAndUpdate(
|
||||
{ permissionId: id },
|
||||
{ _id: uid },
|
||||
{ setting },
|
||||
{ new: true }
|
||||
)
|
||||
.select({
|
||||
_id: 0,
|
||||
permissionId: 1,
|
||||
path: 1,
|
||||
type: 1,
|
||||
setting: 1
|
||||
})
|
||||
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })
|
||||
.select('uid path type setting')
|
||||
.populate({ path: 'user', select: 'uid username displayName isAdmin' })
|
||||
.populate({
|
||||
path: 'group',
|
||||
select: 'groupId name description -_id'
|
||||
select: 'groupId name description'
|
||||
})) as unknown as PermissionDetailsResponse
|
||||
if (!updatedPermission)
|
||||
throw {
|
||||
@@ -356,13 +350,13 @@ const updatePermission = async (
|
||||
return updatedPermission
|
||||
}
|
||||
|
||||
const deletePermission = async (id: number) => {
|
||||
const permission = await Permission.findOne({ permissionId: id })
|
||||
const deletePermission = async (uid: string) => {
|
||||
const permission = await Permission.findOne({ _id: uid })
|
||||
if (!permission)
|
||||
throw {
|
||||
code: 404,
|
||||
status: 'Not Found',
|
||||
message: 'Permission not found.'
|
||||
}
|
||||
await Permission.deleteOne({ permissionId: id })
|
||||
await Permission.deleteOne({ _id: uid })
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import express from 'express'
|
||||
import { Request, Security, Route, Tags, Example, Get } from 'tsoa'
|
||||
import { UserResponse } from './user'
|
||||
import { getSessionController } from './internal'
|
||||
import { SessionState } from '../types'
|
||||
|
||||
interface SessionResponse extends UserResponse {
|
||||
needsToUpdatePassword: boolean
|
||||
needsToUpdatePassword?: boolean
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@@ -14,11 +16,12 @@ export class SessionController {
|
||||
* @summary Get session info (username).
|
||||
*
|
||||
*/
|
||||
@Example<UserResponse>({
|
||||
id: 123,
|
||||
@Example<SessionResponse>({
|
||||
uid: 'userIdString',
|
||||
username: 'johnusername',
|
||||
displayName: 'John',
|
||||
isAdmin: false
|
||||
isAdmin: false,
|
||||
needsToUpdatePassword: false
|
||||
})
|
||||
@Get('/')
|
||||
public async session(
|
||||
@@ -26,12 +29,44 @@ export class SessionController {
|
||||
): Promise<SessionResponse> {
|
||||
return session(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* The polling endpoint is currently implemented for single-server deployments only.<br>
|
||||
* Load balanced / grid topologies will be supported in a future release.<br>
|
||||
* If your site requires this, please reach out to SASjs Support.
|
||||
* @summary Get session state (initialising, pending, running, completed, failed).
|
||||
* @example completed
|
||||
*/
|
||||
@Get('/:sessionId/state')
|
||||
public async sessionState(sessionId: string): Promise<SessionState> {
|
||||
return sessionState(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
const session = (req: express.Request) => ({
|
||||
id: req.user!.userId,
|
||||
uid: req.user!.userId,
|
||||
username: req.user!.username,
|
||||
displayName: req.user!.displayName,
|
||||
isAdmin: req.user!.isAdmin,
|
||||
needsToUpdatePassword: req.user!.needsToUpdatePassword
|
||||
})
|
||||
|
||||
const sessionState = (sessionId: string): SessionState => {
|
||||
for (let runTime of process.runTimes) {
|
||||
// get session controller for each available runTime
|
||||
const sessionController = getSessionController(runTime)
|
||||
|
||||
// get session by sessionId
|
||||
const session = sessionController.getSessionById(sessionId)
|
||||
|
||||
// return session state if session was found
|
||||
if (session) {
|
||||
return session.state
|
||||
}
|
||||
}
|
||||
|
||||
throw {
|
||||
code: 404,
|
||||
message: `Session with ID '${sessionId}' was not found.`
|
||||
}
|
||||
}
|
||||
|
||||
+105
-2
@@ -1,13 +1,16 @@
|
||||
import express from 'express'
|
||||
import { Request, Security, Route, Tags, Post, Body, Get, Query } from 'tsoa'
|
||||
import { ExecutionController, ExecutionVars } from './internal'
|
||||
import {
|
||||
ExecutionController,
|
||||
ExecutionVars,
|
||||
getSessionController
|
||||
} from './internal'
|
||||
import {
|
||||
getPreProgramVariables,
|
||||
makeFilesNamesMap,
|
||||
getRunTimeAndFilePath
|
||||
} from '../utils'
|
||||
import { MulterFile } from '../types/Upload'
|
||||
import { debug } from 'console'
|
||||
|
||||
interface ExecutePostRequestPayload {
|
||||
/**
|
||||
@@ -17,6 +20,36 @@ interface ExecutePostRequestPayload {
|
||||
_program?: string
|
||||
}
|
||||
|
||||
interface TriggerProgramPayload {
|
||||
/**
|
||||
* Location of SAS program.
|
||||
* @example "/Public/somefolder/some.file"
|
||||
*/
|
||||
_program: string
|
||||
/**
|
||||
* Amount of minutes after the completion of the program when the session must be
|
||||
* destroyed.
|
||||
* @example 15
|
||||
*/
|
||||
expiresAfterMins?: number
|
||||
/**
|
||||
* Query param for setting debug mode.
|
||||
*/
|
||||
_debug?: number
|
||||
}
|
||||
|
||||
interface TriggerProgramResponse {
|
||||
/**
|
||||
* `sessionId` is the ID of the session and the name of the temporary folder
|
||||
* used to store program outputs.<br><br>
|
||||
* For SAS, this would be the location of the SASWORK folder.<br><br>
|
||||
* `sessionId` can be used to poll session state using the
|
||||
* GET /SASjsApi/session/{sessionId}/state endpoint.
|
||||
* @example "20241028074744-54132-1730101664824"
|
||||
*/
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/stp')
|
||||
@Tags('STP')
|
||||
@@ -79,6 +112,26 @@ export class STPController {
|
||||
|
||||
return execute(request, program!, vars, otherArgs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger Program on the Specified Runtime.
|
||||
* @summary Triggers program and returns SessionId immediately - does not wait for program completion.
|
||||
* @param _program Location of code in SASjs Drive.
|
||||
* @param expiresAfterMins Optional query param for setting amount of minutes after the completion of the program when the session must be destroyed.
|
||||
* @param _debug Optional query param for setting debug mode.
|
||||
* @example _program "/Projects/myApp/some/program"
|
||||
* @example _debug 131
|
||||
* @example expiresAfterMins 15
|
||||
*/
|
||||
@Post('/trigger')
|
||||
public async triggerProgram(
|
||||
@Request() request: express.Request,
|
||||
@Query() _program: string,
|
||||
@Query() _debug?: number,
|
||||
@Query() expiresAfterMins?: number
|
||||
): Promise<TriggerProgramResponse> {
|
||||
return triggerProgram(request, { _program, _debug, expiresAfterMins })
|
||||
}
|
||||
}
|
||||
|
||||
const execute = async (
|
||||
@@ -108,6 +161,56 @@ const execute = async (
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (err: any) {
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'failure',
|
||||
message: 'Job execution failed.',
|
||||
error: typeof err === 'object' ? err.toString() : err,
|
||||
log: err?.log
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const triggerProgram = async (
|
||||
req: express.Request,
|
||||
{ _program, _debug, expiresAfterMins }: TriggerProgramPayload
|
||||
): Promise<TriggerProgramResponse> => {
|
||||
try {
|
||||
// put _program query param into vars object
|
||||
const vars: { [key: string]: string | number } = { _program }
|
||||
|
||||
// if present add _debug query param to vars object
|
||||
if (_debug) {
|
||||
vars._debug = _debug
|
||||
}
|
||||
|
||||
// get code path and runTime
|
||||
const { codePath, runTime } = await getRunTimeAndFilePath(_program)
|
||||
|
||||
// get session controller based on runTime
|
||||
const sessionController = getSessionController(runTime)
|
||||
|
||||
// get session
|
||||
const session = await sessionController.getSession()
|
||||
|
||||
// add expiresAfterMins to session if provided
|
||||
if (expiresAfterMins) {
|
||||
// expiresAfterMins.used is set initially to false
|
||||
session.expiresAfterMins = { mins: expiresAfterMins, used: false }
|
||||
}
|
||||
|
||||
// call executeFile method of ExecutionController without awaiting
|
||||
new ExecutionController().executeFile({
|
||||
programPath: codePath,
|
||||
runTime,
|
||||
preProgramVariables: getPreProgramVariables(req),
|
||||
vars,
|
||||
session
|
||||
})
|
||||
|
||||
// return session id
|
||||
return { sessionId: session.id }
|
||||
} catch (err: any) {
|
||||
throw {
|
||||
code: 400,
|
||||
|
||||
+31
-37
@@ -26,18 +26,14 @@ import {
|
||||
import { GroupController, GroupResponse } from './group'
|
||||
|
||||
export interface UserResponse {
|
||||
id: number
|
||||
uid: string
|
||||
username: string
|
||||
displayName: string
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
export interface UserDetailsResponse {
|
||||
id: number
|
||||
displayName: string
|
||||
username: string
|
||||
export interface UserDetailsResponse extends UserResponse {
|
||||
isActive: boolean
|
||||
isAdmin: boolean
|
||||
autoExec?: string
|
||||
groups?: GroupResponse[]
|
||||
}
|
||||
@@ -52,13 +48,13 @@ export class UserController {
|
||||
*/
|
||||
@Example<UserResponse[]>([
|
||||
{
|
||||
id: 123,
|
||||
uid: 'userIdString',
|
||||
username: 'johnusername',
|
||||
displayName: 'John',
|
||||
isAdmin: false
|
||||
},
|
||||
{
|
||||
id: 456,
|
||||
uid: 'anotherUserIdString',
|
||||
username: 'starkusername',
|
||||
displayName: 'Stark',
|
||||
isAdmin: true
|
||||
@@ -74,7 +70,7 @@ export class UserController {
|
||||
*
|
||||
*/
|
||||
@Example<UserDetailsResponse>({
|
||||
id: 1234,
|
||||
uid: 'userIdString',
|
||||
displayName: 'John Snow',
|
||||
username: 'johnSnow01',
|
||||
isAdmin: false,
|
||||
@@ -111,20 +107,20 @@ export class UserController {
|
||||
* Only Admin or user itself will get user autoExec code.
|
||||
* @summary Get user properties - such as group memberships, userName, displayName.
|
||||
* @param userId The user's identifier
|
||||
* @example userId 1234
|
||||
* @example userId "userIdString"
|
||||
*/
|
||||
@Get('{userId}')
|
||||
@Get('{uid}')
|
||||
public async getUser(
|
||||
@Request() req: express.Request,
|
||||
@Path() userId: number
|
||||
@Path() uid: string
|
||||
): Promise<UserDetailsResponse> {
|
||||
const { MODE } = process.env
|
||||
|
||||
if (MODE === ModeType.Desktop) return getDesktopAutoExec()
|
||||
|
||||
const { user } = req
|
||||
const getAutoExec = user!.isAdmin || user!.userId == userId
|
||||
return getUser({ id: userId }, getAutoExec)
|
||||
const getAutoExec = user!.isAdmin || user!.userId === uid
|
||||
return getUser({ _id: uid }, getAutoExec)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,7 +129,7 @@ export class UserController {
|
||||
* @example username "johnSnow01"
|
||||
*/
|
||||
@Example<UserDetailsResponse>({
|
||||
id: 1234,
|
||||
uid: 'userIdString',
|
||||
displayName: 'John Snow',
|
||||
username: 'johnSnow01',
|
||||
isAdmin: false,
|
||||
@@ -158,7 +154,7 @@ export class UserController {
|
||||
* @example userId "1234"
|
||||
*/
|
||||
@Example<UserDetailsResponse>({
|
||||
id: 1234,
|
||||
uid: 'userIdString',
|
||||
displayName: 'John Snow',
|
||||
username: 'johnSnow01',
|
||||
isAdmin: false,
|
||||
@@ -166,7 +162,7 @@ export class UserController {
|
||||
})
|
||||
@Patch('{userId}')
|
||||
public async updateUser(
|
||||
@Path() userId: number,
|
||||
@Path() userId: string,
|
||||
@Body() body: UserPayload
|
||||
): Promise<UserDetailsResponse> {
|
||||
const { MODE } = process.env
|
||||
@@ -174,7 +170,7 @@ export class UserController {
|
||||
if (MODE === ModeType.Desktop)
|
||||
return updateDesktopAutoExec(body.autoExec ?? '')
|
||||
|
||||
return updateUser({ id: userId }, body)
|
||||
return updateUser({ _id: userId }, body)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,18 +194,16 @@ export class UserController {
|
||||
*/
|
||||
@Delete('{userId}')
|
||||
public async deleteUser(
|
||||
@Path() userId: number,
|
||||
@Path() userId: string,
|
||||
@Body() body: { password?: string },
|
||||
@Query() @Hidden() isAdmin: boolean = false
|
||||
) {
|
||||
return deleteUser({ id: userId }, isAdmin, body)
|
||||
return deleteUser({ _id: userId }, isAdmin, body)
|
||||
}
|
||||
}
|
||||
|
||||
const getAllUsers = async (): Promise<UserResponse[]> =>
|
||||
await User.find({})
|
||||
.select({ _id: 0, id: 1, username: 1, displayName: 1, isAdmin: 1 })
|
||||
.exec()
|
||||
await User.find({}).select('uid username displayName isAdmin').exec()
|
||||
|
||||
const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
|
||||
const { displayName, username, password, isAdmin, isActive, autoExec } = data
|
||||
@@ -239,15 +233,15 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
|
||||
|
||||
const groupController = new GroupController()
|
||||
const allUsersGroup = await groupController
|
||||
.getGroupByGroupName(ALL_USERS_GROUP.name)
|
||||
.getGroupByName(ALL_USERS_GROUP.name)
|
||||
.catch(() => {})
|
||||
|
||||
if (allUsersGroup) {
|
||||
await groupController.addUserToGroup(allUsersGroup.groupId, savedUser.id)
|
||||
await groupController.addUserToGroup(allUsersGroup.uid, savedUser.uid)
|
||||
}
|
||||
|
||||
return {
|
||||
id: savedUser.id,
|
||||
uid: savedUser.uid,
|
||||
displayName: savedUser.displayName,
|
||||
username: savedUser.username,
|
||||
isActive: savedUser.isActive,
|
||||
@@ -256,8 +250,8 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
|
||||
}
|
||||
}
|
||||
|
||||
interface GetUserBy {
|
||||
id?: number
|
||||
export interface GetUserBy {
|
||||
_id?: string
|
||||
username?: string
|
||||
}
|
||||
|
||||
@@ -267,10 +261,10 @@ const getUser = async (
|
||||
): Promise<UserDetailsResponse> => {
|
||||
const user = (await User.findOne(
|
||||
findBy,
|
||||
`id displayName username isActive isAdmin autoExec -_id`
|
||||
`uid displayName username isActive isAdmin autoExec`
|
||||
).populate(
|
||||
'groups',
|
||||
'groupId name description -_id'
|
||||
'uid name description'
|
||||
)) as unknown as UserDetailsResponse
|
||||
|
||||
if (!user)
|
||||
@@ -280,7 +274,7 @@ const getUser = async (
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
uid: user.uid,
|
||||
displayName: user.displayName,
|
||||
username: user.username,
|
||||
isActive: user.isActive,
|
||||
@@ -293,7 +287,7 @@ const getUser = async (
|
||||
const getDesktopAutoExec = async () => {
|
||||
return {
|
||||
...desktopUser,
|
||||
id: desktopUser.userId,
|
||||
uid: desktopUser.userId,
|
||||
autoExec: await getUserAutoExec()
|
||||
}
|
||||
}
|
||||
@@ -329,8 +323,8 @@ const updateUser = async (
|
||||
const usernameExist = await User.findOne({ username })
|
||||
if (usernameExist) {
|
||||
if (
|
||||
(findBy.id && usernameExist.id != findBy.id) ||
|
||||
(findBy.username && usernameExist.username != findBy.username)
|
||||
(findBy._id && usernameExist.uid !== findBy._id) ||
|
||||
(findBy.username && usernameExist.username !== findBy.username)
|
||||
)
|
||||
throw {
|
||||
code: 409,
|
||||
@@ -350,11 +344,11 @@ const updateUser = async (
|
||||
if (!updatedUser)
|
||||
throw {
|
||||
code: 404,
|
||||
message: `Unable to find user with ${findBy.id || findBy.username}`
|
||||
message: `Unable to find user with ${findBy._id || findBy.username}`
|
||||
}
|
||||
|
||||
return {
|
||||
id: updatedUser.id,
|
||||
uid: updatedUser.uid,
|
||||
username: updatedUser.username,
|
||||
displayName: updatedUser.displayName,
|
||||
isAdmin: updatedUser.isAdmin,
|
||||
@@ -367,7 +361,7 @@ const updateDesktopAutoExec = async (autoExec: string) => {
|
||||
await updateUserAutoExec(autoExec)
|
||||
return {
|
||||
...desktopUser,
|
||||
id: desktopUser.userId,
|
||||
uid: desktopUser.userId,
|
||||
autoExec
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,10 @@ const login = async (
|
||||
const rateLimiter = RateLimiter.getInstance()
|
||||
|
||||
if (!validPass) {
|
||||
const retrySecs = await rateLimiter.consume(req.ip, user?.username)
|
||||
const retrySecs = await rateLimiter.consume(
|
||||
req.ip || 'unknown',
|
||||
user?.username
|
||||
)
|
||||
if (retrySecs > 0) throw errors.tooManyRequests(retrySecs)
|
||||
}
|
||||
|
||||
@@ -114,11 +117,11 @@ const login = async (
|
||||
if (!validPass) throw errors.invalidPassword
|
||||
|
||||
// Reset on successful authorization
|
||||
rateLimiter.resetOnSuccess(req.ip, user.username)
|
||||
rateLimiter.resetOnSuccess(req.ip || 'unknown', user.username)
|
||||
|
||||
req.session.loggedIn = true
|
||||
req.session.user = {
|
||||
userId: user.id,
|
||||
userId: user.uid,
|
||||
clientId: 'web_app',
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
@@ -131,7 +134,7 @@ const login = async (
|
||||
return {
|
||||
loggedIn: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
uid: user.uid,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin,
|
||||
|
||||
@@ -37,10 +37,10 @@ export const authenticateAccessToken: RequestHandler = async (
|
||||
if (user.isActive) {
|
||||
req.user = user
|
||||
return csrfProtection(req, res, nextFunction)
|
||||
} else return res.sendStatus(401)
|
||||
} else return res.status(401).send('Unauthorized')
|
||||
}
|
||||
}
|
||||
return res.sendStatus(401)
|
||||
return res.status(401).send('Unauthorized')
|
||||
}
|
||||
|
||||
await authenticateToken(
|
||||
@@ -76,7 +76,7 @@ const authenticateToken = async (
|
||||
const { MODE } = process.env
|
||||
if (MODE === ModeType.Desktop) {
|
||||
req.user = {
|
||||
userId: 1234,
|
||||
userId: '1234',
|
||||
clientId: 'desktopModeClientId',
|
||||
username: 'desktopModeUsername',
|
||||
displayName: 'desktopModeDisplayName',
|
||||
@@ -118,6 +118,6 @@ const authenticateToken = async (
|
||||
return next()
|
||||
}
|
||||
|
||||
res.sendStatus(401)
|
||||
res.status(401).send('Unauthorized')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export const authorize: RequestHandler = async (req, res, next) => {
|
||||
// no need to check for permissions when route is Public
|
||||
if (await isPublicRoute(req)) return next()
|
||||
|
||||
const dbUser = await User.findOne({ id: user.userId })
|
||||
const dbUser = await User.findOne({ _id: user.userId })
|
||||
if (!dbUser) return res.sendStatus(401)
|
||||
|
||||
const path = getPath(req)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { convertSecondsToHms } from '@sasjs/utils'
|
||||
import { RateLimiter } from '../utils'
|
||||
|
||||
export const bruteForceProtection: RequestHandler = async (req, res, next) => {
|
||||
const ip = req.ip
|
||||
const ip = req.ip || 'unknown'
|
||||
const username = req.body.username
|
||||
|
||||
const rateLimiter = RateLimiter.getInstance()
|
||||
|
||||
@@ -28,7 +28,7 @@ export const desktopRestrict: RequestHandler = (req, res, next) => {
|
||||
}
|
||||
|
||||
export const desktopUser: RequestUser = {
|
||||
userId: 12345,
|
||||
userId: '12345',
|
||||
clientId: 'desktop_app',
|
||||
username: userInfo().username,
|
||||
displayName: userInfo().username,
|
||||
|
||||
@@ -8,8 +8,8 @@ export const verifyAdminIfNeeded: RequestHandler = (req, res, next) => {
|
||||
if (!user?.isAdmin) {
|
||||
let adminAccountRequired: boolean = true
|
||||
|
||||
if (req.params.userId) {
|
||||
adminAccountRequired = user?.userId !== parseInt(req.params.userId)
|
||||
if (req.params.uid) {
|
||||
adminAccountRequired = user?.userId !== req.params.uid
|
||||
} else if (req.params.username) {
|
||||
adminAccountRequired = user?.username !== req.params.username
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import mongoose, { Schema } from 'mongoose'
|
||||
|
||||
const CounterSchema = new Schema({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
seq: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
export default mongoose.model('Counter', CounterSchema)
|
||||
+24
-16
@@ -1,9 +1,9 @@
|
||||
import { Schema, model, Document, Model } from 'mongoose'
|
||||
import { GroupDetailsResponse } from '../controllers'
|
||||
import User, { IUser } from './User'
|
||||
import { AuthProviderType, getSequenceNextValue } from '../utils'
|
||||
import { AuthProviderType } from '../utils'
|
||||
|
||||
export const PUBLIC_GROUP_NAME = 'Public'
|
||||
export const PUBLIC_GROUP_NAME = 'public'
|
||||
|
||||
export interface GroupPayload {
|
||||
/**
|
||||
@@ -24,10 +24,12 @@ export interface GroupPayload {
|
||||
}
|
||||
|
||||
interface IGroupDocument extends GroupPayload, Document {
|
||||
groupId: number
|
||||
isActive: boolean
|
||||
users: Schema.Types.ObjectId[]
|
||||
authProvider?: AuthProviderType
|
||||
|
||||
// Declare virtual properties as read-only properties
|
||||
readonly uid: string
|
||||
}
|
||||
|
||||
interface IGroup extends IGroupDocument {
|
||||
@@ -37,16 +39,23 @@ interface IGroup extends IGroupDocument {
|
||||
}
|
||||
interface IGroupModel extends Model<IGroup> {}
|
||||
|
||||
const groupSchema = new Schema<IGroupDocument>({
|
||||
const opts = {
|
||||
toJSON: {
|
||||
virtuals: true,
|
||||
transform: function (doc: any, ret: any, options: any) {
|
||||
delete ret._id
|
||||
delete ret.id
|
||||
return ret
|
||||
}
|
||||
}
|
||||
}
|
||||
const groupSchema = new Schema<IGroupDocument>(
|
||||
{
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
groupId: {
|
||||
type: Number,
|
||||
unique: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: 'Group description.'
|
||||
@@ -60,23 +69,22 @@ const groupSchema = new Schema<IGroupDocument>({
|
||||
default: true
|
||||
},
|
||||
users: [{ type: Schema.Types.ObjectId, ref: 'User' }]
|
||||
})
|
||||
},
|
||||
opts
|
||||
)
|
||||
|
||||
// Hooks
|
||||
groupSchema.pre('save', async function () {
|
||||
if (this.isNew) {
|
||||
this.groupId = await getSequenceNextValue('groupId')
|
||||
}
|
||||
groupSchema.virtual('uid').get(function () {
|
||||
return this._id.toString()
|
||||
})
|
||||
|
||||
groupSchema.post('save', function (group: IGroup, next: Function) {
|
||||
group.populate('users', 'id username displayName -_id').then(function () {
|
||||
group.populate('users', 'uid username displayName').then(function () {
|
||||
next()
|
||||
})
|
||||
})
|
||||
|
||||
// pre remove hook to remove all references of group from users
|
||||
groupSchema.pre('remove', async function () {
|
||||
groupSchema.pre('remove', async function (this: IGroupDocument) {
|
||||
const userIds = this.users
|
||||
await Promise.all(
|
||||
userIds.map(async (userId) => {
|
||||
|
||||
+25
-23
@@ -1,6 +1,5 @@
|
||||
import { Schema, model, Document, Model } from 'mongoose'
|
||||
import { PermissionDetailsResponse } from '../controllers'
|
||||
import { getSequenceNextValue } from '../utils'
|
||||
|
||||
interface GetPermissionBy {
|
||||
user?: Schema.Types.ObjectId
|
||||
@@ -11,9 +10,11 @@ interface IPermissionDocument extends Document {
|
||||
path: string
|
||||
type: string
|
||||
setting: string
|
||||
permissionId: number
|
||||
user: Schema.Types.ObjectId
|
||||
group: Schema.Types.ObjectId
|
||||
|
||||
// Declare virtual properties as read-only properties
|
||||
readonly uid: string
|
||||
}
|
||||
|
||||
interface IPermission extends IPermissionDocument {}
|
||||
@@ -22,11 +23,19 @@ interface IPermissionModel extends Model<IPermission> {
|
||||
get(getBy: GetPermissionBy): Promise<PermissionDetailsResponse[]>
|
||||
}
|
||||
|
||||
const permissionSchema = new Schema<IPermissionDocument>({
|
||||
permissionId: {
|
||||
type: Number,
|
||||
unique: true
|
||||
},
|
||||
const opts = {
|
||||
toJSON: {
|
||||
virtuals: true,
|
||||
transform: function (doc: any, ret: any, options: any) {
|
||||
delete ret._id
|
||||
delete ret.id
|
||||
return ret
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const permissionSchema = new Schema<IPermissionDocument>(
|
||||
{
|
||||
path: {
|
||||
type: String,
|
||||
required: true
|
||||
@@ -41,13 +50,12 @@ const permissionSchema = new Schema<IPermissionDocument>({
|
||||
},
|
||||
user: { type: Schema.Types.ObjectId, ref: 'User' },
|
||||
group: { type: Schema.Types.ObjectId, ref: 'Group' }
|
||||
})
|
||||
},
|
||||
opts
|
||||
)
|
||||
|
||||
// Hooks
|
||||
permissionSchema.pre('save', async function () {
|
||||
if (this.isNew) {
|
||||
this.permissionId = await getSequenceNextValue('permissionId')
|
||||
}
|
||||
permissionSchema.virtual('uid').get(function () {
|
||||
return this._id.toString()
|
||||
})
|
||||
|
||||
// Static Methods
|
||||
@@ -55,20 +63,14 @@ permissionSchema.static('get', async function (getBy: GetPermissionBy): Promise<
|
||||
PermissionDetailsResponse[]
|
||||
> {
|
||||
return (await this.find(getBy)
|
||||
.select({
|
||||
_id: 0,
|
||||
permissionId: 1,
|
||||
path: 1,
|
||||
type: 1,
|
||||
setting: 1
|
||||
})
|
||||
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })
|
||||
.select('uid path type setting')
|
||||
.populate({ path: 'user', select: 'uid username displayName isAdmin' })
|
||||
.populate({
|
||||
path: 'group',
|
||||
select: 'groupId name description -_id',
|
||||
select: 'uid name description',
|
||||
populate: {
|
||||
path: 'users',
|
||||
select: 'id username displayName isAdmin -_id',
|
||||
select: 'uid username displayName isAdmin',
|
||||
options: { limit: 15 }
|
||||
}
|
||||
})) as unknown as PermissionDetailsResponse[]
|
||||
|
||||
+22
-16
@@ -1,6 +1,6 @@
|
||||
import { Schema, model, Document, Model } from 'mongoose'
|
||||
import { Schema, model, Document, Model, ObjectId } from 'mongoose'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { AuthProviderType, getSequenceNextValue } from '../utils'
|
||||
import { AuthProviderType } from '../utils'
|
||||
|
||||
export interface UserPayload {
|
||||
/**
|
||||
@@ -36,7 +36,6 @@ export interface UserPayload {
|
||||
|
||||
interface IUserDocument extends UserPayload, Document {
|
||||
_id: Schema.Types.ObjectId
|
||||
id: number
|
||||
isAdmin: boolean
|
||||
isActive: boolean
|
||||
needsToUpdatePassword: boolean
|
||||
@@ -44,6 +43,9 @@ interface IUserDocument extends UserPayload, Document {
|
||||
groups: Schema.Types.ObjectId[]
|
||||
tokens: [{ [key: string]: string }]
|
||||
authProvider?: AuthProviderType
|
||||
|
||||
// Declare virtual properties as read-only properties
|
||||
readonly uid: string
|
||||
}
|
||||
|
||||
export interface IUser extends IUserDocument {
|
||||
@@ -54,8 +56,19 @@ export interface IUser extends IUserDocument {
|
||||
interface IUserModel extends Model<IUser> {
|
||||
hashPassword(password: string): string
|
||||
}
|
||||
const opts = {
|
||||
toJSON: {
|
||||
virtuals: true,
|
||||
transform: function (doc: any, ret: any, options: any) {
|
||||
delete ret._id
|
||||
delete ret.id
|
||||
return ret
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userSchema = new Schema<IUserDocument>({
|
||||
const userSchema = new Schema<IUserDocument>(
|
||||
{
|
||||
displayName: {
|
||||
type: String,
|
||||
required: true
|
||||
@@ -65,10 +78,6 @@ const userSchema = new Schema<IUserDocument>({
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
id: {
|
||||
type: Number,
|
||||
unique: true
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true
|
||||
@@ -109,15 +118,12 @@ const userSchema = new Schema<IUserDocument>({
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
},
|
||||
opts
|
||||
)
|
||||
|
||||
// Hooks
|
||||
userSchema.pre('save', async function (next) {
|
||||
if (this.isNew) {
|
||||
this.id = await getSequenceNextValue('id')
|
||||
}
|
||||
|
||||
next()
|
||||
userSchema.virtual('uid').get(function () {
|
||||
return this._id.toString()
|
||||
})
|
||||
|
||||
// Static Methods
|
||||
|
||||
+24
-20
@@ -1,7 +1,11 @@
|
||||
import express from 'express'
|
||||
import { GroupController } from '../../controllers/'
|
||||
import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
|
||||
import { getGroupValidation, registerGroupValidation } from '../../utils'
|
||||
import {
|
||||
getGroupValidation,
|
||||
registerGroupValidation,
|
||||
uidValidation
|
||||
} from '../../utils'
|
||||
|
||||
const groupRouter = express.Router()
|
||||
|
||||
@@ -33,12 +37,15 @@ groupRouter.get('/', authenticateAccessToken, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
groupRouter.get('/:groupId', authenticateAccessToken, async (req, res) => {
|
||||
const { groupId } = req.params
|
||||
groupRouter.get('/:uid', authenticateAccessToken, async (req, res) => {
|
||||
const { error: uidError, value: params } = uidValidation(req.params)
|
||||
if (uidError) return res.status(400).send(uidError.details[0].message)
|
||||
|
||||
const { uid } = params
|
||||
|
||||
const controller = new GroupController()
|
||||
try {
|
||||
const response = await controller.getGroup(parseInt(groupId))
|
||||
const response = await controller.getGroup(uid)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(err.code).send(err.message)
|
||||
@@ -56,7 +63,7 @@ groupRouter.get(
|
||||
|
||||
const controller = new GroupController()
|
||||
try {
|
||||
const response = await controller.getGroupByGroupName(name)
|
||||
const response = await controller.getGroupByName(name)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(err.code).send(err.message)
|
||||
@@ -65,18 +72,15 @@ groupRouter.get(
|
||||
)
|
||||
|
||||
groupRouter.post(
|
||||
'/:groupId/:userId',
|
||||
'/:groupUid/:userUid',
|
||||
authenticateAccessToken,
|
||||
verifyAdmin,
|
||||
async (req, res) => {
|
||||
const { groupId, userId } = req.params
|
||||
const { groupUid, userUid } = req.params
|
||||
|
||||
const controller = new GroupController()
|
||||
try {
|
||||
const response = await controller.addUserToGroup(
|
||||
parseInt(groupId),
|
||||
parseInt(userId)
|
||||
)
|
||||
const response = await controller.addUserToGroup(groupUid, userUid)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(err.code).send(err.message)
|
||||
@@ -85,18 +89,15 @@ groupRouter.post(
|
||||
)
|
||||
|
||||
groupRouter.delete(
|
||||
'/:groupId/:userId',
|
||||
'/:groupUid/:userUid',
|
||||
authenticateAccessToken,
|
||||
verifyAdmin,
|
||||
async (req, res) => {
|
||||
const { groupId, userId } = req.params
|
||||
const { groupUid, userUid } = req.params
|
||||
|
||||
const controller = new GroupController()
|
||||
try {
|
||||
const response = await controller.removeUserFromGroup(
|
||||
parseInt(groupId),
|
||||
parseInt(userId)
|
||||
)
|
||||
const response = await controller.removeUserFromGroup(groupUid, userUid)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(err.code).send(err.message)
|
||||
@@ -105,15 +106,18 @@ groupRouter.delete(
|
||||
)
|
||||
|
||||
groupRouter.delete(
|
||||
'/:groupId',
|
||||
'/:uid',
|
||||
authenticateAccessToken,
|
||||
verifyAdmin,
|
||||
async (req, res) => {
|
||||
const { groupId } = req.params
|
||||
const { error: uidError, value: params } = uidValidation(req.params)
|
||||
if (uidError) return res.status(400).send(uidError.details[0].message)
|
||||
|
||||
const { uid } = params
|
||||
|
||||
const controller = new GroupController()
|
||||
try {
|
||||
await controller.deleteGroup(parseInt(groupId))
|
||||
await controller.deleteGroup(uid)
|
||||
res.status(200).send('Group Deleted!')
|
||||
} catch (err: any) {
|
||||
res.status(err.code).send(err.message)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PermissionController } from '../../controllers/'
|
||||
import { verifyAdmin } from '../../middlewares'
|
||||
import {
|
||||
registerPermissionValidation,
|
||||
uidValidation,
|
||||
updatePermissionValidation
|
||||
} from '../../utils'
|
||||
|
||||
@@ -34,14 +35,17 @@ permissionRouter.post('/', verifyAdmin, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
permissionRouter.patch('/:permissionId', verifyAdmin, async (req: any, res) => {
|
||||
const { permissionId } = req.params
|
||||
permissionRouter.patch('/:uid', verifyAdmin, async (req: any, res) => {
|
||||
const { error: uidError, value: params } = uidValidation(req.params)
|
||||
if (uidError) return res.status(400).send(uidError.details[0].message)
|
||||
|
||||
const { uid } = params
|
||||
|
||||
const { error, value: body } = updatePermissionValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.updatePermission(permissionId, body)
|
||||
const response = await controller.updatePermission(uid, body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
@@ -50,20 +54,18 @@ permissionRouter.patch('/:permissionId', verifyAdmin, async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
permissionRouter.delete(
|
||||
'/:permissionId',
|
||||
verifyAdmin,
|
||||
async (req: any, res) => {
|
||||
const { permissionId } = req.params
|
||||
permissionRouter.delete('/:uid', verifyAdmin, async (req: any, res) => {
|
||||
const { error: uidError, value: params } = uidValidation(req.params)
|
||||
if (uidError) return res.status(400).send(uidError.details[0].message)
|
||||
|
||||
const { uid } = params
|
||||
try {
|
||||
await controller.deletePermission(permissionId)
|
||||
await controller.deletePermission(uid)
|
||||
res.status(200).send('Permission Deleted!')
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
delete err.code
|
||||
res.status(statusCode).send(err.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
export default permissionRouter
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
import express from 'express'
|
||||
import { SessionController } from '../../controllers'
|
||||
import { sessionIdValidation } from '../../utils'
|
||||
|
||||
const sessionRouter = express.Router()
|
||||
|
||||
sessionRouter.get('/', async (req, res) => {
|
||||
const controller = new SessionController()
|
||||
|
||||
sessionRouter.get('/', async (req, res) => {
|
||||
try {
|
||||
const response = await controller.session(req)
|
||||
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
sessionRouter.get('/:sessionId/state', async (req, res) => {
|
||||
const { error, value: params } = sessionIdValidation(req.params)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.sessionState(params.sessionId)
|
||||
|
||||
res.status(200)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default sessionRouter
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
generateAccessToken,
|
||||
generateAuthCode,
|
||||
generateRefreshToken,
|
||||
randomBytesHexString,
|
||||
saveTokensInDB,
|
||||
verifyTokenInDB
|
||||
} from '../../../utils'
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
const clientId = 'someclientID'
|
||||
const clientSecret = 'someclientSecret'
|
||||
const user = {
|
||||
id: 1234,
|
||||
displayName: 'Test User',
|
||||
username: 'testUsername',
|
||||
password: '87654321',
|
||||
@@ -52,7 +52,7 @@ describe('auth', () => {
|
||||
describe('token', () => {
|
||||
const userInfo: InfoJWT = {
|
||||
clientId,
|
||||
userId: user.id
|
||||
userId: randomBytesHexString(12)
|
||||
}
|
||||
beforeAll(async () => {
|
||||
await userController.createUser(user)
|
||||
@@ -151,10 +151,10 @@ describe('auth', () => {
|
||||
currentUser = await userController.createUser(user)
|
||||
refreshToken = generateRefreshToken({
|
||||
clientId,
|
||||
userId: currentUser.id
|
||||
userId: currentUser.uid
|
||||
})
|
||||
await saveTokensInDB(
|
||||
currentUser.id,
|
||||
currentUser.uid,
|
||||
clientId,
|
||||
'accessToken',
|
||||
refreshToken
|
||||
@@ -202,11 +202,11 @@ describe('auth', () => {
|
||||
currentUser = await userController.createUser(user)
|
||||
accessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId: currentUser.id
|
||||
userId: currentUser.uid
|
||||
})
|
||||
|
||||
await saveTokensInDB(
|
||||
currentUser.id,
|
||||
currentUser.uid,
|
||||
clientId,
|
||||
accessToken,
|
||||
'refreshToken'
|
||||
|
||||
@@ -40,10 +40,10 @@ describe('client', () => {
|
||||
const dbUser = await userController.createUser(adminUser)
|
||||
adminAccessToken = generateAccessToken({
|
||||
clientId: client.clientId,
|
||||
userId: dbUser.id
|
||||
userId: dbUser.uid
|
||||
})
|
||||
await saveTokensInDB(
|
||||
dbUser.id,
|
||||
dbUser.uid,
|
||||
client.clientId,
|
||||
adminAccessToken,
|
||||
'refreshToken'
|
||||
@@ -95,10 +95,10 @@ describe('client', () => {
|
||||
const dbUser = await userController.createUser(user)
|
||||
const accessToken = generateAccessToken({
|
||||
clientId: client.clientId,
|
||||
userId: dbUser.id
|
||||
userId: dbUser.uid
|
||||
})
|
||||
await saveTokensInDB(
|
||||
dbUser.id,
|
||||
dbUser.uid,
|
||||
client.clientId,
|
||||
accessToken,
|
||||
'refreshToken'
|
||||
@@ -212,10 +212,10 @@ describe('client', () => {
|
||||
const dbUser = await userController.createUser(user)
|
||||
const accessToken = generateAccessToken({
|
||||
clientId: client.clientId,
|
||||
userId: dbUser.id
|
||||
userId: dbUser.uid
|
||||
})
|
||||
await saveTokensInDB(
|
||||
dbUser.id,
|
||||
dbUser.uid,
|
||||
client.clientId,
|
||||
accessToken,
|
||||
'refreshToken'
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import path from 'path'
|
||||
import { createFile, fileExists } from '@sasjs/utils'
|
||||
import { Express } from 'express'
|
||||
import mongoose, { Mongoose } from 'mongoose'
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server'
|
||||
import request from 'supertest'
|
||||
import {
|
||||
UserController,
|
||||
PermissionController,
|
||||
PermissionType,
|
||||
PermissionSettingForRoute,
|
||||
PrincipalType
|
||||
} from '../../../controllers/'
|
||||
import {
|
||||
generateAccessToken,
|
||||
saveTokensInDB,
|
||||
RunTimeType,
|
||||
sysInitCompiledPath
|
||||
} from '../../../utils'
|
||||
|
||||
// Real, unmocked end-to-end test of the SAS execution pipeline (session
|
||||
// spawn -> autoexec handshake -> exit code -> HTTP response), using a fake
|
||||
// "SAS executable" (mockSas.js) in place of a real SAS install. This is the
|
||||
// regression test for issue #388: SAS code that aborts (%abort;) used to
|
||||
// hang the request forever instead of returning an error response.
|
||||
const mockSasPath = path.join(__dirname, 'files', 'mockSas.js')
|
||||
|
||||
const clientId = 'codeSpecClientID'
|
||||
|
||||
const user = {
|
||||
displayName: 'Code Spec User',
|
||||
username: 'codeSpecUsername',
|
||||
password: '87654321',
|
||||
isAdmin: false,
|
||||
isActive: true
|
||||
}
|
||||
|
||||
let app: Express
|
||||
let accessToken: string
|
||||
|
||||
describe('code', () => {
|
||||
let con: Mongoose
|
||||
let mongoServer: MongoMemoryServer
|
||||
let userController: UserController
|
||||
let permissionController: PermissionController
|
||||
|
||||
beforeAll(async () => {
|
||||
// SASSessionController.createSession() unconditionally reads this file
|
||||
// (Session.ts:105) before it ever spawns the SAS process. It's normally
|
||||
// produced by `npm run compileSysInit` (part of the `initial`/`build`
|
||||
// scripts), which `npm test` does not run - so on a fresh checkout
|
||||
// (e.g. CI, where "Run Unit Tests" happens before "Build Package") it
|
||||
// doesn't exist yet. Content is irrelevant here since mockSas.js never
|
||||
// interprets it; it just needs to exist and be readable.
|
||||
if (!(await fileExists(sysInitCompiledPath))) {
|
||||
await createFile(sysInitCompiledPath, '')
|
||||
}
|
||||
|
||||
mongoServer = await MongoMemoryServer.create()
|
||||
process.env.DB_CONNECT = mongoServer.getUri()
|
||||
process.env.MODE = 'server'
|
||||
process.env.RUN_TIMES = 'sas'
|
||||
process.env.SAS_PATH = mockSasPath
|
||||
|
||||
const appPromise = (await import('../../../app')).default
|
||||
app = await appPromise
|
||||
|
||||
con = await mongoose.connect(mongoServer.getUri())
|
||||
|
||||
userController = new UserController()
|
||||
permissionController = new PermissionController()
|
||||
|
||||
const dbUser = await userController.createUser(user)
|
||||
accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
|
||||
await permissionController.createPermission({
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: PermissionType.route,
|
||||
principalType: PrincipalType.user,
|
||||
principalId: dbUser.uid,
|
||||
setting: PermissionSettingForRoute.grant
|
||||
})
|
||||
|
||||
process.runTimes = [RunTimeType.SAS]
|
||||
process.sasLoc = mockSasPath
|
||||
}, 30000)
|
||||
|
||||
afterAll(async () => {
|
||||
await con.connection.dropDatabase()
|
||||
await con.connection.close()
|
||||
await mongoServer.stop()
|
||||
})
|
||||
|
||||
describe('execute', () => {
|
||||
it('returns 200 with the mock log when the SAS program completes normally', async () => {
|
||||
const response = await request(app)
|
||||
.post('/SASjsApi/code/execute')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ code: '%put hello world;', runTime: 'sas' })
|
||||
.expect(200)
|
||||
|
||||
expect(response.text).toEqual(
|
||||
expect.stringContaining('mock SAS execution')
|
||||
)
|
||||
}, 30000)
|
||||
|
||||
it('returns a prompt 400 (not a hang) with the complete log when the SAS session fails (%abort;)', async () => {
|
||||
const response = await request(app)
|
||||
.post('/SASjsApi/code/execute')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ code: '%abort;', runTime: 'sas' })
|
||||
.expect(400)
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
status: 'failure',
|
||||
message: 'Job execution failed.'
|
||||
})
|
||||
expect(response.body.log).toEqual(
|
||||
expect.stringContaining('mock SAS execution')
|
||||
)
|
||||
}, 30000)
|
||||
})
|
||||
})
|
||||
|
||||
const generateAndSaveToken = async (userId: string) => {
|
||||
const accessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId
|
||||
})
|
||||
await saveTokensInDB(userId, clientId, accessToken, 'refreshToken')
|
||||
return accessToken
|
||||
}
|
||||
@@ -28,6 +28,15 @@ jest
|
||||
.spyOn(fileUtilModules, 'getUploadsFolder')
|
||||
.mockImplementation(() => path.join(tmpFolder, 'uploads'))
|
||||
|
||||
// getFilesFolder() resolves via getSasjsDriveFolder()/process.driveLoc, a
|
||||
// separate root from getSasjsRootFolder()/process.sasjsRoot above - without
|
||||
// this, every test in this file that creates content under the drive (e.g.
|
||||
// 'level1', 'my/path/...') writes into the real, shared sasjs_root/drive
|
||||
// instead of this run's isolated tmpFolder, leaking state into later runs.
|
||||
jest
|
||||
.spyOn(fileUtilModules, 'getFilesFolder')
|
||||
.mockImplementation(() => path.join(tmpFolder, 'drive', 'files'))
|
||||
|
||||
import appPromise from '../../../app'
|
||||
import {
|
||||
UserController,
|
||||
@@ -71,31 +80,31 @@ describe('drive', () => {
|
||||
con = await mongoose.connect(mongoServer.getUri())
|
||||
|
||||
const dbUser = await controller.createUser(user)
|
||||
accessToken = await generateAndSaveToken(dbUser.id)
|
||||
accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/deploy',
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/deploy/upload',
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/file',
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/folder',
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/rename',
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1197,7 +1206,7 @@ const getExampleService = (): ServiceMember =>
|
||||
((getTreeExample().members[0] as FolderMember).members[0] as FolderMember)
|
||||
.members[0] as ServiceMember
|
||||
|
||||
const generateAndSaveToken = async (userId: number) => {
|
||||
const generateAndSaveToken = async (userId: string) => {
|
||||
const adminAccessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId
|
||||
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Minimal fake "SAS executable" used by tests in place of a real SAS
|
||||
// install. It only fulfils the CLI/filesystem handshake contract that
|
||||
// Session.ts / processProgram.ts rely on:
|
||||
//
|
||||
// -SYSIN <path> the file used as a signal channel: it starts out
|
||||
// containing a dummy (empty) placeholder, we delete it
|
||||
// to signal "session ready", then wait for it to be
|
||||
// rewritten with the real submitted program.
|
||||
// -LOG <path> where we write a fake log so downstream fileExists()/
|
||||
// readFile() calls have something to find.
|
||||
//
|
||||
// It does NOT interpret real SAS syntax. Exit code is the only thing that
|
||||
// matters to the Node side: 0 mimics a normal SAS termination, non-zero
|
||||
// mimics an abnormal one (e.g. %abort;).
|
||||
|
||||
const fs = require('fs')
|
||||
|
||||
const arg = (flag) => {
|
||||
const idx = process.argv.indexOf(flag)
|
||||
|
||||
return idx === -1 ? undefined : process.argv[idx + 1]
|
||||
}
|
||||
|
||||
const sysin = arg('-SYSIN')
|
||||
const logPath = arg('-LOG')
|
||||
|
||||
// Give up waiting for the real program after this long, so an unused
|
||||
// pre-warmed session (sasjs pools up to 3 ready sessions) doesn't linger
|
||||
// forever and keep a test process alive. Generous rather than tight: CI
|
||||
// runners are frequently slower/more contended than a local dev machine
|
||||
// (shared CPU, coverage instrumentation on the Node side slowing down the
|
||||
// round trip this process is waiting on), and this cost is only ever paid
|
||||
// by sessions nothing is actually waiting on - the session actually used
|
||||
// by a request gets its real code written far sooner than this in practice.
|
||||
const GIVE_UP_AFTER_MS = 8000
|
||||
|
||||
const sleepSync = (ms) => {
|
||||
const until = Date.now() + ms
|
||||
|
||||
while (Date.now() < until) {
|
||||
/* busy-wait, mirroring the real autoexec's SAS-side sleep() loop */
|
||||
}
|
||||
}
|
||||
|
||||
// Any of the filesystem calls below can legitimately race against
|
||||
// processProgram's own write-then-rename (e.g. existsSync sees the file,
|
||||
// then a rename mid-flight makes the following read miss). Treat that as
|
||||
// "not ready yet" and retry a few times, rather than letting an uncaught
|
||||
// exception crash this process with a non-zero exit - which would be
|
||||
// indistinguishable, to the Node side, from a genuine SAS failure.
|
||||
const retry = (fn, attempts = 5, delayMs = 20) => {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
return { ok: true, value: fn() }
|
||||
} catch (err) {
|
||||
if (i === attempts - 1) return { ok: false, error: err }
|
||||
|
||||
sleepSync(delayMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. remove the dummy SYSIN, signalling "session ready" to waitForSession()
|
||||
if (sysin) {
|
||||
retry(() => {
|
||||
if (fs.existsSync(sysin)) fs.unlinkSync(sysin)
|
||||
})
|
||||
}
|
||||
|
||||
// 2. wait for the real program to be written back to the same path
|
||||
const deadline = Date.now() + GIVE_UP_AFTER_MS
|
||||
|
||||
while (!sysin || !fs.existsSync(sysin)) {
|
||||
if (Date.now() > deadline) process.exit(0)
|
||||
|
||||
sleepSync(10)
|
||||
}
|
||||
|
||||
// small settle delay, mirroring the real autoexec's sleep(0.01,1) after
|
||||
// detecting the file, so a fast-moving rename isn't read mid-write
|
||||
sleepSync(50)
|
||||
|
||||
const readResult = retry(() => fs.readFileSync(sysin, 'utf-8'))
|
||||
|
||||
if (!readResult.ok) {
|
||||
process.stderr.write(
|
||||
`mockSas.js: failed to read ${sysin}: ${readResult.error}\n`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const code = readResult.value
|
||||
|
||||
if (logPath) {
|
||||
retry(() => fs.writeFileSync(logPath, `NOTE: mock SAS execution\n${code}\n`))
|
||||
}
|
||||
|
||||
if (code.includes('%abort;')) {
|
||||
process.stderr.write('ERROR: SAS session terminated. See log for details.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.exit(0)
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../../../utils'
|
||||
import Group, { PUBLIC_GROUP_NAME } from '../../../model/Group'
|
||||
import User from '../../../model/User'
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
const clientId = 'someclientID'
|
||||
const adminUser = {
|
||||
@@ -75,7 +76,7 @@ describe('group', () => {
|
||||
.send(group)
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.uid).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
@@ -155,7 +156,7 @@ describe('group', () => {
|
||||
const dbGroup = await groupController.createGroup(group)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}`)
|
||||
.delete(`/SASjsApi/group/${dbGroup.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
@@ -174,17 +175,17 @@ describe('group', () => {
|
||||
username: 'deletegroup2'
|
||||
})
|
||||
|
||||
await groupController.addUserToGroup(dbGroup.groupId, dbUser1.id)
|
||||
await groupController.addUserToGroup(dbGroup.groupId, dbUser2.id)
|
||||
await groupController.addUserToGroup(dbGroup.uid, dbUser1.uid)
|
||||
await groupController.addUserToGroup(dbGroup.uid, dbUser2.uid)
|
||||
|
||||
await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}`)
|
||||
.delete(`/SASjsApi/group/${dbGroup.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
const res1 = await request(app)
|
||||
.get(`/SASjsApi/user/${dbUser1.id}`)
|
||||
.get(`/SASjsApi/user/${dbUser1.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
@@ -192,7 +193,7 @@ describe('group', () => {
|
||||
expect(res1.body.groups).toEqual([])
|
||||
|
||||
const res2 = await request(app)
|
||||
.get(`/SASjsApi/user/${dbUser2.id}`)
|
||||
.get(`/SASjsApi/user/${dbUser2.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
@@ -201,8 +202,10 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
it('should respond with Not Found if groupId is incorrect', async () => {
|
||||
const hexValue = randomBytes(12).toString('hex')
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/1234`)
|
||||
.delete(`/SASjsApi/group/${hexValue}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(404)
|
||||
@@ -229,7 +232,7 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}`)
|
||||
.delete(`/SASjsApi/group/${dbGroup.uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(401)
|
||||
@@ -245,15 +248,15 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
it('should respond with group', async () => {
|
||||
const { groupId } = await groupController.createGroup(group)
|
||||
const { uid } = await groupController.createGroup(group)
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/SASjsApi/group/${groupId}`)
|
||||
.get(`/SASjsApi/group/${uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.uid).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
@@ -266,15 +269,15 @@ describe('group', () => {
|
||||
username: 'get' + user.username
|
||||
})
|
||||
|
||||
const { groupId } = await groupController.createGroup(group)
|
||||
const { uid } = await groupController.createGroup(group)
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/SASjsApi/group/${groupId}`)
|
||||
.get(`/SASjsApi/group/${uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.uid).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
@@ -292,8 +295,10 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
it('should respond with Not Found if groupId is incorrect', async () => {
|
||||
const hexValue = randomBytes(12).toString('hex')
|
||||
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/group/1234')
|
||||
.get(`/SASjsApi/group/${hexValue}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(404)
|
||||
@@ -312,7 +317,7 @@ describe('group', () => {
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.uid).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
@@ -333,7 +338,7 @@ describe('group', () => {
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.uid).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
@@ -379,7 +384,7 @@ describe('group', () => {
|
||||
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
groupId: expect.anything(),
|
||||
uid: expect.anything(),
|
||||
name: group.name,
|
||||
description: group.description
|
||||
}
|
||||
@@ -401,7 +406,7 @@ describe('group', () => {
|
||||
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
groupId: expect.anything(),
|
||||
uid: expect.anything(),
|
||||
name: group.name,
|
||||
description: group.description
|
||||
}
|
||||
@@ -426,18 +431,18 @@ describe('group', () => {
|
||||
const dbUser = await userController.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.uid).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
expect(res.body.users).toEqual([
|
||||
{
|
||||
id: expect.anything(),
|
||||
uid: expect.anything(),
|
||||
username: user.username,
|
||||
displayName: user.displayName
|
||||
}
|
||||
@@ -452,20 +457,20 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
await request(app)
|
||||
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/SASjsApi/user/${dbUser.id}`)
|
||||
.get(`/SASjsApi/user/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groups).toEqual([
|
||||
{
|
||||
groupId: expect.anything(),
|
||||
uid: expect.anything(),
|
||||
name: group.name,
|
||||
description: group.description
|
||||
}
|
||||
@@ -478,21 +483,21 @@ describe('group', () => {
|
||||
...user,
|
||||
username: 'addUserRandomUser'
|
||||
})
|
||||
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id)
|
||||
await groupController.addUserToGroup(dbGroup.uid, dbUser.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.uid).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
expect(res.body.users).toEqual([
|
||||
{
|
||||
id: expect.anything(),
|
||||
uid: expect.anything(),
|
||||
username: 'addUserRandomUser',
|
||||
displayName: user.displayName
|
||||
}
|
||||
@@ -526,8 +531,10 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
it('should respond with Not Found if groupId is incorrect', async () => {
|
||||
const hexValue = randomBytes(12).toString('hex')
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/group/123/123')
|
||||
.post(`/SASjsApi/group/${hexValue}/123`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(404)
|
||||
@@ -538,8 +545,10 @@ describe('group', () => {
|
||||
|
||||
it('should respond with Not Found if userId is incorrect', async () => {
|
||||
const dbGroup = await groupController.createGroup(group)
|
||||
const hexValue = randomBytes(12).toString('hex')
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/SASjsApi/group/${dbGroup.groupId}/123`)
|
||||
.post(`/SASjsApi/group/${dbGroup.uid}/${hexValue}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(404)
|
||||
@@ -556,7 +565,7 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(400)
|
||||
@@ -577,7 +586,7 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(405)
|
||||
@@ -596,7 +605,7 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(405)
|
||||
@@ -618,15 +627,15 @@ describe('group', () => {
|
||||
...user,
|
||||
username: 'removeUserRandomUser'
|
||||
})
|
||||
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id)
|
||||
await groupController.addUserToGroup(dbGroup.uid, dbUser.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.delete(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.uid).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
@@ -639,16 +648,16 @@ describe('group', () => {
|
||||
...user,
|
||||
username: 'removeGroupFromUser'
|
||||
})
|
||||
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id)
|
||||
await groupController.addUserToGroup(dbGroup.uid, dbUser.uid)
|
||||
|
||||
await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.delete(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/SASjsApi/user/${dbUser.id}`)
|
||||
.get(`/SASjsApi/user/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
@@ -667,7 +676,7 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.delete(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(405)
|
||||
@@ -686,7 +695,7 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.delete(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(405)
|
||||
@@ -723,8 +732,10 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
it('should respond with Not Found if groupId is incorrect', async () => {
|
||||
const hexValue = randomBytes(12).toString('hex')
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/SASjsApi/group/123/123')
|
||||
.delete(`/SASjsApi/group/${hexValue}/123`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(404)
|
||||
@@ -735,8 +746,10 @@ describe('group', () => {
|
||||
|
||||
it('should respond with Not Found if userId is incorrect', async () => {
|
||||
const dbGroup = await groupController.createGroup(group)
|
||||
const hexValue = randomBytes(12).toString('hex')
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}/123`)
|
||||
.delete(`/SASjsApi/group/${dbGroup.uid}/${hexValue}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(404)
|
||||
@@ -752,10 +765,10 @@ const generateSaveTokenAndCreateUser = async (
|
||||
): Promise<string> => {
|
||||
const dbUser = await userController.createUser(someUser ?? adminUser)
|
||||
|
||||
return generateAndSaveToken(dbUser.id)
|
||||
return generateAndSaveToken(dbUser.uid)
|
||||
}
|
||||
|
||||
const generateAndSaveToken = async (userId: number) => {
|
||||
const generateAndSaveToken = async (userId: string) => {
|
||||
const adminAccessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
PermissionDetailsResponse
|
||||
} from '../../../controllers'
|
||||
import { generateAccessToken, saveTokensInDB } from '../../../utils'
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
const deployPayload = {
|
||||
appLoc: 'string',
|
||||
@@ -103,10 +104,10 @@ describe('permission', () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ ...permission, principalId: dbUser.id })
|
||||
.send({ ...permission, principalId: dbUser.uid })
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.permissionId).toBeTruthy()
|
||||
expect(res.body.uid).toBeTruthy()
|
||||
expect(res.body.path).toEqual(permission.path)
|
||||
expect(res.body.type).toEqual(permission.type)
|
||||
expect(res.body.setting).toEqual(permission.setting)
|
||||
@@ -122,11 +123,11 @@ describe('permission', () => {
|
||||
.send({
|
||||
...permission,
|
||||
principalType: 'group',
|
||||
principalId: dbGroup.groupId
|
||||
principalId: dbGroup.uid
|
||||
})
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.permissionId).toBeTruthy()
|
||||
expect(res.body.uid).toBeTruthy()
|
||||
expect(res.body.path).toEqual(permission.path)
|
||||
expect(res.body.type).toEqual(permission.type)
|
||||
expect(res.body.setting).toEqual(permission.setting)
|
||||
@@ -144,7 +145,7 @@ describe('permission', () => {
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not of an admin account', async () => {
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
@@ -281,17 +282,19 @@ describe('permission', () => {
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if principalId is not a number', async () => {
|
||||
it('should respond with Bad Request if principalId is not a string of 24 hex characters', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalId: 'someCharacters'
|
||||
principalId: randomBytes(10).toString('hex')
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('"principalId" must be a number')
|
||||
expect(res.text).toEqual(
|
||||
'"principalId" length must be 24 characters long'
|
||||
)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
@@ -307,7 +310,7 @@ describe('permission', () => {
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalId: adminUser.id
|
||||
principalId: adminUser.uid
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
@@ -321,7 +324,7 @@ describe('permission', () => {
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalId: 123
|
||||
principalId: randomBytes(12).toString('hex')
|
||||
})
|
||||
.expect(404)
|
||||
|
||||
@@ -336,7 +339,7 @@ describe('permission', () => {
|
||||
.send({
|
||||
...permission,
|
||||
principalType: 'group',
|
||||
principalId: 123
|
||||
principalId: randomBytes(12).toString('hex')
|
||||
})
|
||||
.expect(404)
|
||||
|
||||
@@ -347,13 +350,13 @@ describe('permission', () => {
|
||||
it('should respond with Conflict (409) if permission already exists', async () => {
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ ...permission, principalId: dbUser.id })
|
||||
.send({ ...permission, principalId: dbUser.uid })
|
||||
.expect(409)
|
||||
|
||||
expect(res.text).toEqual(
|
||||
@@ -368,7 +371,7 @@ describe('permission', () => {
|
||||
beforeAll(async () => {
|
||||
dbPermission = await permissionController.createPermission({
|
||||
...permission,
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
})
|
||||
|
||||
@@ -378,7 +381,7 @@ describe('permission', () => {
|
||||
|
||||
it('should respond with updated permission', async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ setting: PermissionSettingForRoute.deny })
|
||||
.expect(200)
|
||||
@@ -388,7 +391,7 @@ describe('permission', () => {
|
||||
|
||||
it('should respond with Unauthorized if access token is not present', async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.uid}`)
|
||||
.send()
|
||||
.expect(401)
|
||||
|
||||
@@ -403,7 +406,7 @@ describe('permission', () => {
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(401)
|
||||
@@ -414,7 +417,7 @@ describe('permission', () => {
|
||||
|
||||
it('should respond with Bad Request if setting is missing', async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(400)
|
||||
@@ -425,7 +428,7 @@ describe('permission', () => {
|
||||
|
||||
it('should respond with Bad Request if setting is invalid', async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
setting: 'invalid'
|
||||
@@ -437,8 +440,9 @@ describe('permission', () => {
|
||||
})
|
||||
|
||||
it('should respond with not found (404) if permission with provided id does not exist', async () => {
|
||||
const hexValue = randomBytes(12).toString('hex')
|
||||
const res = await request(app)
|
||||
.patch('/SASjsApi/permission/123')
|
||||
.patch(`/SASjsApi/permission/${hexValue}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
setting: PermissionSettingForRoute.deny
|
||||
@@ -454,10 +458,10 @@ describe('permission', () => {
|
||||
it('should delete permission', async () => {
|
||||
const dbPermission = await permissionController.createPermission({
|
||||
...permission,
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.delete(`/SASjsApi/permission/${dbPermission?.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
@@ -466,8 +470,10 @@ describe('permission', () => {
|
||||
})
|
||||
|
||||
it('should respond with not found (404) if permission with provided id does not exists', async () => {
|
||||
const hexValue = randomBytes(12).toString('hex')
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/SASjsApi/permission/123')
|
||||
.delete(`/SASjsApi/permission/${hexValue}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(404)
|
||||
@@ -481,12 +487,12 @@ describe('permission', () => {
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/test-1',
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/test-2',
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
})
|
||||
|
||||
@@ -505,12 +511,12 @@ describe('permission', () => {
|
||||
...user,
|
||||
username: 'get' + user.username
|
||||
})
|
||||
const accessToken = await generateAndSaveToken(nonAdminUser.id)
|
||||
const accessToken = await generateAndSaveToken(nonAdminUser.uid)
|
||||
await permissionController.createPermission({
|
||||
path: '/test-1',
|
||||
type: PermissionType.route,
|
||||
principalType: PrincipalType.user,
|
||||
principalId: nonAdminUser.id,
|
||||
principalId: nonAdminUser.uid,
|
||||
setting: PermissionSettingForRoute.grant
|
||||
})
|
||||
|
||||
@@ -531,7 +537,7 @@ describe('permission', () => {
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/deploy',
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
})
|
||||
|
||||
@@ -551,7 +557,7 @@ describe('permission', () => {
|
||||
})
|
||||
|
||||
it('should create files in SASJS drive', async () => {
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
|
||||
await request(app)
|
||||
.get('/SASjsApi/drive/deploy')
|
||||
@@ -561,7 +567,7 @@ describe('permission', () => {
|
||||
})
|
||||
|
||||
it('should respond unauthorized', async () => {
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
|
||||
await request(app)
|
||||
.get('/SASjsApi/drive/deploy/upload')
|
||||
@@ -577,10 +583,10 @@ const generateSaveTokenAndCreateUser = async (
|
||||
): Promise<string> => {
|
||||
const dbUser = await userController.createUser(someUser ?? adminUser)
|
||||
|
||||
return generateAndSaveToken(dbUser.id)
|
||||
return generateAndSaveToken(dbUser.uid)
|
||||
}
|
||||
|
||||
const generateAndSaveToken = async (userId: number) => {
|
||||
const generateAndSaveToken = async (userId: string) => {
|
||||
const adminAccessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
SASSessionController
|
||||
} from '../../../controllers/internal'
|
||||
import * as ProcessProgramModule from '../../../controllers/internal/processProgram'
|
||||
import { Session } from '../../../types'
|
||||
import { Session, SessionState } from '../../../types'
|
||||
|
||||
const clientId = 'someclientID'
|
||||
|
||||
@@ -58,12 +58,12 @@ describe('stp', () => {
|
||||
mongoServer = await MongoMemoryServer.create()
|
||||
con = await mongoose.connect(mongoServer.getUri())
|
||||
const dbUser = await userController.createUser(user)
|
||||
accessToken = await generateAndSaveToken(dbUser.id)
|
||||
accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
await permissionController.createPermission({
|
||||
path: '/SASjsApi/stp/execute',
|
||||
type: PermissionType.route,
|
||||
principalType: PrincipalType.user,
|
||||
principalId: dbUser.id,
|
||||
principalId: dbUser.uid,
|
||||
setting: PermissionSettingForRoute.grant
|
||||
})
|
||||
})
|
||||
@@ -456,7 +456,7 @@ const makeRequestAndAssert = async (
|
||||
)
|
||||
}
|
||||
|
||||
const generateAndSaveToken = async (userId: number) => {
|
||||
const generateAndSaveToken = async (userId: string) => {
|
||||
const accessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId
|
||||
@@ -493,10 +493,7 @@ const mockedGetSession = async () => {
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ready: true,
|
||||
inUse: true,
|
||||
consumed: false,
|
||||
completed: false,
|
||||
state: SessionState.pending,
|
||||
creationTimeStamp,
|
||||
deathTimeStamp,
|
||||
path: sessionFolder
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
import { Express } from 'express'
|
||||
import mongoose, { Mongoose } from 'mongoose'
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server'
|
||||
@@ -101,9 +102,9 @@ describe('user', () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId: dbUser.id
|
||||
userId: dbUser.uid
|
||||
})
|
||||
await saveTokensInDB(dbUser.id, clientId, accessToken, 'refreshToken')
|
||||
await saveTokensInDB(dbUser.uid, clientId, accessToken, 'refreshToken')
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/user')
|
||||
@@ -187,7 +188,7 @@ describe('user', () => {
|
||||
const newDisplayName = 'My new display Name'
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/user/${dbUser.id}`)
|
||||
.patch(`/SASjsApi/user/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ ...user, displayName: newDisplayName })
|
||||
.expect(200)
|
||||
@@ -200,11 +201,11 @@ describe('user', () => {
|
||||
|
||||
it('should respond with updated user when user himself requests', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
const newDisplayName = 'My new display Name'
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/user/${dbUser.id}`)
|
||||
.patch(`/SASjsApi/user/${dbUser.uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({
|
||||
displayName: newDisplayName,
|
||||
@@ -221,11 +222,11 @@ describe('user', () => {
|
||||
|
||||
it('should respond with Bad Request, only admin can update isAdmin/isActive', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
const newDisplayName = 'My new display Name'
|
||||
|
||||
await request(app)
|
||||
.patch(`/SASjsApi/user/${dbUser.id}`)
|
||||
.patch(`/SASjsApi/user/${dbUser.uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ ...user, displayName: newDisplayName })
|
||||
.expect(400)
|
||||
@@ -277,10 +278,10 @@ describe('user', () => {
|
||||
...user,
|
||||
username: 'randomUser'
|
||||
})
|
||||
const accessToken = await generateAndSaveToken(dbUser2.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser2.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/user/${dbUser1.id}`)
|
||||
.patch(`/SASjsApi/user/${dbUser1.uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send(user)
|
||||
.expect(401)
|
||||
@@ -297,7 +298,7 @@ describe('user', () => {
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/user/${dbUser1.id}`)
|
||||
.patch(`/SASjsApi/user/${dbUser1.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ username: dbUser2.username })
|
||||
.expect(409)
|
||||
@@ -325,7 +326,7 @@ describe('user', () => {
|
||||
|
||||
it('should respond with updated user when user himself requests', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
const newDisplayName = 'My new display Name'
|
||||
|
||||
const res = await request(app)
|
||||
@@ -346,7 +347,7 @@ describe('user', () => {
|
||||
|
||||
it('should respond with Bad Request, only admin can update isAdmin/isActive', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
const newDisplayName = 'My new display Name'
|
||||
|
||||
await request(app)
|
||||
@@ -372,10 +373,10 @@ describe('user', () => {
|
||||
...user,
|
||||
username: 'randomUser'
|
||||
})
|
||||
const accessToken = await generateAndSaveToken(dbUser2.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser2.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/user/${dbUser1.id}`)
|
||||
.patch(`/SASjsApi/user/${dbUser1.uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send(user)
|
||||
.expect(401)
|
||||
@@ -418,7 +419,7 @@ describe('user', () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/${dbUser.id}`)
|
||||
.delete(`/SASjsApi/user/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
@@ -428,10 +429,10 @@ describe('user', () => {
|
||||
|
||||
it('should respond with OK when user himself requests', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/${dbUser.id}`)
|
||||
.delete(`/SASjsApi/user/${dbUser.uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ password: user.password })
|
||||
.expect(200)
|
||||
@@ -441,10 +442,10 @@ describe('user', () => {
|
||||
|
||||
it('should respond with Bad Request when user himself requests and password is missing', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/${dbUser.id}`)
|
||||
.delete(`/SASjsApi/user/${dbUser.uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(400)
|
||||
@@ -469,10 +470,10 @@ describe('user', () => {
|
||||
...user,
|
||||
username: 'randomUser'
|
||||
})
|
||||
const accessToken = await generateAndSaveToken(dbUser2.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser2.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/${dbUser1.id}`)
|
||||
.delete(`/SASjsApi/user/${dbUser1.uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send(user)
|
||||
.expect(401)
|
||||
@@ -483,10 +484,10 @@ describe('user', () => {
|
||||
|
||||
it('should respond with Unauthorized when user himself requests and password is incorrect', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/${dbUser.id}`)
|
||||
.delete(`/SASjsApi/user/${dbUser.uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ password: 'incorrectpassword' })
|
||||
.expect(401)
|
||||
@@ -510,7 +511,7 @@ describe('user', () => {
|
||||
|
||||
it('should respond with OK when user himself requests', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/by/username/${dbUser.username}`)
|
||||
@@ -523,7 +524,7 @@ describe('user', () => {
|
||||
|
||||
it('should respond with Bad Request when user himself requests and password is missing', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/by/username/${dbUser.username}`)
|
||||
@@ -551,7 +552,7 @@ describe('user', () => {
|
||||
...user,
|
||||
username: 'randomUser'
|
||||
})
|
||||
const accessToken = await generateAndSaveToken(dbUser2.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser2.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/by/username/${dbUser1.username}`)
|
||||
@@ -565,7 +566,7 @@ describe('user', () => {
|
||||
|
||||
it('should respond with Unauthorized when user himself requests and password is incorrect', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/by/username/${dbUser.username}`)
|
||||
@@ -592,7 +593,7 @@ describe('user', () => {
|
||||
|
||||
it('should respond with user autoExec when same user requests', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const userId = dbUser.id
|
||||
const userId = dbUser.uid
|
||||
const accessToken = await generateAndSaveToken(userId)
|
||||
|
||||
const res = await request(app)
|
||||
@@ -611,7 +612,7 @@ describe('user', () => {
|
||||
|
||||
it('should respond with user autoExec when admin user requests', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const userId = dbUser.id
|
||||
const userId = dbUser.uid
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/SASjsApi/user/${userId}`)
|
||||
@@ -634,7 +635,7 @@ describe('user', () => {
|
||||
})
|
||||
|
||||
const dbUser = await controller.createUser(user)
|
||||
const userId = dbUser.id
|
||||
const userId = dbUser.uid
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/SASjsApi/user/${userId}`)
|
||||
@@ -652,7 +653,7 @@ describe('user', () => {
|
||||
|
||||
it('should respond with user along with associated groups', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const userId = dbUser.id
|
||||
const userId = dbUser.uid
|
||||
const accessToken = await generateAndSaveToken(userId)
|
||||
|
||||
const group = {
|
||||
@@ -661,7 +662,7 @@ describe('user', () => {
|
||||
}
|
||||
const groupController = new GroupController()
|
||||
const dbGroup = await groupController.createGroup(group)
|
||||
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id)
|
||||
await groupController.addUserToGroup(dbGroup.uid, dbUser.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/SASjsApi/user/${userId}`)
|
||||
@@ -690,8 +691,10 @@ describe('user', () => {
|
||||
it('should respond with Not Found if userId is incorrect', async () => {
|
||||
await controller.createUser(user)
|
||||
|
||||
const hexValue = randomBytes(12).toString('hex')
|
||||
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/user/1234')
|
||||
.get(`/SASjsApi/user/${hexValue}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(404)
|
||||
@@ -703,7 +706,7 @@ describe('user', () => {
|
||||
describe('by username', () => {
|
||||
it('should respond with user autoExec when same user requests', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const userId = dbUser.id
|
||||
const userId = dbUser.uid
|
||||
const accessToken = await generateAndSaveToken(userId)
|
||||
|
||||
const res = await request(app)
|
||||
@@ -803,13 +806,13 @@ describe('user', () => {
|
||||
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
id: expect.anything(),
|
||||
uid: expect.anything(),
|
||||
username: adminUser.username,
|
||||
displayName: adminUser.displayName,
|
||||
isAdmin: adminUser.isAdmin
|
||||
},
|
||||
{
|
||||
id: expect.anything(),
|
||||
uid: expect.anything(),
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin
|
||||
@@ -831,13 +834,13 @@ describe('user', () => {
|
||||
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
id: expect.anything(),
|
||||
uid: expect.anything(),
|
||||
username: adminUser.username,
|
||||
displayName: adminUser.displayName,
|
||||
isAdmin: adminUser.isAdmin
|
||||
},
|
||||
{
|
||||
id: expect.anything(),
|
||||
uid: expect.anything(),
|
||||
username: 'randomUser',
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin
|
||||
@@ -859,10 +862,10 @@ const generateSaveTokenAndCreateUser = async (
|
||||
): Promise<string> => {
|
||||
const dbUser = await controller.createUser(someUser ?? adminUser)
|
||||
|
||||
return generateAndSaveToken(dbUser.id)
|
||||
return generateAndSaveToken(dbUser.uid)
|
||||
}
|
||||
|
||||
const generateAndSaveToken = async (userId: number) => {
|
||||
const generateAndSaveToken = async (userId: string) => {
|
||||
const adminAccessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId
|
||||
|
||||
@@ -145,7 +145,7 @@ describe('web', () => {
|
||||
|
||||
expect(res.body.loggedIn).toBeTruthy()
|
||||
expect(res.body.user).toEqual({
|
||||
id: expect.any(Number),
|
||||
uid: expect.any(String),
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin,
|
||||
@@ -277,7 +277,10 @@ const performLogin = async (
|
||||
.set('x-xsrf-token', csrfToken)
|
||||
.send(credentials)
|
||||
|
||||
return { authCookies: header['set-cookie'].join() }
|
||||
return {
|
||||
authCookies:
|
||||
(header['set-cookie'] as unknown as string[] | undefined)?.join() || ''
|
||||
}
|
||||
}
|
||||
|
||||
const extractCSRF = (text: string) =>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import express from 'express'
|
||||
import { executeProgramRawValidation } from '../../utils'
|
||||
import {
|
||||
executeProgramRawValidation,
|
||||
triggerProgramValidation
|
||||
} from '../../utils'
|
||||
import { STPController } from '../../controllers/'
|
||||
import { FileUploadController } from '../../controllers/internal'
|
||||
|
||||
@@ -69,4 +72,28 @@ stpRouter.post(
|
||||
}
|
||||
)
|
||||
|
||||
stpRouter.post('/trigger', async (req, res) => {
|
||||
const { error, value: query } = triggerProgramValidation(req.query)
|
||||
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.triggerProgram(
|
||||
req,
|
||||
query._program,
|
||||
query._debug,
|
||||
query.expiresAfterMins
|
||||
)
|
||||
|
||||
res.status(200)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default stpRouter
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
deleteUserValidation,
|
||||
getUserValidation,
|
||||
registerUserValidation,
|
||||
uidValidation,
|
||||
updateUserValidation
|
||||
} from '../../utils'
|
||||
|
||||
@@ -56,12 +57,15 @@ userRouter.get(
|
||||
}
|
||||
)
|
||||
|
||||
userRouter.get('/:userId', authenticateAccessToken, async (req, res) => {
|
||||
const { userId } = req.params
|
||||
userRouter.get('/:uid', authenticateAccessToken, async (req, res) => {
|
||||
const { error, value: params } = uidValidation(req.params)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
const { uid } = params
|
||||
|
||||
const controller = new UserController()
|
||||
try {
|
||||
const response = await controller.getUser(req, parseInt(userId))
|
||||
const response = await controller.getUser(req, uid)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(err.code).send(err.message)
|
||||
@@ -97,12 +101,16 @@ userRouter.patch(
|
||||
)
|
||||
|
||||
userRouter.patch(
|
||||
'/:userId',
|
||||
'/:uid',
|
||||
authenticateAccessToken,
|
||||
verifyAdminIfNeeded,
|
||||
async (req, res) => {
|
||||
const { user } = req
|
||||
const { userId } = req.params
|
||||
|
||||
const { error: uidError, value: params } = uidValidation(req.params)
|
||||
if (uidError) return res.status(400).send(uidError.details[0].message)
|
||||
|
||||
const { uid } = params
|
||||
|
||||
// only an admin can update `isActive` and `isAdmin` fields
|
||||
const { error, value: body } = updateUserValidation(req.body, user!.isAdmin)
|
||||
@@ -110,7 +118,7 @@ userRouter.patch(
|
||||
|
||||
const controller = new UserController()
|
||||
try {
|
||||
const response = await controller.updateUser(parseInt(userId), body)
|
||||
const response = await controller.updateUser(uid, body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(err.code).send(err.message)
|
||||
@@ -147,12 +155,16 @@ userRouter.delete(
|
||||
)
|
||||
|
||||
userRouter.delete(
|
||||
'/:userId',
|
||||
'/:uid',
|
||||
authenticateAccessToken,
|
||||
verifyAdminIfNeeded,
|
||||
async (req, res) => {
|
||||
const { user } = req
|
||||
const { userId } = req.params
|
||||
|
||||
const { error: uidError, value: params } = uidValidation(req.params)
|
||||
if (uidError) return res.status(400).send(uidError.details[0].message)
|
||||
|
||||
const { uid } = params
|
||||
|
||||
// only an admin can delete user without providing password
|
||||
const { error, value: data } = deleteUserValidation(req.body, user!.isAdmin)
|
||||
@@ -160,7 +172,7 @@ userRouter.delete(
|
||||
|
||||
const controller = new UserController()
|
||||
try {
|
||||
await controller.deleteUser(parseInt(userId), data, user!.isAdmin)
|
||||
await controller.deleteUser(uid, data, user!.isAdmin)
|
||||
res.status(200).send('Account Deleted!')
|
||||
} catch (err: any) {
|
||||
res.status(err.code).send(err.message)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface InfoJWT {
|
||||
clientId: string
|
||||
userId: number
|
||||
userId: string
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export interface PreProgramVars {
|
||||
username: string
|
||||
userId: number
|
||||
userId: string
|
||||
displayName: string
|
||||
serverUrl: string
|
||||
httpHeaders: string[]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export interface RequestUser {
|
||||
userId: number
|
||||
userId: string
|
||||
clientId: string
|
||||
username: string
|
||||
displayName: string
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
export enum SessionState {
|
||||
initialising = 'initialising', // session is initialising and not ready to be used yet
|
||||
pending = 'pending', // session is ready to be used
|
||||
running = 'running', // session is in use
|
||||
completed = 'completed', // session is completed and can be destroyed
|
||||
failed = 'failed' // session failed
|
||||
}
|
||||
export interface Session {
|
||||
id: string
|
||||
ready: boolean
|
||||
state: SessionState
|
||||
creationTimeStamp: string
|
||||
deathTimeStamp: string
|
||||
path: string
|
||||
inUse: boolean
|
||||
consumed: boolean
|
||||
completed: boolean
|
||||
crashed?: string
|
||||
expiresAfterMins?: { mins: number; used: boolean }
|
||||
failureReason?: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
export const randomBytesHexString = (bytesCount: number) =>
|
||||
randomBytes(bytesCount).toString('hex')
|
||||
@@ -22,7 +22,7 @@ export const getPreProgramVariables = (req: Request): PreProgramVars => {
|
||||
//So this is workaround.
|
||||
return {
|
||||
username: user ? user.username : 'demo',
|
||||
userId: user ? user.userId : 0,
|
||||
userId: user ? user.userId : 'demoId',
|
||||
displayName: user ? user.displayName : 'demo',
|
||||
serverUrl: protocol + host,
|
||||
httpHeaders
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import Counter from '../model/Counter'
|
||||
|
||||
export const getSequenceNextValue = async (seqName: string) => {
|
||||
const seqDoc = await Counter.findOne({ id: seqName })
|
||||
if (!seqDoc) {
|
||||
await Counter.create({ id: seqName, seq: 1 })
|
||||
return 1
|
||||
}
|
||||
|
||||
seqDoc.seq += 1
|
||||
|
||||
await seqDoc.save()
|
||||
|
||||
return seqDoc.seq
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import User from '../model/User'
|
||||
import { InfoJWT } from '../types/InfoJWT'
|
||||
|
||||
const isValidToken = async (
|
||||
token: string,
|
||||
key: string,
|
||||
userId: number,
|
||||
userId: string,
|
||||
clientId: string
|
||||
) => {
|
||||
const promise = new Promise<boolean>((resolve, reject) =>
|
||||
jwt.verify(token, key, (err, decoded) => {
|
||||
if (err) return reject(false)
|
||||
|
||||
if (decoded?.userId === userId && decoded?.clientId === clientId) {
|
||||
const payload = decoded as InfoJWT
|
||||
if (payload?.userId === userId && payload?.clientId === clientId) {
|
||||
return resolve(true)
|
||||
}
|
||||
|
||||
@@ -22,8 +24,8 @@ const isValidToken = async (
|
||||
return await promise.then(() => true).catch(() => false)
|
||||
}
|
||||
|
||||
export const getTokensFromDB = async (userId: number, clientId: string) => {
|
||||
const user = await User.findOne({ id: userId })
|
||||
export const getTokensFromDB = async (userId: string, clientId: string) => {
|
||||
const user = await User.findOne({ _id: userId })
|
||||
if (!user) return
|
||||
|
||||
const currentTokenObj = user.tokens.find(
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './appStreamConfig'
|
||||
export * from './connectDB'
|
||||
export * from './copySASjsCore'
|
||||
export * from './createWeboutSasFile'
|
||||
export * from './crypto'
|
||||
export * from './desktopAutoExec'
|
||||
export * from './extractHeaders'
|
||||
export * from './extractName'
|
||||
@@ -14,7 +15,6 @@ export * from './getCertificates'
|
||||
export * from './getDesktopFields'
|
||||
export * from './getPreProgramVariables'
|
||||
export * from './getRunTimeAndFilePath'
|
||||
export * from './getSequenceNextValue'
|
||||
export * from './getServerUrl'
|
||||
export * from './getTokensFromDB'
|
||||
export * from './instantiateLogger'
|
||||
|
||||
@@ -22,7 +22,7 @@ export const isPublicRoute = async (req: Request): Promise<boolean> => {
|
||||
}
|
||||
|
||||
export const publicUser: RequestUser = {
|
||||
userId: 0,
|
||||
userId: 'public_user_id',
|
||||
clientId: 'public_app',
|
||||
username: 'publicUser',
|
||||
displayName: 'Public User',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import User from '../model/User'
|
||||
|
||||
export const removeTokensInDB = async (userId: number, clientId: string) => {
|
||||
const user = await User.findOne({ id: userId })
|
||||
export const removeTokensInDB = async (userId: string, clientId: string) => {
|
||||
const user = await User.findOne({ _id: userId })
|
||||
if (!user) return
|
||||
|
||||
const tokenObjIndex = user.tokens.findIndex(
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import User from '../model/User'
|
||||
|
||||
export const saveTokensInDB = async (
|
||||
userId: number,
|
||||
userId: string,
|
||||
clientId: string,
|
||||
accessToken: string,
|
||||
refreshToken: string
|
||||
) => {
|
||||
const user = await User.findOne({ id: userId })
|
||||
const user = await User.findOne({ _id: userId })
|
||||
if (!user) return
|
||||
|
||||
const currentTokenObj = user.tokens.find(
|
||||
|
||||
@@ -82,7 +82,7 @@ export const seedDB = async (): Promise<ConfigurationType> => {
|
||||
}
|
||||
|
||||
export const ALL_USERS_GROUP = {
|
||||
name: 'AllUsers',
|
||||
name: 'all-users',
|
||||
description: 'Group contains all users'
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ const groupnameSchema = Joi.string().lowercase().alphanum().min(3).max(16)
|
||||
|
||||
export const blockFileRegex = /\.(exe|sh|htaccess)$/i
|
||||
|
||||
export const uidValidation = (data: any) =>
|
||||
Joi.object({
|
||||
uid: Joi.string().length(24).hex().required()
|
||||
}).validate(data)
|
||||
|
||||
export const getUserValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
username: usernameSchema.required()
|
||||
@@ -113,7 +118,7 @@ export const registerPermissionValidation = (data: any): Joi.ValidationResult =>
|
||||
principalType: Joi.string()
|
||||
.required()
|
||||
.valid(...Object.values(PrincipalType)),
|
||||
principalId: Joi.number().required()
|
||||
principalId: Joi.string().length(24).hex().required()
|
||||
}).validate(data)
|
||||
|
||||
export const updatePermissionValidation = (data: any): Joi.ValidationResult =>
|
||||
@@ -192,3 +197,17 @@ export const executeProgramRawValidation = (data: any): Joi.ValidationResult =>
|
||||
})
|
||||
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
|
||||
.validate(data)
|
||||
|
||||
export const triggerProgramValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
_program: Joi.string().required(),
|
||||
_debug: Joi.number(),
|
||||
expiresAfterMins: Joi.number().greater(0)
|
||||
})
|
||||
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
|
||||
.validate(data)
|
||||
|
||||
export const sessionIdValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
sessionId: Joi.string().required()
|
||||
}).validate(data)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { RequestUser } from '../types'
|
||||
export const fetchLatestAutoExec = async (
|
||||
reqUser: RequestUser
|
||||
): Promise<RequestUser | undefined> => {
|
||||
const dbUser = await User.findOne({ id: reqUser.userId })
|
||||
const dbUser = await User.findOne({ _id: reqUser.userId })
|
||||
|
||||
if (!dbUser) return undefined
|
||||
|
||||
@@ -21,12 +21,12 @@ export const fetchLatestAutoExec = async (
|
||||
}
|
||||
|
||||
export const verifyTokenInDB = async (
|
||||
userId: number,
|
||||
userId: string,
|
||||
clientId: string,
|
||||
token: string,
|
||||
tokenType: 'accessToken' | 'refreshToken'
|
||||
): Promise<RequestUser | undefined> => {
|
||||
const dbUser = await User.findOne({ id: userId })
|
||||
const dbUser = await User.findOne({ _id: userId })
|
||||
|
||||
if (!dbUser) return undefined
|
||||
|
||||
@@ -36,7 +36,7 @@ export const verifyTokenInDB = async (
|
||||
|
||||
return currentTokenObj?.[tokenType] === token
|
||||
? {
|
||||
userId: dbUser.id,
|
||||
userId: dbUser.uid,
|
||||
clientId,
|
||||
username: dbUser.username,
|
||||
displayName: dbUser.displayName,
|
||||
|
||||
Generated
+4231
-8629
File diff suppressed because it is too large
Load Diff
Generated
+1989
-1432
File diff suppressed because it is too large
Load Diff
+6
-4
@@ -4,7 +4,9 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "webpack-dev-server --config webpack.dev.ts --hot",
|
||||
"build": "webpack --config webpack.prod.ts"
|
||||
"build": "webpack --config webpack.prod.ts",
|
||||
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||
"lint:fix": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.4.1",
|
||||
@@ -19,9 +21,8 @@
|
||||
"@types/jest": "^26.0.24",
|
||||
"@types/node": "^12.20.28",
|
||||
"@types/react": "^17.0.27",
|
||||
"axios": "^0.24.0",
|
||||
"axios": "1.12.2",
|
||||
"monaco-editor": "^0.33.0",
|
||||
"monaco-editor-webpack-plugin": "^7.0.1",
|
||||
"react": "^17.0.2",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-dom": "^17.0.2",
|
||||
@@ -54,8 +55,9 @@
|
||||
"eslint-webpack-plugin": "^3.1.1",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-webpack-plugin": "5.5.0",
|
||||
"monaco-editor-webpack-plugin": "^7.0.1",
|
||||
"path": "0.12.7",
|
||||
"prettier": "^2.4.1",
|
||||
"prettier": "^3.0.3",
|
||||
"sass": "^1.44.0",
|
||||
"sass-loader": "^12.3.0",
|
||||
"style-loader": "^3.3.1",
|
||||
|
||||
@@ -40,7 +40,7 @@ const Login = () => {
|
||||
})
|
||||
|
||||
if (loggedIn) {
|
||||
appContext.setUserId?.(user.id)
|
||||
appContext.setUserId?.(user.uid)
|
||||
appContext.setUsername?.(user.username)
|
||||
appContext.setDisplayName?.(user.displayName)
|
||||
appContext.setIsAdmin?.(user.isAdmin)
|
||||
|
||||
@@ -99,8 +99,8 @@ const AddPermissionModal = ({
|
||||
principalType: principalType.toLowerCase(),
|
||||
principalId:
|
||||
principalType.toLowerCase() === 'user'
|
||||
? userPrincipal?.id
|
||||
: groupPrincipal?.groupId
|
||||
? userPrincipal?.uid
|
||||
: groupPrincipal?.uid
|
||||
}
|
||||
|
||||
permissions.push(addPermissionPayload)
|
||||
|
||||
@@ -61,7 +61,7 @@ const PermissionTable = ({
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{permissions.map((permission) => (
|
||||
<TableRow key={permission.permissionId}>
|
||||
<TableRow key={permission.uid}>
|
||||
<BootstrapTableCell>{permission.path}</BootstrapTableCell>
|
||||
<BootstrapTableCell>{permission.type}</BootstrapTableCell>
|
||||
<BootstrapTableCell>
|
||||
|
||||
@@ -69,7 +69,7 @@ const useAddPermission = () => {
|
||||
|
||||
for (const permission of updatingPermissions) {
|
||||
await axios
|
||||
.patch(`/SASjsApi/permission/${permission.permissionId}`, {
|
||||
.patch(`/SASjsApi/permission/${permission.uid}`, {
|
||||
setting: permission.setting === 'Grant' ? 'Deny' : 'Grant'
|
||||
})
|
||||
.then((res) => {
|
||||
|
||||
@@ -24,7 +24,7 @@ const useDeletePermissionModal = () => {
|
||||
setDeleteConfirmationModalOpen(false)
|
||||
setIsLoading(true)
|
||||
axios
|
||||
.delete(`/SASjsApi/permission/${selectedPermission?.permissionId}`)
|
||||
.delete(`/SASjsApi/permission/${selectedPermission?.uid}`)
|
||||
.then((res: any) => {
|
||||
fetchPermissions()
|
||||
setSnackbarMessage('Permission deleted!')
|
||||
|
||||
@@ -62,21 +62,17 @@ const useFilterPermissions = () => {
|
||||
: permissions
|
||||
|
||||
let filteredArray = uriFilteredPermissions.filter((permission) =>
|
||||
principalFilteredPermissions.some(
|
||||
(item) => item.permissionId === permission.permissionId
|
||||
)
|
||||
principalFilteredPermissions.some((item) => item.uid === permission.uid)
|
||||
)
|
||||
|
||||
filteredArray = filteredArray.filter((permission) =>
|
||||
principalTypeFilteredPermissions.some(
|
||||
(item) => item.permissionId === permission.permissionId
|
||||
(item) => item.uid === permission.uid
|
||||
)
|
||||
)
|
||||
|
||||
filteredArray = filteredArray.filter((permission) =>
|
||||
settingFilteredPermissions.some(
|
||||
(item) => item.permissionId === permission.permissionId
|
||||
)
|
||||
settingFilteredPermissions.some((item) => item.uid === permission.uid)
|
||||
)
|
||||
|
||||
setFilteredPermissions(filteredArray)
|
||||
|
||||
@@ -24,7 +24,7 @@ const useUpdatePermissionModal = () => {
|
||||
setUpdatePermissionModalOpen(false)
|
||||
setIsLoading(true)
|
||||
axios
|
||||
.patch(`/SASjsApi/permission/${selectedPermission?.permissionId}`, {
|
||||
.patch(`/SASjsApi/permission/${selectedPermission?.uid}`, {
|
||||
setting
|
||||
})
|
||||
.then((res: any) => {
|
||||
|
||||
@@ -26,6 +26,7 @@ const Profile = () => {
|
||||
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (appContext.userId) {
|
||||
setIsLoading(true)
|
||||
axios
|
||||
.get(`/SASjsApi/user/${appContext.userId}`)
|
||||
@@ -38,6 +39,7 @@ const Profile = () => {
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
}, [appContext.userId])
|
||||
|
||||
const handleChange = (event: any) => {
|
||||
|
||||
@@ -24,39 +24,32 @@ export enum RunTimeType {
|
||||
interface AppContextProps {
|
||||
checkingSession: boolean
|
||||
loggedIn: boolean
|
||||
setLoggedIn: Dispatch<SetStateAction<boolean>> | null
|
||||
setLoggedIn?: Dispatch<SetStateAction<boolean>>
|
||||
needsToUpdatePassword: boolean
|
||||
setNeedsToUpdatePassword: Dispatch<SetStateAction<boolean>> | null
|
||||
userId: number
|
||||
setUserId: Dispatch<SetStateAction<number>> | null
|
||||
setNeedsToUpdatePassword?: Dispatch<SetStateAction<boolean>>
|
||||
userId?: string
|
||||
setUserId?: Dispatch<SetStateAction<string | undefined>>
|
||||
username: string
|
||||
setUsername: Dispatch<SetStateAction<string>> | null
|
||||
setUsername?: Dispatch<SetStateAction<string>>
|
||||
displayName: string
|
||||
setDisplayName: Dispatch<SetStateAction<string>> | null
|
||||
setDisplayName?: Dispatch<SetStateAction<string>>
|
||||
isAdmin: boolean
|
||||
setIsAdmin: Dispatch<SetStateAction<boolean>> | null
|
||||
setIsAdmin?: Dispatch<SetStateAction<boolean>>
|
||||
mode: ModeType
|
||||
runTimes: RunTimeType[]
|
||||
logout: (() => void) | null
|
||||
logout?: () => void
|
||||
}
|
||||
|
||||
export const AppContext = createContext<AppContextProps>({
|
||||
checkingSession: false,
|
||||
loggedIn: false,
|
||||
setLoggedIn: null,
|
||||
needsToUpdatePassword: false,
|
||||
setNeedsToUpdatePassword: null,
|
||||
userId: 0,
|
||||
setUserId: null,
|
||||
userId: '',
|
||||
username: '',
|
||||
setUsername: null,
|
||||
displayName: '',
|
||||
setDisplayName: null,
|
||||
isAdmin: false,
|
||||
setIsAdmin: null,
|
||||
mode: ModeType.Server,
|
||||
runTimes: [],
|
||||
logout: null
|
||||
runTimes: []
|
||||
})
|
||||
|
||||
const AppContextProvider = (props: { children: ReactNode }) => {
|
||||
@@ -64,7 +57,7 @@ const AppContextProvider = (props: { children: ReactNode }) => {
|
||||
const [checkingSession, setCheckingSession] = useState(false)
|
||||
const [loggedIn, setLoggedIn] = useState(false)
|
||||
const [needsToUpdatePassword, setNeedsToUpdatePassword] = useState(false)
|
||||
const [userId, setUserId] = useState(0)
|
||||
const [userId, setUserId] = useState<string>()
|
||||
const [username, setUsername] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
@@ -79,7 +72,7 @@ const AppContextProvider = (props: { children: ReactNode }) => {
|
||||
.then((res) => res.data)
|
||||
.then((data: any) => {
|
||||
setCheckingSession(false)
|
||||
setUserId(data.id)
|
||||
setUserId(data.uid)
|
||||
setUsername(data.username)
|
||||
setDisplayName(data.displayName)
|
||||
setIsAdmin(data.isAdmin)
|
||||
|
||||
+5
-5
@@ -1,15 +1,15 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
|
||||
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
font-family:
|
||||
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.container {
|
||||
|
||||
@@ -6,13 +6,13 @@ export const findExistingPermission = (
|
||||
) => {
|
||||
for (const permission of existingPermissions) {
|
||||
if (
|
||||
permission.user?.id === newPermission.principalId &&
|
||||
permission.user?.uid === newPermission.principalId &&
|
||||
hasSameCombination(permission, newPermission)
|
||||
)
|
||||
return permission
|
||||
|
||||
if (
|
||||
permission.group?.groupId === newPermission.principalId &&
|
||||
permission.group?.uid === newPermission.principalId &&
|
||||
hasSameCombination(permission, newPermission)
|
||||
)
|
||||
return permission
|
||||
@@ -27,13 +27,13 @@ export const findUpdatingPermission = (
|
||||
) => {
|
||||
for (const permission of existingPermissions) {
|
||||
if (
|
||||
permission.user?.id === newPermission.principalId &&
|
||||
permission.user?.uid === newPermission.principalId &&
|
||||
hasDifferentSetting(permission, newPermission)
|
||||
)
|
||||
return permission
|
||||
|
||||
if (
|
||||
permission.group?.groupId === newPermission.principalId &&
|
||||
permission.group?.uid === newPermission.principalId &&
|
||||
hasDifferentSetting(permission, newPermission)
|
||||
)
|
||||
return permission
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export interface UserResponse {
|
||||
id: number
|
||||
uid: string
|
||||
username: string
|
||||
displayName: string
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
export interface GroupResponse {
|
||||
groupId: number
|
||||
uid: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export interface GroupDetailsResponse extends GroupResponse {
|
||||
}
|
||||
|
||||
export interface PermissionResponse {
|
||||
permissionId: number
|
||||
uid: string
|
||||
path: string
|
||||
type: string
|
||||
setting: string
|
||||
@@ -30,7 +30,7 @@ export interface RegisterPermissionPayload {
|
||||
type: string
|
||||
setting: string
|
||||
principalType: string
|
||||
principalId: number
|
||||
principalId: string
|
||||
}
|
||||
|
||||
export interface TreeNode {
|
||||
|
||||
Reference in New Issue
Block a user