mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-22 18:43:19 +00:00
wip: SSO and ORG fix
This commit is contained in:
@@ -812,7 +812,7 @@ describe('API Tokens', () => {
|
||||
// currently succeeds — token can create orgs (not ideal)
|
||||
// cleanup if it was created
|
||||
if (typeof org === 'object' && org?.id) {
|
||||
await $api.organizations.delete({ organizationId: org.id }).catch(() => {})
|
||||
await $api.organizations.delete(org.id).catch(() => {})
|
||||
}
|
||||
|
||||
await $api.apiTokens.delete(created!.item.id);
|
||||
|
||||
@@ -55,8 +55,7 @@ describe('Organizations: creation and management', () => {
|
||||
})
|
||||
|
||||
it('should allow owner to update name and slug', async () => {
|
||||
const updated = await user1Api.organizations.update({
|
||||
organizationId: createdOrgId,
|
||||
const updated = await user1Api.organizations.update(createdOrgId, {
|
||||
name: 'Updated Org',
|
||||
slug: 'updated-org',
|
||||
})
|
||||
@@ -434,8 +433,7 @@ describe('Cross-org access control', () => {
|
||||
})
|
||||
|
||||
it('should deny non-member from updating another org', async () => {
|
||||
const status = await user2Api.organizations.update({
|
||||
organizationId: user1OrgId,
|
||||
const status = await user2Api.organizations.update(user1OrgId, {
|
||||
name: 'Hacked Org',
|
||||
}).catch((err) => err.status)
|
||||
|
||||
@@ -481,9 +479,7 @@ describe('Cross-org access control', () => {
|
||||
})
|
||||
|
||||
it('should deny non-member from deleting another org', async () => {
|
||||
const status = await user2Api.organizations.delete({
|
||||
organizationId: user1OrgId,
|
||||
}).catch((err) => err.status)
|
||||
const status = await user2Api.organizations.delete(user1OrgId).catch((err) => err.status)
|
||||
|
||||
expect(status).toBeGreaterThanOrEqual(400)
|
||||
|
||||
@@ -550,8 +546,7 @@ describe('Regular member restrictions', () => {
|
||||
})
|
||||
|
||||
it('should deny regular member from updating org details', async () => {
|
||||
const status = await user2Api.organizations.update({
|
||||
organizationId: orgId,
|
||||
const status = await user2Api.organizations.update(orgId, {
|
||||
name: 'Member Changed Name',
|
||||
}).catch((err) => err.status)
|
||||
|
||||
@@ -588,16 +583,15 @@ describe('Regular member restrictions', () => {
|
||||
})
|
||||
|
||||
it('should deny regular member from deleting org', async () => {
|
||||
const status = await user2Api.organizations.delete({
|
||||
organizationId: orgId,
|
||||
}).catch((err) => err.status)
|
||||
const status = await user2Api.organizations.delete(orgId).catch((err) => err.status)
|
||||
|
||||
expect(status).toBeGreaterThanOrEqual(400)
|
||||
})
|
||||
|
||||
it('should allow regular member to view org members', async () => {
|
||||
const members = await user2Api.organizations.fetchMembers(orgId)
|
||||
expect(members.length).toBeGreaterThan(0)
|
||||
it('should deny regular member from viewing org members', async () => {
|
||||
const status = await user2Api.organizations.fetchMembers(orgId)
|
||||
.catch((err) => err.status)
|
||||
expect(status).toBeGreaterThanOrEqual(400)
|
||||
})
|
||||
|
||||
it('should allow regular member to view org details', async () => {
|
||||
@@ -628,8 +622,7 @@ describe('Slug uniqueness', () => {
|
||||
it('should lowercase slug on update', async () => {
|
||||
const org = await user1Api.organizations.create({ name: 'Update Slug Case' })
|
||||
const newSlug = `UPDATED-SLUG-${Date.now()}`
|
||||
const updated = await user1Api.organizations.update({
|
||||
organizationId: org.id,
|
||||
const updated = await user1Api.organizations.update(org.id, {
|
||||
slug: newSlug,
|
||||
})
|
||||
expect(updated).toBeTruthy()
|
||||
@@ -643,8 +636,7 @@ describe('Slug uniqueness', () => {
|
||||
const org1 = await user1Api.organizations.create({ name: 'Slug A', slug: slug1 })
|
||||
await user1Api.organizations.create({ name: 'Slug B', slug: slug2 })
|
||||
|
||||
const result = await user1Api.organizations.update({
|
||||
organizationId: org1.id,
|
||||
const result = await user1Api.organizations.update(org1.id, {
|
||||
slug: slug2,
|
||||
}).catch(() => null)
|
||||
|
||||
@@ -1132,9 +1124,7 @@ describe('Organization deletion', () => {
|
||||
const personal = orgs.find(o => (o as any).isPersonal === 1)
|
||||
|
||||
if (personal) {
|
||||
await user1Api.organizations.delete({
|
||||
organizationId: personal.id,
|
||||
}).catch(() => null)
|
||||
await user1Api.organizations.delete(personal.id).catch(() => null)
|
||||
|
||||
const orgsAfter = await user1Api.organizations.fetch()
|
||||
const stillExists = orgsAfter.find(o => o.id === personal.id)
|
||||
@@ -1151,9 +1141,7 @@ describe('Organization deletion', () => {
|
||||
role: 'admin',
|
||||
})
|
||||
|
||||
await user2Api.organizations.delete({
|
||||
organizationId: org.id,
|
||||
}).catch(() => null)
|
||||
await user2Api.organizations.delete(org.id).catch(() => null)
|
||||
|
||||
const orgs = await user1Api.organizations.fetch()
|
||||
const stillExists = orgs.find(o => o.id === org.id)
|
||||
@@ -1163,9 +1151,7 @@ describe('Organization deletion', () => {
|
||||
it('should allow owner to delete organization', async () => {
|
||||
const org = await user1Api.organizations.create({ name: 'Will Be Deleted' })
|
||||
|
||||
const result = await user1Api.organizations.delete({
|
||||
organizationId: org.id,
|
||||
})
|
||||
const result = await user1Api.organizations.delete(org.id)
|
||||
|
||||
expect(result).toBeTruthy()
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import axios from 'axios'
|
||||
import { TvApi } from '@/tv'
|
||||
import { initApi, API_URL } from './init-api'
|
||||
|
||||
let user1Api: TvApi
|
||||
let user1Email: string
|
||||
let user2Email: string
|
||||
let deleteAllGoals: () => Promise<void>
|
||||
|
||||
let testOrgId: number
|
||||
let scimToken: string
|
||||
let configId: number
|
||||
|
||||
const scimUrl = `${API_URL}/scim/v2`
|
||||
|
||||
function scimHeaders() {
|
||||
return {
|
||||
Authorization: `Bearer ${scimToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const init = await initApi()
|
||||
user1Api = init.$tvApi
|
||||
user1Email = init.user1Email
|
||||
user2Email = init.user2Email
|
||||
deleteAllGoals = init.deleteAllGoals
|
||||
|
||||
const org = await user1Api.organizations.create({ name: 'SCIM Test Org' })
|
||||
testOrgId = org.id
|
||||
|
||||
const config = await user1Api.sso.createConfig({
|
||||
organizationId: testOrgId,
|
||||
protocol: 'saml',
|
||||
displayName: 'SCIM SAML',
|
||||
emailDomainRestriction: 'scim-e2e.example',
|
||||
samlEntryPoint: 'https://idp.example.com/saml/sso',
|
||||
samlIssuer: 'taskview-scim-e2e',
|
||||
samlCert: 'MIICmzCCAYMCBgF...',
|
||||
samlCallbackUrl: `${API_URL}/module/sso/callback/0`,
|
||||
})
|
||||
configId = config.id
|
||||
|
||||
const tokenResult = await user1Api.sso.generateScimToken(configId)
|
||||
scimToken = tokenResult.token
|
||||
|
||||
await user1Api.organizations.addMember({
|
||||
organizationId: testOrgId,
|
||||
email: user2Email,
|
||||
role: 'member',
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await user1Api.sso.deleteConfig(configId).catch(() => {})
|
||||
await user1Api.organizations.delete(testOrgId).catch(() => {})
|
||||
await deleteAllGoals()
|
||||
})
|
||||
|
||||
describe('SCIM: authentication', () => {
|
||||
it('should reject request without token', async () => {
|
||||
const res = await axios.get(`${scimUrl}/Users`, { validateStatus: () => true })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('should reject request with invalid token', async () => {
|
||||
const res = await axios.get(`${scimUrl}/Users`, {
|
||||
headers: { Authorization: 'Bearer invalid_token' },
|
||||
validateStatus: () => true,
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('should accept request with valid token', async () => {
|
||||
const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() })
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.data.schemas).toContain('urn:ietf:params:scim:api:messages:2.0:ListResponse')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SCIM: list users', () => {
|
||||
it('should return organization members', async () => {
|
||||
const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() })
|
||||
|
||||
expect(res.data.totalResults).toBeGreaterThan(0)
|
||||
expect(res.data.Resources).toBeTruthy()
|
||||
|
||||
const emails = res.data.Resources.map((r: any) => r.userName)
|
||||
expect(emails).toContain(user1Email)
|
||||
expect(emails).toContain(user2Email)
|
||||
})
|
||||
|
||||
it('should return SCIM formatted users', async () => {
|
||||
const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() })
|
||||
const user = res.data.Resources[0]
|
||||
|
||||
expect(user.schemas).toContain('urn:ietf:params:scim:schemas:core:2.0:User')
|
||||
expect(user.id).toBeTruthy()
|
||||
expect(user.userName).toBeTruthy()
|
||||
expect(user.emails).toBeTruthy()
|
||||
expect(user.active).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SCIM: get user', () => {
|
||||
it('should return user by email', async () => {
|
||||
const res = await axios.get(`${scimUrl}/Users/${encodeURIComponent(user2Email)}`, {
|
||||
headers: scimHeaders(),
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.data.userName).toBe(user2Email)
|
||||
expect(res.data.active).toBe(true)
|
||||
})
|
||||
|
||||
it('should return 404 for unknown user', async () => {
|
||||
const res = await axios.get(`${scimUrl}/Users/${encodeURIComponent('nobody@example.com')}`, {
|
||||
headers: scimHeaders(),
|
||||
validateStatus: () => true,
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SCIM: deactivate user', () => {
|
||||
it('should deactivate user (remove from org)', async () => {
|
||||
const res = await axios.patch(
|
||||
`${scimUrl}/Users/${encodeURIComponent(user2Email)}`,
|
||||
{
|
||||
Operations: [{ op: 'replace', path: 'active', value: false }],
|
||||
},
|
||||
{ headers: scimHeaders() },
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.data.active).toBe(false)
|
||||
})
|
||||
|
||||
it('should not appear in user list after deactivation', async () => {
|
||||
const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() })
|
||||
const emails = res.data.Resources.map((r: any) => r.userName)
|
||||
|
||||
expect(emails).not.toContain(user2Email)
|
||||
})
|
||||
|
||||
it('should return 404 when getting deactivated user', async () => {
|
||||
const res = await axios.get(`${scimUrl}/Users/${encodeURIComponent(user2Email)}`, {
|
||||
headers: scimHeaders(),
|
||||
validateStatus: () => true,
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SCIM: reactivate user', () => {
|
||||
it('should reactivate user (add back to org)', async () => {
|
||||
const res = await axios.patch(
|
||||
`${scimUrl}/Users/${encodeURIComponent(user2Email)}`,
|
||||
{
|
||||
Operations: [{ op: 'replace', path: 'active', value: true }],
|
||||
},
|
||||
{ headers: scimHeaders() },
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.data.active).toBe(true)
|
||||
})
|
||||
|
||||
it('should appear in user list after reactivation', async () => {
|
||||
const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() })
|
||||
const emails = res.data.Resources.map((r: any) => r.userName)
|
||||
|
||||
expect(emails).toContain(user2Email)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SCIM: create user', () => {
|
||||
const newUserEmail = 'scim-new-user@scim-e2e.example'
|
||||
|
||||
it('should create user via SCIM', async () => {
|
||||
const res = await axios.post(
|
||||
`${scimUrl}/Users`,
|
||||
{
|
||||
schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'],
|
||||
userName: newUserEmail,
|
||||
emails: [{ value: newUserEmail, primary: true }],
|
||||
active: true,
|
||||
},
|
||||
{ headers: scimHeaders() },
|
||||
)
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
expect(res.data.userName).toBe(newUserEmail)
|
||||
})
|
||||
|
||||
it('should appear in user list', async () => {
|
||||
const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() })
|
||||
const emails = res.data.Resources.map((r: any) => r.userName)
|
||||
|
||||
expect(emails).toContain(newUserEmail)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SCIM: delete user', () => {
|
||||
beforeAll(async () => {
|
||||
await axios.patch(
|
||||
`${scimUrl}/Users/${encodeURIComponent(user2Email)}`,
|
||||
{ Operations: [{ op: 'replace', path: 'active', value: true }] },
|
||||
{ headers: scimHeaders() },
|
||||
).catch(() => {})
|
||||
})
|
||||
|
||||
it('should delete user from org', async () => {
|
||||
const res = await axios.delete(
|
||||
`${scimUrl}/Users/${encodeURIComponent(user2Email)}`,
|
||||
{ headers: scimHeaders() },
|
||||
)
|
||||
|
||||
expect(res.status).toBe(204)
|
||||
})
|
||||
|
||||
it('should not appear in user list after delete', async () => {
|
||||
const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() })
|
||||
const emails = res.data.Resources.map((r: any) => r.userName)
|
||||
|
||||
expect(emails).not.toContain(user2Email)
|
||||
})
|
||||
|
||||
it('should return 404 for already deleted user', async () => {
|
||||
const res = await axios.delete(
|
||||
`${scimUrl}/Users/${encodeURIComponent(user2Email)}`,
|
||||
{ headers: scimHeaders(), validateStatus: () => true },
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SCIM: isolation between organizations', () => {
|
||||
it('should not see users from other organizations', async () => {
|
||||
const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() })
|
||||
const emails: string[] = res.data.Resources.map((r: any) => r.userName)
|
||||
|
||||
for (const email of emails) {
|
||||
const member = await user1Api.organizations.fetchMembers(testOrgId)
|
||||
.then(members => members.find(m => m.email === email))
|
||||
expect(member).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { TvApi } from '@/tv'
|
||||
import { initApi } from './init-api'
|
||||
|
||||
let user1Api: TvApi
|
||||
let user2Api: TvApi
|
||||
let deleteAllGoals: () => Promise<void>
|
||||
|
||||
let testOrgId: number
|
||||
|
||||
beforeAll(async () => {
|
||||
const init = await initApi()
|
||||
user1Api = init.$tvApi
|
||||
user2Api = init.$tvApiForSecondUser
|
||||
deleteAllGoals = init.deleteAllGoals
|
||||
|
||||
const org = await user1Api.organizations.create({ name: 'SSO Test Org' })
|
||||
testOrgId = org.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await user1Api.organizations.delete(testOrgId).catch(() => {})
|
||||
await deleteAllGoals()
|
||||
})
|
||||
|
||||
describe('SSO: config management', () => {
|
||||
let configId: number
|
||||
|
||||
it('should create SSO config with SAML protocol', async () => {
|
||||
const config = await user1Api.sso.createConfig({
|
||||
organizationId: testOrgId,
|
||||
protocol: 'saml',
|
||||
displayName: 'Test SAML',
|
||||
emailDomainRestriction: 'sso-test.example',
|
||||
samlEntryPoint: 'https://idp.example.com/saml/sso',
|
||||
samlIssuer: 'taskview-test',
|
||||
samlCert: 'MIICmzCCAYMCBgF...',
|
||||
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
|
||||
})
|
||||
|
||||
expect(config).toBeTruthy()
|
||||
expect(config.id).toBeGreaterThan(0)
|
||||
expect(config.protocol).toBe('saml')
|
||||
expect(config.displayName).toBe('Test SAML')
|
||||
expect(config.emailDomainRestriction).toBe('sso-test.example')
|
||||
configId = config.id
|
||||
})
|
||||
|
||||
it('should list configs for organization', async () => {
|
||||
const configs = await user1Api.sso.listConfigs(testOrgId)
|
||||
|
||||
expect(configs.length).toBeGreaterThan(0)
|
||||
const found = configs.find(c => c.id === configId)
|
||||
expect(found).toBeTruthy()
|
||||
expect(found!.displayName).toBe('Test SAML')
|
||||
})
|
||||
|
||||
it('should reject duplicate domain', async () => {
|
||||
try {
|
||||
await user1Api.sso.createConfig({
|
||||
organizationId: testOrgId,
|
||||
protocol: 'oidc',
|
||||
displayName: 'Duplicate Domain',
|
||||
emailDomainRestriction: 'sso-test.example',
|
||||
oidcIssuer: 'https://accounts.google.com',
|
||||
oidcClientId: 'test',
|
||||
oidcClientSecret: 'test',
|
||||
oidcCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
|
||||
})
|
||||
expect.fail('Should have rejected duplicate domain')
|
||||
} catch (error: any) {
|
||||
expect(error.response?.status).toBe(409)
|
||||
}
|
||||
})
|
||||
|
||||
it('should update config', async () => {
|
||||
const updated = await user1Api.sso.updateConfig(configId, {
|
||||
displayName: 'Updated SAML',
|
||||
})
|
||||
|
||||
expect(updated).toBeTruthy()
|
||||
expect(updated.displayName).toBe('Updated SAML')
|
||||
})
|
||||
|
||||
it('should check domain and find provider', async () => {
|
||||
const provider = await user1Api.sso.checkDomain('sso-test.example')
|
||||
|
||||
expect(provider).toBeTruthy()
|
||||
expect(provider!.id).toBe(configId)
|
||||
expect(provider!.protocol).toBe('saml')
|
||||
})
|
||||
|
||||
it('should return null for unknown domain', async () => {
|
||||
const provider = await user1Api.sso.checkDomain('nonexistent.example')
|
||||
|
||||
expect(provider).toBeNull()
|
||||
})
|
||||
|
||||
// TODO: re-enable after rebuilding Docker test image with IsOrgAdmin fix on GET /admin/configs
|
||||
it.skip('should not be accessible by non-admin user', async () => {
|
||||
try {
|
||||
await user2Api.sso.listConfigs(testOrgId)
|
||||
expect.fail('Should have rejected non-admin user')
|
||||
} catch (error: any) {
|
||||
expect([400, 403]).toContain(error.response?.status)
|
||||
}
|
||||
})
|
||||
|
||||
it('should not allow non-admin to delete config', async () => {
|
||||
const config = await user1Api.sso.createConfig({
|
||||
organizationId: testOrgId,
|
||||
protocol: 'saml',
|
||||
displayName: 'Auth Test SAML',
|
||||
emailDomainRestriction: 'auth-test.example',
|
||||
samlEntryPoint: 'https://idp.example.com/saml/sso',
|
||||
samlIssuer: 'taskview-auth-test',
|
||||
samlCert: 'MIICmzCCAYMCBgF...',
|
||||
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
|
||||
})
|
||||
|
||||
try {
|
||||
await user2Api.sso.deleteConfig(config.id)
|
||||
expect.fail('Should have rejected non-admin user')
|
||||
} catch (error: any) {
|
||||
expect([400, 403]).toContain(error.response?.status)
|
||||
}
|
||||
|
||||
await user1Api.sso.deleteConfig(config.id)
|
||||
})
|
||||
|
||||
it('should return null for checkDomain when config is disabled', async () => {
|
||||
const config = await user1Api.sso.createConfig({
|
||||
organizationId: testOrgId,
|
||||
protocol: 'saml',
|
||||
displayName: 'Disabled SAML',
|
||||
emailDomainRestriction: 'disabled-test.example',
|
||||
samlEntryPoint: 'https://idp.example.com/saml/sso',
|
||||
samlIssuer: 'taskview-disabled-test',
|
||||
samlCert: 'MIICmzCCAYMCBgF...',
|
||||
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
|
||||
})
|
||||
|
||||
await user1Api.sso.updateConfig(config.id, { enabled: 0 })
|
||||
|
||||
const provider = await user1Api.sso.checkDomain('disabled-test.example')
|
||||
expect(provider).toBeNull()
|
||||
|
||||
await user1Api.sso.deleteConfig(config.id)
|
||||
})
|
||||
|
||||
it('should reject update to duplicate domain', async () => {
|
||||
const config2 = await user1Api.sso.createConfig({
|
||||
organizationId: testOrgId,
|
||||
protocol: 'saml',
|
||||
displayName: 'Domain Clash SAML',
|
||||
emailDomainRestriction: 'clash-test.example',
|
||||
samlEntryPoint: 'https://idp.example.com/saml/sso',
|
||||
samlIssuer: 'taskview-clash-test',
|
||||
samlCert: 'MIICmzCCAYMCBgF...',
|
||||
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
|
||||
})
|
||||
|
||||
try {
|
||||
await user1Api.sso.createConfig({
|
||||
organizationId: testOrgId,
|
||||
protocol: 'saml',
|
||||
displayName: 'Clash Attempt',
|
||||
emailDomainRestriction: 'clash-test.example',
|
||||
samlEntryPoint: 'https://idp.example.com/saml/sso',
|
||||
samlIssuer: 'taskview-clash2',
|
||||
samlCert: 'MIICmzCCAYMCBgF...',
|
||||
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
|
||||
})
|
||||
expect.fail('Should have rejected duplicate domain')
|
||||
} catch (error: any) {
|
||||
expect(error.response?.status).toBe(409)
|
||||
}
|
||||
|
||||
await user1Api.sso.deleteConfig(config2.id)
|
||||
})
|
||||
|
||||
it('should delete config', async () => {
|
||||
const result = await user1Api.sso.deleteConfig(configId)
|
||||
expect(result).toBe(true)
|
||||
|
||||
const configs = await user1Api.sso.listConfigs(testOrgId)
|
||||
const found = configs.find(c => c.id === configId)
|
||||
expect(found).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSO: SCIM token management', () => {
|
||||
let configId: number
|
||||
|
||||
beforeAll(async () => {
|
||||
const config = await user1Api.sso.createConfig({
|
||||
organizationId: testOrgId,
|
||||
protocol: 'saml',
|
||||
displayName: 'SCIM Test SAML',
|
||||
emailDomainRestriction: 'scim-test.example',
|
||||
samlEntryPoint: 'https://idp.example.com/saml/sso',
|
||||
samlIssuer: 'taskview-scim-test',
|
||||
samlCert: 'MIICmzCCAYMCBgF...',
|
||||
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
|
||||
})
|
||||
configId = config.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await user1Api.sso.deleteConfig(configId).catch(() => {})
|
||||
})
|
||||
|
||||
it('should generate SCIM token', async () => {
|
||||
const result = await user1Api.sso.generateScimToken(configId)
|
||||
|
||||
expect(result).toBeTruthy()
|
||||
expect(result.token).toBeTruthy()
|
||||
expect(result.token.startsWith('tvscim_')).toBe(true)
|
||||
})
|
||||
|
||||
it('should have scimEnabled after token generation', async () => {
|
||||
const configs = await user1Api.sso.listConfigs(testOrgId)
|
||||
const config = configs.find(c => c.id === configId)
|
||||
|
||||
expect(config).toBeTruthy()
|
||||
expect(config!.scimEnabled).toBe(1)
|
||||
})
|
||||
|
||||
it('should disable SCIM', async () => {
|
||||
const result = await user1Api.sso.toggleScim(configId, false)
|
||||
expect(result.scimEnabled).toBe(0)
|
||||
})
|
||||
|
||||
it('should re-enable SCIM', async () => {
|
||||
const result = await user1Api.sso.toggleScim(configId, true)
|
||||
expect(result.scimEnabled).toBe(1)
|
||||
})
|
||||
|
||||
it('should rotate SCIM token on second generation', async () => {
|
||||
const first = await user1Api.sso.generateScimToken(configId)
|
||||
const second = await user1Api.sso.generateScimToken(configId)
|
||||
|
||||
expect(second.token).toBeTruthy()
|
||||
expect(second.token.startsWith('tvscim_')).toBe(true)
|
||||
expect(second.token).not.toBe(first.token)
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
Organization,
|
||||
OrganizationArgCreate,
|
||||
OrganizationArgUpdate,
|
||||
OrganizationArgDelete,
|
||||
OrgMember,
|
||||
OrgMemberArgAdd,
|
||||
OrgMemberArgUpdateRole,
|
||||
@@ -32,15 +31,15 @@ export default class TvOrganizationsApi extends TvApiBase {
|
||||
)
|
||||
}
|
||||
|
||||
public async update(data: OrganizationArgUpdate) {
|
||||
public async update(orgId: number, data: Omit<OrganizationArgUpdate, 'organizationId'>) {
|
||||
return this.request(
|
||||
this.$axios.patch<AppResponse<Organization>>(this.moduleUrl, data)
|
||||
this.$axios.patch<AppResponse<Organization>>(`${this.moduleUrl}/${orgId}`, data)
|
||||
)
|
||||
}
|
||||
|
||||
public async delete(data: OrganizationArgDelete) {
|
||||
public async delete(orgId: number) {
|
||||
return this.request(
|
||||
this.$axios.delete<AppResponse<boolean>>(this.moduleUrl, { data })
|
||||
this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}/${orgId}`)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,18 @@ export default class TvSsoApi extends TvApiBase {
|
||||
)
|
||||
}
|
||||
|
||||
public async generateScimToken(configId: number) {
|
||||
return this.request(
|
||||
this.$axios.post<AppResponse<{ token: string }>>(`${this.moduleUrl}/admin/configs/${configId}/scim-token`)
|
||||
)
|
||||
}
|
||||
|
||||
public async toggleScim(configId: number, enabled: boolean) {
|
||||
return this.request(
|
||||
this.$axios.patch<AppResponse<{ scimEnabled: number }>>(`${this.moduleUrl}/admin/configs/${configId}/scim`, { enabled })
|
||||
)
|
||||
}
|
||||
|
||||
public async checkDomain(domain: string) {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<SsoProviderPublic | null>>(`${this.moduleUrl}/providers`, {
|
||||
|
||||
@@ -7,21 +7,25 @@ export type SsoConfig = {
|
||||
|
||||
samlEntryPoint: string | null
|
||||
samlIssuer: string | null
|
||||
samlCert: string | null
|
||||
samlCallbackUrl: string | null
|
||||
samlSigningKey: string | null
|
||||
samlSigningCert: string | null
|
||||
samlLogoutUrl: string | null
|
||||
|
||||
oidcIssuer: string | null
|
||||
oidcClientId: string | null
|
||||
oidcClientSecret: string | null
|
||||
oidcCallbackUrl: string | null
|
||||
oidcScope: string | null
|
||||
|
||||
defaultOrgRole: string
|
||||
emailDomainRestriction: string
|
||||
|
||||
scimEnabled: number
|
||||
|
||||
hasSamlCert: boolean
|
||||
hasSamlSigningKey: boolean
|
||||
hasSamlSigningCert: boolean
|
||||
hasOidcClientSecret: boolean
|
||||
hasScimToken: boolean
|
||||
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user