opensoma 0.1.2 → 0.2.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 +18 -2
- package/dist/src/client.d.ts +8 -0
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +123 -21
- package/dist/src/client.js.map +1 -1
- package/dist/src/commands/auth.d.ts +8 -0
- package/dist/src/commands/auth.d.ts.map +1 -1
- package/dist/src/commands/auth.js +35 -23
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/dashboard.d.ts.map +1 -1
- package/dist/src/commands/dashboard.js +1 -1
- package/dist/src/commands/dashboard.js.map +1 -1
- package/dist/src/commands/event.d.ts.map +1 -1
- package/dist/src/commands/event.js.map +1 -1
- package/dist/src/commands/helpers.d.ts.map +1 -1
- package/dist/src/commands/helpers.js +12 -2
- package/dist/src/commands/helpers.js.map +1 -1
- package/dist/src/commands/member.d.ts.map +1 -1
- package/dist/src/commands/member.js.map +1 -1
- package/dist/src/commands/mentoring.d.ts.map +1 -1
- package/dist/src/commands/mentoring.js +14 -5
- package/dist/src/commands/mentoring.js.map +1 -1
- package/dist/src/commands/notice.d.ts.map +1 -1
- package/dist/src/commands/notice.js.map +1 -1
- package/dist/src/commands/room.d.ts.map +1 -1
- package/dist/src/commands/room.js.map +1 -1
- package/dist/src/commands/team.d.ts.map +1 -1
- package/dist/src/commands/team.js.map +1 -1
- package/dist/src/errors.d.ts +8 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/errors.js +11 -0
- package/dist/src/errors.js.map +1 -0
- package/dist/src/formatters.d.ts.map +1 -1
- package/dist/src/formatters.js +54 -7
- package/dist/src/formatters.js.map +1 -1
- package/dist/src/http.d.ts +5 -0
- package/dist/src/http.d.ts.map +1 -1
- package/dist/src/http.js +140 -6
- package/dist/src/http.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/shared/utils/mentoring-params.d.ts.map +1 -1
- package/dist/src/shared/utils/mentoring-params.js +4 -1
- package/dist/src/shared/utils/mentoring-params.js.map +1 -1
- package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
- package/dist/src/shared/utils/swmaestro.js.map +1 -1
- package/dist/src/token-extractor.d.ts +12 -0
- package/dist/src/token-extractor.d.ts.map +1 -1
- package/dist/src/token-extractor.js +83 -18
- package/dist/src/token-extractor.js.map +1 -1
- package/dist/src/types.d.ts +17 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +6 -0
- package/dist/src/types.js.map +1 -1
- package/package.json +17 -1
- package/src/client.test.ts +112 -12
- package/src/client.ts +136 -36
- package/src/commands/auth.test.ts +55 -0
- package/src/commands/auth.ts +57 -33
- package/src/commands/dashboard.ts +5 -6
- package/src/commands/event.ts +5 -6
- package/src/commands/helpers.ts +21 -4
- package/src/commands/member.ts +4 -5
- package/src/commands/mentoring.ts +36 -19
- package/src/commands/notice.ts +4 -5
- package/src/commands/room.ts +4 -5
- package/src/commands/team.ts +4 -5
- package/src/credential-manager.test.ts +1 -1
- package/src/credential-manager.ts +1 -1
- package/src/errors.ts +10 -0
- package/src/formatters.test.ts +1 -1
- package/src/formatters.ts +91 -18
- package/src/http.test.ts +43 -7
- package/src/http.ts +174 -8
- package/src/index.ts +1 -0
- package/src/shared/utils/mentoring-params.test.ts +9 -4
- package/src/shared/utils/mentoring-params.ts +6 -3
- package/src/shared/utils/swmaestro.ts +2 -2
- package/src/token-extractor.test.ts +84 -8
- package/src/token-extractor.ts +118 -22
- package/src/types.test.ts +4 -2
- package/src/types.ts +6 -0
- package/.claude-plugin/README.md +0 -145
- package/.claude-plugin/plugin.json +0 -23
- package/.github/workflows/release.yml +0 -86
- package/.oxfmtrc.json +0 -9
- package/.oxlintrc.json +0 -4
- package/AGENTS.md +0 -78
- package/README.md +0 -252
- package/bun.lock +0 -297
- package/bunfig.toml +0 -2
- package/e2e/.gitkeep +0 -0
- package/skills/opensoma/SKILL.md +0 -345
- package/skills/opensoma/references/common-patterns.md +0 -182
- package/skills/opensoma/references/output-format.md +0 -130
package/src/client.test.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
|
2
2
|
|
|
3
|
-
import { SomaClient } from '
|
|
4
|
-
import { MENU_NO } from '
|
|
5
|
-
import {
|
|
3
|
+
import { SomaClient } from './client'
|
|
4
|
+
import { MENU_NO } from './constants'
|
|
5
|
+
import { AuthenticationError } from './errors'
|
|
6
|
+
import { SomaHttp } from './http'
|
|
6
7
|
|
|
7
8
|
afterEach(() => {
|
|
8
9
|
mock.restore()
|
|
@@ -21,6 +22,7 @@ describe('SomaClient', () => {
|
|
|
21
22
|
const client = new SomaClient()
|
|
22
23
|
const calls: Array<{ method: string; path: string; data: Record<string, string> | undefined }> = []
|
|
23
24
|
Reflect.set(client, 'http', {
|
|
25
|
+
checkLogin: async () => ({ userId: 'user@example.com', userNm: 'Test' }),
|
|
24
26
|
get: async (path: string, data?: Record<string, string>) => {
|
|
25
27
|
calls.push({ method: 'get', path, data })
|
|
26
28
|
return '<table><tbody><tr><td>1</td><td><a href="/sw/mypage/mentoLec/view.do?qustnrSn=123">[자유 멘토링] 제목 [접수중]</a></td><td>2026-04-01 ~ 2026-04-02</td><td>2026-04-03(목) 10:00 ~ 11:00</td><td>1 /4</td><td>OK</td><td>[접수중]</td><td>작성자</td><td>2026-04-01</td></tr></tbody></table><ul class="bbs-total"><li>Total : 1</li><li>1/1 Page</li></ul>'
|
|
@@ -33,7 +35,12 @@ describe('SomaClient', () => {
|
|
|
33
35
|
{
|
|
34
36
|
method: 'get',
|
|
35
37
|
path: '/mypage/mentoLec/list.do',
|
|
36
|
-
data: {
|
|
38
|
+
data: {
|
|
39
|
+
menuNo: MENU_NO.MENTORING,
|
|
40
|
+
searchStatMentolec: 'A',
|
|
41
|
+
searchGubunMentolec: 'MRC010',
|
|
42
|
+
pageIndex: '2',
|
|
43
|
+
},
|
|
37
44
|
},
|
|
38
45
|
])
|
|
39
46
|
expect(result.items[0]?.title).toBe('제목')
|
|
@@ -44,6 +51,7 @@ describe('SomaClient', () => {
|
|
|
44
51
|
const client = new SomaClient()
|
|
45
52
|
let captured: { path: string; data: Record<string, string> | undefined } | undefined
|
|
46
53
|
Reflect.set(client, 'http', {
|
|
54
|
+
checkLogin: async () => ({ userId: 'user@example.com', userNm: 'Test' }),
|
|
47
55
|
get: async (path: string, data?: Record<string, string>) => {
|
|
48
56
|
captured = { path, data }
|
|
49
57
|
return '<input type="hidden" name="qustnrSn" value="99"><table><tr><th>모집 명</th><td>상세</td><th>상태</th><td>접수중</td></tr><tr><th>접수 기간</th><td>2026-04-01 ~ 2026-04-02</td><th>강의날짜</th><td>2026-04-03(목) 10:00 ~ 11:00</td></tr><tr><th>장소</th><td>온라인(Webex)</td><th>모집인원</th><td>4명</td></tr><tr><th>작성자</th><td>작성자</td><th>등록일</th><td>2026-04-01</td></tr></table><div data-content><p>본문</p></div>'
|
|
@@ -52,7 +60,10 @@ describe('SomaClient', () => {
|
|
|
52
60
|
|
|
53
61
|
const result = await client.mentoring.get(99)
|
|
54
62
|
|
|
55
|
-
expect(captured).toEqual({
|
|
63
|
+
expect(captured).toEqual({
|
|
64
|
+
path: '/mypage/mentoLec/view.do',
|
|
65
|
+
data: { menuNo: MENU_NO.MENTORING, qustnrSn: '99' },
|
|
66
|
+
})
|
|
56
67
|
expect(result).toMatchObject({ id: 99, title: '상세', venue: '온라인(Webex)' })
|
|
57
68
|
})
|
|
58
69
|
|
|
@@ -60,6 +71,7 @@ describe('SomaClient', () => {
|
|
|
60
71
|
const client = new SomaClient()
|
|
61
72
|
const calls: Array<{ path: string; data: Record<string, string> }> = []
|
|
62
73
|
Reflect.set(client, 'http', {
|
|
74
|
+
checkLogin: async () => ({ userId: 'user@example.com', userNm: 'Test' }),
|
|
63
75
|
post: async (path: string, data: Record<string, string>) => {
|
|
64
76
|
calls.push({ path, data })
|
|
65
77
|
return ''
|
|
@@ -78,7 +90,12 @@ describe('SomaClient', () => {
|
|
|
78
90
|
await client.mentoring.delete(7)
|
|
79
91
|
await client.mentoring.apply(8)
|
|
80
92
|
await client.mentoring.cancel({ applySn: 9, qustnrSn: 10 })
|
|
81
|
-
await client.room.reserve({
|
|
93
|
+
await client.room.reserve({
|
|
94
|
+
roomId: 17,
|
|
95
|
+
date: '2026-04-01',
|
|
96
|
+
slots: ['10:00', '10:30'],
|
|
97
|
+
title: '회의',
|
|
98
|
+
})
|
|
82
99
|
await client.event.apply(11)
|
|
83
100
|
|
|
84
101
|
expect(calls.map((call) => call.path)).toEqual([
|
|
@@ -89,10 +106,27 @@ describe('SomaClient', () => {
|
|
|
89
106
|
'/mypage/itemRent/insert.do',
|
|
90
107
|
'/application/application/application.do',
|
|
91
108
|
])
|
|
92
|
-
expect(calls[0]?.data).toMatchObject({
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
109
|
+
expect(calls[0]?.data).toMatchObject({
|
|
110
|
+
menuNo: MENU_NO.MENTORING,
|
|
111
|
+
reportCd: 'MRC020',
|
|
112
|
+
qustnrSj: '새 멘토링',
|
|
113
|
+
})
|
|
114
|
+
expect(calls[1]?.data).toEqual({
|
|
115
|
+
menuNo: MENU_NO.MENTORING,
|
|
116
|
+
qustnrSn: '7',
|
|
117
|
+
pageQueryString: '',
|
|
118
|
+
})
|
|
119
|
+
expect(calls[2]?.data).toEqual({
|
|
120
|
+
menuNo: MENU_NO.EVENT,
|
|
121
|
+
qustnrSn: '8',
|
|
122
|
+
applyGb: 'C',
|
|
123
|
+
stepHeader: '0',
|
|
124
|
+
})
|
|
125
|
+
expect(calls[3]?.data).toEqual({
|
|
126
|
+
menuNo: MENU_NO.APPLICATION_HISTORY,
|
|
127
|
+
applySn: '9',
|
|
128
|
+
qustnrSn: '10',
|
|
129
|
+
})
|
|
96
130
|
expect(calls[4]?.data).toMatchObject({
|
|
97
131
|
menuNo: MENU_NO.ROOM,
|
|
98
132
|
itemId: '17',
|
|
@@ -100,7 +134,12 @@ describe('SomaClient', () => {
|
|
|
100
134
|
'time[0]': '10:00',
|
|
101
135
|
'time[1]': '10:30',
|
|
102
136
|
})
|
|
103
|
-
expect(calls[5]?.data).toEqual({
|
|
137
|
+
expect(calls[5]?.data).toEqual({
|
|
138
|
+
menuNo: MENU_NO.EVENT,
|
|
139
|
+
qustnrSn: '11',
|
|
140
|
+
applyGb: 'C',
|
|
141
|
+
stepHeader: '0',
|
|
142
|
+
})
|
|
104
143
|
})
|
|
105
144
|
|
|
106
145
|
test('room, dashboard, notice, team, member, event, and history routes use expected endpoints', async () => {
|
|
@@ -160,7 +199,15 @@ describe('SomaClient', () => {
|
|
|
160
199
|
expect(roomSlots).toEqual([{ time: '09:00', available: true }])
|
|
161
200
|
expect(dashboard.name).toBe('전수열')
|
|
162
201
|
expect(dashboard.mentoringSessions).toEqual([
|
|
163
|
-
{
|
|
202
|
+
{
|
|
203
|
+
title: '내 멘토링',
|
|
204
|
+
url: '/mypage/mentoLec/view.do?qustnrSn=100',
|
|
205
|
+
status: '접수중',
|
|
206
|
+
date: '2026-04-03',
|
|
207
|
+
time: '10:00',
|
|
208
|
+
timeEnd: '11:00',
|
|
209
|
+
type: '멘토 특강',
|
|
210
|
+
},
|
|
164
211
|
])
|
|
165
212
|
expect(noticeList.items[0]?.title).toBe('공지')
|
|
166
213
|
expect(noticeDetail).toMatchObject({ id: 1, title: '공지' })
|
|
@@ -207,4 +254,57 @@ describe('SomaClient', () => {
|
|
|
207
254
|
expect(calls).toEqual(['neo@example.com:secret'])
|
|
208
255
|
await expect(client.isLoggedIn()).resolves.toBe(true)
|
|
209
256
|
})
|
|
257
|
+
|
|
258
|
+
test('auth-required operations throw AuthenticationError when not logged in', async () => {
|
|
259
|
+
const client = new SomaClient()
|
|
260
|
+
Reflect.set(client, 'http', {
|
|
261
|
+
checkLogin: async () => null,
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
await expect(client.mentoring.list()).rejects.toBeInstanceOf(AuthenticationError)
|
|
265
|
+
await expect(client.mentoring.get(1)).rejects.toBeInstanceOf(AuthenticationError)
|
|
266
|
+
await expect(
|
|
267
|
+
client.mentoring.create({
|
|
268
|
+
title: 'Test',
|
|
269
|
+
type: 'public',
|
|
270
|
+
date: '2026-04-01',
|
|
271
|
+
startTime: '10:00',
|
|
272
|
+
endTime: '11:00',
|
|
273
|
+
venue: 'Test',
|
|
274
|
+
}),
|
|
275
|
+
).rejects.toBeInstanceOf(AuthenticationError)
|
|
276
|
+
await expect(client.mentoring.delete(1)).rejects.toBeInstanceOf(AuthenticationError)
|
|
277
|
+
await expect(client.mentoring.apply(1)).rejects.toBeInstanceOf(AuthenticationError)
|
|
278
|
+
await expect(client.mentoring.cancel({ applySn: 1, qustnrSn: 2 })).rejects.toBeInstanceOf(AuthenticationError)
|
|
279
|
+
await expect(client.mentoring.history()).rejects.toBeInstanceOf(AuthenticationError)
|
|
280
|
+
await expect(client.room.list()).rejects.toBeInstanceOf(AuthenticationError)
|
|
281
|
+
await expect(client.room.available(1, '2026-04-01')).rejects.toBeInstanceOf(AuthenticationError)
|
|
282
|
+
await expect(
|
|
283
|
+
client.room.reserve({ roomId: 1, date: '2026-04-01', slots: ['10:00'], title: 'Test' }),
|
|
284
|
+
).rejects.toBeInstanceOf(AuthenticationError)
|
|
285
|
+
await expect(client.dashboard.get()).rejects.toBeInstanceOf(AuthenticationError)
|
|
286
|
+
await expect(client.notice.list()).rejects.toBeInstanceOf(AuthenticationError)
|
|
287
|
+
await expect(client.notice.get(1)).rejects.toBeInstanceOf(AuthenticationError)
|
|
288
|
+
await expect(client.team.show()).rejects.toBeInstanceOf(AuthenticationError)
|
|
289
|
+
await expect(client.member.show()).rejects.toBeInstanceOf(AuthenticationError)
|
|
290
|
+
await expect(client.event.list()).rejects.toBeInstanceOf(AuthenticationError)
|
|
291
|
+
await expect(client.event.get(1)).rejects.toBeInstanceOf(AuthenticationError)
|
|
292
|
+
await expect(client.event.apply(1)).rejects.toBeInstanceOf(AuthenticationError)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test('AuthenticationError has helpful message', async () => {
|
|
296
|
+
const client = new SomaClient()
|
|
297
|
+
Reflect.set(client, 'http', {
|
|
298
|
+
checkLogin: async () => null,
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
await client.mentoring.list()
|
|
303
|
+
expect.fail('Should have thrown')
|
|
304
|
+
} catch (error) {
|
|
305
|
+
expect(error).toBeInstanceOf(AuthenticationError)
|
|
306
|
+
expect((error as Error).message).toContain('opensoma auth login')
|
|
307
|
+
expect((error as Error).message).toContain('opensoma auth extract')
|
|
308
|
+
}
|
|
309
|
+
})
|
|
210
310
|
})
|
package/src/client.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { MENU_NO } from '
|
|
2
|
-
import { CredentialManager } from '
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import { type
|
|
1
|
+
import { MENU_NO } from './constants'
|
|
2
|
+
import { CredentialManager } from './credential-manager'
|
|
3
|
+
import { AuthenticationError } from './errors'
|
|
4
|
+
import * as formatters from './formatters'
|
|
5
|
+
import { type UserIdentity, SomaHttp } from './http'
|
|
6
|
+
import { type MentoringSearchQuery, buildMentoringListParams } from './shared/utils/mentoring-params'
|
|
6
7
|
import {
|
|
7
8
|
buildApplicationPayload,
|
|
8
9
|
buildCancelApplicationPayload,
|
|
@@ -11,7 +12,7 @@ import {
|
|
|
11
12
|
buildRoomReservationPayload,
|
|
12
13
|
parseEventDetail,
|
|
13
14
|
resolveRoomId,
|
|
14
|
-
} from '
|
|
15
|
+
} from './shared/utils/swmaestro'
|
|
15
16
|
import type {
|
|
16
17
|
ApplicationHistoryItem,
|
|
17
18
|
Dashboard,
|
|
@@ -24,13 +25,14 @@ import type {
|
|
|
24
25
|
Pagination,
|
|
25
26
|
RoomCard,
|
|
26
27
|
TeamInfo,
|
|
27
|
-
} from '
|
|
28
|
+
} from './types'
|
|
28
29
|
|
|
29
30
|
export interface SomaClientOptions {
|
|
30
31
|
sessionCookie?: string
|
|
31
32
|
csrfToken?: string
|
|
32
33
|
username?: string
|
|
33
34
|
password?: string
|
|
35
|
+
verbose?: boolean
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
export class SomaClient {
|
|
@@ -101,10 +103,15 @@ export class SomaClient {
|
|
|
101
103
|
|
|
102
104
|
constructor(options: SomaClientOptions = {}) {
|
|
103
105
|
this.options = options
|
|
104
|
-
this.http = new SomaHttp({
|
|
106
|
+
this.http = new SomaHttp({
|
|
107
|
+
sessionCookie: options.sessionCookie,
|
|
108
|
+
csrfToken: options.csrfToken,
|
|
109
|
+
verbose: options.verbose,
|
|
110
|
+
})
|
|
105
111
|
|
|
106
112
|
this.mentoring = {
|
|
107
113
|
list: async (options) => {
|
|
114
|
+
await this.requireAuth()
|
|
108
115
|
const user = options?.search?.me ? await this.resolveUser() : undefined
|
|
109
116
|
const html = await this.http.get(
|
|
110
117
|
'/mypage/mentoLec/list.do',
|
|
@@ -116,58 +123,83 @@ export class SomaClient {
|
|
|
116
123
|
user,
|
|
117
124
|
}),
|
|
118
125
|
)
|
|
119
|
-
return {
|
|
126
|
+
return {
|
|
127
|
+
items: formatters.parseMentoringList(html),
|
|
128
|
+
pagination: formatters.parsePagination(html),
|
|
129
|
+
}
|
|
120
130
|
},
|
|
121
|
-
get: async (id) =>
|
|
122
|
-
|
|
123
|
-
|
|
131
|
+
get: async (id) => {
|
|
132
|
+
await this.requireAuth()
|
|
133
|
+
return formatters.parseMentoringDetail(
|
|
134
|
+
await this.http.get('/mypage/mentoLec/view.do', {
|
|
135
|
+
menuNo: MENU_NO.MENTORING,
|
|
136
|
+
qustnrSn: String(id),
|
|
137
|
+
}),
|
|
124
138
|
id,
|
|
125
|
-
)
|
|
139
|
+
)
|
|
140
|
+
},
|
|
126
141
|
create: async (params) => {
|
|
127
|
-
await this.
|
|
142
|
+
await this.requireAuth()
|
|
143
|
+
const html = await this.http.post('/mypage/mentoLec/insert.do', buildMentoringPayload(params))
|
|
144
|
+
if (this.containsErrorIndicator(html)) {
|
|
145
|
+
throw new Error(this.extractErrorMessage(html) || '멘토링 등록에 실패했습니다.')
|
|
146
|
+
}
|
|
128
147
|
},
|
|
129
148
|
delete: async (id) => {
|
|
149
|
+
await this.requireAuth()
|
|
130
150
|
await this.http.post('/mypage/mentoLec/delete.do', buildDeleteMentoringPayload(id))
|
|
131
151
|
},
|
|
132
152
|
apply: async (id) => {
|
|
153
|
+
await this.requireAuth()
|
|
133
154
|
await this.http.post('/application/application/application.do', buildApplicationPayload(id))
|
|
134
155
|
},
|
|
135
156
|
cancel: async (params) => {
|
|
157
|
+
await this.requireAuth()
|
|
136
158
|
await this.http.post('/mypage/userAnswer/cancel.do', buildCancelApplicationPayload(params))
|
|
137
159
|
},
|
|
138
160
|
history: async (options) => {
|
|
161
|
+
await this.requireAuth()
|
|
139
162
|
const html = await this.http.get('/mypage/userAnswer/history.do', {
|
|
140
163
|
menuNo: MENU_NO.APPLICATION_HISTORY,
|
|
141
164
|
...(options?.page ? { pageIndex: String(options.page) } : {}),
|
|
142
165
|
})
|
|
143
|
-
return {
|
|
166
|
+
return {
|
|
167
|
+
items: formatters.parseApplicationHistory(html),
|
|
168
|
+
pagination: formatters.parsePagination(html),
|
|
169
|
+
}
|
|
144
170
|
},
|
|
145
171
|
}
|
|
146
172
|
|
|
147
173
|
this.room = {
|
|
148
|
-
list: async (options) =>
|
|
149
|
-
|
|
174
|
+
list: async (options) => {
|
|
175
|
+
await this.requireAuth()
|
|
176
|
+
return formatters.parseRoomList(
|
|
150
177
|
await this.http.post('/mypage/officeMng/list.do', {
|
|
151
178
|
menuNo: MENU_NO.ROOM,
|
|
152
179
|
sdate: options?.date ?? new Date().toISOString().slice(0, 10),
|
|
153
180
|
searchItemId: options?.room ? String(resolveRoomId(options.room)) : '',
|
|
154
181
|
}),
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
|
|
182
|
+
)
|
|
183
|
+
},
|
|
184
|
+
available: async (roomId, date) => {
|
|
185
|
+
await this.requireAuth()
|
|
186
|
+
return formatters.parseRoomSlots(
|
|
158
187
|
await this.http.post('/mypage/officeMng/rentTime.do', {
|
|
159
188
|
viewType: 'CONTBODY',
|
|
160
189
|
itemId: String(roomId),
|
|
161
190
|
rentDt: date,
|
|
162
191
|
}),
|
|
163
|
-
)
|
|
192
|
+
)
|
|
193
|
+
},
|
|
164
194
|
reserve: async (params) => {
|
|
195
|
+
await this.requireAuth()
|
|
165
196
|
await this.http.post('/mypage/itemRent/insert.do', buildRoomReservationPayload(params))
|
|
166
197
|
},
|
|
167
198
|
}
|
|
168
199
|
|
|
169
200
|
this.dashboard = {
|
|
170
201
|
get: async () => {
|
|
202
|
+
await this.requireAuth()
|
|
171
203
|
const [dashboard, { items: myMentoring }] = await Promise.all([
|
|
172
204
|
formatters.parseDashboard(await this.http.get('/mypage/myMain/dashboard.do', { menuNo: MENU_NO.DASHBOARD })),
|
|
173
205
|
this.mentoring.list({ search: { field: 'author', value: '@me', me: true } }),
|
|
@@ -176,6 +208,10 @@ export class SomaClient {
|
|
|
176
208
|
title: item.title,
|
|
177
209
|
url: `/mypage/mentoLec/view.do?qustnrSn=${item.id}`,
|
|
178
210
|
status: item.status,
|
|
211
|
+
date: item.sessionDate,
|
|
212
|
+
time: item.sessionTime.start,
|
|
213
|
+
timeEnd: item.sessionTime.end,
|
|
214
|
+
type: item.type,
|
|
179
215
|
}))
|
|
180
216
|
return dashboard
|
|
181
217
|
},
|
|
@@ -183,49 +219,86 @@ export class SomaClient {
|
|
|
183
219
|
|
|
184
220
|
this.notice = {
|
|
185
221
|
list: async (options) => {
|
|
222
|
+
await this.requireAuth()
|
|
186
223
|
const html = await this.http.get('/mypage/myNotice/list.do', {
|
|
187
224
|
menuNo: MENU_NO.NOTICE,
|
|
188
225
|
...(options?.page ? { pageIndex: String(options.page) } : {}),
|
|
189
226
|
})
|
|
190
|
-
return {
|
|
227
|
+
return {
|
|
228
|
+
items: formatters.parseNoticeList(html),
|
|
229
|
+
pagination: formatters.parsePagination(html),
|
|
230
|
+
}
|
|
191
231
|
},
|
|
192
|
-
get: async (id) =>
|
|
193
|
-
|
|
194
|
-
|
|
232
|
+
get: async (id) => {
|
|
233
|
+
await this.requireAuth()
|
|
234
|
+
return formatters.parseNoticeDetail(
|
|
235
|
+
await this.http.get('/mypage/myNotice/view.do', {
|
|
236
|
+
menuNo: MENU_NO.NOTICE,
|
|
237
|
+
nttId: String(id),
|
|
238
|
+
}),
|
|
195
239
|
id,
|
|
196
|
-
)
|
|
240
|
+
)
|
|
241
|
+
},
|
|
197
242
|
}
|
|
198
243
|
|
|
199
244
|
this.team = {
|
|
200
|
-
show: async () =>
|
|
201
|
-
|
|
245
|
+
show: async () => {
|
|
246
|
+
await this.requireAuth()
|
|
247
|
+
return formatters.parseTeamInfo(await this.http.get('/mypage/myTeam/team.do', { menuNo: MENU_NO.TEAM }))
|
|
248
|
+
},
|
|
202
249
|
}
|
|
203
250
|
|
|
204
251
|
this.member = {
|
|
205
|
-
show: async () =>
|
|
206
|
-
|
|
252
|
+
show: async () => {
|
|
253
|
+
await this.requireAuth()
|
|
254
|
+
return formatters.parseMemberInfo(
|
|
207
255
|
await this.http.get('/mypage/myInfo/forUpdateMy.do', { menuNo: MENU_NO.MEMBER_INFO }),
|
|
208
|
-
)
|
|
256
|
+
)
|
|
257
|
+
},
|
|
209
258
|
}
|
|
210
259
|
|
|
211
260
|
this.event = {
|
|
212
261
|
list: async (options) => {
|
|
262
|
+
await this.requireAuth()
|
|
213
263
|
const html = await this.http.get('/mypage/applicants/list.do', {
|
|
214
264
|
menuNo: MENU_NO.EVENT,
|
|
215
265
|
...(options?.page ? { pageIndex: String(options.page) } : {}),
|
|
216
266
|
})
|
|
217
|
-
return {
|
|
267
|
+
return {
|
|
268
|
+
items: formatters.parseEventList(html),
|
|
269
|
+
pagination: formatters.parsePagination(html),
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
get: async (id) => {
|
|
273
|
+
await this.requireAuth()
|
|
274
|
+
return parseEventDetail(
|
|
275
|
+
await this.http.get('/mypage/applicants/view.do', {
|
|
276
|
+
menuNo: MENU_NO.EVENT,
|
|
277
|
+
bbsId: String(id),
|
|
278
|
+
}),
|
|
279
|
+
)
|
|
218
280
|
},
|
|
219
|
-
get: async (id) =>
|
|
220
|
-
parseEventDetail(
|
|
221
|
-
await this.http.get('/mypage/applicants/view.do', { menuNo: MENU_NO.EVENT, bbsId: String(id) }),
|
|
222
|
-
),
|
|
223
281
|
apply: async (id) => {
|
|
282
|
+
await this.requireAuth()
|
|
224
283
|
await this.http.post('/application/application/application.do', buildApplicationPayload(id))
|
|
225
284
|
},
|
|
226
285
|
}
|
|
227
286
|
}
|
|
228
287
|
|
|
288
|
+
getSessionData(): { sessionCookie: string | undefined; csrfToken: string | null } {
|
|
289
|
+
return {
|
|
290
|
+
sessionCookie: this.http.getSessionCookie(),
|
|
291
|
+
csrfToken: this.http.getCsrfToken(),
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private async requireAuth(): Promise<void> {
|
|
296
|
+
const identity = await this.http.checkLogin()
|
|
297
|
+
if (!identity) {
|
|
298
|
+
throw new AuthenticationError()
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
229
302
|
private async resolveUser(): Promise<UserIdentity | undefined> {
|
|
230
303
|
const identity = await this.http.checkLogin()
|
|
231
304
|
return identity ?? undefined
|
|
@@ -261,4 +334,31 @@ export class SomaClient {
|
|
|
261
334
|
loggedInAt: new Date().toISOString(),
|
|
262
335
|
})
|
|
263
336
|
}
|
|
337
|
+
|
|
338
|
+
private containsErrorIndicator(html: string): boolean {
|
|
339
|
+
const errorPatterns = [
|
|
340
|
+
'class="error"',
|
|
341
|
+
'class="alert-danger"',
|
|
342
|
+
'alert-error',
|
|
343
|
+
'오류가 발생했습니다',
|
|
344
|
+
'등록에 실패했습니다',
|
|
345
|
+
'실패하였습니다',
|
|
346
|
+
'잘못된 접근',
|
|
347
|
+
'권한이 없습니다',
|
|
348
|
+
'<script>alert(',
|
|
349
|
+
]
|
|
350
|
+
return errorPatterns.some((pattern) => html.includes(pattern))
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private extractErrorMessage(html: string): string | null {
|
|
354
|
+
const alertMatch = html.match(/<script>alert\(['"](.+?)['"]\)/)
|
|
355
|
+
if (alertMatch) {
|
|
356
|
+
return alertMatch[1]
|
|
357
|
+
}
|
|
358
|
+
const errorDivMatch = html.match(/class="error[^"]*"[^>]*>\s*([^<]+)/)
|
|
359
|
+
if (errorDivMatch) {
|
|
360
|
+
return errorDivMatch[1].trim()
|
|
361
|
+
}
|
|
362
|
+
return null
|
|
363
|
+
}
|
|
264
364
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { resolveExtractedCredentials } from './auth'
|
|
4
|
+
|
|
5
|
+
describe('resolveExtractedCredentials', () => {
|
|
6
|
+
test('returns the first candidate that validates successfully', async () => {
|
|
7
|
+
const calls: string[] = []
|
|
8
|
+
|
|
9
|
+
const credentials = await resolveExtractedCredentials(
|
|
10
|
+
[
|
|
11
|
+
{ browser: 'Chrome', lastAccessUtc: 30, profile: 'Default', sessionCookie: 'stale-session' },
|
|
12
|
+
{ browser: 'Chrome', lastAccessUtc: 20, profile: 'Profile 1', sessionCookie: 'valid-session' },
|
|
13
|
+
],
|
|
14
|
+
(sessionCookie) => ({
|
|
15
|
+
checkLogin: async () => {
|
|
16
|
+
calls.push(`check:${sessionCookie}`)
|
|
17
|
+
return sessionCookie === 'valid-session' ? { userId: 'neo', userNm: 'Neo' } : null
|
|
18
|
+
},
|
|
19
|
+
extractCsrfToken: async () => {
|
|
20
|
+
calls.push(`csrf:${sessionCookie}`)
|
|
21
|
+
return `${sessionCookie}-csrf`
|
|
22
|
+
},
|
|
23
|
+
}),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
expect(credentials).toEqual({
|
|
27
|
+
sessionCookie: 'valid-session',
|
|
28
|
+
csrfToken: 'valid-session-csrf',
|
|
29
|
+
})
|
|
30
|
+
expect(calls).toEqual(['check:stale-session', 'check:valid-session', 'csrf:valid-session'])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('returns null when every candidate is invalid or throws', async () => {
|
|
34
|
+
const credentials = await resolveExtractedCredentials(
|
|
35
|
+
[
|
|
36
|
+
{ browser: 'Chrome', lastAccessUtc: 30, profile: 'Default', sessionCookie: 'stale-session' },
|
|
37
|
+
{ browser: 'Edge', lastAccessUtc: 20, profile: 'Profile 1', sessionCookie: 'broken-session' },
|
|
38
|
+
],
|
|
39
|
+
(sessionCookie) => ({
|
|
40
|
+
checkLogin: async () => {
|
|
41
|
+
if (sessionCookie === 'broken-session') {
|
|
42
|
+
throw new Error('network error')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return null
|
|
46
|
+
},
|
|
47
|
+
extractCsrfToken: async () => {
|
|
48
|
+
throw new Error('should not be called')
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
expect(credentials).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
})
|
package/src/commands/auth.ts
CHANGED
|
@@ -1,14 +1,39 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
|
|
3
|
-
import { CredentialManager } from '
|
|
4
|
-
import { SomaHttp } from '
|
|
5
|
-
import { handleError } from '
|
|
6
|
-
import { formatOutput } from '
|
|
7
|
-
import {
|
|
3
|
+
import { CredentialManager } from '../credential-manager'
|
|
4
|
+
import { SomaHttp } from '../http'
|
|
5
|
+
import { handleError } from '../shared/utils/error-handler'
|
|
6
|
+
import { formatOutput } from '../shared/utils/output'
|
|
7
|
+
import type { ExtractedSessionCandidate } from '../token-extractor'
|
|
8
8
|
|
|
9
9
|
type LoginOptions = { username?: string; password?: string; pretty?: boolean }
|
|
10
10
|
type StatusOptions = { pretty?: boolean }
|
|
11
11
|
type ExtractOptions = { pretty?: boolean }
|
|
12
|
+
type ExtractedSessionValidator = Pick<SomaHttp, 'checkLogin' | 'extractCsrfToken'>
|
|
13
|
+
|
|
14
|
+
export async function resolveExtractedCredentials(
|
|
15
|
+
candidates: ExtractedSessionCandidate[],
|
|
16
|
+
createValidator: (sessionCookie: string) => ExtractedSessionValidator = (sessionCookie) =>
|
|
17
|
+
new SomaHttp({ sessionCookie }),
|
|
18
|
+
): Promise<{ csrfToken: string; sessionCookie: string } | null> {
|
|
19
|
+
for (const candidate of candidates) {
|
|
20
|
+
const http = createValidator(candidate.sessionCookie)
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const valid = Boolean(await http.checkLogin())
|
|
24
|
+
if (!valid) {
|
|
25
|
+
continue
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
sessionCookie: candidate.sessionCookie,
|
|
30
|
+
csrfToken: await http.extractCsrfToken(),
|
|
31
|
+
}
|
|
32
|
+
} catch {}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
12
37
|
|
|
13
38
|
async function loginAction(options: LoginOptions): Promise<void> {
|
|
14
39
|
try {
|
|
@@ -21,10 +46,19 @@ async function loginAction(options: LoginOptions): Promise<void> {
|
|
|
21
46
|
const http = new SomaHttp()
|
|
22
47
|
await http.login(username, password)
|
|
23
48
|
|
|
24
|
-
const sessionCookie = http.getSessionCookie()
|
|
25
49
|
const csrfToken = http.getCsrfToken()
|
|
26
|
-
if (!
|
|
27
|
-
throw new Error('Login succeeded but
|
|
50
|
+
if (!csrfToken) {
|
|
51
|
+
throw new Error('Login succeeded but CSRF token is missing')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const valid = Boolean(await http.checkLogin())
|
|
55
|
+
if (!valid) {
|
|
56
|
+
throw new Error('Login succeeded but session is not valid')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const sessionCookie = http.getSessionCookie()
|
|
60
|
+
if (!sessionCookie) {
|
|
61
|
+
throw new Error('Login succeeded but session cookie is missing')
|
|
28
62
|
}
|
|
29
63
|
|
|
30
64
|
await new CredentialManager().setCredentials({
|
|
@@ -85,40 +119,30 @@ async function statusAction(options: StatusOptions): Promise<void> {
|
|
|
85
119
|
|
|
86
120
|
async function extractAction(options: ExtractOptions): Promise<void> {
|
|
87
121
|
try {
|
|
88
|
-
const { TokenExtractor } = (await import(
|
|
89
|
-
|
|
90
|
-
)) as {
|
|
91
|
-
TokenExtractor: new () => { extract: () => Promise<{ sessionCookie: string } | null> }
|
|
122
|
+
const { TokenExtractor } = (await import('../token-extractor')) as {
|
|
123
|
+
TokenExtractor: new () => { extractCandidates: () => Promise<ExtractedSessionCandidate[]> }
|
|
92
124
|
}
|
|
93
125
|
const extractor = new TokenExtractor()
|
|
94
|
-
const
|
|
95
|
-
if (
|
|
96
|
-
throw new Error(
|
|
126
|
+
const candidates = await extractor.extractCandidates()
|
|
127
|
+
if (candidates.length === 0) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
'No SWMaestro session found in any browser. Login to swmaestro.ai in a supported Chromium browser (Chrome, Edge, Brave, Arc, Vivaldi) first.',
|
|
130
|
+
)
|
|
97
131
|
}
|
|
98
132
|
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
valid = Boolean(await http.checkLogin())
|
|
105
|
-
if (valid) {
|
|
106
|
-
csrfToken = await http.extractCsrfToken()
|
|
107
|
-
}
|
|
108
|
-
} catch {
|
|
109
|
-
valid = false
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (!valid) {
|
|
113
|
-
warn('Warning: Could not validate session. Cookies may be expired.')
|
|
133
|
+
const credentials = await resolveExtractedCredentials(candidates)
|
|
134
|
+
if (!credentials) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
'Found SWMaestro session cookies in supported browsers, but none are valid. Refresh your swmaestro.ai login in a supported Chromium browser and try again.',
|
|
137
|
+
)
|
|
114
138
|
}
|
|
115
139
|
|
|
116
140
|
await new CredentialManager().setCredentials({
|
|
117
|
-
sessionCookie:
|
|
118
|
-
csrfToken: csrfToken
|
|
141
|
+
sessionCookie: credentials.sessionCookie,
|
|
142
|
+
csrfToken: credentials.csrfToken,
|
|
119
143
|
loggedInAt: new Date().toISOString(),
|
|
120
144
|
})
|
|
121
|
-
console.log(formatOutput({ ok: true, extracted: true, valid }, options.pretty))
|
|
145
|
+
console.log(formatOutput({ ok: true, extracted: true, valid: true }, options.pretty))
|
|
122
146
|
} catch (error) {
|
|
123
147
|
handleError(error)
|
|
124
148
|
}
|