From 9c575accd2fb2f6b78cd69450a7b18d57b16f5a9 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Sun, 4 Jan 2026 19:07:46 +0000 Subject: [PATCH 1/4] Remove QEMU setup from Quay workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use native ARM64 runner for ARM64 builds instead of QEMU emulation, which is faster and more reliable. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/quay.yml | 161 +++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 .github/workflows/quay.yml diff --git a/.github/workflows/quay.yml b/.github/workflows/quay.yml new file mode 100644 index 000000000..7fba6ed55 --- /dev/null +++ b/.github/workflows/quay.yml @@ -0,0 +1,161 @@ +name: Push to Quay.io + +on: + push: + tags: + - '*' + +env: + REGISTRY: quay.io + REGISTRY_IMAGE: quay.io/rocketadmin/rocketadmin + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.platforms.outputs.matrix }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Create matrix + id: platforms + run: | + echo "matrix=$(docker buildx bake image-all -f docker-bake.hcl --print | jq -cr '.target."image-all".platforms')" >>${GITHUB_OUTPUT} + + - name: Show matrix + run: | + echo ${{ steps.platforms.outputs.matrix }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY_IMAGE }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=ref,event=tag + + - name: Rename meta bake definition file + run: | + mv "${{ steps.meta.outputs.bake-file }}" "/tmp/bake-meta.json" + + - name: Upload meta bake definition + uses: actions/upload-artifact@v4 + with: + name: bake-meta + path: /tmp/bake-meta.json + if-no-files-found: error + retention-days: 1 + + build: + permissions: + id-token: write + contents: read + attestations: write + runs-on: ${{ contains(matrix.platform, 'arm') && 'arm64' || 'ubuntu-latest' }} + needs: + - prepare + strategy: + fail-fast: false + matrix: + platform: ${{ fromJson(needs.prepare.outputs.matrix) }} + steps: + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Checkout + uses: actions/checkout@v4 + + - name: Download meta bake definition + uses: actions/download-artifact@v4 + with: + name: bake-meta + path: /tmp + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Quay.io + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.QUAY_USERNAME }} + password: ${{ secrets.QUAY_PASSWORD }} + + - name: Build + id: bake + uses: docker/bake-action@v5 + with: + sbom: true + files: | + ./docker-bake.hcl + /tmp/bake-meta.json + targets: image + set: | + *.tags= + *.platform=${{ matrix.platform }} + *.output=type=image,"name=${{ env.REGISTRY_IMAGE }}",push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ fromJSON(steps.bake.outputs.metadata).image['containerimage.digest'] }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + - name: Attest Build Provenance + uses: actions/attest-build-provenance@v1 + with: + subject-digest: "${{ fromJSON(steps.bake.outputs.metadata).image['containerimage.digest'] }}" + push-to-registry: false + subject-name: ${{ env.REGISTRY_IMAGE }} + + merge: + runs-on: ubuntu-latest + needs: + - build + steps: + - name: Download meta bake definition + uses: actions/download-artifact@v4 + with: + name: bake-meta + path: /tmp + + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Quay.io + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.QUAY_USERNAME }} + password: ${{ secrets.QUAY_PASSWORD }} + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.target."docker-metadata-action".tags | map(select(startswith("${{ env.REGISTRY_IMAGE }}")) | "-t " + .) | join(" ")' /tmp/bake-meta.json) \ + $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:$(jq -r '.target."docker-metadata-action".args.DOCKER_META_VERSION' /tmp/bake-meta.json) From 72642ca884f5d28fef4df1cc4b5bc5d4759feffb Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Mon, 5 Jan 2026 10:17:01 +0000 Subject: [PATCH 2/4] Fix flaky tests caused by unregistered custom SVG icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add MatIconTestingModule to tests using components with custom SVG icons (ai_rocket, github). Icons registered in AppComponent weren't available in unit tests, causing intermittent "Unable to find icon" errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../db-table-ai-panel.component.spec.ts | 49 +- .../db-table-view.component.spec.ts | 463 +++++++++--------- .../components/login/login.component.spec.ts | 107 ++-- .../registration.component.spec.ts | 105 ++-- 4 files changed, 359 insertions(+), 365 deletions(-) diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.spec.ts index 41794ce11..2df7f8e36 100644 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.spec.ts +++ b/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.spec.ts @@ -1,38 +1,31 @@ +import { provideHttpClient } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { DbTableAiPanelComponent } from './db-table-ai-panel.component'; +import { MatIconTestingModule } from '@angular/material/icon/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { Angulartics2Module } from 'angulartics2'; import { MarkdownService } from 'ngx-markdown'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideHttpClient } from '@angular/common/http'; +import { DbTableAiPanelComponent } from './db-table-ai-panel.component'; describe('DbTableAiPanelComponent', () => { - let component: DbTableAiPanelComponent; - let fixture: ComponentFixture; + let component: DbTableAiPanelComponent; + let fixture: ComponentFixture; - const mockMarkdownService = { - parse: jasmine.createSpy('parse').and.returnValue('parsed markdown'), - }; + const mockMarkdownService = { + parse: jasmine.createSpy('parse').and.returnValue('parsed markdown'), + }; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - Angulartics2Module.forRoot(), - DbTableAiPanelComponent, - BrowserAnimationsModule - ], - providers: [ - provideHttpClient(), - { provide: MarkdownService, useValue: mockMarkdownService }, - ] - }).compileComponents(); + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Angulartics2Module.forRoot(), DbTableAiPanelComponent, BrowserAnimationsModule, MatIconTestingModule], + providers: [provideHttpClient(), { provide: MarkdownService, useValue: mockMarkdownService }], + }).compileComponents(); - fixture = TestBed.createComponent(DbTableAiPanelComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); + fixture = TestBed.createComponent(DbTableAiPanelComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); - it('should create', () => { - expect(component).toBeTruthy(); - }); + it('should create', () => { + expect(component).toBeTruthy(); + }); }); diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.spec.ts index 854571086..3fa3698a6 100644 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.spec.ts +++ b/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.spec.ts @@ -1,241 +1,244 @@ +import { SelectionModel } from '@angular/cdk/collections'; +import { provideHttpClient } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { Angulartics2Module } from 'angulartics2'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { DbTableViewComponent } from './db-table-view.component'; import { FormsModule } from '@angular/forms'; import { MatDialogModule } from '@angular/material/dialog'; +import { MatIconTestingModule } from '@angular/material/icon/testing'; import { MatMenuModule } from '@angular/material/menu'; import { MatPaginatorModule } from '@angular/material/paginator'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSortModule } from '@angular/material/sort'; -import { SelectionModel } from '@angular/cdk/collections'; -import { TablesDataSource } from '../db-tables-data-source'; -import { provideHttpClient } from '@angular/common/http'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; +import { Angulartics2Module } from 'angulartics2'; +import { TablesDataSource } from '../db-tables-data-source'; +import { DbTableViewComponent } from './db-table-view.component'; describe('DbTableViewComponent', () => { - let component: DbTableViewComponent; - let fixture: ComponentFixture; - - const _mockWidgets = { - "Region": { - "id": "c768dde8-7348-46e8-a522-718a29b705e8", - "field_name": "Region", - "widget_type": "Select", - "widget_params": { - options: [ - { - value: 'AK', - label: 'Alaska' - }, - { - value: 'CA', - label: 'California' - } - ] - }, - "widget_options": null, - "name": "State", - "description": "" - }, - "address_id": { - "id": "ee3125ca-86cc-4c20-93f9-8a0b2c43d61f", - "field_name": "address_id", - "widget_type": "String", - "widget_params": "", - "widget_options": null, - "name": "", - "description": "" - } - } - - const _mockSelectOption = { - "Region": [ - { - "value": "AK", - "label": "Alaska" - }, - { - "value": "CA", - "label": "California" - } - ] - } - - const mockFilterComparators = { - "first_name": "startswith", - "second_name": "endswith", - "email": "contains", - "age": "gt" - } - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - MatMenuModule, - MatSnackBarModule, - MatPaginatorModule, - BrowserAnimationsModule, - MatSortModule, - FormsModule, - MatDialogModule, - Angulartics2Module.forRoot({}), - DbTableViewComponent - ], - providers: [provideHttpClient(), provideRouter([])] - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DbTableViewComponent); - component = fixture.componentInstance; - component.table = new TablesDataSource({} as any, {} as any, {} as any, {} as any); - component.selection = new SelectionModel(true, []); - component.filterComparators = mockFilterComparators; - fixture.autoDetectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should check if column is foreign key', () => { - component.tableData.foreignKeysList = ['ProductId', 'CustomerId']; - const isForeignKeyResult = component.isForeignKey('ProductId') - expect(isForeignKeyResult).toBeTrue(); - }); - - it('should return query params for link for foreign key', () => { - const foreignKey = { - autocomplete_columns: [ "FirstName" ], - column_name: "CustomerId", - column_default: null, - constraint_name: "Orders_ibfk_2", - referenced_column_name: "Id", - referenced_table_name: "Customers" - }; - - const cell = { - Id: 42 - }; - - const queryParams = component.getForeignKeyQueryParams(foreignKey, cell) - expect(queryParams).toEqual({ Id: 42 }); - }); - - it('should check if it is widget by column name', () => { - component.tableData.widgetsList = ['Name', 'Age']; - - const isWigetAge = component.isWidget('Age'); - - expect(isWigetAge).toBeTrue(); - }); - - it('should return 2 for active filters with object of two fileds', () => { - const numberOfFilters = component.getFiltersCount({ "Country": "С", "Name": "John"}) - - expect(numberOfFilters).toEqual(2); - }); - - it('should emit open filters actions to parent', () => { - const mockStructure = [{ - "column_name": "first_name", - "column_default": null, - "data_type": "character varying", - "isExcluded": false, - "isSearched": false, - "auto_increment": false, - "allow_null": true, - "character_maximum_length": 45 - }]; - const mockForeignKeysList = []; - const mockForeignKeys = {}; - const mockWidgets = []; - component.tableData.structure = mockStructure; - component.tableData.foreignKeysList = mockForeignKeysList; - component.tableData.foreignKeys = mockForeignKeys; - component.tableData.widgets = mockWidgets; - spyOn(component.openFilters, 'emit'); - - component.handleOpenFilters(); - - expect(component.openFilters.emit).toHaveBeenCalledWith({ - structure: mockStructure, - foreignKeysList: mockForeignKeysList, - foreignKeys: mockForeignKeys, - widgets: mockWidgets - }); - expect(component.searchString).toEqual(''); - }); - - it('should clear search string and emit search by string to parent', () => { - spyOn(component.search, 'emit'); - - component.clearSearch(); - - expect(component.searchString).toBeNull(); - expect(component.search.emit).toHaveBeenCalledWith(null); - }); - - it('should return filter chip label for starts with comparator', () => { - const chipFilterLable = component.getFilter({key: 'first_name', value: {startswith: 'A'}}); - - expect(chipFilterLable).toEqual('First Names = A...'); - }); - - it('should return filter chip label for ends with comparator', () => { - const chipFilterLable = component.getFilter({key: 'second_name', value: {endswith: 'y'}}); - - expect(chipFilterLable).toEqual('Second Names = ...y'); - }); - - it('should return filter chip label for contains comparator', () => { - const chipFilterLable = component.getFilter({key: 'email', value: {contains: 'gmail'}}); - - expect(chipFilterLable).toEqual('Emails = ...gmail...'); - }); - - it('should return filter chip label for greater than comparator', () => { - const chipFilterLable = component.getFilter({key: 'age', value: {gt: '20'}}); - - expect(chipFilterLable).toEqual('Ages > 20'); - }); - - it('should return human readable value of foreign key if identitty field was not pointed', () => { - const foreignKey = { - autocomplete_columns: [ "FirstName" ], - column_name: "CustomerId", - column_default: null, - constraint_name: "Orders_ibfk_2", - referenced_column_name: "Id", - referenced_table_name: "Customers" - }; - - const cell = { - Id: 42 - }; - - const value = component.getCellValue(foreignKey, cell) - expect(value).toEqual( 42 ); - }); - - it('should return human readable value of foreign key if identity field was pointed', () => { - const foreignKey = { - autocomplete_columns: [ "FirstName" ], - column_name: "CustomerId", - column_default: null, - constraint_name: "Orders_ibfk_2", - referenced_column_name: "Id", - referenced_table_name: "Customers" - }; - - const cell = { - Id: 42, - name: 'John' - }; - - const value = component.getCellValue(foreignKey, cell) - expect(value).toEqual( 'John' ); - }); + let component: DbTableViewComponent; + let fixture: ComponentFixture; + + const _mockWidgets = { + Region: { + id: 'c768dde8-7348-46e8-a522-718a29b705e8', + field_name: 'Region', + widget_type: 'Select', + widget_params: { + options: [ + { + value: 'AK', + label: 'Alaska', + }, + { + value: 'CA', + label: 'California', + }, + ], + }, + widget_options: null, + name: 'State', + description: '', + }, + address_id: { + id: 'ee3125ca-86cc-4c20-93f9-8a0b2c43d61f', + field_name: 'address_id', + widget_type: 'String', + widget_params: '', + widget_options: null, + name: '', + description: '', + }, + }; + + const _mockSelectOption = { + Region: [ + { + value: 'AK', + label: 'Alaska', + }, + { + value: 'CA', + label: 'California', + }, + ], + }; + + const mockFilterComparators = { + first_name: 'startswith', + second_name: 'endswith', + email: 'contains', + age: 'gt', + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + MatMenuModule, + MatSnackBarModule, + MatPaginatorModule, + BrowserAnimationsModule, + MatSortModule, + FormsModule, + MatDialogModule, + MatIconTestingModule, + Angulartics2Module.forRoot({}), + DbTableViewComponent, + ], + providers: [provideHttpClient(), provideRouter([])], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DbTableViewComponent); + component = fixture.componentInstance; + component.table = new TablesDataSource({} as any, {} as any, {} as any, {} as any); + component.selection = new SelectionModel(true, []); + component.filterComparators = mockFilterComparators; + fixture.autoDetectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should check if column is foreign key', () => { + component.tableData.foreignKeysList = ['ProductId', 'CustomerId']; + const isForeignKeyResult = component.isForeignKey('ProductId'); + expect(isForeignKeyResult).toBeTrue(); + }); + + it('should return query params for link for foreign key', () => { + const foreignKey = { + autocomplete_columns: ['FirstName'], + column_name: 'CustomerId', + column_default: null, + constraint_name: 'Orders_ibfk_2', + referenced_column_name: 'Id', + referenced_table_name: 'Customers', + }; + + const cell = { + Id: 42, + }; + + const queryParams = component.getForeignKeyQueryParams(foreignKey, cell); + expect(queryParams).toEqual({ Id: 42 }); + }); + + it('should check if it is widget by column name', () => { + component.tableData.widgetsList = ['Name', 'Age']; + + const isWigetAge = component.isWidget('Age'); + + expect(isWigetAge).toBeTrue(); + }); + + it('should return 2 for active filters with object of two fileds', () => { + const numberOfFilters = component.getFiltersCount({ Country: 'С', Name: 'John' }); + + expect(numberOfFilters).toEqual(2); + }); + + it('should emit open filters actions to parent', () => { + const mockStructure = [ + { + column_name: 'first_name', + column_default: null, + data_type: 'character varying', + isExcluded: false, + isSearched: false, + auto_increment: false, + allow_null: true, + character_maximum_length: 45, + }, + ]; + const mockForeignKeysList = []; + const mockForeignKeys = {}; + const mockWidgets = []; + component.tableData.structure = mockStructure; + component.tableData.foreignKeysList = mockForeignKeysList; + component.tableData.foreignKeys = mockForeignKeys; + component.tableData.widgets = mockWidgets; + spyOn(component.openFilters, 'emit'); + + component.handleOpenFilters(); + + expect(component.openFilters.emit).toHaveBeenCalledWith({ + structure: mockStructure, + foreignKeysList: mockForeignKeysList, + foreignKeys: mockForeignKeys, + widgets: mockWidgets, + }); + expect(component.searchString).toEqual(''); + }); + + it('should clear search string and emit search by string to parent', () => { + spyOn(component.search, 'emit'); + + component.clearSearch(); + + expect(component.searchString).toBeNull(); + expect(component.search.emit).toHaveBeenCalledWith(null); + }); + + it('should return filter chip label for starts with comparator', () => { + const chipFilterLable = component.getFilter({ key: 'first_name', value: { startswith: 'A' } }); + + expect(chipFilterLable).toEqual('First Names = A...'); + }); + + it('should return filter chip label for ends with comparator', () => { + const chipFilterLable = component.getFilter({ key: 'second_name', value: { endswith: 'y' } }); + + expect(chipFilterLable).toEqual('Second Names = ...y'); + }); + + it('should return filter chip label for contains comparator', () => { + const chipFilterLable = component.getFilter({ key: 'email', value: { contains: 'gmail' } }); + + expect(chipFilterLable).toEqual('Emails = ...gmail...'); + }); + + it('should return filter chip label for greater than comparator', () => { + const chipFilterLable = component.getFilter({ key: 'age', value: { gt: '20' } }); + + expect(chipFilterLable).toEqual('Ages > 20'); + }); + + it('should return human readable value of foreign key if identitty field was not pointed', () => { + const foreignKey = { + autocomplete_columns: ['FirstName'], + column_name: 'CustomerId', + column_default: null, + constraint_name: 'Orders_ibfk_2', + referenced_column_name: 'Id', + referenced_table_name: 'Customers', + }; + + const cell = { + Id: 42, + }; + + const value = component.getCellValue(foreignKey, cell); + expect(value).toEqual(42); + }); + + it('should return human readable value of foreign key if identity field was pointed', () => { + const foreignKey = { + autocomplete_columns: ['FirstName'], + column_name: 'CustomerId', + column_default: null, + constraint_name: 'Orders_ibfk_2', + referenced_column_name: 'Id', + referenced_table_name: 'Customers', + }; + + const cell = { + Id: 42, + name: 'John', + }; + + const value = component.getCellValue(foreignKey, cell); + expect(value).toEqual('John'); + }); }); diff --git a/frontend/src/app/components/login/login.component.spec.ts b/frontend/src/app/components/login/login.component.spec.ts index 304e0f304..442551d5e 100644 --- a/frontend/src/app/components/login/login.component.spec.ts +++ b/frontend/src/app/components/login/login.component.spec.ts @@ -1,68 +1,69 @@ +import { provideHttpClient } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; - +import { FormsModule } from '@angular/forms'; +import { MatIconTestingModule } from '@angular/material/icon/testing'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { provideRouter } from '@angular/router'; import { Angulartics2Module } from 'angulartics2'; +import { of } from 'rxjs'; import { AuthService } from 'src/app/services/auth.service'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { CompanyService } from 'src/app/services/company.service'; -import { FormsModule } from '@angular/forms'; import { LoginComponent } from './login.component'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { of } from 'rxjs'; -import { provideHttpClient } from '@angular/common/http'; -import { provideRouter } from '@angular/router'; describe('LoginComponent', () => { - let component: LoginComponent; - let fixture: ComponentFixture; - let authService: AuthService; - let companyService: CompanyService; + let component: LoginComponent; + let fixture: ComponentFixture; + let authService: AuthService; + let companyService: CompanyService; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - FormsModule, - MatSnackBarModule, - Angulartics2Module.forRoot(), - LoginComponent, - BrowserAnimationsModule - ], - providers: [provideHttpClient(), provideRouter([])] - }).compileComponents(); + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + FormsModule, + MatSnackBarModule, + MatIconTestingModule, + Angulartics2Module.forRoot(), + LoginComponent, + BrowserAnimationsModule, + ], + providers: [provideHttpClient(), provideRouter([])], + }).compileComponents(); - global.window.google = jasmine.createSpyObj(['accounts']); - // @ts-expect-error - global.window.google.accounts = jasmine.createSpyObj(['id']); - global.window.google.accounts.id = jasmine.createSpyObj(['initialize', 'renderButton', 'prompt']); - }); + global.window.google = jasmine.createSpyObj(['accounts']); + // @ts-expect-error + global.window.google.accounts = jasmine.createSpyObj(['id']); + global.window.google.accounts.id = jasmine.createSpyObj(['initialize', 'renderButton', 'prompt']); + }); - beforeEach(() => { - fixture = TestBed.createComponent(LoginComponent); - component = fixture.componentInstance; - authService = TestBed.inject(AuthService); - companyService = TestBed.inject(CompanyService); - spyOn(companyService, 'isCustomDomain').and.returnValue(false); - fixture.detectChanges(); - }); + beforeEach(() => { + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + authService = TestBed.inject(AuthService); + companyService = TestBed.inject(CompanyService); + spyOn(companyService, 'isCustomDomain').and.returnValue(false); + fixture.detectChanges(); + }); - it('should create', () => { - expect(component).toBeTruthy(); - }); + it('should create', () => { + expect(component).toBeTruthy(); + }); - it('should login a user', () => { - component.user = { - email: 'john@smith.com', - password: 'kK123456789', - companyId: 'company_1' - } + it('should login a user', () => { + component.user = { + email: 'john@smith.com', + password: 'kK123456789', + companyId: 'company_1', + }; - const fakeLoginUser = spyOn(authService, 'loginUser').and.returnValue(of()); + const fakeLoginUser = spyOn(authService, 'loginUser').and.returnValue(of()); - component.loginUser(); - expect(fakeLoginUser).toHaveBeenCalledOnceWith({ - email: 'john@smith.com', - password: 'kK123456789', - companyId: 'company_1' - }); - expect(component.submitting).toBeFalse(); - }); + component.loginUser(); + expect(fakeLoginUser).toHaveBeenCalledOnceWith({ + email: 'john@smith.com', + password: 'kK123456789', + companyId: 'company_1', + }); + expect(component.submitting).toBeFalse(); + }); }); diff --git a/frontend/src/app/components/registration/registration.component.spec.ts b/frontend/src/app/components/registration/registration.component.spec.ts index 30f296544..41e7be1ec 100644 --- a/frontend/src/app/components/registration/registration.component.spec.ts +++ b/frontend/src/app/components/registration/registration.component.spec.ts @@ -1,70 +1,67 @@ +import { provideHttpClient } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { Angulartics2Module } from 'angulartics2'; -import { AuthService } from 'src/app/services/auth.service'; import { FormsModule } from '@angular/forms'; +import { MatIconTestingModule } from '@angular/material/icon/testing'; import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { RegistrationComponent } from './registration.component'; -import { of } from 'rxjs'; -import { IPasswordStrengthMeterService } from 'angular-password-strength-meter'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideHttpClient } from '@angular/common/http'; import { provideRouter } from '@angular/router'; +import { IPasswordStrengthMeterService } from 'angular-password-strength-meter'; +import { Angulartics2Module } from 'angulartics2'; +import { of } from 'rxjs'; +import { AuthService } from 'src/app/services/auth.service'; +import { RegistrationComponent } from './registration.component'; describe('RegistrationComponent', () => { - let component: RegistrationComponent; - let fixture: ComponentFixture; - let authService: AuthService; + let component: RegistrationComponent; + let fixture: ComponentFixture; + let authService: AuthService; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - FormsModule, - MatSnackBarModule, - Angulartics2Module.forRoot(), - BrowserAnimationsModule, - RegistrationComponent - ], - providers: [ - provideHttpClient(), - provideRouter([]), - { provide: IPasswordStrengthMeterService, useValue: {} } - ] - }).compileComponents(); + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + FormsModule, + MatSnackBarModule, + MatIconTestingModule, + Angulartics2Module.forRoot(), + BrowserAnimationsModule, + RegistrationComponent, + ], + providers: [provideHttpClient(), provideRouter([]), { provide: IPasswordStrengthMeterService, useValue: {} }], + }).compileComponents(); - // @ts-expect-error - global.window.gtag = jasmine.createSpy(); + // @ts-expect-error + global.window.gtag = jasmine.createSpy(); - global.window.google = jasmine.createSpyObj(['accounts']); - // @ts-expect-error - global.window.google.accounts = jasmine.createSpyObj(['id']); - global.window.google.accounts.id = jasmine.createSpyObj(['initialize', 'renderButton', 'prompt']); - }); + global.window.google = jasmine.createSpyObj(['accounts']); + // @ts-expect-error + global.window.google.accounts = jasmine.createSpyObj(['id']); + global.window.google.accounts.id = jasmine.createSpyObj(['initialize', 'renderButton', 'prompt']); + }); - beforeEach(() => { - fixture = TestBed.createComponent(RegistrationComponent); - component = fixture.componentInstance; - authService = TestBed.inject(AuthService); - fixture.detectChanges(); - }); + beforeEach(() => { + fixture = TestBed.createComponent(RegistrationComponent); + component = fixture.componentInstance; + authService = TestBed.inject(AuthService); + fixture.detectChanges(); + }); - it('should create', () => { - expect(component).toBeTruthy(); - }); + it('should create', () => { + expect(component).toBeTruthy(); + }); - it('should sign a user in', () => { - component.user = { - email: 'john@smith.com', - password: 'kK123456789' - } + it('should sign a user in', () => { + component.user = { + email: 'john@smith.com', + password: 'kK123456789', + }; - const fakeSignUpUser = spyOn(authService, 'signUpUser').and.returnValue(of()); + const fakeSignUpUser = spyOn(authService, 'signUpUser').and.returnValue(of()); - component.registerUser(); - expect(fakeSignUpUser).toHaveBeenCalledOnceWith({ - email: 'john@smith.com', - password: 'kK123456789' - }); - expect(component.submitting).toBeFalse(); - }); + component.registerUser(); + expect(fakeSignUpUser).toHaveBeenCalledOnceWith({ + email: 'john@smith.com', + password: 'kK123456789', + }); + expect(component.submitting).toBeFalse(); + }); }); From 1ca5caf0ab51d4e4fcb4be8c5c1c7226d1ea77a1 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Mon, 5 Jan 2026 10:32:25 +0000 Subject: [PATCH 3/4] fix test --- frontend/package.json | 177 ++--- .../edit-secret-dialog.component.spec.ts | 649 +++++++++--------- frontend/yarn.lock | 362 +++++++++- 3 files changed, 767 insertions(+), 421 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 66500e303..3e7fcbaa3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,90 +1,91 @@ { - "name": "dissendium-v0", - "version": "1.0.0", - "scripts": { - "ng": "ng", - "start": "node scripts/update-version.js && ng serve", - "build": "node scripts/update-version.js && ng build", - "test": "ng test", - "test:ci": "ng test --watch=false --browsers=ChromeHeadlessCustom", - "lint": "ng lint", - "e2e": "ng e2e", - "analyze": "webpack-bundle-analyzer dist/dissendium-v0/stats.json", - "build:stats": "node scripts/update-version.js && ng build --stats-json", - "update-version": "node scripts/update-version.js" - }, - "private": true, - "dependencies": { - "@angular/animations": "~19.2.14", - "@angular/cdk": "~19.2.14", - "@angular/common": "~19.2.14", - "@angular/compiler": "~19.2.14", - "@angular/core": "~19.2.14", - "@angular/forms": "~19.2.14", - "@angular/material": "~19.2.14", - "@angular/platform-browser": "~19.2.14", - "@angular/platform-browser-dynamic": "~19.2.14", - "@angular/router": "~19.2.14", - "@brumeilde/ngx-theme": "^1.2.1", - "@jsonurl/jsonurl": "^1.1.8", - "@ngstack/code-editor": "^9.0.0", - "@sentry-internal/rrweb": "^2.31.0", - "@sentry/angular-ivy": "^7.116.0", - "@sentry/tracing": "^7.116.0", - "@stripe/stripe-js": "^5.3.0", - "@types/google-one-tap": "^1.2.6", - "@types/lodash": "^4.17.13", - "@zxcvbn-ts/core": "^3.0.4", - "@zxcvbn-ts/language-en": "^3.0.2", - "amplitude-js": "^8.21.9", - "angular-password-strength-meter": "npm:@eresearchqut/angular-password-strength-meter@^13.0.7", - "angulartics2": "^14.1.0", - "color-string": "^2.0.1", - "convert": "^5.12.0", - "date-fns": "^4.1.0", - "ipaddr.js": "^2.2.0", - "json5": "^2.2.3", - "libphonenumber-js": "^1.12.9", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "mermaid": "^11.12.1", - "monaco-editor": "0.44.0", - "ng-dynamic-component": "^10.7.0", - "ngx-cookie-service": "^19.0.0", - "ngx-markdown": "^19.1.1", - "ngx-stripe": "^19.0.0", - "pluralize": "^8.0.0", - "postgres-interval": "^4.0.2", - "private-ip": "^3.0.2", - "puppeteer": "^24.29.1", - "rxjs": "^7.4.0", - "tslib": "^2.8.1", - "uuid": "^11.1.0", - "validator": "^13.15.20", - "zone.js": "~0.15.0" - }, - "devDependencies": { - "@angular-devkit/build-angular": "~19.2.19", - "@angular/cli": "~19.0.5", - "@angular/compiler-cli": "~19.0.4", - "@angular/language-service": "~19.0.4", - "@sentry-internal/rrweb": "^2.16.0", - "@types/jasmine": "~5.1.5", - "@types/jasminewd2": "~2.0.13", - "@types/node": "^22.10.2", - "jasmine-core": "~5.5.0", - "jasmine-spec-reporter": "~7.0.0", - "karma": "~6.4.4", - "karma-chrome-launcher": "~3.2.0", - "karma-coverage": "^2.2.1", - "karma-coverage-istanbul-reporter": "^3.0.3", - "karma-jasmine": "~5.1.0", - "karma-jasmine-html-reporter": "^2.1.0", - "ts-node": "~10.9.2", - "typescript": "~5.6.0" - }, - "resolutions": { - "mermaid": "^11.10.0" - }, - "packageManager": "yarn@1.22.22" + "name": "dissendium-v0", + "version": "1.0.0", + "scripts": { + "ng": "ng", + "start": "node scripts/update-version.js && ng serve", + "build": "node scripts/update-version.js && ng build", + "test": "ng test", + "test:ci": "ng test --watch=false --browsers=ChromeHeadlessCustom", + "lint": "ng lint", + "e2e": "ng e2e", + "analyze": "webpack-bundle-analyzer dist/dissendium-v0/stats.json", + "build:stats": "node scripts/update-version.js && ng build --stats-json", + "update-version": "node scripts/update-version.js" + }, + "private": true, + "dependencies": { + "@angular/animations": "~19.2.14", + "@angular/cdk": "~19.2.14", + "@angular/common": "~19.2.14", + "@angular/compiler": "~19.2.14", + "@angular/core": "~19.2.14", + "@angular/forms": "~19.2.14", + "@angular/material": "~19.2.14", + "@angular/platform-browser": "~19.2.14", + "@angular/platform-browser-dynamic": "~19.2.14", + "@angular/router": "~19.2.14", + "@brumeilde/ngx-theme": "^1.2.1", + "@jsonurl/jsonurl": "^1.1.8", + "@ngstack/code-editor": "^9.0.0", + "@sentry-internal/rrweb": "^2.31.0", + "@sentry/angular-ivy": "^7.116.0", + "@sentry/tracing": "^7.116.0", + "@stripe/stripe-js": "^5.3.0", + "@types/google-one-tap": "^1.2.6", + "@types/lodash": "^4.17.13", + "@zxcvbn-ts/core": "^3.0.4", + "@zxcvbn-ts/language-en": "^3.0.2", + "amplitude-js": "^8.21.9", + "angular-password-strength-meter": "npm:@eresearchqut/angular-password-strength-meter@^13.0.7", + "angulartics2": "^14.1.0", + "color-string": "^2.0.1", + "convert": "^5.12.0", + "date-fns": "^4.1.0", + "ipaddr.js": "^2.2.0", + "json5": "^2.2.3", + "knip": "^5.79.0", + "libphonenumber-js": "^1.12.9", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "mermaid": "^11.12.1", + "monaco-editor": "0.44.0", + "ng-dynamic-component": "^10.7.0", + "ngx-cookie-service": "^19.0.0", + "ngx-markdown": "^19.1.1", + "ngx-stripe": "^19.0.0", + "pluralize": "^8.0.0", + "postgres-interval": "^4.0.2", + "private-ip": "^3.0.2", + "puppeteer": "^24.29.1", + "rxjs": "^7.4.0", + "tslib": "^2.8.1", + "uuid": "^11.1.0", + "validator": "^13.15.20", + "zone.js": "~0.15.0" + }, + "devDependencies": { + "@angular-devkit/build-angular": "~19.2.19", + "@angular/cli": "~19.0.5", + "@angular/compiler-cli": "~19.0.4", + "@angular/language-service": "~19.0.4", + "@sentry-internal/rrweb": "^2.16.0", + "@types/jasmine": "~5.1.5", + "@types/jasminewd2": "~2.0.13", + "@types/node": "^22.10.2", + "jasmine-core": "~5.5.0", + "jasmine-spec-reporter": "~7.0.0", + "karma": "~6.4.4", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "^2.2.1", + "karma-coverage-istanbul-reporter": "^3.0.3", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "^2.1.0", + "ts-node": "~10.9.2", + "typescript": "~5.6.0" + }, + "resolutions": { + "mermaid": "^11.10.0" + }, + "packageManager": "yarn@1.22.22" } diff --git a/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.spec.ts b/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.spec.ts index 4ca93068a..0bd0f9b6a 100644 --- a/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.spec.ts +++ b/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.spec.ts @@ -1,345 +1,336 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { Angulartics2Module } from 'angulartics2'; import { of, throwError } from 'rxjs'; - -import { EditSecretDialogComponent } from './edit-secret-dialog.component'; -import { SecretsService } from 'src/app/services/secrets.service'; import { Secret } from 'src/app/models/secret'; +import { SecretsService } from 'src/app/services/secrets.service'; +import { EditSecretDialogComponent } from './edit-secret-dialog.component'; describe('EditSecretDialogComponent', () => { - let component: EditSecretDialogComponent; - let fixture: ComponentFixture; - let mockSecretsService: jasmine.SpyObj; - let mockDialogRef: jasmine.SpyObj>; - - const mockSecret: Secret = { - id: '1', - slug: 'test-secret', - companyId: '1', - createdAt: '2024-01-01', - updatedAt: '2024-01-01', - masterEncryption: false, - }; - - const mockSecretWithExpiration: Secret = { - ...mockSecret, - expiresAt: '2025-12-31T00:00:00Z', - }; - - const mockSecretWithEncryption: Secret = { - ...mockSecret, - masterEncryption: true, - }; - - const createComponent = async (secret: Secret = mockSecret) => { - mockSecretsService = jasmine.createSpyObj('SecretsService', ['updateSecret']); - mockSecretsService.updateSecret.and.returnValue(of(secret)); - - mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); - - await TestBed.configureTestingModule({ - imports: [ - EditSecretDialogComponent, - BrowserAnimationsModule, - MatSnackBarModule, - Angulartics2Module.forRoot(), - ], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: SecretsService, useValue: mockSecretsService }, - { provide: MatDialogRef, useValue: mockDialogRef }, - { provide: MAT_DIALOG_DATA, useValue: { secret } }, - ] - }).compileComponents(); - - fixture = TestBed.createComponent(EditSecretDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }; - - beforeEach(async () => { - await createComponent(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - describe('form initialization', () => { - it('should initialize form with empty value', () => { - expect(component.form.get('value')?.value).toBe(''); - }); - - it('should have all required form controls', () => { - expect(component.form.get('value')).toBeTruthy(); - expect(component.form.get('expiresAt')).toBeTruthy(); - }); - - it('should initialize component properties', () => { - expect(component.submitting).toBeFalse(); - expect(component.showValue).toBeFalse(); - expect(component.masterPassword).toBe(''); - expect(component.masterPasswordError).toBe(''); - expect(component.showMasterPassword).toBeFalse(); - expect(component.clearExpiration).toBeFalse(); - expect(component.minDate).toBeTruthy(); - }); - - it('should initialize with null expiresAt when secret has no expiration', () => { - expect(component.form.get('expiresAt')?.value).toBeNull(); - }); - }); - - describe('form initialization with expiration', () => { - beforeEach(async () => { - await TestBed.resetTestingModule(); - await createComponent(mockSecretWithExpiration); - }); - - it('should initialize with existing expiration date', () => { - const expiresAt = component.form.get('expiresAt')?.value; - expect(expiresAt).toBeTruthy(); - expect(expiresAt instanceof Date).toBeTrue(); - }); - }); - - describe('value validation', () => { - it('should require value field', () => { - expect(component.form.get('value')?.valid).toBeFalse(); - component.form.patchValue({ value: 'some-value' }); - expect(component.form.get('value')?.valid).toBeTrue(); - }); - - it('should validate max length', () => { - const valueControl = component.form.get('value'); - valueControl?.setValue('a'.repeat(10001)); - expect(valueControl?.hasError('maxlength')).toBeTrue(); - - valueControl?.setValue('a'.repeat(10000)); - expect(valueControl?.hasError('maxlength')).toBeFalse(); - }); - }); - - describe('error messages', () => { - it('should return correct value error message for required', () => { - component.form.get('value')?.setValue(''); - component.form.get('value')?.markAsTouched(); - expect(component.valueError).toBe('New value is required'); - }); - - it('should return correct value error message for maxlength', () => { - component.form.get('value')?.setValue('a'.repeat(10001)); - expect(component.valueError).toBe('Value must be 10000 characters or less'); - }); - - it('should return empty string when no errors', () => { - component.form.get('value')?.setValue('valid-value'); - expect(component.valueError).toBe(''); - }); - }); - - describe('visibility toggles', () => { - it('should toggle value visibility', () => { - expect(component.showValue).toBeFalse(); - component.toggleValueVisibility(); - expect(component.showValue).toBeTrue(); - component.toggleValueVisibility(); - expect(component.showValue).toBeFalse(); - }); - - it('should toggle master password visibility', () => { - expect(component.showMasterPassword).toBeFalse(); - component.toggleMasterPasswordVisibility(); - expect(component.showMasterPassword).toBeTrue(); - component.toggleMasterPasswordVisibility(); - expect(component.showMasterPassword).toBeFalse(); - }); - }); - - describe('clear expiration', () => { - it('should disable expiresAt control when clearExpiration is true', () => { - component.onClearExpirationChange(true); - - expect(component.clearExpiration).toBeTrue(); - expect(component.form.get('expiresAt')?.disabled).toBeTrue(); - }); - - it('should enable expiresAt control when clearExpiration is false', () => { - component.onClearExpirationChange(true); - component.onClearExpirationChange(false); - - expect(component.clearExpiration).toBeFalse(); - expect(component.form.get('expiresAt')?.enabled).toBeTrue(); - }); - }); - - describe('form submission', () => { - it('should not submit invalid form', () => { - component.onSubmit(); - expect(mockSecretsService.updateSecret).not.toHaveBeenCalled(); - }); - - it('should submit updated secret', () => { - component.form.patchValue({ value: 'new-value' }); - component.onSubmit(); - expect(mockSecretsService.updateSecret).toHaveBeenCalled(); - }); - - it('should submit with correct payload', () => { - component.form.patchValue({ value: 'new-value' }); - component.onSubmit(); - - expect(mockSecretsService.updateSecret).toHaveBeenCalledWith( - 'test-secret', - jasmine.objectContaining({ value: 'new-value' }), - undefined - ); - }); - - it('should submit with expiration date', () => { - const futureDate = new Date('2026-01-01'); - component.form.patchValue({ - value: 'new-value', - expiresAt: futureDate, - }); - - component.onSubmit(); - - expect(mockSecretsService.updateSecret).toHaveBeenCalledWith( - 'test-secret', - jasmine.objectContaining({ - value: 'new-value', - expiresAt: jasmine.any(String), - }), - undefined - ); - }); - - it('should submit with null expiration when clearExpiration is true', () => { - component.form.patchValue({ value: 'new-value' }); - component.onClearExpirationChange(true); - - component.onSubmit(); - - expect(mockSecretsService.updateSecret).toHaveBeenCalledWith( - 'test-secret', - jasmine.objectContaining({ - value: 'new-value', - expiresAt: null, - }), - undefined - ); - }); - - it('should set submitting to true during submission', () => { - component.form.patchValue({ value: 'new-value' }); - - expect(component.submitting).toBeFalse(); - component.onSubmit(); - }); - - it('should close dialog on successful submission', () => { - component.form.patchValue({ value: 'new-value' }); - - component.onSubmit(); - - expect(mockDialogRef.close).toHaveBeenCalledWith(true); - }); - - it('should reset submitting on successful submission', () => { - component.form.patchValue({ value: 'new-value' }); - - component.onSubmit(); - - expect(component.submitting).toBeFalse(); - }); - }); - - describe('master encryption', () => { - beforeEach(async () => { - await TestBed.resetTestingModule(); - await createComponent(mockSecretWithEncryption); - }); - - it('should require master password for encrypted secrets', () => { - component.form.patchValue({ value: 'new-value' }); - component.masterPassword = ''; - - component.onSubmit(); - - expect(component.masterPasswordError).toBe('Master password is required'); - expect(mockSecretsService.updateSecret).not.toHaveBeenCalled(); - }); - - it('should submit with master password for encrypted secrets', () => { - component.form.patchValue({ value: 'new-value' }); - component.masterPassword = 'my-master-password'; - - component.onSubmit(); - - expect(mockSecretsService.updateSecret).toHaveBeenCalledWith( - 'test-secret', - jasmine.objectContaining({ value: 'new-value' }), - 'my-master-password' - ); - }); - - it('should show error on 403 response (invalid master password)', () => { - mockSecretsService.updateSecret.and.returnValue( - throwError(() => ({ status: 403 })) - ); - - component.form.patchValue({ value: 'new-value' }); - component.masterPassword = 'wrong-password'; - - component.onSubmit(); - - expect(component.masterPasswordError).toBe('Invalid master password'); - expect(component.submitting).toBeFalse(); - }); - - it('should clear master password error on new submission attempt', () => { - component.masterPasswordError = 'Previous error'; - component.form.patchValue({ value: 'new-value' }); - component.masterPassword = ''; + let component: EditSecretDialogComponent; + let fixture: ComponentFixture; + let mockSecretsService: jasmine.SpyObj; + let mockDialogRef: jasmine.SpyObj>; + + const mockSecret: Secret = { + id: '1', + slug: 'test-secret', + companyId: '1', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + masterEncryption: false, + }; + + const mockSecretWithExpiration: Secret = { + ...mockSecret, + expiresAt: '2025-12-31T00:00:00Z', + }; + + const mockSecretWithEncryption: Secret = { + ...mockSecret, + masterEncryption: true, + }; + + const createComponent = async (secret: Secret = mockSecret) => { + mockSecretsService = jasmine.createSpyObj('SecretsService', ['updateSecret']); + mockSecretsService.updateSecret.and.returnValue(of(secret)); + + mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); + + await TestBed.configureTestingModule({ + imports: [EditSecretDialogComponent, BrowserAnimationsModule, MatSnackBarModule, Angulartics2Module.forRoot()], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: SecretsService, useValue: mockSecretsService }, + { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: MAT_DIALOG_DATA, useValue: { secret } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(EditSecretDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }; + + beforeEach(async () => { + await createComponent(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('form initialization', () => { + it('should initialize form with empty value', () => { + expect(component.form.get('value')?.value).toBe(''); + }); + + it('should have all required form controls', () => { + expect(component.form.get('value')).toBeTruthy(); + expect(component.form.get('expiresAt')).toBeTruthy(); + }); + + it('should initialize component properties', () => { + expect(component.submitting).toBeFalse(); + expect(component.showValue).toBeFalse(); + expect(component.masterPassword).toBe(''); + expect(component.masterPasswordError).toBe(''); + expect(component.showMasterPassword).toBeFalse(); + expect(component.clearExpiration).toBeFalse(); + expect(component.minDate).toBeTruthy(); + }); + + it('should initialize with null expiresAt when secret has no expiration', () => { + expect(component.form.get('expiresAt')?.value).toBeNull(); + }); + }); + + describe('form initialization with expiration', () => { + beforeEach(async () => { + await TestBed.resetTestingModule(); + await createComponent(mockSecretWithExpiration); + }); + + it('should initialize with existing expiration date', () => { + const expiresAt = component.form.get('expiresAt')?.value; + expect(expiresAt).toBeTruthy(); + expect(expiresAt instanceof Date).toBeTrue(); + }); + }); + + describe('value validation', () => { + it('should require value field', () => { + expect(component.form.get('value')?.valid).toBeFalse(); + component.form.patchValue({ value: 'some-value' }); + expect(component.form.get('value')?.valid).toBeTrue(); + }); + + it('should validate max length', () => { + const valueControl = component.form.get('value'); + valueControl?.setValue('a'.repeat(10001)); + expect(valueControl?.hasError('maxlength')).toBeTrue(); + + valueControl?.setValue('a'.repeat(10000)); + expect(valueControl?.hasError('maxlength')).toBeFalse(); + }); + }); + + describe('error messages', () => { + it('should return correct value error message for required', () => { + component.form.get('value')?.setValue(''); + component.form.get('value')?.markAsTouched(); + expect(component.valueError).toBe('New value is required'); + }); + + it('should return correct value error message for maxlength', () => { + component.form.get('value')?.setValue('a'.repeat(10001)); + expect(component.valueError).toBe('Value must be 10000 characters or less'); + }); + + it('should return empty string when no errors', () => { + component.form.get('value')?.setValue('valid-value'); + expect(component.valueError).toBe(''); + }); + }); + + describe('visibility toggles', () => { + it('should toggle value visibility', () => { + expect(component.showValue).toBeFalse(); + component.toggleValueVisibility(); + expect(component.showValue).toBeTrue(); + component.toggleValueVisibility(); + expect(component.showValue).toBeFalse(); + }); + + it('should toggle master password visibility', () => { + expect(component.showMasterPassword).toBeFalse(); + component.toggleMasterPasswordVisibility(); + expect(component.showMasterPassword).toBeTrue(); + component.toggleMasterPasswordVisibility(); + expect(component.showMasterPassword).toBeFalse(); + }); + }); + + describe('clear expiration', () => { + it('should disable expiresAt control when clearExpiration is true', () => { + component.onClearExpirationChange(true); + + expect(component.clearExpiration).toBeTrue(); + expect(component.form.get('expiresAt')?.disabled).toBeTrue(); + }); + + it('should enable expiresAt control when clearExpiration is false', () => { + component.onClearExpirationChange(true); + component.onClearExpirationChange(false); + + expect(component.clearExpiration).toBeFalse(); + expect(component.form.get('expiresAt')?.enabled).toBeTrue(); + }); + }); + + describe('form submission', () => { + it('should not submit invalid form', () => { + component.onSubmit(); + expect(mockSecretsService.updateSecret).not.toHaveBeenCalled(); + }); + + it('should submit updated secret', () => { + component.form.patchValue({ value: 'new-value' }); + component.onSubmit(); + expect(mockSecretsService.updateSecret).toHaveBeenCalled(); + }); + + it('should submit with correct payload', () => { + component.form.patchValue({ value: 'new-value' }); + component.onSubmit(); + + expect(mockSecretsService.updateSecret).toHaveBeenCalledWith( + 'test-secret', + jasmine.objectContaining({ value: 'new-value' }), + undefined, + ); + }); + + it('should submit with expiration date', () => { + const futureDate = new Date(); + futureDate.setFullYear(futureDate.getFullYear() + 1); + component.form.patchValue({ + value: 'new-value', + expiresAt: futureDate, + }); + + component.onSubmit(); + + expect(mockSecretsService.updateSecret).toHaveBeenCalledWith( + 'test-secret', + jasmine.objectContaining({ + value: 'new-value', + expiresAt: jasmine.any(String), + }), + undefined, + ); + }); + + it('should submit with null expiration when clearExpiration is true', () => { + component.form.patchValue({ value: 'new-value' }); + component.onClearExpirationChange(true); + + component.onSubmit(); + + expect(mockSecretsService.updateSecret).toHaveBeenCalledWith( + 'test-secret', + jasmine.objectContaining({ + value: 'new-value', + expiresAt: null, + }), + undefined, + ); + }); + + it('should set submitting to true during submission', () => { + component.form.patchValue({ value: 'new-value' }); + + expect(component.submitting).toBeFalse(); + component.onSubmit(); + }); + + it('should close dialog on successful submission', () => { + component.form.patchValue({ value: 'new-value' }); + + component.onSubmit(); + + expect(mockDialogRef.close).toHaveBeenCalledWith(true); + }); + + it('should reset submitting on successful submission', () => { + component.form.patchValue({ value: 'new-value' }); + + component.onSubmit(); + + expect(component.submitting).toBeFalse(); + }); + }); + + describe('master encryption', () => { + beforeEach(async () => { + await TestBed.resetTestingModule(); + await createComponent(mockSecretWithEncryption); + }); + + it('should require master password for encrypted secrets', () => { + component.form.patchValue({ value: 'new-value' }); + component.masterPassword = ''; + + component.onSubmit(); + + expect(component.masterPasswordError).toBe('Master password is required'); + expect(mockSecretsService.updateSecret).not.toHaveBeenCalled(); + }); + + it('should submit with master password for encrypted secrets', () => { + component.form.patchValue({ value: 'new-value' }); + component.masterPassword = 'my-master-password'; + + component.onSubmit(); + + expect(mockSecretsService.updateSecret).toHaveBeenCalledWith( + 'test-secret', + jasmine.objectContaining({ value: 'new-value' }), + 'my-master-password', + ); + }); + + it('should show error on 403 response (invalid master password)', () => { + mockSecretsService.updateSecret.and.returnValue(throwError(() => ({ status: 403 }))); + + component.form.patchValue({ value: 'new-value' }); + component.masterPassword = 'wrong-password'; + + component.onSubmit(); + + expect(component.masterPasswordError).toBe('Invalid master password'); + expect(component.submitting).toBeFalse(); + }); + + it('should clear master password error on new submission attempt', () => { + component.masterPasswordError = 'Previous error'; + component.form.patchValue({ value: 'new-value' }); + component.masterPassword = ''; + + component.onSubmit(); + + expect(component.masterPasswordError).toBe('Master password is required'); + }); + }); - component.onSubmit(); - - expect(component.masterPasswordError).toBe('Master password is required'); - }); - }); - - describe('non-encrypted secret submission', () => { - it('should not send master password for non-encrypted secrets', () => { - component.form.patchValue({ value: 'new-value' }); - component.masterPassword = 'some-password'; + describe('non-encrypted secret submission', () => { + it('should not send master password for non-encrypted secrets', () => { + component.form.patchValue({ value: 'new-value' }); + component.masterPassword = 'some-password'; - component.onSubmit(); + component.onSubmit(); - expect(mockSecretsService.updateSecret).toHaveBeenCalledWith( - 'test-secret', - jasmine.objectContaining({ value: 'new-value' }), - undefined - ); - }); - }); + expect(mockSecretsService.updateSecret).toHaveBeenCalledWith( + 'test-secret', + jasmine.objectContaining({ value: 'new-value' }), + undefined, + ); + }); + }); - describe('error handling', () => { - it('should reset submitting on non-403 error', () => { - mockSecretsService.updateSecret.and.returnValue( - throwError(() => ({ status: 500 })) - ); + describe('error handling', () => { + it('should reset submitting on non-403 error', () => { + mockSecretsService.updateSecret.and.returnValue(throwError(() => ({ status: 500 }))); - component.form.patchValue({ value: 'new-value' }); - component.onSubmit(); + component.form.patchValue({ value: 'new-value' }); + component.onSubmit(); - expect(component.submitting).toBeFalse(); - }); - }); + expect(component.submitting).toBeFalse(); + }); + }); }); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 5a081f5a2..70a0ea860 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2019,6 +2019,34 @@ __metadata: languageName: node linkType: hard +"@emnapi/core@npm:^1.7.1": + version: 1.8.1 + resolution: "@emnapi/core@npm:1.8.1" + dependencies: + "@emnapi/wasi-threads": 1.1.0 + tslib: ^2.4.0 + checksum: 2a2fb36f4e2f90e25f419f8979435160313664bbb833d852d9de4487ff47f05fd36bf2cd77c3555f704ec2b67ce3a949ed5542598664c775cdd5ef35ae1c85a4 + languageName: node + linkType: hard + +"@emnapi/runtime@npm:^1.7.1": + version: 1.8.1 + resolution: "@emnapi/runtime@npm:1.8.1" + dependencies: + tslib: ^2.4.0 + checksum: 0000a91d2d0ec3aaa37cbab9c360de3ff8250592f3ce4706b8c9c6d93e54151e623a8983c85543f33cb6f66cf30bb24bf0ddde466de484d6a6bf1fb2650382de + languageName: node + linkType: hard + +"@emnapi/wasi-threads@npm:1.1.0": + version: 1.1.0 + resolution: "@emnapi/wasi-threads@npm:1.1.0" + dependencies: + tslib: ^2.4.0 + checksum: 6cffe35f3e407ae26236092991786db5968b4265e6e55f4664bf6f2ce0508e2a02a44ce6ebb16f2acd2f6589efb293f4f9d09cc9fbf80c00fc1a203accc94196 + languageName: node + linkType: hard + "@esbuild/aix-ppc64@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/aix-ppc64@npm:0.25.4" @@ -3079,6 +3107,17 @@ __metadata: languageName: node linkType: hard +"@napi-rs/wasm-runtime@npm:^1.1.0": + version: 1.1.1 + resolution: "@napi-rs/wasm-runtime@npm:1.1.1" + dependencies: + "@emnapi/core": ^1.7.1 + "@emnapi/runtime": ^1.7.1 + "@tybys/wasm-util": ^0.10.1 + checksum: 22db9ad68e1d34351e1c0165489200584ab1cb0db866d360eca1c4213cd63169b153d179fe5ea4cbda849a252d20c4b01e56106285e7da8e4a56a4df92318ee7 + languageName: node + linkType: hard + "@ngstack/code-editor@npm:^9.0.0": version: 9.0.0 resolution: "@ngstack/code-editor@npm:9.0.0" @@ -3232,6 +3271,148 @@ __metadata: languageName: node linkType: hard +"@oxc-resolver/binding-android-arm-eabi@npm:11.16.2": + version: 11.16.2 + resolution: "@oxc-resolver/binding-android-arm-eabi@npm:11.16.2" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@oxc-resolver/binding-android-arm64@npm:11.16.2": + version: 11.16.2 + resolution: "@oxc-resolver/binding-android-arm64@npm:11.16.2" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@oxc-resolver/binding-darwin-arm64@npm:11.16.2": + version: 11.16.2 + resolution: "@oxc-resolver/binding-darwin-arm64@npm:11.16.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@oxc-resolver/binding-darwin-x64@npm:11.16.2": + version: 11.16.2 + resolution: "@oxc-resolver/binding-darwin-x64@npm:11.16.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@oxc-resolver/binding-freebsd-x64@npm:11.16.2": + version: 11.16.2 + resolution: "@oxc-resolver/binding-freebsd-x64@npm:11.16.2" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@oxc-resolver/binding-linux-arm-gnueabihf@npm:11.16.2": + version: 11.16.2 + resolution: "@oxc-resolver/binding-linux-arm-gnueabihf@npm:11.16.2" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@oxc-resolver/binding-linux-arm-musleabihf@npm:11.16.2": + version: 11.16.2 + resolution: "@oxc-resolver/binding-linux-arm-musleabihf@npm:11.16.2" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@oxc-resolver/binding-linux-arm64-gnu@npm:11.16.2": + version: 11.16.2 + resolution: "@oxc-resolver/binding-linux-arm64-gnu@npm:11.16.2" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@oxc-resolver/binding-linux-arm64-musl@npm:11.16.2": + version: 11.16.2 + resolution: "@oxc-resolver/binding-linux-arm64-musl@npm:11.16.2" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@oxc-resolver/binding-linux-ppc64-gnu@npm:11.16.2": + version: 11.16.2 + resolution: "@oxc-resolver/binding-linux-ppc64-gnu@npm:11.16.2" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@oxc-resolver/binding-linux-riscv64-gnu@npm:11.16.2": + version: 11.16.2 + resolution: "@oxc-resolver/binding-linux-riscv64-gnu@npm:11.16.2" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@oxc-resolver/binding-linux-riscv64-musl@npm:11.16.2": + version: 11.16.2 + resolution: "@oxc-resolver/binding-linux-riscv64-musl@npm:11.16.2" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + +"@oxc-resolver/binding-linux-s390x-gnu@npm:11.16.2": + version: 11.16.2 + resolution: "@oxc-resolver/binding-linux-s390x-gnu@npm:11.16.2" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@oxc-resolver/binding-linux-x64-gnu@npm:11.16.2": + version: 11.16.2 + resolution: "@oxc-resolver/binding-linux-x64-gnu@npm:11.16.2" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@oxc-resolver/binding-linux-x64-musl@npm:11.16.2": + version: 11.16.2 + resolution: "@oxc-resolver/binding-linux-x64-musl@npm:11.16.2" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@oxc-resolver/binding-openharmony-arm64@npm:11.16.2": + version: 11.16.2 + resolution: "@oxc-resolver/binding-openharmony-arm64@npm:11.16.2" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@oxc-resolver/binding-wasm32-wasi@npm:11.16.2": + version: 11.16.2 + resolution: "@oxc-resolver/binding-wasm32-wasi@npm:11.16.2" + dependencies: + "@napi-rs/wasm-runtime": ^1.1.0 + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@oxc-resolver/binding-win32-arm64-msvc@npm:11.16.2": + version: 11.16.2 + resolution: "@oxc-resolver/binding-win32-arm64-msvc@npm:11.16.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@oxc-resolver/binding-win32-ia32-msvc@npm:11.16.2": + version: 11.16.2 + resolution: "@oxc-resolver/binding-win32-ia32-msvc@npm:11.16.2" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@oxc-resolver/binding-win32-x64-msvc@npm:11.16.2": + version: 11.16.2 + resolution: "@oxc-resolver/binding-win32-x64-msvc@npm:11.16.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@parcel/watcher-android-arm64@npm:2.5.0": version: 2.5.0 resolution: "@parcel/watcher-android-arm64@npm:2.5.0" @@ -4003,6 +4184,15 @@ __metadata: languageName: node linkType: hard +"@tybys/wasm-util@npm:^0.10.1": + version: 0.10.1 + resolution: "@tybys/wasm-util@npm:0.10.1" + dependencies: + tslib: ^2.4.0 + checksum: b8b281ffa9cd01cb6d45a4dddca2e28fd0cb6ad67cf091ba4a73ac87c0d6bd6ce188c332c489e87c20b0750b0b6fe3b99e30e1cd2227ec16da692f51c778944e + languageName: node + linkType: hard + "@types/body-parser@npm:*": version: 1.19.5 resolution: "@types/body-parser@npm:1.19.5" @@ -6724,6 +6914,7 @@ __metadata: karma-coverage-istanbul-reporter: ^3.0.3 karma-jasmine: ~5.1.0 karma-jasmine-html-reporter: ^2.1.0 + knip: ^5.79.0 libphonenumber-js: ^1.12.9 lodash: ^4.17.21 lodash-es: ^4.17.21 @@ -7470,7 +7661,7 @@ __metadata: languageName: node linkType: hard -"fast-glob@npm:3.3.3, fast-glob@npm:^3.2.11": +"fast-glob@npm:3.3.3, fast-glob@npm:^3.2.11, fast-glob@npm:^3.3.3": version: 3.3.3 resolution: "fast-glob@npm:3.3.3" dependencies: @@ -7528,6 +7719,15 @@ __metadata: languageName: node linkType: hard +"fd-package-json@npm:^2.0.0": + version: 2.0.0 + resolution: "fd-package-json@npm:2.0.0" + dependencies: + walk-up-path: ^4.0.0 + checksum: e595a1a23f8e208815cdcf26c92218240da00acce80468324408dc4a5cb6c26b6efb5076f0458a02f044562a1e60253731187a627d5416b4961468ddfc0ae426 + languageName: node + linkType: hard + "fd-slicer@npm:~1.1.0": version: 1.1.0 resolution: "fd-slicer@npm:1.1.0" @@ -7658,6 +7858,17 @@ __metadata: languageName: node linkType: hard +"formatly@npm:^0.3.0": + version: 0.3.0 + resolution: "formatly@npm:0.3.0" + dependencies: + fd-package-json: ^2.0.0 + bin: + formatly: bin/index.mjs + checksum: ef2bf133c048195fc30ced2a20e9acb5251a2a7cf7c2bf67afc71f6bbad78a3f8816b814ee22ec6db1bca7b339fb0d1eddbf168c7d36cc53459c664ff73e8d0d + languageName: node + linkType: hard + "forwarded@npm:0.2.0": version: 0.2.0 resolution: "forwarded@npm:0.2.0" @@ -8613,6 +8824,15 @@ __metadata: languageName: node linkType: hard +"jiti@npm:^2.6.0": + version: 2.6.1 + resolution: "jiti@npm:2.6.1" + bin: + jiti: lib/jiti-cli.mjs + checksum: 9394e29c5e40d1ca8267923160d8d86706173c9ff30c901097883434b0c4866de2c060427b6a9a5843bb3e42fa3a3c8b5b2228531d3dd4f4f10c5c6af355bb86 + languageName: node + linkType: hard + "js-base64@npm:^3.7.5": version: 3.7.7 resolution: "js-base64@npm:3.7.7" @@ -8638,6 +8858,17 @@ __metadata: languageName: node linkType: hard +"js-yaml@npm:^4.1.1": + version: 4.1.1 + resolution: "js-yaml@npm:4.1.1" + dependencies: + argparse: ^2.0.1 + bin: + js-yaml: bin/js-yaml.js + checksum: ea2339c6930fe048ec31b007b3c90be2714ab3e7defcc2c27ebf30c74fd940358f29070b4345af0019ef151875bf3bc3f8644bea1bab0372652b5044813ac02d + languageName: node + linkType: hard + "jsbn@npm:1.1.0": version: 1.1.0 resolution: "jsbn@npm:1.1.0" @@ -8856,6 +9087,32 @@ __metadata: languageName: node linkType: hard +"knip@npm:^5.79.0": + version: 5.79.0 + resolution: "knip@npm:5.79.0" + dependencies: + "@nodelib/fs.walk": ^1.2.3 + fast-glob: ^3.3.3 + formatly: ^0.3.0 + jiti: ^2.6.0 + js-yaml: ^4.1.1 + minimist: ^1.2.8 + oxc-resolver: ^11.15.0 + picocolors: ^1.1.1 + picomatch: ^4.0.1 + smol-toml: ^1.5.2 + strip-json-comments: 5.0.3 + zod: ^4.1.11 + peerDependencies: + "@types/node": ">=18" + typescript: ">=5.0.4 <7" + bin: + knip: bin/knip.js + knip-bun: bin/knip-bun.js + checksum: e9ca55890dcc534fb1596159f1d00f862df8feee9dd2e63afcbaa9c43350e7dc3350b7a710ffbb7d5c7a1f4a6ced318841d1798e31dfddb9e41b4cf2c357ac2d + languageName: node + linkType: hard + "kolorist@npm:^1.8.0": version: 1.8.0 resolution: "kolorist@npm:1.8.0" @@ -9434,7 +9691,7 @@ __metadata: languageName: node linkType: hard -"minimist@npm:^1.2.6": +"minimist@npm:^1.2.6, minimist@npm:^1.2.8": version: 1.2.8 resolution: "minimist@npm:1.2.8" checksum: 75a6d645fb122dad29c06a7597bddea977258957ed88d7a6df59b5cd3fe4a527e253e9bbf2e783e4b73657f9098b96a5fe96ab8a113655d4109108577ecf85b0 @@ -10145,6 +10402,75 @@ __metadata: languageName: node linkType: hard +"oxc-resolver@npm:^11.15.0": + version: 11.16.2 + resolution: "oxc-resolver@npm:11.16.2" + dependencies: + "@oxc-resolver/binding-android-arm-eabi": 11.16.2 + "@oxc-resolver/binding-android-arm64": 11.16.2 + "@oxc-resolver/binding-darwin-arm64": 11.16.2 + "@oxc-resolver/binding-darwin-x64": 11.16.2 + "@oxc-resolver/binding-freebsd-x64": 11.16.2 + "@oxc-resolver/binding-linux-arm-gnueabihf": 11.16.2 + "@oxc-resolver/binding-linux-arm-musleabihf": 11.16.2 + "@oxc-resolver/binding-linux-arm64-gnu": 11.16.2 + "@oxc-resolver/binding-linux-arm64-musl": 11.16.2 + "@oxc-resolver/binding-linux-ppc64-gnu": 11.16.2 + "@oxc-resolver/binding-linux-riscv64-gnu": 11.16.2 + "@oxc-resolver/binding-linux-riscv64-musl": 11.16.2 + "@oxc-resolver/binding-linux-s390x-gnu": 11.16.2 + "@oxc-resolver/binding-linux-x64-gnu": 11.16.2 + "@oxc-resolver/binding-linux-x64-musl": 11.16.2 + "@oxc-resolver/binding-openharmony-arm64": 11.16.2 + "@oxc-resolver/binding-wasm32-wasi": 11.16.2 + "@oxc-resolver/binding-win32-arm64-msvc": 11.16.2 + "@oxc-resolver/binding-win32-ia32-msvc": 11.16.2 + "@oxc-resolver/binding-win32-x64-msvc": 11.16.2 + dependenciesMeta: + "@oxc-resolver/binding-android-arm-eabi": + optional: true + "@oxc-resolver/binding-android-arm64": + optional: true + "@oxc-resolver/binding-darwin-arm64": + optional: true + "@oxc-resolver/binding-darwin-x64": + optional: true + "@oxc-resolver/binding-freebsd-x64": + optional: true + "@oxc-resolver/binding-linux-arm-gnueabihf": + optional: true + "@oxc-resolver/binding-linux-arm-musleabihf": + optional: true + "@oxc-resolver/binding-linux-arm64-gnu": + optional: true + "@oxc-resolver/binding-linux-arm64-musl": + optional: true + "@oxc-resolver/binding-linux-ppc64-gnu": + optional: true + "@oxc-resolver/binding-linux-riscv64-gnu": + optional: true + "@oxc-resolver/binding-linux-riscv64-musl": + optional: true + "@oxc-resolver/binding-linux-s390x-gnu": + optional: true + "@oxc-resolver/binding-linux-x64-gnu": + optional: true + "@oxc-resolver/binding-linux-x64-musl": + optional: true + "@oxc-resolver/binding-openharmony-arm64": + optional: true + "@oxc-resolver/binding-wasm32-wasi": + optional: true + "@oxc-resolver/binding-win32-arm64-msvc": + optional: true + "@oxc-resolver/binding-win32-ia32-msvc": + optional: true + "@oxc-resolver/binding-win32-x64-msvc": + optional: true + checksum: 3451b7b0ed5ffe3712640442f7d26cc7cc7584efed4ea0afb73cbb7fcd6df1fc267481ec52c504b11b1fe920b1b7629b7e6e1d3375b4db585f6589170a1e815c + languageName: node + linkType: hard + "p-limit@npm:^4.0.0": version: 4.0.0 resolution: "p-limit@npm:4.0.0" @@ -10406,7 +10732,7 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^4.0.2, picomatch@npm:^4.0.3": +"picomatch@npm:^4.0.1, picomatch@npm:^4.0.2, picomatch@npm:^4.0.3": version: 4.0.3 resolution: "picomatch@npm:4.0.3" checksum: 6817fb74eb745a71445debe1029768de55fd59a42b75606f478ee1d0dc1aa6e78b711d041a7c9d5550e042642029b7f373dc1a43b224c4b7f12d23436735dba0 @@ -11807,6 +12133,13 @@ __metadata: languageName: node linkType: hard +"smol-toml@npm:^1.5.2": + version: 1.6.0 + resolution: "smol-toml@npm:1.6.0" + checksum: e731aff4c52ff6f5401bc35f78643c30804c6957087a530d0d841b95266e6650a588cc8e59662872312d021aafcea8026372fa230cf6ba8b32a9c8f4c1a51cdf + languageName: node + linkType: hard + "socket.io-adapter@npm:~2.5.2": version: 2.5.5 resolution: "socket.io-adapter@npm:2.5.5" @@ -12110,6 +12443,13 @@ __metadata: languageName: node linkType: hard +"strip-json-comments@npm:5.0.3": + version: 5.0.3 + resolution: "strip-json-comments@npm:5.0.3" + checksum: 3ccbf26f278220f785e4b71f8a719a6a063d72558cc63cb450924254af258a4f4c008b8c9b055373a680dc7bd525be9e543ad742c177f8a7667e0b726258e0e4 + languageName: node + linkType: hard + "stylis@npm:^4.3.6": version: 4.3.6 resolution: "stylis@npm:4.3.6" @@ -12489,7 +12829,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:2.8.1, tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.1, tslib@npm:^2.5.0, tslib@npm:^2.8.0, tslib@npm:^2.8.1": +"tslib@npm:2.8.1, tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.4.1, tslib@npm:^2.5.0, tslib@npm:^2.8.0, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: e4aba30e632b8c8902b47587fd13345e2827fa639e7c3121074d5ee0880723282411a8838f830b55100cbe4517672f84a2472667d355b81e8af165a55dc6203a @@ -12860,6 +13200,13 @@ __metadata: languageName: node linkType: hard +"walk-up-path@npm:^4.0.0": + version: 4.0.0 + resolution: "walk-up-path@npm:4.0.0" + checksum: 6a230b20e5de296895116dc12b09dafaec1f72b8060c089533d296e241aff059dfaebe0d015c77467f857e4b40c78e08f7481add76f340233a1f34fa8af9ed63 + languageName: node + linkType: hard + "watchpack@npm:2.4.2, watchpack@npm:^2.4.1": version: 2.4.2 resolution: "watchpack@npm:2.4.2" @@ -13323,6 +13670,13 @@ __metadata: languageName: node linkType: hard +"zod@npm:^4.1.11": + version: 4.3.5 + resolution: "zod@npm:4.3.5" + checksum: 68691183a91c67c4102db20139f3b5af288c59b4b11eb2239d712aae99dc6c1cecaeebcb0c012b44489771be05fecba21e79f65af4b3163b220239ef0af3ec49 + languageName: node + linkType: hard + "zone.js@npm:~0.15.0": version: 0.15.0 resolution: "zone.js@npm:0.15.0" From 71db16b9468f41c6ae6283a9c2d301d1d5d771b7 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Mon, 5 Jan 2026 10:41:31 +0000 Subject: [PATCH 4/4] Fix flaky test with hardcoded expiration date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use dynamically calculated future date instead of hardcoded '2025-12-31' which is now in the past and fails datepicker min validation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../create-secret-dialog.component.spec.ts | 733 +++++++++--------- 1 file changed, 367 insertions(+), 366 deletions(-) diff --git a/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.spec.ts b/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.spec.ts index b23ea0aca..3caff6962 100644 --- a/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.spec.ts +++ b/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.spec.ts @@ -1,376 +1,377 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MatDialogRef } from '@angular/material/dialog'; import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { Angulartics2Module } from 'angulartics2'; import { of, throwError } from 'rxjs'; - -import { CreateSecretDialogComponent } from './create-secret-dialog.component'; -import { SecretsService } from 'src/app/services/secrets.service'; import { Secret } from 'src/app/models/secret'; +import { SecretsService } from 'src/app/services/secrets.service'; +import { CreateSecretDialogComponent } from './create-secret-dialog.component'; describe('CreateSecretDialogComponent', () => { - let component: CreateSecretDialogComponent; - let fixture: ComponentFixture; - let mockSecretsService: jasmine.SpyObj; - let mockDialogRef: jasmine.SpyObj>; - - const mockSecret: Secret = { - id: '1', - slug: 'test', - companyId: '1', - createdAt: '2024-01-01', - updatedAt: '2024-01-01', - masterEncryption: false, - }; - - beforeEach(async () => { - mockSecretsService = jasmine.createSpyObj('SecretsService', ['createSecret']); - mockSecretsService.createSecret.and.returnValue(of(mockSecret)); - - mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); - - await TestBed.configureTestingModule({ - imports: [ - CreateSecretDialogComponent, - BrowserAnimationsModule, - MatSnackBarModule, - Angulartics2Module.forRoot(), - ], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: SecretsService, useValue: mockSecretsService }, - { provide: MatDialogRef, useValue: mockDialogRef }, - ] - }).compileComponents(); - - fixture = TestBed.createComponent(CreateSecretDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - describe('form initialization', () => { - it('should have invalid form initially', () => { - expect(component.form.invalid).toBeTrue(); - }); - - it('should have all required form controls', () => { - expect(component.form.get('slug')).toBeTruthy(); - expect(component.form.get('value')).toBeTruthy(); - expect(component.form.get('expiresAt')).toBeTruthy(); - expect(component.form.get('masterEncryption')).toBeTruthy(); - expect(component.form.get('masterPassword')).toBeTruthy(); - }); - - it('should initialize with default values', () => { - expect(component.form.get('slug')?.value).toBe(''); - expect(component.form.get('value')?.value).toBe(''); - expect(component.form.get('expiresAt')?.value).toBeNull(); - expect(component.form.get('masterEncryption')?.value).toBeFalse(); - expect(component.form.get('masterPassword')?.value).toBe(''); - }); - - it('should initialize component properties', () => { - expect(component.submitting).toBeFalse(); - expect(component.showValue).toBeFalse(); - expect(component.showMasterPassword).toBeFalse(); - expect(component.minDate).toBeTruthy(); - }); - }); - - describe('slug validation', () => { - it('should require slug', () => { - const slugControl = component.form.get('slug'); - slugControl?.setValue(''); - expect(slugControl?.hasError('required')).toBeTrue(); - }); - - it('should validate slug pattern - reject spaces', () => { - const slugControl = component.form.get('slug'); - slugControl?.setValue('invalid slug'); - expect(slugControl?.hasError('pattern')).toBeTrue(); - }); - - it('should validate slug pattern - reject special characters', () => { - const slugControl = component.form.get('slug'); - slugControl?.setValue('invalid!slug'); - expect(slugControl?.hasError('pattern')).toBeTrue(); - - slugControl?.setValue('invalid@slug'); - expect(slugControl?.hasError('pattern')).toBeTrue(); - - slugControl?.setValue('invalid#slug'); - expect(slugControl?.hasError('pattern')).toBeTrue(); - }); - - it('should validate slug pattern - accept valid slugs', () => { - const slugControl = component.form.get('slug'); - - slugControl?.setValue('valid-slug'); - expect(slugControl?.hasError('pattern')).toBeFalse(); - - slugControl?.setValue('valid_slug'); - expect(slugControl?.hasError('pattern')).toBeFalse(); - - slugControl?.setValue('ValidSlug123'); - expect(slugControl?.hasError('pattern')).toBeFalse(); - - slugControl?.setValue('valid-slug_123'); - expect(slugControl?.hasError('pattern')).toBeFalse(); - }); - - it('should validate max length', () => { - const slugControl = component.form.get('slug'); - slugControl?.setValue('a'.repeat(256)); - expect(slugControl?.hasError('maxlength')).toBeTrue(); - - slugControl?.setValue('a'.repeat(255)); - expect(slugControl?.hasError('maxlength')).toBeFalse(); - }); - }); - - describe('value validation', () => { - it('should require value', () => { - const valueControl = component.form.get('value'); - valueControl?.setValue(''); - expect(valueControl?.hasError('required')).toBeTrue(); - }); - - it('should validate max length', () => { - const valueControl = component.form.get('value'); - valueControl?.setValue('a'.repeat(10001)); - expect(valueControl?.hasError('maxlength')).toBeTrue(); - - valueControl?.setValue('a'.repeat(10000)); - expect(valueControl?.hasError('maxlength')).toBeFalse(); - }); - }); - - describe('master encryption', () => { - it('should require master password when encryption is enabled', () => { - component.form.get('masterEncryption')?.setValue(true); - const masterPasswordControl = component.form.get('masterPassword'); - expect(masterPasswordControl?.hasError('required')).toBeTrue(); - }); - - it('should validate master password min length', () => { - component.form.get('masterEncryption')?.setValue(true); - const masterPasswordControl = component.form.get('masterPassword'); - - masterPasswordControl?.setValue('short'); - expect(masterPasswordControl?.hasError('minlength')).toBeTrue(); - - masterPasswordControl?.setValue('12345678'); - expect(masterPasswordControl?.hasError('minlength')).toBeFalse(); - }); - - it('should clear master password validators when encryption is disabled', () => { - component.form.get('masterEncryption')?.setValue(true); - component.form.get('masterPassword')?.setValue('password123'); - - component.form.get('masterEncryption')?.setValue(false); - - const masterPasswordControl = component.form.get('masterPassword'); - expect(masterPasswordControl?.value).toBe(''); - expect(masterPasswordControl?.valid).toBeTrue(); - }); - - it('should accept valid master password', () => { - component.form.get('masterEncryption')?.setValue(true); - const masterPasswordControl = component.form.get('masterPassword'); - - masterPasswordControl?.setValue('validpassword123'); - expect(masterPasswordControl?.valid).toBeTrue(); - }); - }); - - describe('error messages', () => { - it('should return correct slug error message for required', () => { - component.form.get('slug')?.setValue(''); - component.form.get('slug')?.markAsTouched(); - expect(component.slugError).toBe('Slug is required'); - }); - - it('should return correct slug error message for maxlength', () => { - component.form.get('slug')?.setValue('a'.repeat(256)); - expect(component.slugError).toBe('Slug must be 255 characters or less'); - }); - - it('should return correct slug error message for pattern', () => { - component.form.get('slug')?.setValue('invalid slug!'); - expect(component.slugError).toBe('Only letters, numbers, hyphens, and underscores allowed'); - }); - - it('should return correct value error message for required', () => { - component.form.get('value')?.setValue(''); - component.form.get('value')?.markAsTouched(); - expect(component.valueError).toBe('Value is required'); - }); - - it('should return correct value error message for maxlength', () => { - component.form.get('value')?.setValue('a'.repeat(10001)); - expect(component.valueError).toBe('Value must be 10000 characters or less'); - }); - - it('should return correct master password error for required', () => { - component.form.get('masterEncryption')?.setValue(true); - component.form.get('masterPassword')?.markAsTouched(); - expect(component.masterPasswordError).toBe('Master password is required for encryption'); - }); - - it('should return correct master password error for minlength', () => { - component.form.get('masterEncryption')?.setValue(true); - component.form.get('masterPassword')?.setValue('short'); - expect(component.masterPasswordError).toBe('Master password must be at least 8 characters'); - }); - }); - - describe('visibility toggles', () => { - it('should toggle value visibility', () => { - expect(component.showValue).toBeFalse(); - component.toggleValueVisibility(); - expect(component.showValue).toBeTrue(); - component.toggleValueVisibility(); - expect(component.showValue).toBeFalse(); - }); - - it('should toggle master password visibility', () => { - expect(component.showMasterPassword).toBeFalse(); - component.toggleMasterPasswordVisibility(); - expect(component.showMasterPassword).toBeTrue(); - component.toggleMasterPasswordVisibility(); - expect(component.showMasterPassword).toBeFalse(); - }); - }); - - describe('form submission', () => { - it('should not submit invalid form', () => { - component.onSubmit(); - expect(mockSecretsService.createSecret).not.toHaveBeenCalled(); - }); - - it('should submit valid form', () => { - component.form.patchValue({ - slug: 'test-secret', - value: 'secret-value', - }); - - component.onSubmit(); - expect(mockSecretsService.createSecret).toHaveBeenCalled(); - }); - - it('should submit form with correct basic payload', () => { - component.form.patchValue({ - slug: 'test-secret', - value: 'secret-value', - }); - - component.onSubmit(); - - expect(mockSecretsService.createSecret).toHaveBeenCalledWith(jasmine.objectContaining({ - slug: 'test-secret', - value: 'secret-value', - })); - }); - - it('should submit form with expiration date', () => { - const futureDate = new Date('2025-12-31'); - component.form.patchValue({ - slug: 'test-secret', - value: 'secret-value', - expiresAt: futureDate, - }); - - component.onSubmit(); - - expect(mockSecretsService.createSecret).toHaveBeenCalledWith(jasmine.objectContaining({ - slug: 'test-secret', - value: 'secret-value', - expiresAt: jasmine.any(String), - })); - }); - - it('should submit form with master encryption', () => { - component.form.patchValue({ - slug: 'test-secret', - value: 'secret-value', - masterEncryption: true, - masterPassword: 'password123', - }); - - component.onSubmit(); - - expect(mockSecretsService.createSecret).toHaveBeenCalledWith(jasmine.objectContaining({ - slug: 'test-secret', - value: 'secret-value', - masterEncryption: true, - masterPassword: 'password123', - })); - }); - - it('should set submitting to true during submission', () => { - component.form.patchValue({ - slug: 'test-secret', - value: 'secret-value', - }); - - expect(component.submitting).toBeFalse(); - component.onSubmit(); - }); - - it('should close dialog on successful submission', () => { - component.form.patchValue({ - slug: 'test-secret', - value: 'secret-value', - }); - - component.onSubmit(); - - expect(mockDialogRef.close).toHaveBeenCalledWith(true); - }); - - it('should reset submitting on successful submission', () => { - component.form.patchValue({ - slug: 'test-secret', - value: 'secret-value', - }); - - component.onSubmit(); - - expect(component.submitting).toBeFalse(); - }); - - it('should reset submitting on error', () => { - mockSecretsService.createSecret.and.returnValue(throwError(() => new Error('Error'))); - - component.form.patchValue({ - slug: 'test-secret', - value: 'secret-value', - }); - - component.onSubmit(); - - expect(component.submitting).toBeFalse(); - }); - - it('should not include master password when encryption is disabled', () => { - component.form.patchValue({ - slug: 'test-secret', - value: 'secret-value', - masterEncryption: false, - }); - - component.onSubmit(); - - const callArgs = mockSecretsService.createSecret.calls.mostRecent().args[0]; - expect(callArgs.masterPassword).toBeUndefined(); - }); - }); + let component: CreateSecretDialogComponent; + let fixture: ComponentFixture; + let mockSecretsService: jasmine.SpyObj; + let mockDialogRef: jasmine.SpyObj>; + + const mockSecret: Secret = { + id: '1', + slug: 'test', + companyId: '1', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + masterEncryption: false, + }; + + beforeEach(async () => { + mockSecretsService = jasmine.createSpyObj('SecretsService', ['createSecret']); + mockSecretsService.createSecret.and.returnValue(of(mockSecret)); + + mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); + + await TestBed.configureTestingModule({ + imports: [CreateSecretDialogComponent, BrowserAnimationsModule, MatSnackBarModule, Angulartics2Module.forRoot()], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: SecretsService, useValue: mockSecretsService }, + { provide: MatDialogRef, useValue: mockDialogRef }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CreateSecretDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('form initialization', () => { + it('should have invalid form initially', () => { + expect(component.form.invalid).toBeTrue(); + }); + + it('should have all required form controls', () => { + expect(component.form.get('slug')).toBeTruthy(); + expect(component.form.get('value')).toBeTruthy(); + expect(component.form.get('expiresAt')).toBeTruthy(); + expect(component.form.get('masterEncryption')).toBeTruthy(); + expect(component.form.get('masterPassword')).toBeTruthy(); + }); + + it('should initialize with default values', () => { + expect(component.form.get('slug')?.value).toBe(''); + expect(component.form.get('value')?.value).toBe(''); + expect(component.form.get('expiresAt')?.value).toBeNull(); + expect(component.form.get('masterEncryption')?.value).toBeFalse(); + expect(component.form.get('masterPassword')?.value).toBe(''); + }); + + it('should initialize component properties', () => { + expect(component.submitting).toBeFalse(); + expect(component.showValue).toBeFalse(); + expect(component.showMasterPassword).toBeFalse(); + expect(component.minDate).toBeTruthy(); + }); + }); + + describe('slug validation', () => { + it('should require slug', () => { + const slugControl = component.form.get('slug'); + slugControl?.setValue(''); + expect(slugControl?.hasError('required')).toBeTrue(); + }); + + it('should validate slug pattern - reject spaces', () => { + const slugControl = component.form.get('slug'); + slugControl?.setValue('invalid slug'); + expect(slugControl?.hasError('pattern')).toBeTrue(); + }); + + it('should validate slug pattern - reject special characters', () => { + const slugControl = component.form.get('slug'); + slugControl?.setValue('invalid!slug'); + expect(slugControl?.hasError('pattern')).toBeTrue(); + + slugControl?.setValue('invalid@slug'); + expect(slugControl?.hasError('pattern')).toBeTrue(); + + slugControl?.setValue('invalid#slug'); + expect(slugControl?.hasError('pattern')).toBeTrue(); + }); + + it('should validate slug pattern - accept valid slugs', () => { + const slugControl = component.form.get('slug'); + + slugControl?.setValue('valid-slug'); + expect(slugControl?.hasError('pattern')).toBeFalse(); + + slugControl?.setValue('valid_slug'); + expect(slugControl?.hasError('pattern')).toBeFalse(); + + slugControl?.setValue('ValidSlug123'); + expect(slugControl?.hasError('pattern')).toBeFalse(); + + slugControl?.setValue('valid-slug_123'); + expect(slugControl?.hasError('pattern')).toBeFalse(); + }); + + it('should validate max length', () => { + const slugControl = component.form.get('slug'); + slugControl?.setValue('a'.repeat(256)); + expect(slugControl?.hasError('maxlength')).toBeTrue(); + + slugControl?.setValue('a'.repeat(255)); + expect(slugControl?.hasError('maxlength')).toBeFalse(); + }); + }); + + describe('value validation', () => { + it('should require value', () => { + const valueControl = component.form.get('value'); + valueControl?.setValue(''); + expect(valueControl?.hasError('required')).toBeTrue(); + }); + + it('should validate max length', () => { + const valueControl = component.form.get('value'); + valueControl?.setValue('a'.repeat(10001)); + expect(valueControl?.hasError('maxlength')).toBeTrue(); + + valueControl?.setValue('a'.repeat(10000)); + expect(valueControl?.hasError('maxlength')).toBeFalse(); + }); + }); + + describe('master encryption', () => { + it('should require master password when encryption is enabled', () => { + component.form.get('masterEncryption')?.setValue(true); + const masterPasswordControl = component.form.get('masterPassword'); + expect(masterPasswordControl?.hasError('required')).toBeTrue(); + }); + + it('should validate master password min length', () => { + component.form.get('masterEncryption')?.setValue(true); + const masterPasswordControl = component.form.get('masterPassword'); + + masterPasswordControl?.setValue('short'); + expect(masterPasswordControl?.hasError('minlength')).toBeTrue(); + + masterPasswordControl?.setValue('12345678'); + expect(masterPasswordControl?.hasError('minlength')).toBeFalse(); + }); + + it('should clear master password validators when encryption is disabled', () => { + component.form.get('masterEncryption')?.setValue(true); + component.form.get('masterPassword')?.setValue('password123'); + + component.form.get('masterEncryption')?.setValue(false); + + const masterPasswordControl = component.form.get('masterPassword'); + expect(masterPasswordControl?.value).toBe(''); + expect(masterPasswordControl?.valid).toBeTrue(); + }); + + it('should accept valid master password', () => { + component.form.get('masterEncryption')?.setValue(true); + const masterPasswordControl = component.form.get('masterPassword'); + + masterPasswordControl?.setValue('validpassword123'); + expect(masterPasswordControl?.valid).toBeTrue(); + }); + }); + + describe('error messages', () => { + it('should return correct slug error message for required', () => { + component.form.get('slug')?.setValue(''); + component.form.get('slug')?.markAsTouched(); + expect(component.slugError).toBe('Slug is required'); + }); + + it('should return correct slug error message for maxlength', () => { + component.form.get('slug')?.setValue('a'.repeat(256)); + expect(component.slugError).toBe('Slug must be 255 characters or less'); + }); + + it('should return correct slug error message for pattern', () => { + component.form.get('slug')?.setValue('invalid slug!'); + expect(component.slugError).toBe('Only letters, numbers, hyphens, and underscores allowed'); + }); + + it('should return correct value error message for required', () => { + component.form.get('value')?.setValue(''); + component.form.get('value')?.markAsTouched(); + expect(component.valueError).toBe('Value is required'); + }); + + it('should return correct value error message for maxlength', () => { + component.form.get('value')?.setValue('a'.repeat(10001)); + expect(component.valueError).toBe('Value must be 10000 characters or less'); + }); + + it('should return correct master password error for required', () => { + component.form.get('masterEncryption')?.setValue(true); + component.form.get('masterPassword')?.markAsTouched(); + expect(component.masterPasswordError).toBe('Master password is required for encryption'); + }); + + it('should return correct master password error for minlength', () => { + component.form.get('masterEncryption')?.setValue(true); + component.form.get('masterPassword')?.setValue('short'); + expect(component.masterPasswordError).toBe('Master password must be at least 8 characters'); + }); + }); + + describe('visibility toggles', () => { + it('should toggle value visibility', () => { + expect(component.showValue).toBeFalse(); + component.toggleValueVisibility(); + expect(component.showValue).toBeTrue(); + component.toggleValueVisibility(); + expect(component.showValue).toBeFalse(); + }); + + it('should toggle master password visibility', () => { + expect(component.showMasterPassword).toBeFalse(); + component.toggleMasterPasswordVisibility(); + expect(component.showMasterPassword).toBeTrue(); + component.toggleMasterPasswordVisibility(); + expect(component.showMasterPassword).toBeFalse(); + }); + }); + + describe('form submission', () => { + it('should not submit invalid form', () => { + component.onSubmit(); + expect(mockSecretsService.createSecret).not.toHaveBeenCalled(); + }); + + it('should submit valid form', () => { + component.form.patchValue({ + slug: 'test-secret', + value: 'secret-value', + }); + + component.onSubmit(); + expect(mockSecretsService.createSecret).toHaveBeenCalled(); + }); + + it('should submit form with correct basic payload', () => { + component.form.patchValue({ + slug: 'test-secret', + value: 'secret-value', + }); + + component.onSubmit(); + + expect(mockSecretsService.createSecret).toHaveBeenCalledWith( + jasmine.objectContaining({ + slug: 'test-secret', + value: 'secret-value', + }), + ); + }); + + it('should submit form with expiration date', () => { + const futureDate = new Date(); + futureDate.setFullYear(futureDate.getFullYear() + 1); + component.form.patchValue({ + slug: 'test-secret', + value: 'secret-value', + expiresAt: futureDate, + }); + + component.onSubmit(); + + expect(mockSecretsService.createSecret).toHaveBeenCalledWith( + jasmine.objectContaining({ + slug: 'test-secret', + value: 'secret-value', + expiresAt: jasmine.any(String), + }), + ); + }); + + it('should submit form with master encryption', () => { + component.form.patchValue({ + slug: 'test-secret', + value: 'secret-value', + masterEncryption: true, + masterPassword: 'password123', + }); + + component.onSubmit(); + + expect(mockSecretsService.createSecret).toHaveBeenCalledWith( + jasmine.objectContaining({ + slug: 'test-secret', + value: 'secret-value', + masterEncryption: true, + masterPassword: 'password123', + }), + ); + }); + + it('should set submitting to true during submission', () => { + component.form.patchValue({ + slug: 'test-secret', + value: 'secret-value', + }); + + expect(component.submitting).toBeFalse(); + component.onSubmit(); + }); + + it('should close dialog on successful submission', () => { + component.form.patchValue({ + slug: 'test-secret', + value: 'secret-value', + }); + + component.onSubmit(); + + expect(mockDialogRef.close).toHaveBeenCalledWith(true); + }); + + it('should reset submitting on successful submission', () => { + component.form.patchValue({ + slug: 'test-secret', + value: 'secret-value', + }); + + component.onSubmit(); + + expect(component.submitting).toBeFalse(); + }); + + it('should reset submitting on error', () => { + mockSecretsService.createSecret.and.returnValue(throwError(() => new Error('Error'))); + + component.form.patchValue({ + slug: 'test-secret', + value: 'secret-value', + }); + + component.onSubmit(); + + expect(component.submitting).toBeFalse(); + }); + + it('should not include master password when encryption is disabled', () => { + component.form.patchValue({ + slug: 'test-secret', + value: 'secret-value', + masterEncryption: false, + }); + + component.onSubmit(); + + const callArgs = mockSecretsService.createSecret.calls.mostRecent().args[0]; + expect(callArgs.masterPassword).toBeUndefined(); + }); + }); });