Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion config/facs/httpd-oauth2.config.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,21 @@
},
"startRedirectPath": "/oauth/google",
"callbackUri": "http://localhost:3000/oauth/google/callback",
"callbackUriUI": "http://localhost:3030",
"callbackUriUI": "http://localhost:3000",
"users": []
},
"h1": {
"method": "microsoft",
"credentials": {
"client": {
"id": "<CLIENT_ID>",
"secret": "<CLIENT_SECRET>"
},
"tenant":""
},
"startRedirectPath": "/oauth/microsoft",
"callbackUri": "http://localhost:3000/oauth/microsoft/callback",
"callbackUriUI": "http://localhost:3000",
"users": []
}
}
425 changes: 230 additions & 195 deletions package-lock.json

Large diffs are not rendered by default.

29 changes: 28 additions & 1 deletion tests/integration/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ test('Api', { timeout: 90000 }, async (main) => {
})

const createConfig = () => {
fs.rmSync(`./${baseDir}/store`, { recursive: true, force: true })
fs.rmSync(`./${baseDir}/status`, { recursive: true, force: true })
fs.rmSync(`./${baseDir}/config`, { recursive: true, force: true })
fs.rmSync(`./${baseDir}/db`, { recursive: true, force: true })

if (!fs.existsSync(`./${baseDir}/config/facs`)) {
if (!fs.existsSync(`./${baseDir}/config`)) fs.mkdirSync(`./${baseDir}/config`)
fs.mkdirSync(`./${baseDir}/config/facs`)
Expand All @@ -47,7 +52,10 @@ test('Api', { timeout: 90000 }, async (main) => {
const commonConf = { dir_log: 'logs', debug: 0, orks: { 'cluster-1': { rpcPublicKey: '' } }, cacheTiming: {}, featureConfig: {} }
const netConf = { r0: {} }
const httpdConf = { h0: {} }
const httpdOauthConf = { h0: { method: 'google', credentials: { client: { id: 'i', secret: 's' } }, users: [{ email: readonlyUser }, { email: tokenExpiredUser }, { email: siteOperatorUser, write: true }] } }
const httpdOauthConf = {
h0: { method: 'google', credentials: { client: { id: 'i', secret: 's' } }, users: [{ email: readonlyUser }, { email: tokenExpiredUser }, { email: siteOperatorUser, write: true }] },
h1: { method: 'microsoft', credentials: { client: { id: 'i', secret: 's' }, tenant: 'test-tenant' }, users: [] }
}
const authConf = require('../../config/facs/auth.config.json')
superadminUser = authConf.a0.superAdmin

Expand Down Expand Up @@ -97,6 +105,14 @@ test('Api', { timeout: 90000 }, async (main) => {
return token
}

const getTestTokenMicrosoft = async (email) => {
worker.worker.authLib._auth.addHandlers({
microsoft: () => { return { email } }
})
const token = await worker.worker.auth_a0.authCallbackHandler('microsoft', { ip })
return token
}

const createUser = async (email, role, token) => {
if (!token) token = await getTestToken(superadminUser)

Expand Down Expand Up @@ -930,6 +946,17 @@ test('Api', { timeout: 90000 }, async (main) => {
await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding)
})

await main.test('Api: microsoft auth callback handler', async (n) => {
await n.test('should generate valid token from microsoft provider callback', async (t) => {
try {
const token = await getTestTokenMicrosoft(readonlyUser)
t.ok(typeof token === 'string' && token.length > 10, 'should return a non-empty auth token string')
} catch (e) {
t.fail(`Expected microsoft callback token generation to succeed: ${e.message || e}`)
}
})
})

await main.test('Api: get finance/ebitda', async (n) => {
const api = `${appNodeBaseUrl}${ENDPOINTS.FINANCE_EBITDA}?start=1700000000000&end=1700100000000`
await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding)
Expand Down
10 changes: 9 additions & 1 deletion tests/integration/ws.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ test('WebSocket endpoint', { timeout: 90000 }, async (main) => {
})

const createConfig = () => {
fs.rmSync(`./${baseDir}/store`, { recursive: true, force: true })
fs.rmSync(`./${baseDir}/status`, { recursive: true, force: true })
fs.rmSync(`./${baseDir}/config`, { recursive: true, force: true })
fs.rmSync(`./${baseDir}/db`, { recursive: true, force: true })

if (!fs.existsSync(`./${baseDir}/config/facs`)) {
if (!fs.existsSync(`./${baseDir}/config`)) fs.mkdirSync(`./${baseDir}/config`)
fs.mkdirSync(`./${baseDir}/config/facs`)
Expand All @@ -110,7 +115,10 @@ test('WebSocket endpoint', { timeout: 90000 }, async (main) => {
const commonConf = { dir_log: 'logs', debug: 0, orks: { 'cluster-1': { rpcPublicKey: '' } }, cacheTiming: {}, featureConfig: {} }
const netConf = { r0: {} }
const httpdConf = { h0: {} }
const httpdOauthConf = { h0: { method: 'google', credentials: { client: { id: 'i', secret: 's' } }, users: [{ email: readonlyUser }, { email: siteOperatorUser, write: true }] } }
const httpdOauthConf = {
h0: { method: 'google', credentials: { client: { id: 'i', secret: 's' } }, users: [{ email: readonlyUser }, { email: siteOperatorUser, write: true }] },
h1: { method: 'microsoft', credentials: { client: { id: 'i', secret: 's' }, tenant: 'test-tenant' }, users: [] }
}
const authConf = require('../../config/facs/auth.config.json')
superadminUser = authConf.a0.superAdmin

Expand Down
12 changes: 6 additions & 6 deletions tests/unit/handlers/coolingSystem.handlers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,12 +216,12 @@ const createMockSnapData = () => ({

function createMockCtx (featureEnabled = true, customDcsResponse = null) {
const snapData = createMockSnapData()
const defaultResponse = [{
const defaultResponse = [[{
id: 'dcs-1',
type: 'dcs',
tags: ['t-dcs'],
last: { snap: snapData }
}]
}]]

const featureConfig = {
centralDCSSetup: {
Expand All @@ -235,8 +235,8 @@ function createMockCtx (featureEnabled = true, customDcsResponse = null) {
featureConfig,
orks: [{ rpcPublicKey: 'key1' }]
},
net_r0: {
jRequest: async () => {
dataProxy: {
requestDataMap: async () => {
return customDcsResponse !== null ? customDcsResponse : defaultResponse
}
}
Expand All @@ -250,9 +250,9 @@ test('isCentralDCSEnabled - returns true with new config', (t) => {
t.pass()
})

test('isCentralDCSEnabled - returns true with legacy config', (t) => {
test('isCentralDCSEnabled - ignores legacy config key', (t) => {
const ctx = { conf: { featureConfig: { isCentralPCS7Setup: true } } }
t.is(isCentralDCSEnabled(ctx), true)
t.is(isCentralDCSEnabled(ctx), false)
t.pass()
})

Expand Down
129 changes: 129 additions & 0 deletions tests/unit/lib/auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ test('AuthLib - start adds OAuth handlers', async (t) => {
t.ok(handlersAdded, 'should call addHandlers')
t.ok(handlersAdded.google, 'should add google handler')
t.ok(typeof handlersAdded.google === 'function', 'google handler should be function')
t.ok(handlersAdded.microsoft, 'should add microsoft handler')
t.ok(typeof handlersAdded.microsoft === 'function', 'microsoft handler should be function')

t.pass()
})
Expand Down Expand Up @@ -448,3 +450,130 @@ test('AuthLib - _resolveOAuthGoogle with null info', async (t) => {

t.pass()
})

test('AuthLib - _resolveOAuthMicrosoft with valid profile mail', async (t) => {
const originalFetch = global.fetch
global.fetch = async (url, opts) => {
t.is(url, 'https://graph.microsoft.com/v1.0/me?$select=mail,userPrincipalName,otherMails', 'should call graph /me endpoint')
t.is(opts.headers.authorization, 'Bearer ms-token-123', 'should pass bearer token')
return {
ok: true,
json: async () => ({ mail: 'microsoft.user@example.com' })
}
}

const authLib = new AuthLib({
httpc: {},
httpd: {
server: {
microsoftOAuth2: {
getAccessTokenFromAuthorizationCodeFlow: async () => ({ token: { access_token: 'ms-token-123' } })
}
}
},
userService: {},
auth: {}
})

try {
const result = await authLib._resolveOAuthMicrosoft({}, {})
t.alike(result, { email: 'microsoft.user@example.com' }, 'should return email from profile mail')
} finally {
global.fetch = originalFetch
}

t.pass()
})

test('AuthLib - _resolveOAuthMicrosoft prefers otherMails for guest mail', async (t) => {
const originalFetch = global.fetch
global.fetch = async () => ({
ok: true,
json: async () => ({
mail: 'guest_user_example.com#EXT#@tenant.onmicrosoft.com',
userPrincipalName: 'guest_user_example.com#EXT#@tenant.onmicrosoft.com',
otherMails: ['primary.user@example.com']
})
})

const authLib = new AuthLib({
httpc: {},
httpd: {
server: {
microsoftOAuth2: {
getAccessTokenFromAuthorizationCodeFlow: async () => ({ token: { access_token: 'ms-token-123' } })
}
}
},
userService: {},
auth: {}
})

try {
const result = await authLib._resolveOAuthMicrosoft({}, {})
t.alike(result, { email: 'primary.user@example.com' }, 'should prefer non-guest email from otherMails')
} finally {
global.fetch = originalFetch
}

t.pass()
})

test('AuthLib - _resolveOAuthMicrosoft throws when token exchange fails', async (t) => {
const authLib = new AuthLib({
httpc: {},
httpd: {
server: {
microsoftOAuth2: {
getAccessTokenFromAuthorizationCodeFlow: async () => {
throw new Error('token exchange failed')
}
}
}
},
userService: {},
auth: {}
})

try {
await authLib._resolveOAuthMicrosoft({}, {})
t.fail('should throw on token exchange failure')
} catch (err) {
t.is(err.message, 'token exchange failed', 'should bubble token exchange error message')
}

t.pass()
})

test('AuthLib - _resolveOAuthMicrosoft throws when graph request fails', async (t) => {
const originalFetch = global.fetch
global.fetch = async () => ({
ok: false,
status: 401,
text: async () => 'unauthorized'
})

const authLib = new AuthLib({
httpc: {},
httpd: {
server: {
microsoftOAuth2: {
getAccessTokenFromAuthorizationCodeFlow: async () => ({ token: { access_token: 'ms-token-123' } })
}
}
},
userService: {},
auth: {}
})

try {
await authLib._resolveOAuthMicrosoft({}, {})
t.fail('should throw on graph failure')
} catch (err) {
t.is(err.message, 'ERR_MICROSOFT_GRAPH_401: unauthorized', 'should include graph status and body')
} finally {
global.fetch = originalFetch
}

t.pass()
})
1 change: 1 addition & 0 deletions tests/unit/lib/constants.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ test('constants - COMMENT_ACTION', (t) => {
test('constants - ENDPOINTS', (t) => {
t.ok(typeof ENDPOINTS === 'object', 'should be object')
t.is(ENDPOINTS.OAUTH_GOOGLE_CALLBACK, '/oauth/google/callback', 'should have OAuth callback endpoint')
t.is(ENDPOINTS.OAUTH_MICROSOFT_CALLBACK, '/oauth/microsoft/callback', 'should have OAuth Microsoft callback endpoint')
t.is(ENDPOINTS.USERINFO, '/auth/userinfo', 'should have userinfo endpoint')
t.is(ENDPOINTS.TOKEN, '/auth/token', 'should have token endpoint')
t.is(ENDPOINTS.USERS, '/auth/users', 'should have users endpoint')
Expand Down
71 changes: 70 additions & 1 deletion tests/unit/routes/auth.routes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ test('auth routes - route definitions', (t) => {

const routeUrls = routes.map(route => route.url)
t.ok(routeUrls.includes('/oauth/google/callback'), 'should have OAuth Google callback route')
t.ok(routeUrls.includes('/oauth/microsoft/callback'), 'should have OAuth Microsoft callback route')
t.ok(routeUrls.includes('/auth/userinfo'), 'should have userinfo route')
t.ok(routeUrls.includes('/auth/token'), 'should have token route')
t.ok(routeUrls.includes('/auth/permissions'), 'should have permissions route')
Expand All @@ -40,20 +41,88 @@ test('auth routes - HTTP methods', (t) => {
t.pass()
})

test('auth routes - OAuth callback handler', (t) => {
test('auth routes - OAuth callback handlers exist', (t) => {
const mockCtx = {
auth_a0: {
authCallbackHandler: async () => 'test-token'
},
httpdOauth2_h0: {
callbackUriUI: () => 'http://localhost:3000/callback'
},
httpdOauth2_h1: {
callbackUriUI: () => 'http://localhost:3000/ms-callback'
}
}
const routes = require('../../../workers/lib/server/routes/auth.routes.js')(mockCtx)

const oauthRoute = routes.find(r => r.url === '/oauth/google/callback')
t.ok(oauthRoute, 'should have OAuth callback route')
t.ok(typeof oauthRoute.handler === 'function', 'OAuth callback should have handler')
const microsoftOauthRoute = routes.find(r => r.url === '/oauth/microsoft/callback')
t.ok(microsoftOauthRoute, 'should have Microsoft OAuth callback route')
t.ok(typeof microsoftOauthRoute.handler === 'function', 'Microsoft OAuth callback should have handler')

t.pass()
})

test('auth routes - Google callback redirects with token', async (t) => {
const mockCtx = {
auth_a0: {
authCallbackHandler: async (provider) => {
t.is(provider, 'google', 'should invoke google auth provider')
return 'google-token'
}
},
httpdOauth2_h0: {
callbackUriUI: () => 'http://localhost:3000/callback'
}
}
const routes = require('../../../workers/lib/server/routes/auth.routes.js')(mockCtx)
const oauthRoute = routes.find(r => r.url === '/oauth/google/callback')

let redirectUrl
const rep = {
redirect: (url) => {
redirectUrl = url
return url
}
}

await oauthRoute.handler({}, rep)
t.ok(redirectUrl.includes('http://localhost:3000/callback?'), 'should redirect to UI callback URI')
t.ok(redirectUrl.includes('authToken=google-token'), 'should include auth token in querystring')
t.pass()
})

test('auth routes - Microsoft callback redirects with error', async (t) => {
const mockCtx = {
auth_a0: {
authCallbackHandler: async (provider) => {
t.is(provider, 'microsoft', 'should invoke microsoft auth provider')
throw new Error('ERR_MICROSOFT_AUTH')
}
},
httpdOauth2_h0: {
callbackUriUI: () => 'http://localhost:3000/callback'
},
httpdOauth2_h1: {
callbackUriUI: () => 'http://localhost:3000/ms-callback'
}
}
const routes = require('../../../workers/lib/server/routes/auth.routes.js')(mockCtx)
const oauthRoute = routes.find(r => r.url === '/oauth/microsoft/callback')

let redirectUrl
const rep = {
redirect: (url) => {
redirectUrl = url
return url
}
}

await oauthRoute.handler({}, rep)
t.ok(redirectUrl.includes('http://localhost:3000/ms-callback?'), 'should redirect to microsoft UI callback URI')
t.ok(redirectUrl.includes('error=ERR_MICROSOFT_AUTH'), 'should include error in querystring')

t.pass()
})
Expand Down
Loading
Loading