opensoma 0.4.0 → 0.5.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.
Files changed (56) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/commands/auth.d.ts +1 -1
  3. package/dist/src/commands/auth.d.ts.map +1 -1
  4. package/dist/src/commands/auth.js +94 -52
  5. package/dist/src/commands/auth.js.map +1 -1
  6. package/dist/src/constants.d.ts +40 -0
  7. package/dist/src/constants.d.ts.map +1 -1
  8. package/dist/src/constants.js +42 -0
  9. package/dist/src/constants.js.map +1 -1
  10. package/dist/src/formatters.d.ts.map +1 -1
  11. package/dist/src/formatters.js +42 -16
  12. package/dist/src/formatters.js.map +1 -1
  13. package/dist/src/index.d.ts +3 -0
  14. package/dist/src/index.d.ts.map +1 -1
  15. package/dist/src/index.js +2 -0
  16. package/dist/src/index.js.map +1 -1
  17. package/dist/src/shared/utils/toz.d.ts +23 -0
  18. package/dist/src/shared/utils/toz.d.ts.map +1 -0
  19. package/dist/src/shared/utils/toz.js +72 -0
  20. package/dist/src/shared/utils/toz.js.map +1 -0
  21. package/dist/src/token-extractor.d.ts +9 -1
  22. package/dist/src/token-extractor.d.ts.map +1 -1
  23. package/dist/src/token-extractor.js +54 -10
  24. package/dist/src/token-extractor.js.map +1 -1
  25. package/dist/src/toz-formatters.d.ts +9 -0
  26. package/dist/src/toz-formatters.d.ts.map +1 -0
  27. package/dist/src/toz-formatters.js +151 -0
  28. package/dist/src/toz-formatters.js.map +1 -0
  29. package/dist/src/toz-http.d.ts +27 -0
  30. package/dist/src/toz-http.d.ts.map +1 -0
  31. package/dist/src/toz-http.js +154 -0
  32. package/dist/src/toz-http.js.map +1 -0
  33. package/dist/src/types.d.ts +52 -0
  34. package/dist/src/types.d.ts.map +1 -1
  35. package/dist/src/types.js +46 -0
  36. package/dist/src/types.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/__fixtures__/toz/toz_all_branches.json +211 -0
  39. package/src/__fixtures__/toz/toz_booking.html +2190 -0
  40. package/src/__fixtures__/toz/toz_boothes.json +59 -0
  41. package/src/__fixtures__/toz/toz_duration.json +25 -0
  42. package/src/__fixtures__/toz/toz_mypage_response.html +388 -0
  43. package/src/__fixtures__/toz/toz_page.html +211 -0
  44. package/src/commands/auth.ts +107 -50
  45. package/src/constants.ts +50 -0
  46. package/src/formatters.ts +44 -16
  47. package/src/index.ts +3 -0
  48. package/src/shared/utils/toz.test.ts +133 -0
  49. package/src/shared/utils/toz.ts +100 -0
  50. package/src/token-extractor.test.ts +30 -5
  51. package/src/token-extractor.ts +65 -13
  52. package/src/toz-formatters.test.ts +197 -0
  53. package/src/toz-formatters.ts +211 -0
  54. package/src/toz-http.test.ts +157 -0
  55. package/src/toz-http.ts +188 -0
  56. package/src/types.ts +58 -0
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import {
4
+ assertDurationInRange,
5
+ buildBranchIdsParam,
6
+ buildStartTimeParam,
7
+ formatDuration,
8
+ formatPhone,
9
+ formatStartTime,
10
+ parseDurationKey,
11
+ parseEmail,
12
+ parsePhone,
13
+ } from './toz'
14
+
15
+ describe('formatDuration', () => {
16
+ it('formats hours+minutes as HHMM', () => {
17
+ expect(formatDuration(120)).toBe('0200')
18
+ expect(formatDuration(150)).toBe('0230')
19
+ expect(formatDuration(180)).toBe('0300')
20
+ expect(formatDuration(90)).toBe('0130')
21
+ })
22
+
23
+ it('throws on invalid input', () => {
24
+ expect(() => formatDuration(0)).toThrow()
25
+ expect(() => formatDuration(-30)).toThrow()
26
+ expect(() => formatDuration(1.5)).toThrow()
27
+ })
28
+ })
29
+
30
+ describe('parseDurationKey', () => {
31
+ it('parses HHMM keys to minutes', () => {
32
+ expect(parseDurationKey('0200')).toBe(120)
33
+ expect(parseDurationKey('0230')).toBe(150)
34
+ expect(parseDurationKey('1300')).toBe(780)
35
+ })
36
+
37
+ it('throws on invalid key', () => {
38
+ expect(() => parseDurationKey('0')).toThrow()
39
+ expect(() => parseDurationKey('abcd')).toThrow()
40
+ expect(() => parseDurationKey('20000')).toThrow()
41
+ })
42
+ })
43
+
44
+ describe('formatStartTime / buildStartTimeParam', () => {
45
+ it('splits HH:MM', () => {
46
+ expect(formatStartTime('10:00')).toEqual({ hour: '10', minute: '00' })
47
+ expect(formatStartTime('9:30')).toEqual({ hour: '09', minute: '30' })
48
+ })
49
+
50
+ it('joins to 4-digit param', () => {
51
+ expect(buildStartTimeParam('10:00')).toBe('1000')
52
+ expect(buildStartTimeParam('9:05')).toBe('0905')
53
+ })
54
+
55
+ it('rejects invalid time', () => {
56
+ expect(() => formatStartTime('1000')).toThrow()
57
+ expect(() => formatStartTime('25:00')).not.toThrow()
58
+ })
59
+ })
60
+
61
+ describe('buildBranchIdsParam', () => {
62
+ it('joins with trailing comma', () => {
63
+ expect(buildBranchIdsParam([27])).toBe('27,')
64
+ expect(buildBranchIdsParam([27, 145])).toBe('27,145,')
65
+ expect(buildBranchIdsParam([27, 145, 19, 20, 15, 139, 134, 30, 149])).toBe('27,145,19,20,15,139,134,30,149,')
66
+ })
67
+
68
+ it('rejects empty list', () => {
69
+ expect(() => buildBranchIdsParam([])).toThrow()
70
+ })
71
+ })
72
+
73
+ describe('parsePhone', () => {
74
+ it('parses standard 010-XXXX-YYYY', () => {
75
+ expect(parsePhone('010-1234-5678')).toEqual({ phone1: '010', phone2: '1234', phone3: '5678' })
76
+ })
77
+
78
+ it('parses 010 with no dashes', () => {
79
+ expect(parsePhone('01012345678')).toEqual({ phone1: '010', phone2: '1234', phone3: '5678' })
80
+ })
81
+
82
+ it('parses old 011-XXX-YYYY', () => {
83
+ expect(parsePhone('011-123-4567')).toEqual({ phone1: '011', phone2: '123', phone3: '4567' })
84
+ })
85
+
86
+ it('rejects unsupported prefix', () => {
87
+ expect(() => parsePhone('012-1234-5678')).toThrow()
88
+ })
89
+
90
+ it('rejects invalid length', () => {
91
+ expect(() => parsePhone('010-12-34')).toThrow()
92
+ })
93
+ })
94
+
95
+ describe('formatPhone', () => {
96
+ it('joins to dashed format', () => {
97
+ expect(formatPhone({ phone1: '010', phone2: '1234', phone3: '5678' })).toBe('010-1234-5678')
98
+ })
99
+ })
100
+
101
+ describe('parseEmail', () => {
102
+ it('uses select option for known domain', () => {
103
+ expect(parseEmail('user@gmail.com')).toEqual({ email1: 'user', email2: 'gmail.com', email3: '' })
104
+ expect(parseEmail('me@naver.com')).toEqual({ email1: 'me', email2: 'naver.com', email3: '' })
105
+ })
106
+
107
+ it('uses 직접입력 for custom domain', () => {
108
+ expect(parseEmail('user@customdomain.io')).toEqual({
109
+ email1: 'user',
110
+ email2: '직접입력',
111
+ email3: 'customdomain.io',
112
+ })
113
+ })
114
+
115
+ it('rejects malformed email', () => {
116
+ expect(() => parseEmail('userdomain.com')).toThrow()
117
+ expect(() => parseEmail('@domain.com')).toThrow()
118
+ expect(() => parseEmail('user@')).toThrow()
119
+ })
120
+ })
121
+
122
+ describe('assertDurationInRange', () => {
123
+ it('accepts 120-180', () => {
124
+ expect(() => assertDurationInRange(120)).not.toThrow()
125
+ expect(() => assertDurationInRange(150)).not.toThrow()
126
+ expect(() => assertDurationInRange(180)).not.toThrow()
127
+ })
128
+
129
+ it('rejects out-of-range', () => {
130
+ expect(() => assertDurationInRange(60)).toThrow()
131
+ expect(() => assertDurationInRange(210)).toThrow()
132
+ })
133
+ })
@@ -0,0 +1,100 @@
1
+ import {
2
+ TOZ_EMAIL_DOMAIN_CUSTOM,
3
+ TOZ_EMAIL_DOMAINS,
4
+ TOZ_MAX_DURATION_MINUTES,
5
+ TOZ_MIN_DURATION_MINUTES,
6
+ TOZ_PHONE_PREFIXES,
7
+ } from '../../constants'
8
+
9
+ export interface ParsedPhone {
10
+ phone1: string
11
+ phone2: string
12
+ phone3: string
13
+ }
14
+
15
+ export interface ParsedEmail {
16
+ email1: string
17
+ email2: string
18
+ email3: string
19
+ }
20
+
21
+ export function formatDuration(minutes: number): string {
22
+ if (!Number.isInteger(minutes) || minutes <= 0) {
23
+ throw new Error(`Invalid duration minutes: ${minutes}`)
24
+ }
25
+ const hh = Math.floor(minutes / 60)
26
+ const mm = minutes % 60
27
+ return `${String(hh).padStart(2, '0')}${String(mm).padStart(2, '0')}`
28
+ }
29
+
30
+ export function parseDurationKey(key: string): number {
31
+ if (!/^\d{4}$/.test(key)) {
32
+ throw new Error(`Invalid duration key: ${key}`)
33
+ }
34
+ const hh = Number.parseInt(key.slice(0, 2), 10)
35
+ const mm = Number.parseInt(key.slice(2, 4), 10)
36
+ return hh * 60 + mm
37
+ }
38
+
39
+ export function formatStartTime(time: string): { hour: string; minute: string } {
40
+ const match = /^(\d{1,2}):(\d{2})$/.exec(time)
41
+ if (!match) {
42
+ throw new Error(`Invalid time: ${time} (expected HH:MM)`)
43
+ }
44
+ return { hour: match[1].padStart(2, '0'), minute: match[2] }
45
+ }
46
+
47
+ export function buildStartTimeParam(time: string): string {
48
+ const { hour, minute } = formatStartTime(time)
49
+ return `${hour}${minute}`
50
+ }
51
+
52
+ export function buildBranchIdsParam(branchIds: readonly number[]): string {
53
+ if (branchIds.length === 0) {
54
+ throw new Error('At least one branchId is required')
55
+ }
56
+ return `${branchIds.join(',')},`
57
+ }
58
+
59
+ export function parsePhone(phone: string): ParsedPhone {
60
+ const digits = phone.replace(/[^0-9]/g, '')
61
+ const prefix = TOZ_PHONE_PREFIXES.find((p) => digits.startsWith(p))
62
+ if (!prefix) {
63
+ throw new Error(`Unsupported phone prefix in: ${phone}`)
64
+ }
65
+ const rest = digits.slice(prefix.length)
66
+ if (rest.length !== 7 && rest.length !== 8) {
67
+ throw new Error(`Invalid phone number: ${phone}`)
68
+ }
69
+ const split = rest.length - 4
70
+ return {
71
+ phone1: prefix,
72
+ phone2: rest.slice(0, split),
73
+ phone3: rest.slice(split),
74
+ }
75
+ }
76
+
77
+ export function formatPhone(parsed: ParsedPhone): string {
78
+ return `${parsed.phone1}-${parsed.phone2}-${parsed.phone3}`
79
+ }
80
+
81
+ export function parseEmail(email: string): ParsedEmail {
82
+ const at = email.indexOf('@')
83
+ if (at <= 0 || at === email.length - 1) {
84
+ throw new Error(`Invalid email: ${email}`)
85
+ }
86
+ const local = email.slice(0, at)
87
+ const domain = email.slice(at + 1)
88
+ const known = (TOZ_EMAIL_DOMAINS as readonly string[]).includes(domain)
89
+ return known
90
+ ? { email1: local, email2: domain, email3: '' }
91
+ : { email1: local, email2: TOZ_EMAIL_DOMAIN_CUSTOM, email3: domain }
92
+ }
93
+
94
+ export function assertDurationInRange(minutes: number): void {
95
+ if (minutes < TOZ_MIN_DURATION_MINUTES || minutes > TOZ_MAX_DURATION_MINUTES) {
96
+ throw new Error(
97
+ `SW마에스트로 partnership requires duration between ${TOZ_MIN_DURATION_MINUTES / 60}h and ${TOZ_MAX_DURATION_MINUTES / 60}h (got ${minutes} minutes)`,
98
+ )
99
+ }
100
+ }
@@ -104,6 +104,25 @@ describe('TokenExtractor', () => {
104
104
  ])
105
105
  })
106
106
 
107
+ test('extracts plaintext cookie from opensoma.dev host', async () => {
108
+ const home = await makeTempDir()
109
+ const dbPath = join(home, '.config', 'google-chrome', 'Default', 'Cookies')
110
+ createCookieDbWithPlaintext(dbPath, 'opensoma-dev-session', 0, 'opensoma.dev')
111
+
112
+ const extractor = new TokenExtractor('linux', home)
113
+ expect(await extractor.extract()).toEqual({ sessionCookie: 'opensoma-dev-session' })
114
+ })
115
+
116
+ test('decrypts encrypted cookie from opensoma.dev host on Linux', async () => {
117
+ const home = await makeTempDir()
118
+ const dbPath = join(home, '.config', 'google-chrome', 'Default', 'Cookies')
119
+ const encrypted = encryptLinuxCookie('opensoma-dev-encrypted')
120
+ createCookieDbWithEncrypted(dbPath, encrypted, 'opensoma.dev')
121
+
122
+ const extractor = new TokenExtractor('linux', home)
123
+ expect(await extractor.extract()).toEqual({ sessionCookie: 'opensoma-dev-encrypted' })
124
+ })
125
+
107
126
  test('decrypts encrypted cookie on Linux', async () => {
108
127
  const home = await makeTempDir()
109
128
  const dbPath = join(home, '.config', 'google-chrome', 'Default', 'Cookies')
@@ -148,26 +167,32 @@ function createCookieFile(filePath: string): void {
148
167
  writeFileSync(filePath, '')
149
168
  }
150
169
 
151
- function createCookieDbWithPlaintext(filePath: string, value: string, lastAccessUtc = 0): void {
170
+ function createCookieDbWithPlaintext(
171
+ filePath: string,
172
+ value: string,
173
+ lastAccessUtc = 0,
174
+ hostKey = 'swmaestro.ai',
175
+ ): void {
152
176
  mkdirSync(dirname(filePath), { recursive: true })
153
177
  const db = new Database(filePath)
154
178
  db.run(
155
179
  'CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, creation_utc INTEGER, expires_utc INTEGER, is_httponly INTEGER, has_expires INTEGER, is_persistent INTEGER, priority INTEGER, samesite INTEGER, source_scheme INTEGER, is_secure INTEGER, path TEXT, last_access_utc INTEGER, last_update_utc INTEGER, source_port INTEGER, source_type INTEGER)',
156
180
  )
157
181
  db.run(
158
- "INSERT INTO cookies (host_key, name, value, encrypted_value, last_access_utc) VALUES ('swmaestro.ai', 'JSESSIONID', ?, '', ?)",
159
- [value, lastAccessUtc],
182
+ "INSERT INTO cookies (host_key, name, value, encrypted_value, last_access_utc) VALUES (?, 'JSESSIONID', ?, '', ?)",
183
+ [hostKey, value, lastAccessUtc],
160
184
  )
161
185
  db.close()
162
186
  }
163
187
 
164
- function createCookieDbWithEncrypted(filePath: string, encrypted: Buffer): void {
188
+ function createCookieDbWithEncrypted(filePath: string, encrypted: Buffer, hostKey = 'swmaestro.ai'): void {
165
189
  mkdirSync(dirname(filePath), { recursive: true })
166
190
  const db = new Database(filePath)
167
191
  db.run(
168
192
  'CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, creation_utc INTEGER, expires_utc INTEGER, is_httponly INTEGER, has_expires INTEGER, is_persistent INTEGER, priority INTEGER, samesite INTEGER, source_scheme INTEGER, is_secure INTEGER, path TEXT, last_access_utc INTEGER, last_update_utc INTEGER, source_port INTEGER, source_type INTEGER)',
169
193
  )
170
- db.run("INSERT INTO cookies (host_key, name, value, encrypted_value) VALUES ('swmaestro.ai', 'JSESSIONID', '', ?)", [
194
+ db.run("INSERT INTO cookies (host_key, name, value, encrypted_value) VALUES (?, 'JSESSIONID', '', ?)", [
195
+ hostKey,
171
196
  encrypted,
172
197
  ])
173
198
  db.close()
@@ -8,7 +8,7 @@ import { basename, dirname, join } from 'node:path'
8
8
  const require = createRequire(import.meta.url)
9
9
 
10
10
  const COOKIE_QUERY =
11
- "SELECT encrypted_value, last_access_utc, value FROM cookies WHERE host_key LIKE '%swmaestro.ai' AND name = 'JSESSIONID' ORDER BY last_access_utc DESC LIMIT 1"
11
+ "SELECT encrypted_value, last_access_utc, value FROM cookies WHERE (host_key LIKE '%swmaestro.ai' OR host_key LIKE '%opensoma.dev') AND name = 'JSESSIONID' ORDER BY last_access_utc DESC LIMIT 1"
12
12
  const CHROMIUM_SALT = 'saltysalt'
13
13
  const CHROMIUM_IV = Buffer.alloc(16, 0x20)
14
14
  const PROFILE_DIR_PATTERN = /^Profile\s+\d+$/
@@ -124,11 +124,36 @@ function queryCookieDb(dbPath: string): CookieRow | undefined {
124
124
  }
125
125
  }
126
126
 
127
+ export type TokenExtractorOptions = {
128
+ platform?: NodeJS.Platform
129
+ homeDirectory?: string
130
+ debug?: boolean
131
+ }
132
+
127
133
  export class TokenExtractor {
128
- constructor(
129
- private readonly platform: NodeJS.Platform = process.platform,
130
- private readonly homeDirectory: string = homedir(),
131
- ) {}
134
+ private readonly platform: NodeJS.Platform
135
+ private readonly homeDirectory: string
136
+ private readonly debugEnabled: boolean
137
+
138
+ constructor(options?: TokenExtractorOptions)
139
+ constructor(platform?: NodeJS.Platform, homeDirectory?: string, debug?: boolean)
140
+ constructor(platformOrOptions?: NodeJS.Platform | TokenExtractorOptions, homeDirectory?: string, debug?: boolean) {
141
+ if (typeof platformOrOptions === 'object' && platformOrOptions !== null) {
142
+ this.platform = platformOrOptions.platform ?? process.platform
143
+ this.homeDirectory = platformOrOptions.homeDirectory ?? homedir()
144
+ this.debugEnabled = platformOrOptions.debug ?? false
145
+ } else {
146
+ this.platform = platformOrOptions ?? process.platform
147
+ this.homeDirectory = homeDirectory ?? homedir()
148
+ this.debugEnabled = debug ?? false
149
+ }
150
+ }
151
+
152
+ private log(...args: unknown[]): void {
153
+ if (!this.debugEnabled) return
154
+ const message = args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')
155
+ process.stderr.write(`[extract] ${message}\n`)
156
+ }
132
157
 
133
158
  async extract(): Promise<{ sessionCookie: string } | null> {
134
159
  const candidates = await this.extractCandidates()
@@ -143,28 +168,35 @@ export class TokenExtractor {
143
168
 
144
169
  async extractCandidates(): Promise<ExtractedSessionCandidate[]> {
145
170
  const candidates = new Map<string, ExtractedSessionCandidate>()
171
+ const cookieDatabases = this.findCookieDatabases()
172
+ this.log(`Found ${cookieDatabases.length} cookie database(s)`)
146
173
 
147
- for (const databasePath of this.findCookieDatabases()) {
174
+ for (const databasePath of cookieDatabases) {
148
175
  const browser = this.getBrowserByPath(databasePath)
149
176
  if (!browser) {
177
+ this.log(`Skipping ${databasePath} (no matching browser config)`)
150
178
  continue
151
179
  }
152
180
 
153
181
  const profile = basename(dirname(databasePath))
182
+ this.log(`Processing ${browser.name} / ${profile}`)
154
183
 
155
184
  const tempDirectory = mkdtempSync(join(tmpdir(), 'opensoma-cookie-db-'))
156
185
  const tempDatabasePath = join(tempDirectory, 'Cookies')
157
186
 
158
187
  try {
159
188
  copySqliteDatabase(databasePath, tempDatabasePath)
189
+ this.log(` Copied DB to ${tempDatabasePath}`)
160
190
 
161
191
  const row = queryCookieDb(tempDatabasePath)
162
192
  if (!row) {
193
+ this.log(' No JSESSIONID cookie found')
163
194
  continue
164
195
  }
165
196
 
166
197
  const plaintextValue = normalizeCookieText(row.value)
167
198
  if (plaintextValue) {
199
+ this.log(` Found plaintext cookie (${plaintextValue.length} chars)`)
168
200
  this.addCandidate(candidates, {
169
201
  browser: browser.name,
170
202
  lastAccessUtc: this.normalizeLastAccessUtc(row.last_access_utc),
@@ -176,29 +208,40 @@ export class TokenExtractor {
176
208
 
177
209
  const encryptedValue = normalizeCookieBytes(row.encrypted_value)
178
210
  if (!encryptedValue || encryptedValue.length === 0) {
211
+ this.log(' No plaintext or encrypted value')
179
212
  continue
180
213
  }
181
214
 
215
+ this.log(` Decrypting encrypted cookie (${encryptedValue.length} bytes)...`)
182
216
  const decryptedValue = await this.decryptCookie(encryptedValue, browser.name)
183
217
  if (decryptedValue) {
218
+ this.log(` Decrypted successfully (${decryptedValue.length} chars)`)
184
219
  this.addCandidate(candidates, {
185
220
  browser: browser.name,
186
221
  lastAccessUtc: this.normalizeLastAccessUtc(row.last_access_utc),
187
222
  profile,
188
223
  sessionCookie: decryptedValue,
189
224
  })
225
+ } else {
226
+ this.log(' Decryption returned empty value')
190
227
  }
191
- } catch {
228
+ } catch (error) {
229
+ this.log(` Error: ${error instanceof Error ? error.message : String(error)}`)
192
230
  } finally {
193
231
  rmSync(tempDirectory, { recursive: true, force: true })
194
232
  }
195
233
  }
196
234
 
235
+ this.log(`Total unique candidates: ${candidates.size}`)
197
236
  return [...candidates.values()].sort((left, right) => right.lastAccessUtc - left.lastAccessUtc)
198
237
  }
199
238
 
200
239
  findCookieDatabases(): string[] {
201
- return BROWSERS.flatMap((browser) => this.findBrowserCookieDatabases(browser))
240
+ const results = BROWSERS.flatMap((browser) => this.findBrowserCookieDatabases(browser))
241
+ if (results.length === 0) {
242
+ this.log('No cookie databases found. Searched browsers:', BROWSERS.map((b) => b.name).join(', '))
243
+ }
244
+ return results
202
245
  }
203
246
 
204
247
  private async decryptCookie(encryptedValue: Buffer, browserName: string): Promise<string> {
@@ -207,14 +250,18 @@ export class TokenExtractor {
207
250
  }
208
251
 
209
252
  if (this.platform === 'linux') {
253
+ this.log(' Using Linux PBKDF2 decryption')
210
254
  return this.decryptChromiumValue(encryptedValue, pbkdf2Sync('peanuts', CHROMIUM_SALT, 1, 16, 'sha1'))
211
255
  }
212
256
 
213
257
  if (this.platform === 'darwin') {
258
+ this.log(` Fetching macOS Keychain key for ${browserName}...`)
214
259
  const key = await this.getMacOSEncryptionKey(browserName)
260
+ this.log(' Keychain key obtained')
215
261
  return this.decryptChromiumValue(encryptedValue, key)
216
262
  }
217
263
 
264
+ this.log(` Unsupported platform for decryption: ${this.platform}`)
218
265
  return ''
219
266
  }
220
267
 
@@ -224,11 +271,10 @@ export class TokenExtractor {
224
271
  throw new Error(`Unsupported browser: ${browserName}`)
225
272
  }
226
273
 
227
- const password = execSync(
228
- `security find-generic-password -s ${JSON.stringify(browser.keychainService)} -a ${JSON.stringify(browser.keychainAccount)} -w`,
229
- { encoding: 'utf8' },
230
- ).trimEnd()
274
+ const command = `security find-generic-password -s ${JSON.stringify(browser.keychainService)} -a ${JSON.stringify(browser.keychainAccount)} -w`
275
+ this.log(` Keychain command: ${command}`)
231
276
 
277
+ const password = execSync(command, { encoding: 'utf8' }).trimEnd()
232
278
  return pbkdf2Sync(password, CHROMIUM_SALT, 1003, 16, 'sha1')
233
279
  }
234
280
 
@@ -266,14 +312,20 @@ export class TokenExtractor {
266
312
  private findBrowserCookieDatabases(browser: BrowserConfig): string[] {
267
313
  const browserRoot = this.getBrowserRoot(browser)
268
314
  if (!browserRoot || !existsSync(browserRoot)) {
315
+ this.log(`${browser.name}: not found at ${browserRoot ?? '(unsupported platform)'}`)
269
316
  return []
270
317
  }
271
318
 
272
- return readdirSync(browserRoot, { withFileTypes: true })
319
+ const profiles = readdirSync(browserRoot, { withFileTypes: true })
273
320
  .filter((entry) => entry.isDirectory() && this.isSupportedProfileDirectory(entry.name))
274
321
  .sort((left, right) => left.name.localeCompare(right.name))
322
+
323
+ const databases = profiles
275
324
  .map((entry) => join(browserRoot, entry.name, 'Cookies'))
276
325
  .filter((databasePath) => existsSync(databasePath))
326
+
327
+ this.log(`${browser.name}: ${profiles.length} profile(s), ${databases.length} cookie DB(s)`)
328
+ return databases
277
329
  }
278
330
 
279
331
  private getBrowserRoot(browser: BrowserConfig): string | null {
@@ -0,0 +1,197 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ import {
6
+ isReservationConfirmSuccess,
7
+ parseBookingPageBranches,
8
+ parseBookingPageMeetings,
9
+ parseMypageReservations,
10
+ parseTozBoothes,
11
+ parseTozDurations,
12
+ parseTozReserved,
13
+ } from './toz-formatters'
14
+
15
+ const FIXTURES_DIR = join(__dirname, '__fixtures__', 'toz')
16
+
17
+ function readFixture(name: string): string {
18
+ return readFileSync(join(FIXTURES_DIR, name), 'utf8')
19
+ }
20
+
21
+ function readJsonFixture<T>(name: string): T {
22
+ return JSON.parse(readFixture(name)) as T
23
+ }
24
+
25
+ describe('parseBookingPageBranches', () => {
26
+ it('extracts all 9 SW마에스트로 partner branches', () => {
27
+ const html = readFixture('toz_booking.html')
28
+ const branches = parseBookingPageBranches(html)
29
+
30
+ expect(branches).toEqual([
31
+ { id: 27, name: '강남토즈타워점' },
32
+ { id: 145, name: '강남컨퍼런스센터' },
33
+ { id: 19, name: '양재점' },
34
+ { id: 20, name: '건대점' },
35
+ { id: 15, name: '선릉점' },
36
+ { id: 139, name: '마이스 역삼센터' },
37
+ { id: 134, name: '마이스 광화문센터' },
38
+ { id: 30, name: '신촌비즈센터' },
39
+ { id: 149, name: '홍대점' },
40
+ ])
41
+ })
42
+ })
43
+
44
+ describe('parseBookingPageMeetings', () => {
45
+ it('extracts meeting options excluding placeholder and 새모임', () => {
46
+ const html = readFixture('toz_booking.html')
47
+ const meetings = parseBookingPageMeetings(html)
48
+
49
+ expect(meetings.length).toBeGreaterThan(50)
50
+ expect(meetings[0]).toEqual({ id: 2305094, name: '멘토특강_김현동 님' })
51
+ expect(meetings.find((m) => m.id === 2322942)).toEqual({
52
+ id: 2322942,
53
+ name: '자유멘토링_SW마에스트로',
54
+ })
55
+
56
+ expect(meetings.find((m) => m.name === '[새 모임]')).toBeUndefined()
57
+ for (const meeting of meetings) {
58
+ expect(Number.isInteger(meeting.id)).toBe(true)
59
+ expect(meeting.id).toBeGreaterThan(0)
60
+ }
61
+ })
62
+ })
63
+
64
+ describe('parseTozDurations', () => {
65
+ it('parses duration JSON with computed minutes', () => {
66
+ const json = readJsonFixture('toz_duration.json')
67
+ const durations = parseTozDurations(json)
68
+
69
+ expect(durations[0]).toEqual({ key: '0200', value: '2시간', minutes: 120 })
70
+ expect(durations[1]).toEqual({ key: '0230', value: '2시간 30분', minutes: 150 })
71
+ expect(durations[2]).toEqual({ key: '0300', value: '3시간', minutes: 180 })
72
+ })
73
+
74
+ it('returns [] for non-array', () => {
75
+ expect(parseTozDurations(null)).toEqual([])
76
+ expect(parseTozDurations({})).toEqual([])
77
+ })
78
+ })
79
+
80
+ describe('parseTozBoothes', () => {
81
+ it('parses booth JSON array', () => {
82
+ const json = readJsonFixture('toz_boothes.json')
83
+ const boothes = parseTozBoothes(json)
84
+
85
+ expect(boothes).toHaveLength(3)
86
+ expect(boothes[0]).toEqual({
87
+ id: 740,
88
+ name: '304 _ A',
89
+ branchName: '토즈타워점',
90
+ branchTel: '02-3454-0116',
91
+ minUseUserCount: 1,
92
+ enableMaxUserCount: 2,
93
+ boothGroupName: '2인부스 A타입',
94
+ boothGroupUrl: 'https://moim.toz.co.kr/branchDetail?branch_id=28&url=&path=',
95
+ boothMemoForUser: '15,000원(기본2시간) / 기업, 학생할인 적용',
96
+ isLargeBooth: false,
97
+ })
98
+ })
99
+
100
+ it('throws on non-SUCCESS error response', () => {
101
+ const error = [{ resultMsg: '예약 가능한 부스가 없습니다.' }]
102
+ expect(() => parseTozBoothes(error)).toThrow('예약 가능한 부스가 없습니다.')
103
+ })
104
+
105
+ it('returns [] for empty', () => {
106
+ expect(parseTozBoothes([])).toEqual([])
107
+ expect(parseTozBoothes(null)).toEqual([])
108
+ })
109
+ })
110
+
111
+ describe('parseTozReserved', () => {
112
+ it('parses successful booth reservation response', () => {
113
+ const reserved = parseTozReserved({
114
+ result: 'SUCCESS',
115
+ resultMsg: 'abc-123',
116
+ branchName: '토즈타워점',
117
+ branchTel: '02-3454-0116',
118
+ boothGroupName: '2인부스 A타입',
119
+ boothIsLarge: false,
120
+ entitys: [],
121
+ })
122
+
123
+ expect(reserved).toEqual({
124
+ reservationId: 'abc-123',
125
+ branchName: '토즈타워점',
126
+ branchTel: '02-3454-0116',
127
+ boothGroupName: '2인부스 A타입',
128
+ isLargeBooth: false,
129
+ })
130
+ })
131
+
132
+ it('throws on failure response', () => {
133
+ expect(() => parseTozReserved({ result: 'FAIL', resultMsg: '이미 예약된 부스입니다.' })).toThrow(
134
+ '이미 예약된 부스입니다.',
135
+ )
136
+ })
137
+ })
138
+
139
+ describe('isReservationConfirmSuccess', () => {
140
+ it('detects standard success', () => {
141
+ expect(isReservationConfirmSuccess('예약 되었습니다.')).toBe(true)
142
+ })
143
+
144
+ it('detects large-booth success', () => {
145
+ expect(isReservationConfirmSuccess('대형부스 예약 신청되었습니다. 지점에서 확인 전화를 드릴 예정입니다.')).toBe(
146
+ true,
147
+ )
148
+ })
149
+
150
+ it('rejects error messages', () => {
151
+ expect(isReservationConfirmSuccess('인증번호가 올바르지 않습니다.')).toBe(false)
152
+ })
153
+ })
154
+
155
+ describe('parseMypageReservations', () => {
156
+ it('returns empty array when no reservations', () => {
157
+ const html = readFixture('toz_mypage_response.html')
158
+ expect(parseMypageReservations(html)).toEqual([])
159
+ })
160
+
161
+ it('parses synthetic reservation row', () => {
162
+ const html = `
163
+ <html><body>
164
+ <table class="reservation"><thead><tr><th>NO</th></tr></thead><tbody>
165
+ <tr><td colspan="7">예약 요청 정보가 없습니다.</td></tr>
166
+ </tbody></table>
167
+ <table class="reservation"><thead></thead><tbody>
168
+ <tr>
169
+ <td>1</td>
170
+ <td>자유멘토링_홍길동</td>
171
+ <td>2026-04-21</td>
172
+ <td>14:00 ~ 16:00</td>
173
+ <td>토즈타워점</td>
174
+ <td>304 _ A</td>
175
+ <td>2026-04-17 18:45</td>
176
+ <td>예약완료 <a href="javascript:destroyReservation(987654)">취소</a></td>
177
+ </tr>
178
+ </tbody></table>
179
+ </body></html>
180
+ `
181
+
182
+ expect(parseMypageReservations(html)).toEqual([
183
+ {
184
+ no: 1,
185
+ reservationId: 987654,
186
+ meetingName: '자유멘토링_홍길동',
187
+ date: '2026-04-21',
188
+ startTime: '14:00',
189
+ endTime: '16:00',
190
+ branchName: '토즈타워점',
191
+ boothName: '304 _ A',
192
+ reservedAt: '2026-04-17 18:45',
193
+ status: '예약완료 취소',
194
+ },
195
+ ])
196
+ })
197
+ })