opensoma 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/package.json +2 -2
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +2 -1
- package/dist/src/cli.js.map +1 -1
- package/dist/src/client.d.ts +26 -1
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +141 -3
- package/dist/src/client.js.map +1 -1
- package/dist/src/commands/auth.d.ts +12 -0
- package/dist/src/commands/auth.d.ts.map +1 -1
- package/dist/src/commands/auth.js +151 -23
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/helpers.d.ts +12 -0
- package/dist/src/commands/helpers.d.ts.map +1 -1
- package/dist/src/commands/helpers.js +55 -9
- package/dist/src/commands/helpers.js.map +1 -1
- package/dist/src/commands/index.d.ts +1 -0
- package/dist/src/commands/index.d.ts.map +1 -1
- package/dist/src/commands/index.js +1 -0
- package/dist/src/commands/index.js.map +1 -1
- package/dist/src/commands/mentoring.d.ts.map +1 -1
- package/dist/src/commands/mentoring.js +37 -1
- package/dist/src/commands/mentoring.js.map +1 -1
- package/dist/src/commands/report.d.ts +27 -0
- package/dist/src/commands/report.d.ts.map +1 -0
- package/dist/src/commands/report.js +224 -0
- package/dist/src/commands/report.js.map +1 -0
- package/dist/src/constants.d.ts +2 -0
- package/dist/src/constants.d.ts.map +1 -1
- package/dist/src/constants.js +2 -0
- package/dist/src/constants.js.map +1 -1
- package/dist/src/credential-manager.d.ts +7 -0
- package/dist/src/credential-manager.d.ts.map +1 -1
- package/dist/src/credential-manager.js +76 -2
- package/dist/src/credential-manager.js.map +1 -1
- package/dist/src/formatters.d.ts +4 -1
- package/dist/src/formatters.d.ts.map +1 -1
- package/dist/src/formatters.js +91 -1
- package/dist/src/formatters.js.map +1 -1
- package/dist/src/http.d.ts +3 -0
- package/dist/src/http.d.ts.map +1 -1
- package/dist/src/http.js +109 -4
- package/dist/src/http.js.map +1 -1
- package/dist/src/session-recovery.d.ts +12 -0
- package/dist/src/session-recovery.d.ts.map +1 -0
- package/dist/src/session-recovery.js +34 -0
- package/dist/src/session-recovery.js.map +1 -0
- package/dist/src/shared/utils/report-params.d.ts +11 -0
- package/dist/src/shared/utils/report-params.d.ts.map +1 -0
- package/dist/src/shared/utils/report-params.js +27 -0
- package/dist/src/shared/utils/report-params.js.map +1 -0
- package/dist/src/shared/utils/swmaestro.d.ts +24 -0
- package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
- package/dist/src/shared/utils/swmaestro.js +50 -2
- package/dist/src/shared/utils/swmaestro.js.map +1 -1
- package/dist/src/token-extractor.d.ts.map +1 -1
- package/dist/src/token-extractor.js +38 -8
- package/dist/src/token-extractor.js.map +1 -1
- package/dist/src/types.d.ts +121 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +72 -0
- package/dist/src/types.js.map +1 -1
- package/package.json +2 -2
- package/src/cli.ts +2 -0
- package/src/client.test.ts +95 -6
- package/src/client.ts +172 -4
- package/src/commands/auth.test.ts +152 -1
- package/src/commands/auth.ts +174 -28
- package/src/commands/helpers.test.ts +216 -0
- package/src/commands/helpers.ts +77 -12
- package/src/commands/index.ts +1 -0
- package/src/commands/mentoring.ts +55 -0
- package/src/commands/report.test.ts +49 -0
- package/src/commands/report.ts +322 -0
- package/src/constants.ts +2 -0
- package/src/credential-manager.test.ts +41 -1
- package/src/credential-manager.ts +103 -2
- package/src/formatters.test.ts +287 -0
- package/src/formatters.ts +105 -0
- package/src/http.test.ts +190 -4
- package/src/http.ts +132 -4
- package/src/session-recovery.ts +56 -0
- package/src/shared/utils/report-params.ts +41 -0
- package/src/shared/utils/swmaestro.ts +77 -5
- package/src/token-extractor.ts +59 -20
- package/src/types.test.ts +97 -0
- package/src/types.ts +84 -0
package/src/commands/auth.ts
CHANGED
|
@@ -2,14 +2,86 @@ import { Command } from 'commander'
|
|
|
2
2
|
|
|
3
3
|
import { CredentialManager } from '../credential-manager'
|
|
4
4
|
import { SomaHttp } from '../http'
|
|
5
|
+
import { recoverSession } from '../session-recovery'
|
|
5
6
|
import { handleError } from '../shared/utils/error-handler'
|
|
6
7
|
import { formatOutput } from '../shared/utils/output'
|
|
7
8
|
import type { ExtractedSessionCandidate } from '../token-extractor'
|
|
8
9
|
|
|
10
|
+
async function promptInput(message: string): Promise<string> {
|
|
11
|
+
process.stdout.write(message)
|
|
12
|
+
const input = await Bun.stdin.text()
|
|
13
|
+
return input.trim()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function promptPassword(message: string): Promise<string> {
|
|
17
|
+
process.stdout.write(message)
|
|
18
|
+
|
|
19
|
+
const originalStdin = process.stdin.isTTY
|
|
20
|
+
? (process.stdin as typeof process.stdin & { setRawMode?: (mode: boolean) => void })
|
|
21
|
+
: null
|
|
22
|
+
|
|
23
|
+
if (originalStdin?.setRawMode) {
|
|
24
|
+
originalStdin.setRawMode(true)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let password = ''
|
|
28
|
+
const decoder = new TextDecoder()
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
32
|
+
const text = decoder.decode(chunk)
|
|
33
|
+
for (const char of text) {
|
|
34
|
+
const code = char.charCodeAt(0)
|
|
35
|
+
if (code === 13 || code === 10) {
|
|
36
|
+
// Enter key
|
|
37
|
+
process.stdout.write('\n')
|
|
38
|
+
return password
|
|
39
|
+
} else if (code === 3) {
|
|
40
|
+
// Ctrl+C
|
|
41
|
+
process.exit(1)
|
|
42
|
+
} else if (code === 127) {
|
|
43
|
+
// Backspace
|
|
44
|
+
if (password.length > 0) {
|
|
45
|
+
password = password.slice(0, -1)
|
|
46
|
+
process.stdout.write('\b \b')
|
|
47
|
+
}
|
|
48
|
+
} else if (code >= 32 && code <= 126) {
|
|
49
|
+
// Printable characters
|
|
50
|
+
password += char
|
|
51
|
+
process.stdout.write('*')
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} finally {
|
|
56
|
+
if (originalStdin?.setRawMode) {
|
|
57
|
+
originalStdin.setRawMode(false)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return password
|
|
62
|
+
}
|
|
63
|
+
|
|
9
64
|
type LoginOptions = { username?: string; password?: string; pretty?: boolean }
|
|
10
65
|
type StatusOptions = { pretty?: boolean }
|
|
11
66
|
type ExtractOptions = { pretty?: boolean }
|
|
12
67
|
type ExtractedSessionValidator = Pick<SomaHttp, 'checkLogin' | 'extractCsrfToken'>
|
|
68
|
+
type CredentialStore = Pick<CredentialManager, 'getCredentials' | 'remove' | 'setCredentials'>
|
|
69
|
+
type StatusValidator = Pick<SomaHttp, 'checkLogin'>
|
|
70
|
+
type ReloginHttp = Pick<SomaHttp, 'checkLogin' | 'getCsrfToken' | 'getSessionCookie' | 'login'>
|
|
71
|
+
type BrowserExtractor = () => Promise<{ csrfToken: string; sessionCookie: string } | null>
|
|
72
|
+
|
|
73
|
+
async function defaultExtractBrowserCredentials(): Promise<{ csrfToken: string; sessionCookie: string } | null> {
|
|
74
|
+
const { TokenExtractor } = (await import('../token-extractor')) as {
|
|
75
|
+
TokenExtractor: new () => { extractCandidates: () => Promise<ExtractedSessionCandidate[]> }
|
|
76
|
+
}
|
|
77
|
+
const candidates = await new TokenExtractor().extractCandidates()
|
|
78
|
+
if (candidates.length === 0) return null
|
|
79
|
+
return resolveExtractedCredentials(candidates)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const EXPIRED_SESSION_HINT = 'Session expired. Run: opensoma auth login or opensoma auth extract'
|
|
83
|
+
const UNVERIFIED_SESSION_HINT =
|
|
84
|
+
'Could not verify session. Try again or run: opensoma auth login or opensoma auth extract'
|
|
13
85
|
|
|
14
86
|
export async function resolveExtractedCredentials(
|
|
15
87
|
candidates: ExtractedSessionCandidate[],
|
|
@@ -37,10 +109,18 @@ export async function resolveExtractedCredentials(
|
|
|
37
109
|
|
|
38
110
|
async function loginAction(options: LoginOptions): Promise<void> {
|
|
39
111
|
try {
|
|
40
|
-
|
|
41
|
-
|
|
112
|
+
let username = options.username ?? process.env.OPENSOMA_USERNAME
|
|
113
|
+
let password = options.password ?? process.env.OPENSOMA_PASSWORD
|
|
114
|
+
|
|
115
|
+
if (!username) {
|
|
116
|
+
username = await promptInput('Username: ')
|
|
117
|
+
}
|
|
118
|
+
if (!password) {
|
|
119
|
+
password = await promptPassword('Password: ')
|
|
120
|
+
}
|
|
121
|
+
|
|
42
122
|
if (!username || !password) {
|
|
43
|
-
throw new Error('
|
|
123
|
+
throw new Error('Username and password are required')
|
|
44
124
|
}
|
|
45
125
|
|
|
46
126
|
const http = new SomaHttp()
|
|
@@ -65,6 +145,7 @@ async function loginAction(options: LoginOptions): Promise<void> {
|
|
|
65
145
|
sessionCookie,
|
|
66
146
|
csrfToken,
|
|
67
147
|
username,
|
|
148
|
+
password,
|
|
68
149
|
loggedInAt: new Date().toISOString(),
|
|
69
150
|
})
|
|
70
151
|
|
|
@@ -76,42 +157,107 @@ async function loginAction(options: LoginOptions): Promise<void> {
|
|
|
76
157
|
|
|
77
158
|
async function logoutAction(options: StatusOptions): Promise<void> {
|
|
78
159
|
try {
|
|
79
|
-
|
|
80
|
-
|
|
160
|
+
const manager = new CredentialManager()
|
|
161
|
+
const credentials = await manager.getCredentials()
|
|
162
|
+
let upstreamLoggedOut = false
|
|
163
|
+
|
|
164
|
+
if (credentials) {
|
|
165
|
+
const http = new SomaHttp({ sessionCookie: credentials.sessionCookie, csrfToken: credentials.csrfToken })
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
await http.logout()
|
|
169
|
+
upstreamLoggedOut = true
|
|
170
|
+
} catch {}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await manager.remove()
|
|
174
|
+
console.log(formatOutput({ ok: true, loggedIn: false, upstreamLoggedOut }, options.pretty))
|
|
81
175
|
} catch (error) {
|
|
82
176
|
handleError(error)
|
|
83
177
|
}
|
|
84
178
|
}
|
|
85
179
|
|
|
86
|
-
async function
|
|
180
|
+
export async function inspectStoredAuthStatus(
|
|
181
|
+
manager: CredentialStore = new CredentialManager(),
|
|
182
|
+
createValidator: (credentials: { sessionCookie: string; csrfToken: string }) => StatusValidator = (credentials) =>
|
|
183
|
+
new SomaHttp({ sessionCookie: credentials.sessionCookie, csrfToken: credentials.csrfToken }),
|
|
184
|
+
createReloginHttp: () => ReloginHttp = () => new SomaHttp(),
|
|
185
|
+
recoverViaBrowser: BrowserExtractor = defaultExtractBrowserCredentials,
|
|
186
|
+
): Promise<Record<string, boolean | null | string>> {
|
|
187
|
+
const creds = await manager.getCredentials()
|
|
188
|
+
if (!creds) {
|
|
189
|
+
return { authenticated: false, credentials: null }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let identity = null
|
|
87
193
|
try {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
194
|
+
identity = await createValidator(creds).checkLogin()
|
|
195
|
+
} catch {
|
|
196
|
+
return {
|
|
197
|
+
authenticated: true,
|
|
198
|
+
valid: false,
|
|
199
|
+
username: creds.username ?? null,
|
|
200
|
+
loggedInAt: creds.loggedInAt ?? null,
|
|
201
|
+
hint: UNVERIFIED_SESSION_HINT,
|
|
93
202
|
}
|
|
203
|
+
}
|
|
94
204
|
|
|
95
|
-
|
|
205
|
+
if (!identity) {
|
|
96
206
|
try {
|
|
97
|
-
const
|
|
98
|
-
|
|
207
|
+
const refreshedCredentials = await recoverSession(creds, manager, createReloginHttp)
|
|
208
|
+
if (refreshedCredentials) {
|
|
209
|
+
return {
|
|
210
|
+
authenticated: true,
|
|
211
|
+
valid: true,
|
|
212
|
+
username: refreshedCredentials.username ?? null,
|
|
213
|
+
loggedInAt: refreshedCredentials.loggedInAt ?? null,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
99
216
|
} catch {
|
|
100
|
-
|
|
217
|
+
return {
|
|
218
|
+
authenticated: true,
|
|
219
|
+
valid: false,
|
|
220
|
+
username: creds.username ?? null,
|
|
221
|
+
loggedInAt: creds.loggedInAt ?? null,
|
|
222
|
+
hint: UNVERIFIED_SESSION_HINT,
|
|
223
|
+
}
|
|
101
224
|
}
|
|
102
225
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
226
|
+
try {
|
|
227
|
+
const extracted = await recoverViaBrowser()
|
|
228
|
+
if (extracted) {
|
|
229
|
+
const loggedInAt = new Date().toISOString()
|
|
230
|
+
await manager.setCredentials({
|
|
231
|
+
sessionCookie: extracted.sessionCookie,
|
|
232
|
+
csrfToken: extracted.csrfToken,
|
|
233
|
+
loggedInAt,
|
|
234
|
+
})
|
|
235
|
+
return { authenticated: true, valid: true, username: null, loggedInAt }
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
// Browser extraction failed — fall through to credential removal
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
await manager.remove()
|
|
242
|
+
return {
|
|
243
|
+
authenticated: false,
|
|
244
|
+
credentials: null,
|
|
245
|
+
clearedStaleCredentials: true,
|
|
246
|
+
hint: EXPIRED_SESSION_HINT,
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
authenticated: true,
|
|
252
|
+
valid: true,
|
|
253
|
+
username: creds.username ?? null,
|
|
254
|
+
loggedInAt: creds.loggedInAt ?? null,
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function statusAction(options: StatusOptions): Promise<void> {
|
|
259
|
+
try {
|
|
260
|
+
console.log(formatOutput(await inspectStoredAuthStatus(), options.pretty))
|
|
115
261
|
} catch (error) {
|
|
116
262
|
handleError(error)
|
|
117
263
|
}
|
|
@@ -160,7 +306,7 @@ export const authCommand = new Command('auth')
|
|
|
160
306
|
)
|
|
161
307
|
.addCommand(
|
|
162
308
|
new Command('logout')
|
|
163
|
-
.description('
|
|
309
|
+
.description('Log out upstream session and remove saved credentials')
|
|
164
310
|
.option('--pretty', 'Pretty print JSON output')
|
|
165
311
|
.action(logoutAction),
|
|
166
312
|
)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { createAuthenticatedHttp } from './helpers'
|
|
4
|
+
|
|
5
|
+
const noBrowserExtraction = async () => null
|
|
6
|
+
|
|
7
|
+
describe('createAuthenticatedHttp', () => {
|
|
8
|
+
test('throws a login hint when no credentials are stored', async () => {
|
|
9
|
+
const manager = {
|
|
10
|
+
getCredentials: async () => null,
|
|
11
|
+
remove: async () => {},
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
await expect(createAuthenticatedHttp(manager)).rejects.toThrow(
|
|
15
|
+
'Not logged in. Run: opensoma auth login or opensoma auth extract',
|
|
16
|
+
)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('clears stale credentials when both recovery methods fail', async () => {
|
|
20
|
+
let removed = false
|
|
21
|
+
const manager = {
|
|
22
|
+
getCredentials: async () => ({
|
|
23
|
+
sessionCookie: 'stale-session',
|
|
24
|
+
csrfToken: 'csrf-token',
|
|
25
|
+
}),
|
|
26
|
+
setCredentials: async () => {
|
|
27
|
+
throw new Error('should not save unrecoverable credentials')
|
|
28
|
+
},
|
|
29
|
+
remove: async () => {
|
|
30
|
+
removed = true
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await expect(
|
|
35
|
+
createAuthenticatedHttp(manager, () => ({ checkLogin: async () => null }), undefined, noBrowserExtraction),
|
|
36
|
+
).rejects.toThrow(
|
|
37
|
+
'Session expired. Saved credentials were cleared. Run: opensoma auth login or opensoma auth extract',
|
|
38
|
+
)
|
|
39
|
+
expect(removed).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('returns the authenticated http client when the session is valid', async () => {
|
|
43
|
+
const http = {
|
|
44
|
+
checkLogin: async () => ({ userId: 'neo@example.com', userNm: '전수열' }),
|
|
45
|
+
get: async () => '',
|
|
46
|
+
}
|
|
47
|
+
const manager = {
|
|
48
|
+
getCredentials: async () => ({
|
|
49
|
+
sessionCookie: 'valid-session',
|
|
50
|
+
csrfToken: 'csrf-token',
|
|
51
|
+
}),
|
|
52
|
+
setCredentials: async () => {
|
|
53
|
+
throw new Error('should not rewrite valid credentials')
|
|
54
|
+
},
|
|
55
|
+
remove: async () => {
|
|
56
|
+
throw new Error('should not remove valid credentials')
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await expect(createAuthenticatedHttp(manager, () => http)).resolves.toBe(http)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('re-authenticates automatically when stored username/password are available', async () => {
|
|
64
|
+
let savedCredentials: Record<string, string> | null = null
|
|
65
|
+
const manager = {
|
|
66
|
+
getCredentials: async () => ({
|
|
67
|
+
sessionCookie: 'stale-session',
|
|
68
|
+
csrfToken: 'stale-csrf',
|
|
69
|
+
username: 'neo@example.com',
|
|
70
|
+
password: 'secret',
|
|
71
|
+
}),
|
|
72
|
+
setCredentials: async (credentials: Record<string, string>) => {
|
|
73
|
+
savedCredentials = credentials
|
|
74
|
+
},
|
|
75
|
+
remove: async () => {
|
|
76
|
+
throw new Error('should not remove recoverable credentials')
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
const recoveredHttp = {
|
|
80
|
+
checkLogin: async () => ({ userId: 'neo@example.com', userNm: 'Neo' }),
|
|
81
|
+
get: async () => '',
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await expect(
|
|
85
|
+
createAuthenticatedHttp(
|
|
86
|
+
manager,
|
|
87
|
+
(credentials) => {
|
|
88
|
+
if (credentials.sessionCookie === 'fresh-session') {
|
|
89
|
+
return recoveredHttp
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
checkLogin: async () => null,
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
() => ({
|
|
97
|
+
login: async () => {},
|
|
98
|
+
checkLogin: async () => ({ userId: 'neo@example.com', userNm: 'Neo' }),
|
|
99
|
+
getSessionCookie: () => 'fresh-session',
|
|
100
|
+
getCsrfToken: () => 'fresh-csrf',
|
|
101
|
+
}),
|
|
102
|
+
),
|
|
103
|
+
).resolves.toBe(recoveredHttp)
|
|
104
|
+
|
|
105
|
+
expect(savedCredentials).toMatchObject({
|
|
106
|
+
sessionCookie: 'fresh-session',
|
|
107
|
+
csrfToken: 'fresh-csrf',
|
|
108
|
+
username: 'neo@example.com',
|
|
109
|
+
password: 'secret',
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('recovers via browser extraction when no stored password is available', async () => {
|
|
114
|
+
let savedCredentials: Record<string, unknown> | null = null
|
|
115
|
+
const manager = {
|
|
116
|
+
getCredentials: async () => ({
|
|
117
|
+
sessionCookie: 'stale-session',
|
|
118
|
+
csrfToken: 'stale-csrf',
|
|
119
|
+
}),
|
|
120
|
+
setCredentials: async (credentials: Record<string, unknown>) => {
|
|
121
|
+
savedCredentials = credentials
|
|
122
|
+
},
|
|
123
|
+
remove: async () => {
|
|
124
|
+
throw new Error('should not remove when browser extraction succeeds')
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
const recoveredHttp = {
|
|
128
|
+
checkLogin: async () => ({ userId: 'neo@example.com', userNm: 'Neo' }),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const result = await createAuthenticatedHttp(
|
|
132
|
+
manager,
|
|
133
|
+
(credentials) => {
|
|
134
|
+
if (credentials.sessionCookie === 'browser-session') return recoveredHttp
|
|
135
|
+
return { checkLogin: async () => null }
|
|
136
|
+
},
|
|
137
|
+
undefined,
|
|
138
|
+
async () => ({ sessionCookie: 'browser-session', csrfToken: 'browser-csrf' }),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
expect(result).toBe(recoveredHttp)
|
|
142
|
+
expect(savedCredentials).toMatchObject({
|
|
143
|
+
sessionCookie: 'browser-session',
|
|
144
|
+
csrfToken: 'browser-csrf',
|
|
145
|
+
})
|
|
146
|
+
expect(savedCredentials).toHaveProperty('loggedInAt')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('falls back to browser extraction when password re-login throws', async () => {
|
|
150
|
+
let savedCredentials: Record<string, unknown> | null = null
|
|
151
|
+
const manager = {
|
|
152
|
+
getCredentials: async () => ({
|
|
153
|
+
sessionCookie: 'stale-session',
|
|
154
|
+
csrfToken: 'stale-csrf',
|
|
155
|
+
username: 'neo@example.com',
|
|
156
|
+
password: 'wrong-password',
|
|
157
|
+
}),
|
|
158
|
+
setCredentials: async (credentials: Record<string, unknown>) => {
|
|
159
|
+
savedCredentials = credentials
|
|
160
|
+
},
|
|
161
|
+
remove: async () => {
|
|
162
|
+
throw new Error('should not remove when browser extraction succeeds')
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
const recoveredHttp = {
|
|
166
|
+
checkLogin: async () => ({ userId: 'neo@example.com', userNm: 'Neo' }),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const result = await createAuthenticatedHttp(
|
|
170
|
+
manager,
|
|
171
|
+
(credentials) => {
|
|
172
|
+
if (credentials.sessionCookie === 'browser-session') return recoveredHttp
|
|
173
|
+
return { checkLogin: async () => null }
|
|
174
|
+
},
|
|
175
|
+
() => ({
|
|
176
|
+
login: async () => {
|
|
177
|
+
throw new Error('wrong password')
|
|
178
|
+
},
|
|
179
|
+
checkLogin: async () => null,
|
|
180
|
+
getSessionCookie: () => null,
|
|
181
|
+
getCsrfToken: () => null,
|
|
182
|
+
}),
|
|
183
|
+
async () => ({ sessionCookie: 'browser-session', csrfToken: 'browser-csrf' }),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
expect(result).toBe(recoveredHttp)
|
|
187
|
+
expect(savedCredentials).toMatchObject({
|
|
188
|
+
sessionCookie: 'browser-session',
|
|
189
|
+
csrfToken: 'browser-csrf',
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('does not attempt browser extraction when no credentials exist', async () => {
|
|
194
|
+
let browserExtractionCalled = false
|
|
195
|
+
const manager = {
|
|
196
|
+
getCredentials: async () => null,
|
|
197
|
+
setCredentials: async () => {
|
|
198
|
+
throw new Error('should not save')
|
|
199
|
+
},
|
|
200
|
+
remove: async () => {},
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
await expect(
|
|
204
|
+
createAuthenticatedHttp(
|
|
205
|
+
manager,
|
|
206
|
+
() => ({ checkLogin: async () => null }),
|
|
207
|
+
undefined,
|
|
208
|
+
async () => {
|
|
209
|
+
browserExtractionCalled = true
|
|
210
|
+
return { sessionCookie: 's', csrfToken: 'c' }
|
|
211
|
+
},
|
|
212
|
+
),
|
|
213
|
+
).rejects.toThrow('Not logged in')
|
|
214
|
+
expect(browserExtractionCalled).toBe(false)
|
|
215
|
+
})
|
|
216
|
+
})
|
package/src/commands/helpers.ts
CHANGED
|
@@ -1,29 +1,94 @@
|
|
|
1
1
|
import { CredentialManager } from '../credential-manager'
|
|
2
2
|
import { SomaHttp } from '../http'
|
|
3
|
+
import { recoverSession } from '../session-recovery'
|
|
4
|
+
import * as stderr from '../shared/utils/stderr'
|
|
5
|
+
import type { Credentials } from '../types'
|
|
3
6
|
|
|
4
|
-
|
|
5
|
-
|
|
7
|
+
type CredentialStore = Pick<CredentialManager, 'getCredentials' | 'remove' | 'setCredentials'>
|
|
8
|
+
type AuthenticatedHttp = Pick<SomaHttp, 'checkLogin'>
|
|
9
|
+
type ReloginHttp = Pick<SomaHttp, 'checkLogin' | 'getCsrfToken' | 'getSessionCookie' | 'login'>
|
|
10
|
+
type BrowserExtractor = () => Promise<{ csrfToken: string; sessionCookie: string } | null>
|
|
11
|
+
|
|
12
|
+
const NOT_LOGGED_IN_MESSAGE = 'Not logged in. Run: opensoma auth login or opensoma auth extract'
|
|
13
|
+
const STALE_SESSION_MESSAGE =
|
|
14
|
+
'Session expired. Saved credentials were cleared. Run: opensoma auth login or opensoma auth extract'
|
|
15
|
+
|
|
16
|
+
function defaultCreateHttp(credentials: Credentials): SomaHttp {
|
|
17
|
+
return new SomaHttp({ sessionCookie: credentials.sessionCookie, csrfToken: credentials.csrfToken })
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function defaultExtractBrowserCredentials(): Promise<{ csrfToken: string; sessionCookie: string } | null> {
|
|
21
|
+
const { TokenExtractor } = await import('../token-extractor')
|
|
22
|
+
const { resolveExtractedCredentials } = await import('./auth')
|
|
23
|
+
const candidates = await new TokenExtractor().extractCandidates()
|
|
24
|
+
if (candidates.length === 0) return null
|
|
25
|
+
return resolveExtractedCredentials(candidates)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createAuthenticatedHttp(): Promise<SomaHttp>
|
|
29
|
+
export function createAuthenticatedHttp<T extends AuthenticatedHttp>(
|
|
30
|
+
manager: CredentialStore,
|
|
31
|
+
createHttp: (credentials: Credentials) => T,
|
|
32
|
+
createReloginHttp?: () => ReloginHttp,
|
|
33
|
+
recoverViaBrowser?: BrowserExtractor,
|
|
34
|
+
): Promise<T>
|
|
35
|
+
export async function createAuthenticatedHttp<T extends AuthenticatedHttp>(
|
|
36
|
+
manager: CredentialStore = new CredentialManager(),
|
|
37
|
+
createHttp?: (credentials: Credentials) => T,
|
|
38
|
+
createReloginHttp: () => ReloginHttp = () => new SomaHttp(),
|
|
39
|
+
recoverViaBrowser: BrowserExtractor = defaultExtractBrowserCredentials,
|
|
40
|
+
): Promise<SomaHttp | T> {
|
|
6
41
|
const creds = await manager.getCredentials()
|
|
7
42
|
if (!creds) {
|
|
8
|
-
|
|
9
|
-
JSON.stringify({
|
|
10
|
-
error: 'Not logged in. Run: opensoma auth login or opensoma auth extract',
|
|
11
|
-
}),
|
|
12
|
-
)
|
|
13
|
-
process.exit(1)
|
|
43
|
+
throw new Error(NOT_LOGGED_IN_MESSAGE)
|
|
14
44
|
}
|
|
15
45
|
|
|
16
|
-
const http =
|
|
46
|
+
const http = createHttp ? createHttp(creds) : defaultCreateHttp(creds)
|
|
17
47
|
|
|
18
48
|
const identity = await http.checkLogin()
|
|
19
49
|
if (!identity) {
|
|
50
|
+
try {
|
|
51
|
+
const refreshedCredentials = await recoverSession(creds, manager, createReloginHttp)
|
|
52
|
+
if (refreshedCredentials) {
|
|
53
|
+
return createHttp ? createHttp(refreshedCredentials) : defaultCreateHttp(refreshedCredentials)
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// Password recovery failed — try browser extraction next
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
stderr.info('Session expired. Attempting browser token extraction...')
|
|
61
|
+
const extracted = await recoverViaBrowser()
|
|
62
|
+
if (extracted) {
|
|
63
|
+
const browserCredentials: Credentials = {
|
|
64
|
+
sessionCookie: extracted.sessionCookie,
|
|
65
|
+
csrfToken: extracted.csrfToken,
|
|
66
|
+
loggedInAt: new Date().toISOString(),
|
|
67
|
+
}
|
|
68
|
+
await manager.setCredentials(browserCredentials)
|
|
69
|
+
stderr.info('Browser token extraction successful.')
|
|
70
|
+
return createHttp ? createHttp(browserCredentials) : defaultCreateHttp(browserCredentials)
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
// Browser extraction also failed
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await manager.remove()
|
|
77
|
+
throw new Error(STALE_SESSION_MESSAGE)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return http
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function getHttpOrExit(): Promise<SomaHttp> {
|
|
84
|
+
try {
|
|
85
|
+
return await createAuthenticatedHttp()
|
|
86
|
+
} catch (error) {
|
|
20
87
|
console.error(
|
|
21
88
|
JSON.stringify({
|
|
22
|
-
error:
|
|
89
|
+
error: error instanceof Error ? error.message : STALE_SESSION_MESSAGE,
|
|
23
90
|
}),
|
|
24
91
|
)
|
|
25
92
|
process.exit(1)
|
|
26
93
|
}
|
|
27
|
-
|
|
28
|
-
return http
|
|
29
94
|
}
|
package/src/commands/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
buildCancelApplicationPayload,
|
|
11
11
|
buildDeleteMentoringPayload,
|
|
12
12
|
buildMentoringPayload,
|
|
13
|
+
buildUpdateMentoringPayload,
|
|
13
14
|
} from '../shared/utils/swmaestro'
|
|
14
15
|
import { getHttpOrExit } from './helpers'
|
|
15
16
|
|
|
@@ -34,6 +35,19 @@ type CreateOptions = {
|
|
|
34
35
|
content?: string
|
|
35
36
|
pretty?: boolean
|
|
36
37
|
}
|
|
38
|
+
type UpdateOptions = {
|
|
39
|
+
title: string
|
|
40
|
+
type: 'public' | 'lecture'
|
|
41
|
+
date: string
|
|
42
|
+
start: string
|
|
43
|
+
end: string
|
|
44
|
+
venue: string
|
|
45
|
+
maxAttendees?: string
|
|
46
|
+
regStart?: string
|
|
47
|
+
regEnd?: string
|
|
48
|
+
content?: string
|
|
49
|
+
pretty?: boolean
|
|
50
|
+
}
|
|
37
51
|
type CancelOptions = { applySn: string; qustnrSn: string; pretty?: boolean }
|
|
38
52
|
type HistoryOptions = { page?: string; pretty?: boolean }
|
|
39
53
|
|
|
@@ -103,6 +117,30 @@ async function createAction(options: CreateOptions): Promise<void> {
|
|
|
103
117
|
}
|
|
104
118
|
}
|
|
105
119
|
|
|
120
|
+
async function updateAction(id: string, options: UpdateOptions): Promise<void> {
|
|
121
|
+
try {
|
|
122
|
+
const http = await getHttpOrExit()
|
|
123
|
+
await http.post(
|
|
124
|
+
'/mypage/mentoLec/update.do',
|
|
125
|
+
buildUpdateMentoringPayload(Number.parseInt(id, 10), {
|
|
126
|
+
title: options.title,
|
|
127
|
+
type: options.type,
|
|
128
|
+
date: options.date,
|
|
129
|
+
startTime: options.start,
|
|
130
|
+
endTime: options.end,
|
|
131
|
+
venue: options.venue,
|
|
132
|
+
maxAttendees: options.maxAttendees ? Number.parseInt(options.maxAttendees, 10) : undefined,
|
|
133
|
+
regStart: options.regStart,
|
|
134
|
+
regEnd: options.regEnd,
|
|
135
|
+
content: options.content,
|
|
136
|
+
}),
|
|
137
|
+
)
|
|
138
|
+
console.log(formatOutput({ ok: true }, options.pretty))
|
|
139
|
+
} catch (error) {
|
|
140
|
+
handleError(error)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
106
144
|
async function deleteAction(id: string, options: GetOptions): Promise<void> {
|
|
107
145
|
try {
|
|
108
146
|
const http = await getHttpOrExit()
|
|
@@ -195,6 +233,23 @@ export const mentoringCommand = new Command('mentoring')
|
|
|
195
233
|
.option('--pretty', 'Pretty print JSON output')
|
|
196
234
|
.action(createAction),
|
|
197
235
|
)
|
|
236
|
+
.addCommand(
|
|
237
|
+
new Command('update')
|
|
238
|
+
.description('Update a mentoring session')
|
|
239
|
+
.argument('<id>')
|
|
240
|
+
.requiredOption('--title <title>', 'Title')
|
|
241
|
+
.requiredOption('--type <type>', 'Mentoring type (public|lecture)')
|
|
242
|
+
.requiredOption('--date <date>', 'Session date')
|
|
243
|
+
.requiredOption('--start <time>', 'Start time')
|
|
244
|
+
.requiredOption('--end <time>', 'End time')
|
|
245
|
+
.requiredOption('--venue <venue>', 'Venue')
|
|
246
|
+
.option('--max-attendees <count>', 'Maximum attendees')
|
|
247
|
+
.option('--reg-start <date>', 'Registration start date')
|
|
248
|
+
.option('--reg-end <date>', 'Registration end date')
|
|
249
|
+
.option('--content <html>', 'HTML content')
|
|
250
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
251
|
+
.action(updateAction),
|
|
252
|
+
)
|
|
198
253
|
.addCommand(
|
|
199
254
|
new Command('delete')
|
|
200
255
|
.description('Delete a mentoring session')
|